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