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.MainApplication;
050import org.openstreetmap.josm.gui.PleaseWaitRunnable;
051import org.openstreetmap.josm.gui.util.GuiHelper;
052import org.openstreetmap.josm.gui.widgets.HistoryComboBox;
053import org.openstreetmap.josm.gui.widgets.JosmComboBox;
054import org.openstreetmap.josm.io.NameFinder;
055import org.openstreetmap.josm.io.NameFinder.SearchResult;
056import org.openstreetmap.josm.io.OsmTransferException;
057import org.openstreetmap.josm.spi.preferences.Config;
058import org.openstreetmap.josm.tools.GBC;
059import org.openstreetmap.josm.tools.HttpClient;
060import org.openstreetmap.josm.tools.ImageProvider;
061import org.openstreetmap.josm.tools.Logging;
062import org.openstreetmap.josm.tools.Utils;
063import org.xml.sax.SAXException;
064import org.xml.sax.SAXParseException;
065
066/**
067 * Place selector.
068 * @since 1329
069 */
070public class PlaceSelection implements DownloadSelection {
071    private static final String HISTORY_KEY = "download.places.history";
072
073    private HistoryComboBox cbSearchExpression;
074    private NamedResultTableModel model;
075    private NamedResultTableColumnModel columnmodel;
076    private JTable tblSearchResults;
077    private DownloadDialog parent;
078    private static final Server[] SERVERS = new Server[] {
079        new Server("Nominatim", NameFinder.NOMINATIM_URL, tr("Class Type"), tr("Bounds"))
080    };
081    private final JosmComboBox<Server> server = new JosmComboBox<>(SERVERS);
082
083    private static class Server {
084        public final String name;
085        public final String url;
086        public final String thirdcol;
087        public final String fourthcol;
088
089        Server(String n, String u, String t, String f) {
090            name = n;
091            url = u;
092            thirdcol = t;
093            fourthcol = f;
094        }
095
096        @Override
097        public String toString() {
098            return name;
099        }
100    }
101
102    protected JPanel buildSearchPanel() {
103        JPanel lpanel = new JPanel(new GridLayout(2, 2));
104        JPanel panel = new JPanel(new GridBagLayout());
105
106        lpanel.add(new JLabel(tr("Choose the server for searching:")));
107        lpanel.add(server);
108        String s = Config.getPref().get("namefinder.server", SERVERS[0].name);
109        for (int i = 0; i < SERVERS.length; ++i) {
110            if (SERVERS[i].name.equals(s)) {
111                server.setSelectedIndex(i);
112            }
113        }
114        lpanel.add(new JLabel(tr("Enter a place name to search for:")));
115
116        cbSearchExpression = new HistoryComboBox();
117        cbSearchExpression.setToolTipText(tr("Enter a place name to search for"));
118        List<String> cmtHistory = new LinkedList<>(Config.getPref().getList(HISTORY_KEY, new LinkedList<String>()));
119        Collections.reverse(cmtHistory);
120        cbSearchExpression.setPossibleItems(cmtHistory);
121        lpanel.add(cbSearchExpression);
122
123        panel.add(lpanel, GBC.std().fill(GBC.HORIZONTAL).insets(5, 5, 0, 5));
124        SearchAction searchAction = new SearchAction();
125        JButton btnSearch = new JButton(searchAction);
126        cbSearchExpression.getEditorComponent().getDocument().addDocumentListener(searchAction);
127        cbSearchExpression.getEditorComponent().addActionListener(searchAction);
128
129        panel.add(btnSearch, GBC.eol().insets(5, 5, 0, 5));
130
131        return panel;
132    }
133
134    /**
135     * Adds a new tab to the download dialog in JOSM.
136     *
137     * This method is, for all intents and purposes, the constructor for this class.
138     */
139    @Override
140    public void addGui(final DownloadDialog gui) {
141        JPanel panel = new JPanel(new BorderLayout());
142        panel.add(buildSearchPanel(), BorderLayout.NORTH);
143
144        DefaultListSelectionModel selectionModel = new DefaultListSelectionModel();
145        model = new NamedResultTableModel(selectionModel);
146        columnmodel = new NamedResultTableColumnModel();
147        tblSearchResults = new JTable(model, columnmodel);
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    class SearchAction extends AbstractAction implements DocumentListener {
179
180        SearchAction() {
181            putValue(NAME, tr("Search..."));
182            new ImageProvider("dialogs", "search").getResource().attachImageIcon(this, true);
183            putValue(SHORT_DESCRIPTION, tr("Click to start searching for places"));
184            updateEnabledState();
185        }
186
187        @Override
188        public void actionPerformed(ActionEvent e) {
189            if (!isEnabled() || cbSearchExpression.getText().trim().isEmpty())
190                return;
191            cbSearchExpression.addCurrentItemToHistory();
192            Config.getPref().putList(HISTORY_KEY, cbSearchExpression.getHistory());
193            NameQueryTask task = new NameQueryTask(cbSearchExpression.getText());
194            MainApplication.worker.submit(task);
195        }
196
197        protected final void updateEnabledState() {
198            setEnabled(!cbSearchExpression.getText().trim().isEmpty());
199        }
200
201        @Override
202        public void changedUpdate(DocumentEvent e) {
203            updateEnabledState();
204        }
205
206        @Override
207        public void insertUpdate(DocumentEvent e) {
208            updateEnabledState();
209        }
210
211        @Override
212        public void removeUpdate(DocumentEvent e) {
213            updateEnabledState();
214        }
215    }
216
217    class NameQueryTask extends PleaseWaitRunnable {
218
219        private final String searchExpression;
220        private HttpClient connection;
221        private List<SearchResult> data;
222        private boolean canceled;
223        private final Server useserver;
224        private Exception lastException;
225
226        NameQueryTask(String searchExpression) {
227            super(tr("Querying name server"), false /* don't ignore exceptions */);
228            this.searchExpression = searchExpression;
229            useserver = (Server) server.getSelectedItem();
230            Config.getPref().put("namefinder.server", useserver.name);
231        }
232
233        @Override
234        protected void cancel() {
235            this.canceled = true;
236            synchronized (this) {
237                if (connection != null) {
238                    connection.disconnect();
239                }
240            }
241        }
242
243        @Override
244        protected void finish() {
245            if (canceled)
246                return;
247            if (lastException != null) {
248                ExceptionDialogUtil.explainException(lastException);
249                return;
250            }
251            columnmodel.setHeadlines(useserver.thirdcol, useserver.fourthcol);
252            model.setData(this.data);
253        }
254
255        @Override
256        protected void realRun() throws SAXException, IOException, OsmTransferException {
257            String urlString = useserver.url+Utils.encodeUrl(searchExpression);
258
259            try {
260                getProgressMonitor().indeterminateSubTask(tr("Querying name server ..."));
261                URL url = new URL(urlString);
262                synchronized (this) {
263                    connection = HttpClient.create(url);
264                    connection.connect();
265                }
266                try (Reader reader = connection.getResponse().getContentReader()) {
267                    data = NameFinder.parseSearchResults(reader);
268                }
269            } catch (SAXParseException e) {
270                if (!canceled) {
271                    // Nominatim sometimes returns garbage, see #5934, #10643
272                    Logging.log(Logging.LEVEL_WARN, tr("Error occured with query ''{0}'': ''{1}''", urlString, e.getMessage()), e);
273                    GuiHelper.runInEDTAndWait(() -> HelpAwareOptionPane.showOptionDialog(
274                            Main.parent,
275                            tr("Name server returned invalid data. Please try again."),
276                            tr("Bad response"),
277                            JOptionPane.WARNING_MESSAGE, null
278                    ));
279                }
280            } catch (IOException | ParserConfigurationException e) {
281                if (!canceled) {
282                    OsmTransferException ex = new OsmTransferException(e);
283                    ex.setUrl(urlString);
284                    lastException = ex;
285                }
286            }
287        }
288    }
289
290    static class NamedResultTableModel extends DefaultTableModel {
291        private transient List<SearchResult> data;
292        private final transient ListSelectionModel selectionModel;
293
294        NamedResultTableModel(ListSelectionModel selectionModel) {
295            data = new ArrayList<>();
296            this.selectionModel = selectionModel;
297        }
298
299        @Override
300        public int getRowCount() {
301            return data != null ? data.size() : 0;
302        }
303
304        @Override
305        public Object getValueAt(int row, int column) {
306            return data != null ? data.get(row) : null;
307        }
308
309        public void setData(List<SearchResult> data) {
310            if (data == null) {
311                this.data.clear();
312            } else {
313                this.data = new ArrayList<>(data);
314            }
315            fireTableDataChanged();
316        }
317
318        @Override
319        public boolean isCellEditable(int row, int column) {
320            return false;
321        }
322
323        public SearchResult getSelectedSearchResult() {
324            if (selectionModel.getMinSelectionIndex() < 0)
325                return null;
326            return data.get(selectionModel.getMinSelectionIndex());
327        }
328    }
329
330    static class NamedResultTableColumnModel extends DefaultTableColumnModel {
331        private TableColumn col3;
332        private TableColumn col4;
333
334        NamedResultTableColumnModel() {
335            createColumns();
336        }
337
338        protected final void createColumns() {
339            TableColumn col;
340            NamedResultCellRenderer renderer = new NamedResultCellRenderer();
341
342            // column 0 - Name
343            col = new TableColumn(0);
344            col.setHeaderValue(tr("Name"));
345            col.setResizable(true);
346            col.setPreferredWidth(200);
347            col.setCellRenderer(renderer);
348            addColumn(col);
349
350            // column 1 - Version
351            col = new TableColumn(1);
352            col.setHeaderValue(tr("Type"));
353            col.setResizable(true);
354            col.setPreferredWidth(100);
355            col.setCellRenderer(renderer);
356            addColumn(col);
357
358            // column 2 - Near
359            col3 = new TableColumn(2);
360            col3.setHeaderValue(SERVERS[0].thirdcol);
361            col3.setResizable(true);
362            col3.setPreferredWidth(100);
363            col3.setCellRenderer(renderer);
364            addColumn(col3);
365
366            // column 3 - Zoom
367            col4 = new TableColumn(3);
368            col4.setHeaderValue(SERVERS[0].fourthcol);
369            col4.setResizable(true);
370            col4.setPreferredWidth(50);
371            col4.setCellRenderer(renderer);
372            addColumn(col4);
373        }
374
375        public void setHeadlines(String third, String fourth) {
376            col3.setHeaderValue(third);
377            col4.setHeaderValue(fourth);
378            fireColumnMarginChanged();
379        }
380    }
381
382    class ListSelectionHandler implements ListSelectionListener {
383        @Override
384        public void valueChanged(ListSelectionEvent lse) {
385            SearchResult r = model.getSelectedSearchResult();
386            if (r != null) {
387                parent.boundingBoxChanged(r.getDownloadArea(), PlaceSelection.this);
388            }
389        }
390    }
391
392    static class NamedResultCellRenderer extends JLabel implements TableCellRenderer {
393
394        /**
395         * Constructs a new {@code NamedResultCellRenderer}.
396         */
397        NamedResultCellRenderer() {
398            setOpaque(true);
399            setBorder(BorderFactory.createEmptyBorder(2, 2, 2, 2));
400        }
401
402        protected void reset() {
403            setText("");
404            setIcon(null);
405        }
406
407        protected void renderColor(boolean selected) {
408            if (selected) {
409                setForeground(UIManager.getColor("Table.selectionForeground"));
410                setBackground(UIManager.getColor("Table.selectionBackground"));
411            } else {
412                setForeground(UIManager.getColor("Table.foreground"));
413                setBackground(UIManager.getColor("Table.background"));
414            }
415        }
416
417        protected String lineWrapDescription(String description) {
418            StringBuilder ret = new StringBuilder();
419            StringBuilder line = new StringBuilder();
420            StringTokenizer tok = new StringTokenizer(description, " ");
421            while (tok.hasMoreElements()) {
422                String t = tok.nextToken();
423                if (line.length() == 0) {
424                    line.append(t);
425                } else if (line.length() < 80) {
426                    line.append(' ').append(t);
427                } else {
428                    line.append(' ').append(t).append("<br>");
429                    ret.append(line);
430                    line = new StringBuilder();
431                }
432            }
433            ret.insert(0, "<html>");
434            ret.append("</html>");
435            return ret.toString();
436        }
437
438        @Override
439        public Component getTableCellRendererComponent(JTable table, Object value,
440                boolean isSelected, boolean hasFocus, int row, int column) {
441
442            reset();
443            renderColor(isSelected);
444
445            if (value == null)
446                return this;
447            SearchResult sr = (SearchResult) value;
448            switch(column) {
449            case 0:
450                setText(sr.getName());
451                break;
452            case 1:
453                setText(sr.getInfo());
454                break;
455            case 2:
456                setText(sr.getNearestPlace());
457                break;
458            case 3:
459                if (sr.getBounds() != null) {
460                    setText(sr.getBounds().toShortString(new DecimalFormat("0.000")));
461                } else {
462                    setText(sr.getZoom() != 0 ? Integer.toString(sr.getZoom()) : tr("unknown"));
463                }
464                break;
465            default: // Do nothing
466            }
467            setToolTipText(lineWrapDescription(sr.getDescription()));
468            return this;
469        }
470    }
471}