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