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}