001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.data.imagery;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.io.StringReader;
007import java.util.ArrayList;
008import java.util.Arrays;
009import java.util.Collection;
010import java.util.Collections;
011import java.util.EnumMap;
012import java.util.List;
013import java.util.Locale;
014import java.util.Map;
015import java.util.Objects;
016import java.util.Optional;
017import java.util.concurrent.TimeUnit;
018import java.util.regex.Matcher;
019import java.util.regex.Pattern;
020import java.util.stream.Collectors;
021
022import javax.json.Json;
023import javax.json.JsonObject;
024import javax.json.JsonReader;
025import javax.swing.ImageIcon;
026
027import org.openstreetmap.josm.data.StructUtils.StructEntry;
028import org.openstreetmap.josm.data.sources.ISourceCategory;
029import org.openstreetmap.josm.data.sources.ISourceType;
030import org.openstreetmap.josm.data.sources.SourceBounds;
031import org.openstreetmap.josm.data.sources.SourceInfo;
032import org.openstreetmap.josm.data.sources.SourcePreferenceEntry;
033import org.openstreetmap.josm.tools.CheckParameterUtil;
034import org.openstreetmap.josm.tools.ImageProvider;
035import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
036import org.openstreetmap.josm.tools.Logging;
037import org.openstreetmap.josm.tools.MultiMap;
038import org.openstreetmap.josm.tools.PlatformManager;
039import org.openstreetmap.josm.tools.StreamUtils;
040import org.openstreetmap.josm.tools.Utils;
041
042/**
043 * Class that stores info about an image background layer.
044 *
045 * @author Frederik Ramm
046 */
047public class ImageryInfo extends
048        SourceInfo<ImageryInfo.ImageryCategory, ImageryInfo.ImageryType, ImageryInfo.ImageryBounds, ImageryInfo.ImageryPreferenceEntry> {
049
050    /**
051     * Type of imagery entry.
052     */
053    public enum ImageryType implements ISourceType<ImageryType> {
054        /** A WMS (Web Map Service) entry. **/
055        WMS("wms"),
056        /** A TMS (Tile Map Service) entry. **/
057        TMS("tms"),
058        /** TMS entry for Microsoft Bing. */
059        BING("bing"),
060        /** TMS entry for Russian company <a href="https://wiki.openstreetmap.org/wiki/WikiProject_Russia/kosmosnimki">ScanEx</a>. **/
061        SCANEX("scanex"),
062        /** A WMS endpoint entry only stores the WMS server info, without layer, which are chosen later by the user. **/
063        WMS_ENDPOINT("wms_endpoint"),
064        /** WMTS stores GetCapabilities URL. Does not store any information about the layer **/
065        WMTS("wmts"),
066        /** Mapbox Vector Tiles entry*/
067        MVT("mvt");
068
069        private final String typeString;
070
071        ImageryType(String typeString) {
072            this.typeString = typeString;
073        }
074
075        /**
076         * Returns the unique string identifying this type.
077         * @return the unique string identifying this type
078         * @since 6690
079         */
080        @Override
081        public final String getTypeString() {
082            return typeString;
083        }
084
085        /**
086         * Returns the imagery type from the given type string.
087         * @param s The type string
088         * @return the imagery type matching the given type string
089         */
090        public static ImageryType fromString(String s) {
091            return Arrays.stream(ImageryType.values())
092                    .filter(type -> type.getTypeString().equals(s))
093                    .findFirst().orElse(null);
094        }
095
096        @Override
097        public ImageryType getFromString(String s) {
098            return fromString(s);
099        }
100
101        @Override
102        public ImageryType getDefault() {
103            return WMS;
104        }
105    }
106
107    /**
108     * Category of imagery entry.
109     * @since 13792
110     */
111    public enum ImageryCategory implements ISourceCategory<ImageryCategory> {
112        /** A aerial or satellite photo. **/
113        PHOTO(/* ICON(data/imagery/) */ "photo", tr("Aerial or satellite photo")),
114        /** A map of digital terrain model, digital surface model or contour lines. **/
115        ELEVATION(/* ICON(data/imagery/) */ "elevation", tr("Elevation map")),
116        /** A map. **/
117        MAP(/* ICON(data/imagery/) */ "map", tr("Map")),
118        /** A historic or otherwise outdated map. */
119        HISTORICMAP(/* ICON(data/imagery/) */ "historicmap", tr("Historic or otherwise outdated map")),
120        /** A map based on OSM data. **/
121        OSMBASEDMAP(/* ICON(data/imagery/) */ "osmbasedmap", tr("Map based on OSM data")),
122        /** A historic or otherwise outdated aerial or satellite photo. **/
123        HISTORICPHOTO(/* ICON(data/imagery/) */ "historicphoto", tr("Historic or otherwise outdated aerial or satellite photo")),
124        /** A map for quality assurance **/
125        QUALITY_ASSURANCE(/* ICON(data/imagery/) */ "qa", tr("Map for quality assurance")),
126        /** Any other type of imagery **/
127        OTHER(/* ICON(data/imagery/) */ "other", tr("Imagery not matching any other category"));
128
129        private final String category;
130        private final String description;
131        private static final Map<ImageSizes, Map<ImageryCategory, ImageIcon>> iconCache =
132                Collections.synchronizedMap(new EnumMap<>(ImageSizes.class));
133
134        ImageryCategory(String category, String description) {
135            this.category = category;
136            this.description = description;
137        }
138
139        /**
140         * Returns the unique string identifying this category.
141         * @return the unique string identifying this category
142         */
143        @Override
144        public final String getCategoryString() {
145            return category;
146        }
147
148        /**
149         * Returns the description of this category.
150         * @return the description of this category
151         */
152        @Override
153        public final String getDescription() {
154            return description;
155        }
156
157        /**
158         * Returns the category icon at the given size.
159         * @param size icon wanted size
160         * @return the category icon at the given size
161         * @since 15049
162         */
163        @Override
164        public final ImageIcon getIcon(ImageSizes size) {
165            return iconCache
166                    .computeIfAbsent(size, x -> Collections.synchronizedMap(new EnumMap<>(ImageryCategory.class)))
167                    .computeIfAbsent(this, x -> ImageProvider.get("data/imagery", x.category, size));
168        }
169
170        /**
171         * Returns the imagery category from the given category string.
172         * @param s The category string
173         * @return the imagery category matching the given category string
174         */
175        public static ImageryCategory fromString(String s) {
176            return Arrays.stream(ImageryCategory.values())
177                    .filter(category -> category.getCategoryString().equals(s))
178                    .findFirst().orElse(null);
179        }
180
181        @Override
182        public ImageryCategory getDefault() {
183            return OTHER;
184        }
185
186        @Override
187        public ImageryCategory getFromString(String s) {
188            return fromString(s);
189        }
190    }
191
192    /**
193     * Multi-polygon bounds for imagery backgrounds.
194     * Used to display imagery coverage in preferences and to determine relevant imagery entries based on edit location.
195     */
196    public static class ImageryBounds extends SourceBounds {
197
198        /**
199         * Constructs a new {@code ImageryBounds} from string.
200         * @param asString The string containing the list of shapes defining this bounds
201         * @param separator The shape separator in the given string, usually a comma
202         */
203        public ImageryBounds(String asString, String separator) {
204            super(asString, separator);
205        }
206    }
207
208    private double pixelPerDegree;
209    /** maximum zoom level for TMS imagery */
210    private int defaultMaxZoom;
211    /** minimum zoom level for TMS imagery */
212    private int defaultMinZoom;
213    /** projections supported by WMS servers */
214    private List<String> serverProjections = Collections.emptyList();
215    /**
216      * marked as best in other editors
217      * @since 11575
218      */
219    private boolean bestMarked;
220    /**
221      * marked as overlay
222      * @since 13536
223      */
224    private boolean overlay;
225
226    /** mirrors of different type for this entry */
227    protected List<ImageryInfo> mirrors;
228    /**
229     * Auxiliary class to save an {@link ImageryInfo} object in the preferences.
230     */
231    /** is the geo reference correct - don't offer offset handling */
232    private boolean isGeoreferenceValid;
233    /** Should this map be transparent **/
234    private boolean transparent = true;
235    private int minimumTileExpire = (int) TimeUnit.MILLISECONDS.toSeconds(TMSCachedTileLoaderJob.MINIMUM_EXPIRES.get());
236
237    /**
238     * The ImageryPreferenceEntry class for storing data in JOSM preferences.
239     *
240     * @author Frederik Ramm, modified by Taylor Smock
241     */
242    public static class ImageryPreferenceEntry extends SourcePreferenceEntry<ImageryInfo> {
243        @StructEntry String d;
244        @StructEntry double pixel_per_eastnorth;
245        @StructEntry int max_zoom;
246        @StructEntry int min_zoom;
247        @StructEntry String projections;
248        @StructEntry MultiMap<String, String> noTileHeaders;
249        @StructEntry MultiMap<String, String> noTileChecksums;
250        @StructEntry int tileSize = -1;
251        @StructEntry Map<String, String> metadataHeaders;
252        @StructEntry boolean valid_georeference;
253        @StructEntry boolean bestMarked;
254        @StructEntry boolean modTileFeatures;
255        @StructEntry boolean overlay;
256        @StructEntry boolean transparent;
257        @StructEntry int minimumTileExpire;
258
259        /**
260         * Constructs a new empty WMS {@code ImageryPreferenceEntry}.
261         */
262        public ImageryPreferenceEntry() {
263            super();
264        }
265
266        /**
267         * Constructs a new {@code ImageryPreferenceEntry} from a given {@code ImageryInfo}.
268         * @param i The corresponding imagery info
269         */
270        public ImageryPreferenceEntry(ImageryInfo i) {
271            super(i);
272            pixel_per_eastnorth = i.pixelPerDegree;
273            bestMarked = i.bestMarked;
274            overlay = i.overlay;
275            max_zoom = i.defaultMaxZoom;
276            min_zoom = i.defaultMinZoom;
277            if (!i.serverProjections.isEmpty()) {
278                projections = String.join(",", i.serverProjections);
279            }
280            if (!Utils.isEmpty(i.noTileHeaders)) {
281                noTileHeaders = new MultiMap<>(i.noTileHeaders);
282            }
283
284            if (!Utils.isEmpty(i.noTileChecksums)) {
285                noTileChecksums = new MultiMap<>(i.noTileChecksums);
286            }
287
288            if (!Utils.isEmpty(i.metadataHeaders)) {
289                metadataHeaders = i.metadataHeaders;
290            }
291
292            tileSize = i.getTileSize();
293
294            valid_georeference = i.isGeoreferenceValid();
295            modTileFeatures = i.isModTileFeatures();
296            transparent = i.isTransparent();
297            minimumTileExpire = i.minimumTileExpire;
298        }
299
300        @Override
301        public String toString() {
302            StringBuilder s = new StringBuilder("ImageryPreferenceEntry [name=").append(name);
303            if (id != null) {
304                s.append(" id=").append(id);
305            }
306            s.append(']');
307            return s.toString();
308        }
309    }
310
311    /**
312     * Constructs a new WMS {@code ImageryInfo}.
313     */
314    public ImageryInfo() {
315        super();
316    }
317
318    /**
319     * Constructs a new WMS {@code ImageryInfo} with a given name.
320     * @param name The entry name
321     */
322    public ImageryInfo(String name) {
323        super(name);
324    }
325
326    /**
327     * Constructs a new WMS {@code ImageryInfo} with given name and extended URL.
328     * @param name The entry name
329     * @param url The entry extended URL
330     */
331    public ImageryInfo(String name, String url) {
332        this(name);
333        setExtendedUrl(url);
334    }
335
336    /**
337     * Constructs a new WMS {@code ImageryInfo} with given name, extended and EULA URLs.
338     * @param name The entry name
339     * @param url The entry URL
340     * @param eulaAcceptanceRequired The EULA URL
341     */
342    public ImageryInfo(String name, String url, String eulaAcceptanceRequired) {
343        this(name);
344        setExtendedUrl(url);
345        this.eulaAcceptanceRequired = eulaAcceptanceRequired;
346    }
347
348    /**
349     * Constructs a new {@code ImageryInfo} with given name, url, extended and EULA URLs.
350     * @param name The entry name
351     * @param url The entry URL
352     * @param type The entry imagery type. If null, WMS will be used as default
353     * @param eulaAcceptanceRequired The EULA URL
354     * @param cookies The data part of HTTP cookies header in case the service requires cookies to work
355     * @throws IllegalArgumentException if type refers to an unknown imagery type
356     */
357    public ImageryInfo(String name, String url, String type, String eulaAcceptanceRequired, String cookies) {
358        this(name);
359        setExtendedUrl(url);
360        ImageryType t = ImageryType.fromString(type);
361        this.cookies = cookies;
362        this.eulaAcceptanceRequired = eulaAcceptanceRequired;
363        if (t != null) {
364            this.sourceType = t;
365        } else if (!Utils.isEmpty(type)) {
366            throw new IllegalArgumentException("unknown type: "+type);
367        }
368    }
369
370    /**
371     * Constructs a new {@code ImageryInfo} with given name, url, id, extended and EULA URLs.
372     * @param name The entry name
373     * @param url The entry URL
374     * @param type The entry imagery type. If null, WMS will be used as default
375     * @param eulaAcceptanceRequired The EULA URL
376     * @param cookies The data part of HTTP cookies header in case the service requires cookies to work
377     * @param id tile id
378     * @throws IllegalArgumentException if type refers to an unknown imagery type
379     */
380    public ImageryInfo(String name, String url, String type, String eulaAcceptanceRequired, String cookies, String id) {
381        this(name, url, type, eulaAcceptanceRequired, cookies);
382        setId(id);
383    }
384
385    /**
386     * Constructs a new {@code ImageryInfo} from an imagery preference entry.
387     * @param e The imagery preference entry
388     */
389    public ImageryInfo(ImageryPreferenceEntry e) {
390        super(e.name, e.url, e.id);
391        CheckParameterUtil.ensureParameterNotNull(e.name, "name");
392        CheckParameterUtil.ensureParameterNotNull(e.url, "url");
393        description = e.description;
394        cookies = e.cookies;
395        eulaAcceptanceRequired = e.eula;
396        sourceType = ImageryType.fromString(e.type);
397        if (sourceType == null) throw new IllegalArgumentException("unknown type");
398        pixelPerDegree = e.pixel_per_eastnorth;
399        defaultMaxZoom = e.max_zoom;
400        defaultMinZoom = e.min_zoom;
401        if (e.bounds != null) {
402            bounds = new ImageryBounds(e.bounds, ",");
403            if (e.shapes != null) {
404                try {
405                    for (String s : e.shapes.split(";", -1)) {
406                        bounds.addShape(new Shape(s, ","));
407                    }
408                } catch (IllegalArgumentException ex) {
409                    Logging.warn(ex);
410                }
411            }
412        }
413        if (!Utils.isEmpty(e.projections)) {
414            // split generates null element on empty string which gives one element Array[null]
415            setServerProjections(Arrays.asList(e.projections.split(",", -1)));
416        }
417        attributionText = Utils.intern(e.attribution_text);
418        attributionLinkURL = e.attribution_url;
419        permissionReferenceURL = e.permission_reference_url;
420        attributionImage = e.logo_image;
421        attributionImageURL = e.logo_url;
422        date = e.date;
423        bestMarked = e.bestMarked;
424        overlay = e.overlay;
425        termsOfUseText = e.terms_of_use_text;
426        termsOfUseURL = e.terms_of_use_url;
427        countryCode = Utils.intern(e.country_code);
428        icon = Utils.intern(e.icon);
429        if (e.noTileHeaders != null) {
430            noTileHeaders = e.noTileHeaders.toMap();
431        }
432        if (e.noTileChecksums != null) {
433            noTileChecksums = e.noTileChecksums.toMap();
434        }
435        setTileSize(e.tileSize);
436        metadataHeaders = e.metadataHeaders;
437        isGeoreferenceValid = e.valid_georeference;
438        modTileFeatures = e.modTileFeatures;
439        if (e.default_layers != null) {
440            try (JsonReader jsonReader = Json.createReader(new StringReader(e.default_layers))) {
441                defaultLayers = jsonReader.
442                        readArray().
443                        stream().
444                        map(x -> DefaultLayer.fromJson((JsonObject) x, sourceType)).
445                        collect(Collectors.toList());
446            }
447        }
448        setCustomHttpHeaders(e.customHttpHeaders);
449        transparent = e.transparent;
450        minimumTileExpire = e.minimumTileExpire;
451        category = ImageryCategory.fromString(e.category);
452    }
453
454    /**
455     * Constructs a new {@code ImageryInfo} from an existing one.
456     * @param i The other imagery info
457     */
458    public ImageryInfo(ImageryInfo i) {
459        super(i.name, i.url, i.id);
460        this.noTileHeaders = i.noTileHeaders;
461        this.noTileChecksums = i.noTileChecksums;
462        this.minZoom = i.minZoom;
463        this.maxZoom = i.maxZoom;
464        this.cookies = i.cookies;
465        this.tileSize = i.tileSize;
466        this.metadataHeaders = i.metadataHeaders;
467        this.modTileFeatures = i.modTileFeatures;
468
469        this.origName = i.origName;
470        this.langName = i.langName;
471        this.defaultEntry = i.defaultEntry;
472        this.eulaAcceptanceRequired = null;
473        this.sourceType = i.sourceType;
474        this.pixelPerDegree = i.pixelPerDegree;
475        this.defaultMaxZoom = i.defaultMaxZoom;
476        this.defaultMinZoom = i.defaultMinZoom;
477        this.bounds = i.bounds;
478        this.serverProjections = i.serverProjections;
479        this.description = i.description;
480        this.langDescription = i.langDescription;
481        this.attributionText = i.attributionText;
482        this.privacyPolicyURL = i.privacyPolicyURL;
483        this.permissionReferenceURL = i.permissionReferenceURL;
484        this.attributionLinkURL = i.attributionLinkURL;
485        this.attributionImage = i.attributionImage;
486        this.attributionImageURL = i.attributionImageURL;
487        this.termsOfUseText = i.termsOfUseText;
488        this.termsOfUseURL = i.termsOfUseURL;
489        this.countryCode = i.countryCode;
490        this.date = i.date;
491        this.bestMarked = i.bestMarked;
492        this.overlay = i.overlay;
493        // do not copy field {@code mirrors}
494        this.icon = Utils.intern(i.icon);
495        this.isGeoreferenceValid = i.isGeoreferenceValid;
496        setDefaultLayers(i.defaultLayers);
497        setCustomHttpHeaders(i.customHttpHeaders);
498        this.transparent = i.transparent;
499        this.minimumTileExpire = i.minimumTileExpire;
500        this.categoryOriginalString = Utils.intern(i.categoryOriginalString);
501        this.category = i.category;
502    }
503
504    /**
505     * Adds a mirror entry. Mirror entries are completed with the data from the master entry
506     * and only describe another method to access identical data.
507     *
508     * @param entry the mirror to be added
509     * @since 9658
510     */
511    public void addMirror(ImageryInfo entry) {
512        if (mirrors == null) {
513            mirrors = new ArrayList<>();
514        }
515        mirrors.add(entry);
516    }
517
518    /**
519     * Returns the mirror entries. Entries are completed with master entry data.
520     *
521     * @return the list of mirrors
522     * @since 9658
523     */
524    public List<ImageryInfo> getMirrors() {
525        List<ImageryInfo> l = new ArrayList<>();
526        if (mirrors != null) {
527            int num = 1;
528            for (ImageryInfo i : mirrors) {
529                ImageryInfo n = new ImageryInfo(this);
530                if (i.defaultMaxZoom != 0) {
531                    n.defaultMaxZoom = i.defaultMaxZoom;
532                }
533                if (i.defaultMinZoom != 0) {
534                    n.defaultMinZoom = i.defaultMinZoom;
535                }
536                n.setServerProjections(i.getServerProjections());
537                n.url = i.url;
538                n.sourceType = i.sourceType;
539                if (i.getTileSize() != 0) {
540                    n.setTileSize(i.getTileSize());
541                }
542                if (i.getPrivacyPolicyURL() != null) {
543                    n.setPrivacyPolicyURL(i.getPrivacyPolicyURL());
544                }
545                if (n.id != null) {
546                    n.id = n.id + "_mirror" + num;
547                }
548                if (num > 1) {
549                    n.name = tr("{0} mirror server {1}", n.name, num);
550                    if (n.origName != null) {
551                        n.origName += " mirror server " + num;
552                    }
553                } else {
554                    n.name = tr("{0} mirror server", n.name);
555                    if (n.origName != null) {
556                        n.origName += " mirror server";
557                    }
558                }
559                l.add(n);
560                ++num;
561            }
562        }
563        return l;
564    }
565
566    /**
567     * Check if this object equals another ImageryInfo with respect to the properties
568     * that get written to the preference file.
569     *
570     * The field {@link #pixelPerDegree} is ignored.
571     *
572     * @param other the ImageryInfo object to compare to
573     * @return true if they are equal
574     */
575    @Override
576    public boolean equalsPref(SourceInfo<ImageryInfo.ImageryCategory, ImageryInfo.ImageryType,
577            ImageryInfo.ImageryBounds, ImageryInfo.ImageryPreferenceEntry> other) {
578        if (!(other instanceof ImageryInfo)) {
579            return false;
580        }
581        ImageryInfo realOther = (ImageryInfo) other;
582
583        // CHECKSTYLE.OFF: BooleanExpressionComplexity
584        return super.equalsPref(realOther) &&
585                this.bestMarked == realOther.bestMarked &&
586                this.overlay == realOther.overlay &&
587                this.isGeoreferenceValid == realOther.isGeoreferenceValid &&
588                this.defaultMaxZoom == realOther.defaultMaxZoom &&
589                this.defaultMinZoom == realOther.defaultMinZoom &&
590                Objects.equals(this.serverProjections, realOther.serverProjections) &&
591                this.transparent == realOther.transparent &&
592                this.minimumTileExpire == realOther.minimumTileExpire;
593        // CHECKSTYLE.ON: BooleanExpressionComplexity
594    }
595
596    @Override
597    public int compareTo(SourceInfo<ImageryInfo.ImageryCategory, ImageryInfo.ImageryType,
598            ImageryInfo.ImageryBounds, ImageryInfo.ImageryPreferenceEntry> other) {
599        int i = super.compareTo(other);
600        if (other instanceof ImageryInfo) {
601            ImageryInfo in = (ImageryInfo) other;
602            if (i == 0) {
603                i = Double.compare(pixelPerDegree, in.pixelPerDegree);
604            }
605        }
606        return i;
607    }
608
609    /**
610     * Sets the pixel per degree value.
611     * @param ppd The ppd value
612     * @see #getPixelPerDegree()
613     */
614    public void setPixelPerDegree(double ppd) {
615        this.pixelPerDegree = ppd;
616    }
617
618    /**
619     * Sets the maximum zoom level.
620     * @param defaultMaxZoom The maximum zoom level
621     */
622    public void setDefaultMaxZoom(int defaultMaxZoom) {
623        this.defaultMaxZoom = defaultMaxZoom;
624    }
625
626    /**
627     * Sets the minimum zoom level.
628     * @param defaultMinZoom The minimum zoom level
629     */
630    public void setDefaultMinZoom(int defaultMinZoom) {
631        this.defaultMinZoom = defaultMinZoom;
632    }
633
634    @Override
635    public void setBounds(ImageryBounds b) {
636        // for binary compatibility
637        this.bounds = b;
638    }
639
640    @Override
641    public ImageryBounds getBounds() {
642        // for binary compatibility
643        return super.getBounds();
644    }
645
646    /**
647     * Sets the extended URL of this entry.
648     * @param url Entry extended URL containing in addition of service URL, its type and min/max zoom info
649     */
650    public void setExtendedUrl(String url) {
651        CheckParameterUtil.ensureParameterNotNull(url);
652
653        // Default imagery type is WMS
654        this.url = url;
655        this.sourceType = ImageryType.WMS;
656
657        defaultMaxZoom = 0;
658        defaultMinZoom = 0;
659        for (ImageryType type : ImageryType.values()) {
660            Matcher m = Pattern.compile(type.getTypeString()+"(?:\\[(?:(\\d+)[,-])?(\\d+)])?:(.*)").matcher(url);
661            if (m.matches()) {
662                this.url = m.group(3);
663                this.sourceType = type;
664                if (m.group(2) != null) {
665                    defaultMaxZoom = Integer.parseInt(m.group(2));
666                }
667                if (m.group(1) != null) {
668                    defaultMinZoom = Integer.parseInt(m.group(1));
669                }
670                break;
671            }
672        }
673
674        if (serverProjections.isEmpty()) {
675            Matcher m = Pattern.compile(".*\\{PROJ\\(([^)}]+)\\)}.*").matcher(url.toUpperCase(Locale.ENGLISH));
676            if (m.matches()) {
677                setServerProjections(Arrays.asList(m.group(1).split(",", -1)));
678            }
679        }
680    }
681
682    /**
683     * Gets the pixel per degree value
684     * @return The ppd value.
685     */
686    public double getPixelPerDegree() {
687        return this.pixelPerDegree;
688    }
689
690    /**
691     * Returns the maximum zoom level.
692     * @return The maximum zoom level
693     */
694    @Override
695    public int getMaxZoom() {
696        return this.defaultMaxZoom;
697    }
698
699    /**
700     * Returns the minimum zoom level.
701     * @return The minimum zoom level
702     */
703    @Override
704    public int getMinZoom() {
705        return this.defaultMinZoom;
706    }
707
708    /**
709     * Returns a tool tip text for display.
710     * @return The text
711     * @since 8065
712     */
713    @Override
714    public String getToolTipText() {
715        boolean htmlSupported = PlatformManager.getPlatform().isHtmlSupportedInMenuTooltips();
716        StringBuilder res = new StringBuilder(getName());
717        boolean html = false;
718        String dateStr = getDate();
719        if (!Utils.isEmpty(dateStr)) {
720            html = addNewLineInTooltip(res, tr("Date of imagery: {0}", dateStr), htmlSupported);
721        }
722        if (category != null && category.getDescription() != null) {
723            html = addNewLineInTooltip(res, tr("Imagery category: {0}", category.getDescription()), htmlSupported);
724        }
725        if (bestMarked) {
726            html = addNewLineInTooltip(res, tr("This imagery is marked as best in this region in other editors."), htmlSupported);
727        }
728        if (overlay) {
729            html = addNewLineInTooltip(res, tr("This imagery is an overlay."), htmlSupported);
730        }
731        String desc = getDescription();
732        if (!Utils.isEmpty(desc)) {
733            html = addNewLineInTooltip(res, desc, htmlSupported);
734        }
735        if (html) {
736            res.insert(0, "<html>").append("</html>");
737        }
738        return res.toString();
739    }
740
741    private static boolean addNewLineInTooltip(StringBuilder res, String line, boolean htmlSupported) {
742        if (htmlSupported) {
743            res.append("<br>").append(Utils.escapeReservedCharactersHTML(line));
744        } else {
745            res.append('\n').append(line);
746        }
747        return htmlSupported;
748    }
749
750    /**
751     * Get the projections supported by the server. Only relevant for
752     * WMS-type ImageryInfo at the moment.
753     * @return null, if no projections have been specified; the list
754     * of supported projections otherwise.
755     */
756    public List<String> getServerProjections() {
757        return Collections.unmodifiableList(serverProjections);
758    }
759
760    /**
761     * Sets the list of collections the server supports
762     * @param serverProjections The list of supported projections
763     */
764    public void setServerProjections(Collection<String> serverProjections) {
765        CheckParameterUtil.ensureParameterNotNull(serverProjections, "serverProjections");
766        this.serverProjections = serverProjections.stream()
767                .map(String::intern)
768                .collect(StreamUtils.toUnmodifiableList());
769    }
770
771    /**
772     * Returns the extended URL, containing in addition of service URL, its type and min/max zoom info.
773     * @return The extended URL
774     */
775    public String getExtendedUrl() {
776        return sourceType.getTypeString() + (defaultMaxZoom != 0
777            ? ('['+(defaultMinZoom != 0 ? (Integer.toString(defaultMinZoom) + ',') : "")+defaultMaxZoom+']') : "") + ':' + url;
778    }
779
780    /**
781     * Gets a unique toolbar key to store this layer as toolbar item
782     * @return The key.
783     */
784    public String getToolbarName() {
785        String res = name;
786        if (pixelPerDegree != 0) {
787            res += "#PPD="+pixelPerDegree;
788        }
789        return res;
790    }
791
792    /**
793     * Gets the name that should be displayed in the menu to add this imagery layer.
794     * @return The text.
795     */
796    public String getMenuName() {
797        String res = name;
798        if (pixelPerDegree != 0) {
799            res += " ("+pixelPerDegree+')';
800        }
801        return res;
802    }
803
804    /**
805     * Returns the imagery type.
806     * @return The imagery type
807     * @see SourceInfo#getSourceType
808     */
809    public ImageryType getImageryType() {
810        return super.getSourceType() != null ? super.getSourceType() : ImageryType.WMS.getDefault();
811    }
812
813    /**
814     * Sets the imagery type.
815     * @param imageryType The imagery type
816     * @see SourceInfo#setSourceType
817     */
818    public void setImageryType(ImageryType imageryType) {
819        super.setSourceType(imageryType);
820    }
821
822    /**
823     * Returns the imagery category.
824     * @return The imagery category
825     * @see SourceInfo#getSourceCategory
826     * @since 13792
827     */
828    public ImageryCategory getImageryCategory() {
829        return super.getSourceCategory();
830    }
831
832    /**
833     * Sets the imagery category.
834     * @param category The imagery category
835     * @see SourceInfo#setSourceCategory
836     * @since 13792
837     */
838    public void setImageryCategory(ImageryCategory category) {
839        super.setSourceCategory(category);
840    }
841
842    /**
843     * Returns the imagery category original string (don't use except for error checks).
844     * @return The imagery category original string
845     * @see SourceInfo#getSourceCategoryOriginalString
846     * @since 13792
847     */
848    public String getImageryCategoryOriginalString() {
849        return super.getSourceCategoryOriginalString();
850    }
851
852    /**
853     * Sets the imagery category original string (don't use except for error checks).
854     * @param categoryOriginalString The imagery category original string
855     * @see SourceInfo#setSourceCategoryOriginalString
856     * @since 13792
857     */
858    public void setImageryCategoryOriginalString(String categoryOriginalString) {
859        super.setSourceCategoryOriginalString(categoryOriginalString);
860    }
861
862    /**
863     * Gets the flag if the georeference is valid.
864     * @return <code>true</code> if it is valid.
865     */
866    public boolean isGeoreferenceValid() {
867        return isGeoreferenceValid;
868    }
869
870    /**
871     * Sets an indicator that the georeference is valid
872     * @param isGeoreferenceValid <code>true</code> if it is marked as valid.
873     */
874    public void setGeoreferenceValid(boolean isGeoreferenceValid) {
875        this.isGeoreferenceValid = isGeoreferenceValid;
876    }
877
878    /**
879     * Returns the status of "best" marked status in other editors.
880     * @return <code>true</code> if it is marked as best.
881     * @since 11575
882     */
883    public boolean isBestMarked() {
884        return bestMarked;
885    }
886
887    /**
888     * Returns the overlay indication.
889     * @return <code>true</code> if it is an overlay.
890     * @since 13536
891     */
892    public boolean isOverlay() {
893        return overlay;
894    }
895
896    /**
897     * Sets an indicator that in other editors it is marked as best imagery
898     * @param bestMarked <code>true</code> if it is marked as best in other editors.
899     * @since 11575
900     */
901    public void setBestMarked(boolean bestMarked) {
902        this.bestMarked = bestMarked;
903    }
904
905    /**
906     * Sets overlay indication
907     * @param overlay <code>true</code> if it is an overlay.
908     * @since 13536
909     */
910    public void setOverlay(boolean overlay) {
911        this.overlay = overlay;
912    }
913
914    /**
915     * Determines if this imagery should be transparent.
916     * @return should this imagery be transparent
917     */
918    public boolean isTransparent() {
919        return transparent;
920    }
921
922    /**
923     * Sets whether imagery should be transparent.
924     * @param transparent set to true if imagery should be transparent
925     */
926    public void setTransparent(boolean transparent) {
927        this.transparent = transparent;
928    }
929
930    /**
931     * Returns minimum tile expiration in seconds.
932     * @return minimum tile expiration in seconds
933     */
934    public int getMinimumTileExpire() {
935        return minimumTileExpire;
936    }
937
938    /**
939     * Sets minimum tile expiration in seconds.
940     * @param minimumTileExpire minimum tile expiration in seconds
941     */
942    public void setMinimumTileExpire(int minimumTileExpire) {
943        this.minimumTileExpire = minimumTileExpire;
944    }
945
946    /**
947     * Get a string representation of this imagery info suitable for the {@code source} changeset tag.
948     * @return English name, if known
949     * @since 13890
950     */
951    public String getSourceName() {
952        if (ImageryType.BING == getImageryType()) {
953            return "Bing";
954        } else {
955            if (id != null) {
956                // Retrieve english name, unfortunately not saved in preferences
957                Optional<ImageryInfo> infoEn = ImageryLayerInfo.allDefaultLayers.stream().filter(x -> id.equals(x.getId())).findAny();
958                if (infoEn.isPresent()) {
959                    return infoEn.get().getOriginalName();
960                }
961            }
962            return getOriginalName();
963        }
964    }
965
966    /**
967     * Return the sorted list of activated source IDs.
968     * @return sorted list of activated source IDs
969     * @since 13536
970     */
971    public static Collection<String> getActiveIds() {
972        return getActiveIds(ImageryInfo.class);
973    }
974}