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}