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