001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.tools;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.I18n.trn;
006
007import java.io.BufferedReader;
008import java.io.IOException;
009import java.net.MalformedURLException;
010import java.net.URL;
011import java.nio.charset.StandardCharsets;
012import java.util.regex.Matcher;
013import java.util.regex.Pattern;
014import java.util.stream.Collectors;
015import java.util.stream.Stream;
016
017import javax.json.Json;
018import javax.json.JsonArray;
019import javax.json.JsonReader;
020import javax.json.JsonValue;
021
022import org.openstreetmap.josm.data.osm.OsmUtils;
023import org.openstreetmap.josm.io.CachedFile;
024
025/**
026 * Extracts web links from OSM tags.
027 * <p></p>
028 * The following rules are used:
029 * <ul>
030 * <li>internal rules for basic tags</li>
031 * <li>rules from Wikidata based on OSM tag or key (P1282); formatter URL (P1630); third-party formatter URL (P3303)</li>
032 * <li>rules from OSM Sophox based on permanent key ID (P16); formatter URL (P8)</li>
033 * </ul>
034 *
035 * @since 15673
036 */
037public final class Tag2Link {
038
039    // Related implementations:
040    // - https://github.com/openstreetmap/openstreetmap-website/blob/master/app/helpers/browse_tags_helper.rb
041
042    /**
043     * Maps OSM keys to formatter URLs from Wikidata and OSM Sophox where {@code "$1"} has to be replaced by a value.
044     */
045    static final MultiMap<String, String> wikidataRules = new MultiMap<>();
046
047    static final String languagePattern = LanguageInfo.getLanguageCodes(null).stream()
048            .map(Pattern::quote)
049            .collect(Collectors.joining("|"));
050
051    private Tag2Link() {
052        // private constructor for utility class
053    }
054
055    /**
056     * Represents an operation that accepts a link.
057     */
058    @FunctionalInterface
059    public interface LinkConsumer {
060        /**
061         * Performs the operation on the given arguments.
062         * @param name the name/label of the link
063         * @param url the URL of the link
064         */
065        void acceptLink(String name, String url);
066    }
067
068    /**
069     * Initializes the tag2link rules
070     */
071    public static void initialize() {
072        try {
073            wikidataRules.clear();
074            fetchRulesViaSPARQL("resource://data/tag2link.wikidata.sparql", "https://query.wikidata.org/sparql");
075            fetchRulesViaSPARQL("resource://data/tag2link.sophox.sparql", "https://sophox.org/sparql");
076        } catch (Exception e) {
077            Logging.error("Failed to initialize tag2link rules");
078            Logging.error(e);
079        }
080    }
081
082    /**
083     * Fetches rules from Wikidata using a SPARQL query.
084     *
085     * @param query the SPARQL query
086     * @param server the query server
087     * @throws IOException in case of I/O error
088     */
089    private static void fetchRulesViaSPARQL(final String query, final String server) throws IOException {
090        final int initialSize = wikidataRules.size();
091        final String sparql;
092        try (CachedFile cachedFile = new CachedFile(query)) {
093            sparql = new String(cachedFile.getByteContent(), StandardCharsets.UTF_8);
094        }
095
096        final JsonArray rules;
097        try (CachedFile cachedFile = new CachedFile(server + "?query=" + Utils.encodeUrl(sparql));
098             BufferedReader reader = cachedFile.setHttpAccept("application/json").getContentReader();
099             JsonReader jsonReader = Json.createReader(reader)) {
100            rules = jsonReader.read().asJsonObject().getJsonObject("results").getJsonArray("bindings");
101        }
102
103        for (JsonValue rule : rules) {
104            final String key = rule.asJsonObject().getJsonObject("OSM_key").getString("value");
105            final String url = rule.asJsonObject().getJsonObject("formatter_URL").getString("value");
106            if (key.startsWith("Key:")) {
107                wikidataRules.put(key.substring("Key:".length()), url);
108            }
109        }
110        // We handle those keys ourselves
111        Stream.of("image", "url", "website", "wikidata", "wikimedia_commons")
112                .forEach(wikidataRules::remove);
113
114        final int size = wikidataRules.size() - initialSize;
115        Logging.info(trn(
116                "Obtained {0} Tag2Link rule from {1}",
117                "Obtained {0} Tag2Link rules from {1}",
118                size, size, server));
119    }
120
121    /**
122     * Generates the links for the tag given by {@code key} and {@code value}, and sends 0, 1 or more links to the {@code linkConsumer}.
123     * @param key the tag key
124     * @param value the tag value
125     * @param linkConsumer the receiver of the generated links
126     */
127    public static void getLinksForTag(String key, String value, LinkConsumer linkConsumer) {
128
129        if (value == null || value.isEmpty()) {
130            return;
131        }
132
133        // Search
134        if (key.matches("^(.+[:_])?name([:_]" + languagePattern + ")?$")) {
135            linkConsumer.acceptLink(tr("Search on DuckDuckGo"), "https://duckduckgo.com/?q=" + value);
136        }
137
138        // Common
139        final String validURL = value.startsWith("http:") || value.startsWith("https:")
140                ? value
141                : value.startsWith("www.")
142                ? "http://" + value
143                : null;
144        if (key.matches("^(.+[:_])?website([:_].+)?$") && validURL != null) {
145            linkConsumer.acceptLink(getLinkName(validURL, key), validURL);
146        }
147        if (key.matches("^(.+[:_])?source([:_].+)?$") && validURL != null) {
148            linkConsumer.acceptLink(getLinkName(validURL, key), validURL);
149        }
150        if (key.matches("^(.+[:_])?url([:_].+)?$") && validURL != null) {
151            linkConsumer.acceptLink(getLinkName(validURL, key), validURL);
152        }
153        if (key.matches("image") && validURL != null) {
154            linkConsumer.acceptLink(tr("View image"), validURL);
155        }
156
157        // Wikimedia
158        final Matcher keyMatcher = Pattern.compile("wikipedia(:(?<lang>\\p{Lower}{2,}))?").matcher(key);
159        final Matcher valueMatcher = Pattern.compile("((?<lang>\\p{Lower}{2,}):)?(?<article>.*)").matcher(value);
160        if (keyMatcher.matches() && valueMatcher.matches()) {
161            final String lang = Utils.firstNotEmptyString("en", keyMatcher.group("lang"), valueMatcher.group("lang"));
162            linkConsumer.acceptLink(tr("View Wikipedia article"), "https://" + lang + ".wikipedia.org/wiki/" + valueMatcher.group("article"));
163        }
164        if (key.matches("(.*:)?wikidata")) {
165            OsmUtils.splitMultipleValues(value)
166                    .forEach(q -> linkConsumer.acceptLink(tr("View Wikidata item"), "https://www.wikidata.org/wiki/" + q));
167        }
168        if (key.matches("(.*:)?species")) {
169            final String url = "https://species.wikimedia.org/wiki/" + value;
170            linkConsumer.acceptLink(getLinkName(url, key), url);
171        }
172        if (key.matches("wikimedia_commons|image") && value.matches("(?i:File):.*")) {
173            linkConsumer.acceptLink(tr("View image on Wikimedia Commons"), "https://commons.wikimedia.org/wiki/" + value);
174        }
175        if (key.matches("wikimedia_commons|image") && value.matches("(?i:Category):.*")) {
176            linkConsumer.acceptLink(tr("View category on Wikimedia Commons"), "https://commons.wikimedia.org/wiki/" + value);
177        }
178
179        wikidataRules.getValues(key).forEach(urlFormatter -> {
180            final String url = urlFormatter.replace("$1", value);
181            linkConsumer.acceptLink(getLinkName(url, key), url);
182        });
183    }
184
185    private static String getLinkName(String url, String fallback) {
186        try {
187            return tr("Open {0}", new URL(url).getHost());
188        } catch (MalformedURLException e) {
189            return tr("Open {0}", fallback);
190        }
191    }
192
193}