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