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