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}