001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.actions;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.tr;
006
007import java.awt.Dimension;
008import java.awt.GraphicsEnvironment;
009import java.awt.GridBagLayout;
010import java.awt.event.ActionEvent;
011import java.io.IOException;
012import java.net.MalformedURLException;
013import java.nio.file.InvalidPathException;
014import java.util.Collection;
015import java.util.Collections;
016import java.util.List;
017import java.util.function.Function;
018import java.util.stream.Collectors;
019
020import javax.swing.JComboBox;
021import javax.swing.JOptionPane;
022import javax.swing.JPanel;
023import javax.swing.JScrollPane;
024
025import org.openstreetmap.josm.data.imagery.DefaultLayer;
026import org.openstreetmap.josm.data.imagery.ImageryInfo;
027import org.openstreetmap.josm.data.imagery.ImageryInfo.ImageryType;
028import org.openstreetmap.josm.data.imagery.LayerDetails;
029import org.openstreetmap.josm.data.imagery.WMTSTileSource;
030import org.openstreetmap.josm.data.imagery.WMTSTileSource.Layer;
031import org.openstreetmap.josm.data.imagery.WMTSTileSource.WMTSGetCapabilitiesException;
032import org.openstreetmap.josm.gui.ExtendedDialog;
033import org.openstreetmap.josm.gui.MainApplication;
034import org.openstreetmap.josm.gui.layer.AlignImageryPanel;
035import org.openstreetmap.josm.gui.layer.ImageryLayer;
036import org.openstreetmap.josm.gui.preferences.ToolbarPreferences;
037import org.openstreetmap.josm.gui.preferences.imagery.WMSLayerTree;
038import org.openstreetmap.josm.gui.util.GuiHelper;
039import org.openstreetmap.josm.io.imagery.WMSImagery;
040import org.openstreetmap.josm.io.imagery.WMSImagery.WMSGetCapabilitiesException;
041import org.openstreetmap.josm.tools.CheckParameterUtil;
042import org.openstreetmap.josm.tools.GBC;
043import org.openstreetmap.josm.tools.ImageProvider;
044import org.openstreetmap.josm.tools.Logging;
045import org.openstreetmap.josm.tools.bugreport.ReportedException;
046
047/**
048 * Action displayed in imagery menu to add a new imagery layer.
049 * @since 3715
050 */
051public class AddImageryLayerAction extends JosmAction implements AdaptableAction {
052    private final transient ImageryInfo info;
053
054    static class SelectWmsLayersDialog extends ExtendedDialog {
055        SelectWmsLayersDialog(WMSLayerTree tree, JComboBox<String> formats) {
056            super(MainApplication.getMainFrame(), tr("Select WMS layers"), tr("Add layers"), tr("Cancel"));
057            final JScrollPane scrollPane = new JScrollPane(tree.getLayerTree());
058            scrollPane.setPreferredSize(new Dimension(400, 400));
059            final JPanel panel = new JPanel(new GridBagLayout());
060            panel.add(scrollPane, GBC.eol().fill());
061            panel.add(formats, GBC.eol().fill(GBC.HORIZONTAL));
062            setContent(panel);
063        }
064    }
065
066    /**
067     * Constructs a new {@code AddImageryLayerAction} for the given {@code ImageryInfo}.
068     * If an http:// icon is specified, it is fetched asynchronously.
069     * @param info The imagery info
070     */
071    public AddImageryLayerAction(ImageryInfo info) {
072        super(info.getMenuName(), /* ICON */"imagery_menu", tr("Add imagery layer {0}", info.getName()), null,
073                true, ToolbarPreferences.IMAGERY_PREFIX + info.getToolbarName(), false);
074        setHelpId(ht("/Preferences/Imagery"));
075        setTooltip(info.getToolTipText().replaceAll("</?html>", ""));
076        this.info = info;
077        installAdapters();
078
079        // change toolbar icon from if specified
080        String icon = info.getIcon();
081        if (icon != null) {
082            new ImageProvider(icon).setOptional(true).getResourceAsync(result -> {
083                if (result != null) {
084                    GuiHelper.runInEDT(() -> result.attachImageIcon(this));
085                }
086            });
087        }
088    }
089
090    /**
091     * Converts general ImageryInfo to specific one, that does not need any user action to initialize
092     * see: https://josm.openstreetmap.de/ticket/13868
093     * @param info ImageryInfo that will be converted (or returned when no conversion needed)
094     * @return ImageryInfo object that's ready to be used to create TileSource
095     */
096    private static ImageryInfo convertImagery(ImageryInfo info) {
097        try {
098            switch(info.getImageryType()) {
099            case WMS_ENDPOINT:
100                // convert to WMS type
101                if (info.getDefaultLayers() == null || info.getDefaultLayers().isEmpty()) {
102                    return getWMSLayerInfo(info);
103                } else {
104                    return info;
105                }
106            case WMTS:
107                // specify which layer to use
108                if (info.getDefaultLayers() == null || info.getDefaultLayers().isEmpty()) {
109                    WMTSTileSource tileSource = new WMTSTileSource(info);
110                    DefaultLayer layerId = tileSource.userSelectLayer();
111                    if (layerId != null) {
112                        ImageryInfo copy = new ImageryInfo(info);
113                        copy.setDefaultLayers(Collections.singletonList(layerId));
114                        String layerName = tileSource.getLayers().stream()
115                                .filter(x -> x.getIdentifier().equals(layerId.getLayerName()))
116                                .map(Layer::getUserTitle)
117                                .findFirst()
118                                .orElse("");
119                        copy.setName(copy.getName() + ": " + layerName);
120                        return copy;
121                    }
122                    return null;
123                } else {
124                    return info;
125                }
126            default:
127                return info;
128            }
129        } catch (MalformedURLException ex) {
130            handleException(ex, tr("Invalid service URL."), tr("WMS Error"), null);
131        } catch (IOException ex) {
132            handleException(ex, tr("Could not retrieve WMS layer list."), tr("WMS Error"), null);
133        } catch (WMSGetCapabilitiesException ex) {
134            handleException(ex, tr("Could not parse WMS layer list."), tr("WMS Error"),
135                    "Could not parse WMS layer list. Incoming data:\n" + ex.getIncomingData());
136        } catch (WMTSGetCapabilitiesException ex) {
137            handleException(ex, tr("Could not parse WMTS layer list."), tr("WMTS Error"),
138                    "Could not parse WMTS layer list.");
139        }
140        return null;
141    }
142
143    @Override
144    public void actionPerformed(ActionEvent e) {
145        if (!isEnabled()) return;
146        ImageryLayer layer = null;
147        try {
148            final ImageryInfo infoToAdd = convertImagery(info);
149            if (infoToAdd != null) {
150                layer = ImageryLayer.create(infoToAdd);
151                getLayerManager().addLayer(layer);
152                AlignImageryPanel.addNagPanelIfNeeded(infoToAdd);
153            }
154        } catch (IllegalArgumentException | ReportedException ex) {
155            if (ex.getMessage() == null || ex.getMessage().isEmpty() || GraphicsEnvironment.isHeadless()) {
156                throw ex;
157            } else {
158                Logging.error(ex);
159                JOptionPane.showMessageDialog(MainApplication.getMainFrame(), ex.getMessage(), tr("Error"), JOptionPane.ERROR_MESSAGE);
160                if (layer != null) {
161                    getLayerManager().removeLayer(layer);
162                }
163            }
164        }
165    }
166
167    /**
168     * Represents the user choices when selecting layers to display.
169     * @since 14549
170     */
171    public static class LayerSelection {
172        private final List<LayerDetails> layers;
173        private final String format;
174        private final boolean transparent;
175
176        /**
177         * Constructs a new {@code LayerSelection}.
178         * @param layers selected layers
179         * @param format selected image format
180         * @param transparent enable transparency?
181         */
182        public LayerSelection(List<LayerDetails> layers, String format, boolean transparent) {
183            this.layers = layers;
184            this.format = format;
185            this.transparent = transparent;
186        }
187    }
188
189    private static LayerSelection askToSelectLayers(WMSImagery wms) {
190        final WMSLayerTree tree = new WMSLayerTree();
191        tree.updateTree(wms);
192
193        Collection<String> wmsFormats = wms.getFormats();
194        final JComboBox<String> formats = new JComboBox<>(wmsFormats.toArray(new String[0]));
195        formats.setSelectedItem(wms.getPreferredFormat());
196        formats.setToolTipText(tr("Select image format for WMS layer"));
197
198        if (!GraphicsEnvironment.isHeadless()) {
199            ExtendedDialog dialog = new ExtendedDialog(MainApplication.getMainFrame(),
200                    tr("Select WMS layers"), tr("Add layers"), tr("Cancel"));
201            final JScrollPane scrollPane = new JScrollPane(tree.getLayerTree());
202            scrollPane.setPreferredSize(new Dimension(400, 400));
203            final JPanel panel = new JPanel(new GridBagLayout());
204            panel.add(scrollPane, GBC.eol().fill());
205            panel.add(formats, GBC.eol().fill(GBC.HORIZONTAL));
206            dialog.setContent(panel);
207
208            if (dialog.showDialog().getValue() != 1) {
209                return null;
210            }
211        }
212        return new LayerSelection(
213                tree.getSelectedLayers(),
214                (String) formats.getSelectedItem(),
215                true); // TODO: ask the user if transparent layer is wanted
216    }
217
218    /**
219     * Asks user to choose a WMS layer from a WMS endpoint.
220     * @param info the WMS endpoint.
221     * @return chosen WMS layer, or null
222     * @throws IOException if any I/O error occurs while contacting the WMS endpoint
223     * @throws WMSGetCapabilitiesException if the WMS getCapabilities request fails
224     * @throws InvalidPathException if a Path object cannot be constructed for the capabilities cached file
225     */
226    protected static ImageryInfo getWMSLayerInfo(ImageryInfo info) throws IOException, WMSGetCapabilitiesException {
227        try {
228            return getWMSLayerInfo(info, AddImageryLayerAction::askToSelectLayers);
229        } catch (MalformedURLException ex) {
230            handleException(ex, tr("Invalid service URL."), tr("WMS Error"), null);
231        } catch (IOException ex) {
232            handleException(ex, tr("Could not retrieve WMS layer list."), tr("WMS Error"), null);
233        } catch (WMSGetCapabilitiesException ex) {
234            handleException(ex, tr("Could not parse WMS layer list."), tr("WMS Error"),
235                    "Could not parse WMS layer list. Incoming data:\n" + ex.getIncomingData());
236        }
237        return null;
238    }
239
240    /**
241     * Asks user to choose a WMS layer from a WMS endpoint.
242     * @param info the WMS endpoint.
243     * @param choice how the user may choose the WMS layer
244     * @return chosen WMS layer, or null
245     * @throws IOException if any I/O error occurs while contacting the WMS endpoint
246     * @throws WMSGetCapabilitiesException if the WMS getCapabilities request fails
247     * @throws InvalidPathException if a Path object cannot be constructed for the capabilities cached file
248     * @since 14549
249     */
250    public static ImageryInfo getWMSLayerInfo(ImageryInfo info, Function<WMSImagery, LayerSelection> choice)
251            throws IOException, WMSGetCapabilitiesException {
252        CheckParameterUtil.ensureThat(ImageryType.WMS_ENDPOINT == info.getImageryType(), "wms_endpoint imagery type expected");
253        final WMSImagery wms = new WMSImagery(info.getUrl(), info.getCustomHttpHeaders());
254        LayerSelection selection = choice.apply(wms);
255        if (selection == null) {
256            return null;
257        }
258
259        final String url = wms.buildGetMapUrl(
260                selection.layers.stream().map(LayerDetails::getName).collect(Collectors.toList()),
261                (List<String>) null,
262                selection.format,
263                selection.transparent
264                );
265
266        String selectedLayers = selection.layers.stream()
267                .map(LayerDetails::getName)
268                .collect(Collectors.joining(", "));
269        // Use full copy of original Imagery info to copy all attributes. Only overwrite what's different
270        ImageryInfo ret = new ImageryInfo(info);
271        ret.setUrl(url);
272        ret.setImageryType(ImageryType.WMS);
273        ret.setName(info.getName() + selectedLayers);
274        ret.setServerProjections(wms.getServerProjections(selection.layers));
275        return ret;
276    }
277
278    private static void handleException(Exception ex, String uiMessage, String uiTitle, String logMessage) {
279        if (!GraphicsEnvironment.isHeadless()) {
280            JOptionPane.showMessageDialog(MainApplication.getMainFrame(), uiMessage, uiTitle, JOptionPane.ERROR_MESSAGE);
281        }
282        Logging.log(Logging.LEVEL_ERROR, logMessage, ex);
283    }
284
285    @Override
286    protected void updateEnabledState() {
287        if (info.isBlacklisted()) {
288            setEnabled(false);
289        } else {
290            setEnabled(true);
291        }
292    }
293
294    @Override
295    public String toString() {
296        return "AddImageryLayerAction [info=" + info + ']';
297    }
298}