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