001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.download; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.BorderLayout; 007import java.awt.Component; 008import java.awt.Dimension; 009import java.awt.GridBagLayout; 010import java.awt.GridLayout; 011import java.awt.event.ActionEvent; 012import java.awt.event.MouseAdapter; 013import java.awt.event.MouseEvent; 014import java.io.IOException; 015import java.io.InputStream; 016import java.io.InputStreamReader; 017import java.io.Reader; 018import java.net.HttpURLConnection; 019import java.net.URL; 020import java.nio.charset.StandardCharsets; 021import java.text.DecimalFormat; 022import java.util.ArrayList; 023import java.util.Collections; 024import java.util.LinkedList; 025import java.util.List; 026import java.util.StringTokenizer; 027 028import javax.swing.AbstractAction; 029import javax.swing.BorderFactory; 030import javax.swing.DefaultListSelectionModel; 031import javax.swing.JButton; 032import javax.swing.JLabel; 033import javax.swing.JOptionPane; 034import javax.swing.JPanel; 035import javax.swing.JScrollPane; 036import javax.swing.JTable; 037import javax.swing.JTextField; 038import javax.swing.ListSelectionModel; 039import javax.swing.UIManager; 040import javax.swing.event.DocumentEvent; 041import javax.swing.event.DocumentListener; 042import javax.swing.event.ListSelectionEvent; 043import javax.swing.event.ListSelectionListener; 044import javax.swing.table.DefaultTableColumnModel; 045import javax.swing.table.DefaultTableModel; 046import javax.swing.table.TableCellRenderer; 047import javax.swing.table.TableColumn; 048import javax.xml.parsers.SAXParserFactory; 049 050import org.openstreetmap.josm.Main; 051import org.openstreetmap.josm.data.Bounds; 052import org.openstreetmap.josm.gui.ExceptionDialogUtil; 053import org.openstreetmap.josm.gui.HelpAwareOptionPane; 054import org.openstreetmap.josm.gui.PleaseWaitRunnable; 055import org.openstreetmap.josm.gui.util.GuiHelper; 056import org.openstreetmap.josm.gui.widgets.HistoryComboBox; 057import org.openstreetmap.josm.gui.widgets.JosmComboBox; 058import org.openstreetmap.josm.io.OsmTransferException; 059import org.openstreetmap.josm.tools.GBC; 060import org.openstreetmap.josm.tools.ImageProvider; 061import org.openstreetmap.josm.tools.OsmUrlToBounds; 062import org.openstreetmap.josm.tools.Utils; 063import org.xml.sax.Attributes; 064import org.xml.sax.InputSource; 065import org.xml.sax.SAXException; 066import org.xml.sax.SAXParseException; 067import org.xml.sax.helpers.DefaultHandler; 068 069public class PlaceSelection implements DownloadSelection { 070 private static final String HISTORY_KEY = "download.places.history"; 071 072 private HistoryComboBox cbSearchExpression; 073 private JButton btnSearch; 074 private NamedResultTableModel model; 075 private NamedResultTableColumnModel columnmodel; 076 private JTable tblSearchResults; 077 private DownloadDialog parent; 078 private static final Server[] SERVERS = new Server[] { 079 new Server("Nominatim","https://nominatim.openstreetmap.org/search?format=xml&q=",tr("Class Type"),tr("Bounds")) 080 }; 081 private final JosmComboBox<Server> server = new JosmComboBox<>(SERVERS); 082 083 private static class Server { 084 public String name; 085 public String url; 086 public String thirdcol; 087 public String fourthcol; 088 @Override 089 public String toString() { 090 return name; 091 } 092 public Server(String n, String u, String t, String f) { 093 name = n; 094 url = u; 095 thirdcol = t; 096 fourthcol = f; 097 } 098 } 099 100 protected JPanel buildSearchPanel() { 101 JPanel lpanel = new JPanel(); 102 lpanel.setLayout(new GridLayout(2,2)); 103 JPanel panel = new JPanel(); 104 panel.setLayout(new GridBagLayout()); 105 106 lpanel.add(new JLabel(tr("Choose the server for searching:"))); 107 lpanel.add(server); 108 String s = Main.pref.get("namefinder.server", SERVERS[0].name); 109 for (int i = 0; i < SERVERS.length; ++i) { 110 if (SERVERS[i].name.equals(s)) { 111 server.setSelectedIndex(i); 112 } 113 } 114 lpanel.add(new JLabel(tr("Enter a place name to search for:"))); 115 116 cbSearchExpression = new HistoryComboBox(); 117 cbSearchExpression.setToolTipText(tr("Enter a place name to search for")); 118 List<String> cmtHistory = new LinkedList<>(Main.pref.getCollection(HISTORY_KEY, new LinkedList<String>())); 119 Collections.reverse(cmtHistory); 120 cbSearchExpression.setPossibleItems(cmtHistory); 121 lpanel.add(cbSearchExpression); 122 123 panel.add(lpanel, GBC.std().fill(GBC.HORIZONTAL).insets(5, 5, 0, 5)); 124 SearchAction searchAction = new SearchAction(); 125 btnSearch = new JButton(searchAction); 126 ((JTextField)cbSearchExpression.getEditor().getEditorComponent()).getDocument().addDocumentListener(searchAction); 127 ((JTextField)cbSearchExpression.getEditor().getEditorComponent()).addActionListener(searchAction); 128 129 panel.add(btnSearch, GBC.eol().insets(5, 5, 0, 5)); 130 131 return panel; 132 } 133 134 /** 135 * Adds a new tab to the download dialog in JOSM. 136 * 137 * This method is, for all intents and purposes, the constructor for this class. 138 */ 139 @Override 140 public void addGui(final DownloadDialog gui) { 141 JPanel panel = new JPanel(); 142 panel.setLayout(new BorderLayout()); 143 panel.add(buildSearchPanel(), BorderLayout.NORTH); 144 145 DefaultListSelectionModel selectionModel = new DefaultListSelectionModel(); 146 model = new NamedResultTableModel(selectionModel); 147 columnmodel = new NamedResultTableColumnModel(); 148 tblSearchResults = new JTable(model, columnmodel); 149 tblSearchResults.setSelectionModel(selectionModel); 150 JScrollPane scrollPane = new JScrollPane(tblSearchResults); 151 scrollPane.setPreferredSize(new Dimension(200,200)); 152 panel.add(scrollPane, BorderLayout.CENTER); 153 154 gui.addDownloadAreaSelector(panel, tr("Areas around places")); 155 156 scrollPane.setPreferredSize(scrollPane.getPreferredSize()); 157 tblSearchResults.getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION); 158 tblSearchResults.getSelectionModel().addListSelectionListener(new ListSelectionHandler()); 159 tblSearchResults.addMouseListener(new MouseAdapter() { 160 @Override public void mouseClicked(MouseEvent e) { 161 if (e.getClickCount() > 1) { 162 SearchResult sr = model.getSelectedSearchResult(); 163 if (sr == null) return; 164 parent.startDownload(sr.getDownloadArea()); 165 } 166 } 167 }); 168 parent = gui; 169 } 170 171 @Override 172 public void setDownloadArea(Bounds area) { 173 tblSearchResults.clearSelection(); 174 } 175 176 /** 177 * Data storage for search results. 178 */ 179 private static class SearchResult { 180 public String name; 181 public String info; 182 public String nearestPlace; 183 public String description; 184 public double lat; 185 public double lon; 186 public int zoom = 0; 187 public Bounds bounds = null; 188 189 public Bounds getDownloadArea() { 190 return bounds != null ? bounds : OsmUrlToBounds.positionToBounds(lat, lon, zoom); 191 } 192 } 193 194 /** 195 * A very primitive parser for the name finder's output. 196 * Structure of xml described here: http://wiki.openstreetmap.org/index.php/Name_finder 197 * 198 */ 199 private static class NameFinderResultParser extends DefaultHandler { 200 private SearchResult currentResult = null; 201 private StringBuffer description = null; 202 private int depth = 0; 203 private List<SearchResult> data = new LinkedList<>(); 204 205 /** 206 * Detect starting elements. 207 * 208 */ 209 @Override 210 public void startElement(String namespaceURI, String localName, String qName, Attributes atts) 211 throws SAXException { 212 depth++; 213 try { 214 if ("searchresults".equals(qName)) { 215 // do nothing 216 } else if ("named".equals(qName) && (depth == 2)) { 217 currentResult = new PlaceSelection.SearchResult(); 218 currentResult.name = atts.getValue("name"); 219 currentResult.info = atts.getValue("info"); 220 if(currentResult.info != null) { 221 currentResult.info = tr(currentResult.info); 222 } 223 currentResult.lat = Double.parseDouble(atts.getValue("lat")); 224 currentResult.lon = Double.parseDouble(atts.getValue("lon")); 225 currentResult.zoom = Integer.parseInt(atts.getValue("zoom")); 226 data.add(currentResult); 227 } else if ("description".equals(qName) && (depth == 3)) { 228 description = new StringBuffer(); 229 } else if ("named".equals(qName) && (depth == 4)) { 230 // this is a "named" place in the nearest places list. 231 String info = atts.getValue("info"); 232 if ("city".equals(info) || "town".equals(info) || "village".equals(info)) { 233 currentResult.nearestPlace = atts.getValue("name"); 234 } 235 } else if ("place".equals(qName) && atts.getValue("lat") != null) { 236 currentResult = new PlaceSelection.SearchResult(); 237 currentResult.name = atts.getValue("display_name"); 238 currentResult.description = currentResult.name; 239 currentResult.info = atts.getValue("class"); 240 if (currentResult.info != null) { 241 currentResult.info = tr(currentResult.info); 242 } 243 currentResult.nearestPlace = tr(atts.getValue("type")); 244 currentResult.lat = Double.parseDouble(atts.getValue("lat")); 245 currentResult.lon = Double.parseDouble(atts.getValue("lon")); 246 String[] bbox = atts.getValue("boundingbox").split(","); 247 currentResult.bounds = new Bounds( 248 Double.parseDouble(bbox[0]), Double.parseDouble(bbox[2]), 249 Double.parseDouble(bbox[1]), Double.parseDouble(bbox[3])); 250 data.add(currentResult); 251 } 252 } catch (NumberFormatException x) { 253 Main.error(x); // SAXException does not chain correctly 254 throw new SAXException(x.getMessage(), x); 255 } catch (NullPointerException x) { 256 Main.error(x); // SAXException does not chain correctly 257 throw new SAXException(tr("Null pointer exception, possibly some missing tags."), x); 258 } 259 } 260 261 /** 262 * Detect ending elements. 263 */ 264 @Override 265 public void endElement(String namespaceURI, String localName, String qName) throws SAXException { 266 if ("description".equals(qName) && description != null) { 267 currentResult.description = description.toString(); 268 description = null; 269 } 270 depth--; 271 } 272 273 /** 274 * Read characters for description. 275 */ 276 @Override 277 public void characters(char[] data, int start, int length) throws org.xml.sax.SAXException { 278 if (description != null) { 279 description.append(data, start, length); 280 } 281 } 282 283 public List<SearchResult> getResult() { 284 return data; 285 } 286 } 287 288 class SearchAction extends AbstractAction implements DocumentListener { 289 290 public SearchAction() { 291 putValue(NAME, tr("Search ...")); 292 putValue(SMALL_ICON, ImageProvider.get("dialogs","search")); 293 putValue(SHORT_DESCRIPTION, tr("Click to start searching for places")); 294 updateEnabledState(); 295 } 296 297 @Override 298 public void actionPerformed(ActionEvent e) { 299 if (!isEnabled() || cbSearchExpression.getText().trim().length() == 0) 300 return; 301 cbSearchExpression.addCurrentItemToHistory(); 302 Main.pref.putCollection(HISTORY_KEY, cbSearchExpression.getHistory()); 303 NameQueryTask task = new NameQueryTask(cbSearchExpression.getText()); 304 Main.worker.submit(task); 305 } 306 307 protected final void updateEnabledState() { 308 setEnabled(cbSearchExpression.getText().trim().length() > 0); 309 } 310 311 @Override 312 public void changedUpdate(DocumentEvent e) { 313 updateEnabledState(); 314 } 315 316 @Override 317 public void insertUpdate(DocumentEvent e) { 318 updateEnabledState(); 319 } 320 321 @Override 322 public void removeUpdate(DocumentEvent e) { 323 updateEnabledState(); 324 } 325 } 326 327 class NameQueryTask extends PleaseWaitRunnable { 328 329 private String searchExpression; 330 private HttpURLConnection connection; 331 private List<SearchResult> data; 332 private boolean canceled = false; 333 private Server useserver; 334 private Exception lastException; 335 336 public NameQueryTask(String searchExpression) { 337 super(tr("Querying name server"),false /* don't ignore exceptions */); 338 this.searchExpression = searchExpression; 339 useserver = (Server)server.getSelectedItem(); 340 Main.pref.put("namefinder.server", useserver.name); 341 } 342 343 @Override 344 protected void cancel() { 345 this.canceled = true; 346 synchronized (this) { 347 if (connection != null) { 348 connection.disconnect(); 349 } 350 } 351 } 352 353 @Override 354 protected void finish() { 355 if (canceled) 356 return; 357 if (lastException != null) { 358 ExceptionDialogUtil.explainException(lastException); 359 return; 360 } 361 columnmodel.setHeadlines(useserver.thirdcol, useserver.fourthcol); 362 model.setData(this.data); 363 } 364 365 @Override 366 protected void realRun() throws SAXException, IOException, OsmTransferException { 367 String urlString = useserver.url+java.net.URLEncoder.encode(searchExpression, "UTF-8"); 368 369 try { 370 getProgressMonitor().indeterminateSubTask(tr("Querying name server ...")); 371 URL url = new URL(urlString); 372 synchronized(this) { 373 connection = Utils.openHttpConnection(url); 374 } 375 connection.setConnectTimeout(Main.pref.getInteger("socket.timeout.connect",15)*1000); 376 try ( 377 InputStream inputStream = connection.getInputStream(); 378 Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8); 379 ) { 380 InputSource inputSource = new InputSource(reader); 381 NameFinderResultParser parser = new NameFinderResultParser(); 382 SAXParserFactory.newInstance().newSAXParser().parse(inputSource, parser); 383 this.data = parser.getResult(); 384 } 385 } catch (SAXParseException e) { 386 if (!canceled) { 387 // Nominatim sometimes returns garbage, see #5934, #10643 388 Main.warn(tr("Error occured with query ''{0}'': ''{1}''", urlString, e.getMessage())); 389 GuiHelper.runInEDTAndWait(new Runnable() { 390 @Override 391 public void run() { 392 HelpAwareOptionPane.showOptionDialog( 393 Main.parent, 394 tr("Name server returned invalid data. Please try again."), 395 tr("Bad response"), 396 JOptionPane.WARNING_MESSAGE, null 397 ); 398 } 399 }); 400 } 401 } catch (Exception e) { 402 if (!canceled) { 403 OsmTransferException ex = new OsmTransferException(e); 404 ex.setUrl(urlString); 405 lastException = ex; 406 } 407 } 408 } 409 } 410 411 static class NamedResultTableModel extends DefaultTableModel { 412 private List<SearchResult> data; 413 private ListSelectionModel selectionModel; 414 415 public NamedResultTableModel(ListSelectionModel selectionModel) { 416 data = new ArrayList<>(); 417 this.selectionModel = selectionModel; 418 } 419 @Override 420 public int getRowCount() { 421 if (data == null) return 0; 422 return data.size(); 423 } 424 425 @Override 426 public Object getValueAt(int row, int column) { 427 if (data == null) return null; 428 return data.get(row); 429 } 430 431 public void setData(List<SearchResult> data) { 432 if (data == null) { 433 this.data.clear(); 434 } else { 435 this.data = new ArrayList<>(data); 436 } 437 fireTableDataChanged(); 438 } 439 @Override 440 public boolean isCellEditable(int row, int column) { 441 return false; 442 } 443 444 public SearchResult getSelectedSearchResult() { 445 if (selectionModel.getMinSelectionIndex() < 0) 446 return null; 447 return data.get(selectionModel.getMinSelectionIndex()); 448 } 449 } 450 451 static class NamedResultTableColumnModel extends DefaultTableColumnModel { 452 TableColumn col3 = null; 453 TableColumn col4 = null; 454 protected final void createColumns() { 455 TableColumn col = null; 456 NamedResultCellRenderer renderer = new NamedResultCellRenderer(); 457 458 // column 0 - Name 459 col = new TableColumn(0); 460 col.setHeaderValue(tr("Name")); 461 col.setResizable(true); 462 col.setPreferredWidth(200); 463 col.setCellRenderer(renderer); 464 addColumn(col); 465 466 // column 1 - Version 467 col = new TableColumn(1); 468 col.setHeaderValue(tr("Type")); 469 col.setResizable(true); 470 col.setPreferredWidth(100); 471 col.setCellRenderer(renderer); 472 addColumn(col); 473 474 // column 2 - Near 475 col3 = new TableColumn(2); 476 col3.setHeaderValue(SERVERS[0].thirdcol); 477 col3.setResizable(true); 478 col3.setPreferredWidth(100); 479 col3.setCellRenderer(renderer); 480 addColumn(col3); 481 482 // column 3 - Zoom 483 col4 = new TableColumn(3); 484 col4.setHeaderValue(SERVERS[0].fourthcol); 485 col4.setResizable(true); 486 col4.setPreferredWidth(50); 487 col4.setCellRenderer(renderer); 488 addColumn(col4); 489 } 490 public void setHeadlines(String third, String fourth) { 491 col3.setHeaderValue(third); 492 col4.setHeaderValue(fourth); 493 fireColumnMarginChanged(); 494 } 495 496 public NamedResultTableColumnModel() { 497 createColumns(); 498 } 499 } 500 501 class ListSelectionHandler implements ListSelectionListener { 502 @Override 503 public void valueChanged(ListSelectionEvent lse) { 504 SearchResult r = model.getSelectedSearchResult(); 505 if (r != null) { 506 parent.boundingBoxChanged(r.getDownloadArea(), PlaceSelection.this); 507 } 508 } 509 } 510 511 static class NamedResultCellRenderer extends JLabel implements TableCellRenderer { 512 513 public NamedResultCellRenderer() { 514 setOpaque(true); 515 setBorder(BorderFactory.createEmptyBorder(2,2,2,2)); 516 } 517 518 protected void reset() { 519 setText(""); 520 setIcon(null); 521 } 522 523 protected void renderColor(boolean selected) { 524 if (selected) { 525 setForeground(UIManager.getColor("Table.selectionForeground")); 526 setBackground(UIManager.getColor("Table.selectionBackground")); 527 } else { 528 setForeground(UIManager.getColor("Table.foreground")); 529 setBackground(UIManager.getColor("Table.background")); 530 } 531 } 532 533 protected String lineWrapDescription(String description) { 534 StringBuilder ret = new StringBuilder(); 535 StringBuilder line = new StringBuilder(); 536 StringTokenizer tok = new StringTokenizer(description, " "); 537 while(tok.hasMoreElements()) { 538 String t = tok.nextToken(); 539 if (line.length() == 0) { 540 line.append(t); 541 } else if (line.length() < 80) { 542 line.append(" ").append(t); 543 } else { 544 line.append(" ").append(t).append("<br>"); 545 ret.append(line); 546 line = new StringBuilder(); 547 } 548 } 549 ret.insert(0, "<html>"); 550 ret.append("</html>"); 551 return ret.toString(); 552 } 553 554 @Override 555 public Component getTableCellRendererComponent(JTable table, Object value, 556 boolean isSelected, boolean hasFocus, int row, int column) { 557 558 reset(); 559 renderColor(isSelected); 560 561 if (value == null) return this; 562 SearchResult sr = (SearchResult) value; 563 switch(column) { 564 case 0: 565 setText(sr.name); 566 break; 567 case 1: 568 setText(sr.info); 569 break; 570 case 2: 571 setText(sr.nearestPlace); 572 break; 573 case 3: 574 if(sr.bounds != null) { 575 setText(sr.bounds.toShortString(new DecimalFormat("0.000"))); 576 } else { 577 setText(sr.zoom != 0 ? Integer.toString(sr.zoom) : tr("unknown")); 578 } 579 break; 580 } 581 setToolTipText(lineWrapDescription(sr.description)); 582 return this; 583 } 584 } 585}