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.JTextField;
034import javax.swing.ListSelectionModel;
035import javax.swing.UIManager;
036import javax.swing.event.DocumentEvent;
037import javax.swing.event.DocumentListener;
038import javax.swing.event.ListSelectionEvent;
039import javax.swing.event.ListSelectionListener;
040import javax.swing.table.DefaultTableColumnModel;
041import javax.swing.table.DefaultTableModel;
042import javax.swing.table.TableCellRenderer;
043import javax.swing.table.TableColumn;
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
065public class PlaceSelection implements DownloadSelection {
066    private static final String HISTORY_KEY = "download.places.history";
067
068    private HistoryComboBox cbSearchExpression;
069    private NamedResultTableModel model;
070    private NamedResultTableColumnModel columnmodel;
071    private JTable tblSearchResults;
072    private DownloadDialog parent;
073    private static final Server[] SERVERS = new Server[] {
074        new Server("Nominatim", "https://nominatim.openstreetmap.org/search?format=xml&q=", tr("Class Type"), tr("Bounds"))
075    };
076    private final JosmComboBox<Server> server = new JosmComboBox<>(SERVERS);
077
078    private static class Server {
079        public final String name;
080        public final String url;
081        public final String thirdcol;
082        public final String fourthcol;
083
084        Server(String n, String u, String t, String f) {
085            name = n;
086            url = u;
087            thirdcol = t;
088            fourthcol = f;
089        }
090
091        @Override
092        public String toString() {
093            return name;
094        }
095    }
096
097    protected JPanel buildSearchPanel() {
098        JPanel lpanel = new JPanel();
099        lpanel.setLayout(new GridLayout(2, 2));
100        JPanel panel = new JPanel();
101        panel.setLayout(new GridBagLayout());
102
103        lpanel.add(new JLabel(tr("Choose the server for searching:")));
104        lpanel.add(server);
105        String s = Main.pref.get("namefinder.server", SERVERS[0].name);
106        for (int i = 0; i < SERVERS.length; ++i) {
107            if (SERVERS[i].name.equals(s)) {
108                server.setSelectedIndex(i);
109            }
110        }
111        lpanel.add(new JLabel(tr("Enter a place name to search for:")));
112
113        cbSearchExpression = new HistoryComboBox();
114        cbSearchExpression.setToolTipText(tr("Enter a place name to search for"));
115        List<String> cmtHistory = new LinkedList<>(Main.pref.getCollection(HISTORY_KEY, new LinkedList<String>()));
116        Collections.reverse(cmtHistory);
117        cbSearchExpression.setPossibleItems(cmtHistory);
118        lpanel.add(cbSearchExpression);
119
120        panel.add(lpanel, GBC.std().fill(GBC.HORIZONTAL).insets(5, 5, 0, 5));
121        SearchAction searchAction = new SearchAction();
122        JButton btnSearch = new JButton(searchAction);
123        ((JTextField) cbSearchExpression.getEditor().getEditorComponent()).getDocument().addDocumentListener(searchAction);
124        ((JTextField) cbSearchExpression.getEditor().getEditorComponent()).addActionListener(searchAction);
125
126        panel.add(btnSearch, GBC.eol().insets(5, 5, 0, 5));
127
128        return panel;
129    }
130
131    /**
132     * Adds a new tab to the download dialog in JOSM.
133     *
134     * This method is, for all intents and purposes, the constructor for this class.
135     */
136    @Override
137    public void addGui(final DownloadDialog gui) {
138        JPanel panel = new JPanel();
139        panel.setLayout(new BorderLayout());
140        panel.add(buildSearchPanel(), BorderLayout.NORTH);
141
142        DefaultListSelectionModel selectionModel = new DefaultListSelectionModel();
143        model = new NamedResultTableModel(selectionModel);
144        columnmodel = new NamedResultTableColumnModel();
145        tblSearchResults = new JTable(model, columnmodel);
146        tblSearchResults.setSelectionModel(selectionModel);
147        JScrollPane scrollPane = new JScrollPane(tblSearchResults);
148        scrollPane.setPreferredSize(new Dimension(200, 200));
149        panel.add(scrollPane, BorderLayout.CENTER);
150
151        gui.addDownloadAreaSelector(panel, tr("Areas around places"));
152
153        scrollPane.setPreferredSize(scrollPane.getPreferredSize());
154        tblSearchResults.getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
155        tblSearchResults.getSelectionModel().addListSelectionListener(new ListSelectionHandler());
156        tblSearchResults.addMouseListener(new MouseAdapter() {
157            @Override public void mouseClicked(MouseEvent e) {
158                if (e.getClickCount() > 1) {
159                    SearchResult sr = model.getSelectedSearchResult();
160                    if (sr == null) return;
161                    parent.startDownload(sr.getDownloadArea());
162                }
163            }
164        });
165        parent = gui;
166    }
167
168    @Override
169    public void setDownloadArea(Bounds area) {
170        tblSearchResults.clearSelection();
171    }
172
173    /**
174     * Data storage for search results.
175     */
176    private static class SearchResult {
177        public String name;
178        public String info;
179        public String nearestPlace;
180        public String description;
181        public double lat;
182        public double lon;
183        public int zoom;
184        public Bounds bounds;
185
186        public Bounds getDownloadArea() {
187            return bounds != null ? bounds : OsmUrlToBounds.positionToBounds(lat, lon, zoom);
188        }
189    }
190
191    /**
192     * A very primitive parser for the name finder's output.
193     * Structure of xml described here:  http://wiki.openstreetmap.org/index.php/Name_finder
194     *
195     */
196    private static class NameFinderResultParser extends DefaultHandler {
197        private SearchResult currentResult;
198        private StringBuilder description;
199        private int depth;
200        private final List<SearchResult> data = new LinkedList<>();
201
202        /**
203         * Detect starting elements.
204         *
205         */
206        @Override
207        public void startElement(String namespaceURI, String localName, String qName, Attributes atts)
208        throws SAXException {
209            depth++;
210            try {
211                if ("searchresults".equals(qName)) {
212                    // do nothing
213                } else if ("named".equals(qName) && (depth == 2)) {
214                    currentResult = new PlaceSelection.SearchResult();
215                    currentResult.name = atts.getValue("name");
216                    currentResult.info = atts.getValue("info");
217                    if (currentResult.info != null) {
218                        currentResult.info = tr(currentResult.info);
219                    }
220                    currentResult.lat = Double.parseDouble(atts.getValue("lat"));
221                    currentResult.lon = Double.parseDouble(atts.getValue("lon"));
222                    currentResult.zoom = Integer.parseInt(atts.getValue("zoom"));
223                    data.add(currentResult);
224                } else if ("description".equals(qName) && (depth == 3)) {
225                    description = new StringBuilder();
226                } else if ("named".equals(qName) && (depth == 4)) {
227                    // this is a "named" place in the nearest places list.
228                    String info = atts.getValue("info");
229                    if ("city".equals(info) || "town".equals(info) || "village".equals(info)) {
230                        currentResult.nearestPlace = atts.getValue("name");
231                    }
232                } else if ("place".equals(qName) && atts.getValue("lat") != null) {
233                    currentResult = new PlaceSelection.SearchResult();
234                    currentResult.name = atts.getValue("display_name");
235                    currentResult.description = currentResult.name;
236                    currentResult.info = atts.getValue("class");
237                    if (currentResult.info != null) {
238                        currentResult.info = tr(currentResult.info);
239                    }
240                    currentResult.nearestPlace = tr(atts.getValue("type"));
241                    currentResult.lat = Double.parseDouble(atts.getValue("lat"));
242                    currentResult.lon = Double.parseDouble(atts.getValue("lon"));
243                    String[] bbox = atts.getValue("boundingbox").split(",");
244                    currentResult.bounds = new Bounds(
245                            Double.parseDouble(bbox[0]), Double.parseDouble(bbox[2]),
246                            Double.parseDouble(bbox[1]), Double.parseDouble(bbox[3]));
247                    data.add(currentResult);
248                }
249            } catch (NumberFormatException x) {
250                Main.error(x); // SAXException does not chain correctly
251                throw new SAXException(x.getMessage(), x);
252            } catch (NullPointerException x) {
253                Main.error(x); // SAXException does not chain correctly
254                throw new SAXException(tr("Null pointer exception, possibly some missing tags."), x);
255            }
256        }
257
258        /**
259         * Detect ending elements.
260         */
261        @Override
262        public void endElement(String namespaceURI, String localName, String qName) throws SAXException {
263            if ("description".equals(qName) && description != null) {
264                currentResult.description = description.toString();
265                description = null;
266            }
267            depth--;
268        }
269
270        /**
271         * Read characters for description.
272         */
273        @Override
274        public void characters(char[] data, int start, int length) throws org.xml.sax.SAXException {
275            if (description != null) {
276                description.append(data, start, length);
277            }
278        }
279
280        public List<SearchResult> getResult() {
281            return data;
282        }
283    }
284
285    class SearchAction extends AbstractAction implements DocumentListener {
286
287        SearchAction() {
288            putValue(NAME, tr("Search ..."));
289            putValue(SMALL_ICON, ImageProvider.get("dialogs", "search"));
290            putValue(SHORT_DESCRIPTION, tr("Click to start searching for places"));
291            updateEnabledState();
292        }
293
294        @Override
295        public void actionPerformed(ActionEvent e) {
296            if (!isEnabled() || cbSearchExpression.getText().trim().isEmpty())
297                return;
298            cbSearchExpression.addCurrentItemToHistory();
299            Main.pref.putCollection(HISTORY_KEY, cbSearchExpression.getHistory());
300            NameQueryTask task = new NameQueryTask(cbSearchExpression.getText());
301            Main.worker.submit(task);
302        }
303
304        protected final void updateEnabledState() {
305            setEnabled(!cbSearchExpression.getText().trim().isEmpty());
306        }
307
308        @Override
309        public void changedUpdate(DocumentEvent e) {
310            updateEnabledState();
311        }
312
313        @Override
314        public void insertUpdate(DocumentEvent e) {
315            updateEnabledState();
316        }
317
318        @Override
319        public void removeUpdate(DocumentEvent e) {
320            updateEnabledState();
321        }
322    }
323
324    class NameQueryTask extends PleaseWaitRunnable {
325
326        private final String searchExpression;
327        private HttpClient connection;
328        private List<SearchResult> data;
329        private boolean canceled;
330        private final Server useserver;
331        private Exception lastException;
332
333        NameQueryTask(String searchExpression) {
334            super(tr("Querying name server"), false /* don't ignore exceptions */);
335            this.searchExpression = searchExpression;
336            useserver = (Server) server.getSelectedItem();
337            Main.pref.put("namefinder.server", useserver.name);
338        }
339
340        @Override
341        protected void cancel() {
342            this.canceled = true;
343            synchronized (this) {
344                if (connection != null) {
345                    connection.disconnect();
346                }
347            }
348        }
349
350        @Override
351        protected void finish() {
352            if (canceled)
353                return;
354            if (lastException != null) {
355                ExceptionDialogUtil.explainException(lastException);
356                return;
357            }
358            columnmodel.setHeadlines(useserver.thirdcol, useserver.fourthcol);
359            model.setData(this.data);
360        }
361
362        @Override
363        protected void realRun() throws SAXException, IOException, OsmTransferException {
364            String urlString = useserver.url+Utils.encodeUrl(searchExpression);
365
366            try {
367                getProgressMonitor().indeterminateSubTask(tr("Querying name server ..."));
368                URL url = new URL(urlString);
369                synchronized (this) {
370                    connection = HttpClient.create(url);
371                    connection.connect();
372                }
373                try (Reader reader = connection.getResponse().getContentReader()) {
374                    InputSource inputSource = new InputSource(reader);
375                    NameFinderResultParser parser = new NameFinderResultParser();
376                    Utils.parseSafeSAX(inputSource, parser);
377                    this.data = parser.getResult();
378                }
379            } catch (SAXParseException e) {
380                if (!canceled) {
381                    // Nominatim sometimes returns garbage, see #5934, #10643
382                    Main.warn(tr("Error occured with query ''{0}'': ''{1}''", urlString, e.getMessage()));
383                    GuiHelper.runInEDTAndWait(new Runnable() {
384                        @Override
385                        public void run() {
386                            HelpAwareOptionPane.showOptionDialog(
387                                    Main.parent,
388                                    tr("Name server returned invalid data. Please try again."),
389                                    tr("Bad response"),
390                                    JOptionPane.WARNING_MESSAGE, null
391                            );
392                        }
393                    });
394                }
395            } catch (Exception e) {
396                if (!canceled) {
397                    OsmTransferException ex = new OsmTransferException(e);
398                    ex.setUrl(urlString);
399                    lastException = ex;
400                }
401            }
402        }
403    }
404
405    static class NamedResultTableModel extends DefaultTableModel {
406        private transient List<SearchResult> data;
407        private final transient ListSelectionModel selectionModel;
408
409        NamedResultTableModel(ListSelectionModel selectionModel) {
410            data = new ArrayList<>();
411            this.selectionModel = selectionModel;
412        }
413
414        @Override
415        public int getRowCount() {
416            if (data == null) return 0;
417            return data.size();
418        }
419
420        @Override
421        public Object getValueAt(int row, int column) {
422            if (data == null) return null;
423            return data.get(row);
424        }
425
426        public void setData(List<SearchResult> data) {
427            if (data == null) {
428                this.data.clear();
429            } else {
430                this.data = new ArrayList<>(data);
431            }
432            fireTableDataChanged();
433        }
434
435        @Override
436        public boolean isCellEditable(int row, int column) {
437            return false;
438        }
439
440        public SearchResult getSelectedSearchResult() {
441            if (selectionModel.getMinSelectionIndex() < 0)
442                return null;
443            return data.get(selectionModel.getMinSelectionIndex());
444        }
445    }
446
447    static class NamedResultTableColumnModel extends DefaultTableColumnModel {
448        private TableColumn col3;
449        private TableColumn col4;
450        protected final void createColumns() {
451            TableColumn col = null;
452            NamedResultCellRenderer renderer = new NamedResultCellRenderer();
453
454            // column 0 - Name
455            col = new TableColumn(0);
456            col.setHeaderValue(tr("Name"));
457            col.setResizable(true);
458            col.setPreferredWidth(200);
459            col.setCellRenderer(renderer);
460            addColumn(col);
461
462            // column 1 - Version
463            col = new TableColumn(1);
464            col.setHeaderValue(tr("Type"));
465            col.setResizable(true);
466            col.setPreferredWidth(100);
467            col.setCellRenderer(renderer);
468            addColumn(col);
469
470            // column 2 - Near
471            col3 = new TableColumn(2);
472            col3.setHeaderValue(SERVERS[0].thirdcol);
473            col3.setResizable(true);
474            col3.setPreferredWidth(100);
475            col3.setCellRenderer(renderer);
476            addColumn(col3);
477
478            // column 3 - Zoom
479            col4 = new TableColumn(3);
480            col4.setHeaderValue(SERVERS[0].fourthcol);
481            col4.setResizable(true);
482            col4.setPreferredWidth(50);
483            col4.setCellRenderer(renderer);
484            addColumn(col4);
485        }
486
487        public void setHeadlines(String third, String fourth) {
488            col3.setHeaderValue(third);
489            col4.setHeaderValue(fourth);
490            fireColumnMarginChanged();
491        }
492
493        NamedResultTableColumnModel() {
494            createColumns();
495        }
496    }
497
498    class ListSelectionHandler implements ListSelectionListener {
499        @Override
500        public void valueChanged(ListSelectionEvent lse) {
501            SearchResult r = model.getSelectedSearchResult();
502            if (r != null) {
503                parent.boundingBoxChanged(r.getDownloadArea(), PlaceSelection.this);
504            }
505        }
506    }
507
508    static class NamedResultCellRenderer extends JLabel implements TableCellRenderer {
509
510        /**
511         * Constructs a new {@code NamedResultCellRenderer}.
512         */
513        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}