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.event.ActionEvent; 011import java.awt.event.MouseAdapter; 012import java.awt.event.MouseEvent; 013import java.io.IOException; 014import java.io.Reader; 015import java.net.URL; 016import java.text.DecimalFormat; 017import java.util.ArrayList; 018import java.util.Collection; 019import java.util.Collections; 020import java.util.List; 021import java.util.Objects; 022import java.util.StringTokenizer; 023import java.util.function.BiFunction; 024import java.util.function.Consumer; 025 026import javax.swing.AbstractAction; 027import javax.swing.BorderFactory; 028import javax.swing.DefaultListSelectionModel; 029import javax.swing.JButton; 030import javax.swing.JLabel; 031import javax.swing.JOptionPane; 032import javax.swing.JPanel; 033import javax.swing.JScrollPane; 034import javax.swing.JTable; 035import javax.swing.ListSelectionModel; 036import javax.swing.UIManager; 037import javax.swing.event.DocumentEvent; 038import javax.swing.event.DocumentListener; 039import javax.swing.event.ListSelectionEvent; 040import javax.swing.event.ListSelectionListener; 041import javax.swing.table.DefaultTableColumnModel; 042import javax.swing.table.DefaultTableModel; 043import javax.swing.table.TableCellRenderer; 044import javax.swing.table.TableColumn; 045import javax.xml.parsers.ParserConfigurationException; 046 047import org.openstreetmap.josm.data.Bounds; 048import org.openstreetmap.josm.gui.ExceptionDialogUtil; 049import org.openstreetmap.josm.gui.HelpAwareOptionPane; 050import org.openstreetmap.josm.gui.MainApplication; 051import org.openstreetmap.josm.gui.PleaseWaitRunnable; 052import org.openstreetmap.josm.gui.util.GuiHelper; 053import org.openstreetmap.josm.gui.util.TableHelper; 054import org.openstreetmap.josm.gui.widgets.HistoryComboBox; 055import org.openstreetmap.josm.gui.widgets.JosmComboBox; 056import org.openstreetmap.josm.io.NameFinder; 057import org.openstreetmap.josm.io.NameFinder.SearchResult; 058import org.openstreetmap.josm.io.OsmTransferException; 059import org.openstreetmap.josm.spi.preferences.Config; 060import org.openstreetmap.josm.tools.GBC; 061import org.openstreetmap.josm.tools.HttpClient; 062import org.openstreetmap.josm.tools.ImageProvider; 063import org.openstreetmap.josm.tools.Logging; 064import org.xml.sax.SAXException; 065import org.xml.sax.SAXParseException; 066 067/** 068 * Place selector. 069 * @since 1329 070 */ 071public class PlaceSelection implements DownloadSelection { 072 private static final String HISTORY_KEY = "download.places.history"; 073 074 private HistoryComboBox cbSearchExpression; 075 private NamedResultTableModel model; 076 private NamedResultTableColumnModel columnmodel; 077 private JTable tblSearchResults; 078 private DownloadDialog parent; 079 private static final Server[] SERVERS = { 080 new Server("Nominatim", NameFinder::buildNominatimURL, tr("Class Type"), tr("Bounds")) 081 }; 082 private final JosmComboBox<Server> serverComboBox = new JosmComboBox<>(SERVERS); 083 084 private static class Server { 085 public final String name; 086 public final BiFunction<String, Collection<SearchResult>, URL> urlFunction; 087 public final String thirdcol; 088 public final String fourthcol; 089 090 Server(String n, BiFunction<String, Collection<SearchResult>, URL> u, String t, String f) { 091 name = n; 092 urlFunction = u; 093 thirdcol = t; 094 fourthcol = f; 095 } 096 097 @Override 098 public String toString() { 099 return name; 100 } 101 } 102 103 protected JPanel buildSearchPanel() { 104 JPanel lpanel = new JPanel(new GridBagLayout()); 105 JPanel panel = new JPanel(new GridBagLayout()); 106 107 lpanel.add(new JLabel(tr("Choose the server for searching:")), GBC.std(0, 0).weight(0, 0).insets(0, 0, 5, 0)); 108 lpanel.add(serverComboBox, GBC.std(1, 0).fill(GBC.HORIZONTAL)); 109 String s = Config.getPref().get("namefinder.server", SERVERS[0].name); 110 for (int i = 0; i < SERVERS.length; ++i) { 111 if (SERVERS[i].name.equals(s)) { 112 serverComboBox.setSelectedIndex(i); 113 } 114 } 115 lpanel.add(new JLabel(tr("Enter a place name to search for:")), GBC.std(0, 1).weight(0, 0).insets(0, 0, 5, 0)); 116 117 cbSearchExpression = new HistoryComboBox(); 118 cbSearchExpression.setToolTipText(tr("Enter a place name to search for")); 119 cbSearchExpression.getModel().prefs().load(HISTORY_KEY); 120 lpanel.add(cbSearchExpression, GBC.std(1, 1).fill(GBC.HORIZONTAL)); 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 TableHelper.setFont(tblSearchResults, DownloadDialog.class); 148 tblSearchResults.setSelectionModel(selectionModel); 149 JScrollPane scrollPane = new JScrollPane(tblSearchResults); 150 scrollPane.setPreferredSize(new Dimension(200, 200)); 151 panel.add(scrollPane, BorderLayout.CENTER); 152 153 if (gui != null) 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 161 public void mouseClicked(MouseEvent e) { 162 if (e.getClickCount() > 1) { 163 SearchResult sr = model.getSelectedSearchResult(); 164 if (sr != null) { 165 parent.startDownload(sr.getDownloadArea()); 166 } 167 } 168 } 169 }); 170 parent = gui; 171 } 172 173 @Override 174 public void setDownloadArea(Bounds area) { 175 tblSearchResults.clearSelection(); 176 } 177 178 /** 179 * Action to perform initial search, and (if query is unchanged) load more results. 180 */ 181 class SearchAction extends AbstractAction implements DocumentListener { 182 183 String lastSearchExpression; 184 boolean isSearchMore; 185 186 SearchAction() { 187 new ImageProvider("dialogs", "search").getResource().attachImageIcon(this, true); 188 updateState(); 189 } 190 191 @Override 192 public void actionPerformed(ActionEvent e) { 193 String searchExpression = cbSearchExpression.getText(); 194 if (!isEnabled() || searchExpression.trim().isEmpty()) 195 return; 196 cbSearchExpression.addCurrentItemToHistory(); 197 cbSearchExpression.getModel().prefs().save(HISTORY_KEY); 198 Server server = (Server) serverComboBox.getSelectedItem(); 199 URL url = server.urlFunction.apply(searchExpression, isSearchMore ? model.getData() : Collections.emptyList()); 200 NameQueryTask task = new NameQueryTask(url, data -> { 201 if (isSearchMore) { 202 model.addData(data); 203 } else { 204 model.setData(data); 205 } 206 Config.getPref().put("namefinder.server", server.name); 207 columnmodel.setHeadlines(server.thirdcol, server.fourthcol); 208 lastSearchExpression = searchExpression; 209 updateState(); 210 }); 211 MainApplication.worker.submit(task); 212 } 213 214 protected final void updateState() { 215 String searchExpression = cbSearchExpression.getText(); 216 setEnabled(!searchExpression.trim().isEmpty()); 217 isSearchMore = Objects.equals(lastSearchExpression, searchExpression) && !model.getData().isEmpty(); 218 if (isSearchMore) { 219 putValue(NAME, tr("Search more...")); 220 putValue(SHORT_DESCRIPTION, tr("Click to search for more places")); 221 } else { 222 putValue(NAME, tr("Search...")); 223 putValue(SHORT_DESCRIPTION, tr("Click to start searching for places")); 224 } 225 } 226 227 @Override 228 public void changedUpdate(DocumentEvent e) { 229 updateState(); 230 } 231 232 @Override 233 public void insertUpdate(DocumentEvent e) { 234 updateState(); 235 } 236 237 @Override 238 public void removeUpdate(DocumentEvent e) { 239 updateState(); 240 } 241 } 242 243 static class NameQueryTask extends PleaseWaitRunnable { 244 245 private final URL url; 246 private final Consumer<List<SearchResult>> dataConsumer; 247 private HttpClient connection; 248 private List<SearchResult> data; 249 private boolean canceled; 250 private Exception lastException; 251 252 NameQueryTask(URL url, Consumer<List<SearchResult>> dataConsumer) { 253 super(tr("Querying name server"), false /* don't ignore exceptions */); 254 this.url = url; 255 this.dataConsumer = dataConsumer; 256 } 257 258 @Override 259 protected void cancel() { 260 this.canceled = true; 261 synchronized (this) { 262 if (connection != null) { 263 connection.disconnect(); 264 } 265 } 266 } 267 268 @Override 269 protected void finish() { 270 if (canceled) 271 return; 272 if (lastException != null) { 273 ExceptionDialogUtil.explainException(lastException); 274 return; 275 } 276 dataConsumer.accept(data); 277 } 278 279 @Override 280 protected void realRun() throws SAXException, IOException, OsmTransferException { 281 try { 282 getProgressMonitor().indeterminateSubTask(tr("Querying name server ...")); 283 synchronized (this) { 284 connection = HttpClient.create(url); 285 connection.connect(); 286 } 287 try (Reader reader = connection.getResponse().getContentReader()) { 288 data = NameFinder.parseSearchResults(reader); 289 } 290 } catch (SAXParseException e) { 291 if (!canceled) { 292 // Nominatim sometimes returns garbage, see #5934, #10643 293 Logging.log(Logging.LEVEL_WARN, tr("Error occurred with query ''{0}'': ''{1}''", url, e.getMessage()), e); 294 GuiHelper.runInEDTAndWait(() -> HelpAwareOptionPane.showOptionDialog( 295 MainApplication.getMainFrame(), 296 tr("Name server returned invalid data. Please try again."), 297 tr("Bad response"), 298 JOptionPane.WARNING_MESSAGE, null 299 )); 300 } 301 } catch (IOException | ParserConfigurationException e) { 302 if (!canceled) { 303 OsmTransferException ex = new OsmTransferException(e); 304 ex.setUrl(url.toString()); 305 lastException = ex; 306 } 307 } 308 } 309 } 310 311 static class NamedResultTableModel extends DefaultTableModel { 312 private transient List<SearchResult> data; 313 private final transient ListSelectionModel selectionModel; 314 315 NamedResultTableModel(ListSelectionModel selectionModel) { 316 data = new ArrayList<>(); 317 this.selectionModel = selectionModel; 318 } 319 320 @Override 321 public int getRowCount() { 322 return data != null ? data.size() : 0; 323 } 324 325 @Override 326 public Object getValueAt(int row, int column) { 327 return data != null ? data.get(row) : null; 328 } 329 330 public void setData(List<SearchResult> data) { 331 if (data == null) { 332 this.data.clear(); 333 } else { 334 this.data = new ArrayList<>(data); 335 } 336 fireTableDataChanged(); 337 } 338 339 public void addData(List<SearchResult> data) { 340 this.data.addAll(data); 341 fireTableDataChanged(); 342 } 343 344 public List<SearchResult> getData() { 345 return Collections.unmodifiableList(data); 346 } 347 348 @Override 349 public boolean isCellEditable(int row, int column) { 350 return false; 351 } 352 353 public SearchResult getSelectedSearchResult() { 354 if (selectionModel.getMinSelectionIndex() < 0) 355 return null; 356 return data.get(selectionModel.getMinSelectionIndex()); 357 } 358 } 359 360 static class NamedResultTableColumnModel extends DefaultTableColumnModel { 361 private TableColumn col3; 362 private TableColumn col4; 363 364 NamedResultTableColumnModel() { 365 createColumns(); 366 } 367 368 protected final void createColumns() { 369 TableColumn col; 370 NamedResultCellRenderer renderer = new NamedResultCellRenderer(); 371 372 // column 0 - Name 373 col = new TableColumn(0); 374 col.setHeaderValue(tr("Name")); 375 col.setResizable(true); 376 col.setPreferredWidth(200); 377 col.setCellRenderer(renderer); 378 addColumn(col); 379 380 // column 1 - Version 381 col = new TableColumn(1); 382 col.setHeaderValue(tr("Type")); 383 col.setResizable(true); 384 col.setPreferredWidth(100); 385 col.setCellRenderer(renderer); 386 addColumn(col); 387 388 // column 2 - Near 389 col3 = new TableColumn(2); 390 col3.setHeaderValue(SERVERS[0].thirdcol); 391 col3.setResizable(true); 392 col3.setPreferredWidth(100); 393 col3.setCellRenderer(renderer); 394 addColumn(col3); 395 396 // column 3 - Zoom 397 col4 = new TableColumn(3); 398 col4.setHeaderValue(SERVERS[0].fourthcol); 399 col4.setResizable(true); 400 col4.setPreferredWidth(50); 401 col4.setCellRenderer(renderer); 402 addColumn(col4); 403 } 404 405 public void setHeadlines(String third, String fourth) { 406 col3.setHeaderValue(third); 407 col4.setHeaderValue(fourth); 408 fireColumnMarginChanged(); 409 } 410 } 411 412 class ListSelectionHandler implements ListSelectionListener { 413 @Override 414 public void valueChanged(ListSelectionEvent lse) { 415 SearchResult r = model.getSelectedSearchResult(); 416 if (r != null) { 417 parent.boundingBoxChanged(r.getDownloadArea(), PlaceSelection.this); 418 } 419 } 420 } 421 422 static class NamedResultCellRenderer extends JLabel implements TableCellRenderer { 423 424 /** 425 * Constructs a new {@code NamedResultCellRenderer}. 426 */ 427 NamedResultCellRenderer() { 428 setOpaque(true); 429 setBorder(BorderFactory.createEmptyBorder(2, 2, 2, 2)); 430 } 431 432 protected void reset() { 433 setText(""); 434 setIcon(null); 435 } 436 437 protected void renderColor(boolean selected) { 438 if (selected) { 439 setForeground(UIManager.getColor("Table.selectionForeground")); 440 setBackground(UIManager.getColor("Table.selectionBackground")); 441 } else { 442 setForeground(UIManager.getColor("Table.foreground")); 443 setBackground(UIManager.getColor("Table.background")); 444 } 445 } 446 447 protected String lineWrapDescription(String description) { 448 StringBuilder ret = new StringBuilder(); 449 StringBuilder line = new StringBuilder(); 450 StringTokenizer tok = new StringTokenizer(description, " "); 451 while (tok.hasMoreElements()) { 452 String t = tok.nextToken(); 453 if (line.length() == 0) { 454 line.append(t); 455 } else if (line.length() < 80) { 456 line.append(' ').append(t); 457 } else { 458 line.append(' ').append(t).append("<br>"); 459 ret.append(line); 460 line = new StringBuilder(); 461 } 462 } 463 ret.insert(0, "<html>"); 464 ret.append("</html>"); 465 return ret.toString(); 466 } 467 468 @Override 469 public Component getTableCellRendererComponent(JTable table, Object value, 470 boolean isSelected, boolean hasFocus, int row, int column) { 471 472 reset(); 473 renderColor(isSelected); 474 475 if (value == null) 476 return this; 477 SearchResult sr = (SearchResult) value; 478 switch(column) { 479 case 0: 480 setText(sr.getName()); 481 break; 482 case 1: 483 setText(sr.getInfo()); 484 break; 485 case 2: 486 setText(sr.getNearestPlace()); 487 break; 488 case 3: 489 if (sr.getBounds() != null) { 490 setText(sr.getBounds().toShortString(new DecimalFormat("0.000"))); 491 } else { 492 setText(sr.getZoom() != 0 ? Integer.toString(sr.getZoom()) : tr("unknown")); 493 } 494 break; 495 default: // Do nothing 496 } 497 setToolTipText(lineWrapDescription(sr.getDescription())); 498 return this; 499 } 500 } 501}