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