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