001/* 002 * SPDX-License-Identifier: Apache-2.0 003 * 004 * Copyright 2024-2026 The Enola <https://enola.dev> Authors 005 * 006 * Licensed under the Apache License, Version 2.0 (the "License"); 007 * you may not use this file except in compliance with the License. 008 * You may obtain a copy of the License at 009 * 010 * https://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package dev.enola.thing.metadata; 019 020import dev.enola.common.io.iri.URIs; 021import dev.enola.common.io.metadata.Metadata; 022import dev.enola.common.io.metadata.MetadataProvider; 023import dev.enola.data.iri.NamespaceConverter; 024import dev.enola.thing.KIRI; 025import dev.enola.thing.Thing; 026import dev.enola.thing.proto.Things; 027import dev.enola.thing.repo.ThingProvider; 028import dev.enola.thing.template.Templates; 029 030import org.jspecify.annotations.Nullable; 031import org.slf4j.Logger; 032import org.slf4j.LoggerFactory; 033 034import java.net.URI; 035import java.net.URISyntaxException; 036import java.util.function.Function; 037 038/** 039 * {@link MetadataProvider} implementation based on looking at {@link Things}s obtained via {@link 040 * ThingProvider}; see also related <a href="https://docs.enola.dev/concepts/metadata/">end-user 041 * documentation</a>. 042 * 043 * <p>Logs errors, but does not propagate exceptions from the <code>ThingProvider</code>, because we 044 * do not want to fail operations "just" because Metadata could not be obtained; all the methods 045 * have fallbacks. 046 */ 047public class ThingMetadataProvider implements MetadataProvider<Thing> { 048 049 private final Logger log = LoggerFactory.getLogger(getClass()); 050 051 private final ThingProvider tp; 052 private final NamespaceConverter ns; 053 054 public ThingMetadataProvider(ThingProvider tp, NamespaceConverter ns) { 055 this.tp = tp; 056 this.ns = ns; 057 } 058 059 public Metadata get(Thing thing) { 060 return get(thing, thing.iri()); 061 } 062 063 @Override 064 public Metadata get(String iri) { 065 Thing thing = null; 066 try { 067 // TODO We should call for "internal" but skip for ext - but how to distinguish, here?! 068 if (!Templates.hasVariables(iri)) thing = tp.get(iri); 069 } catch (Exception e) { 070 log.warn("Failed to get {}", iri, e); 071 } 072 return get(thing, iri); 073 } 074 075 @Override 076 public Metadata get(@Nullable Thing thing, String fallbackIRI) { 077 var imageURL = getImageURL_(thing); 078 if (imageURL == null) imageURL = ""; 079 080 var emoji = getEmoji_(thing); 081 if (emoji == null) emoji = ""; 082 083 var imageHTML = getImageHTML(thing); 084 var descriptionHTML = getDescriptionHTML(thing); 085 var curie = ns.toCURIE(fallbackIRI); 086 var label = getLabel(thing, curie, fallbackIRI); 087 var curieIfDifferentFromFallbackIRI = !curie.equals(fallbackIRI) ? curie : ""; 088 return new Metadata( 089 fallbackIRI, 090 imageHTML, 091 imageURL, 092 emoji, 093 curieIfDifferentFromFallbackIRI, 094 label, 095 descriptionHTML); 096 } 097 098 /** 099 * Returns the Thing's {@link KIRI.RDFS#LABEL} or {@link KIRI.SCHEMA#NAME}, if any; otherwise 100 * attempts to convert the IRI to a "CURIE", and if that also fails, it just extracts a "file 101 * name" (last part of the path) from the IRI, and if that fails just returns the IRI argument 102 * as-is. 103 */ 104 private String getLabel(@Nullable Thing thing, String curie, String fallbackIRI) { 105 var label = getLabel_(thing); 106 if (label != null) return label; 107 108 label = getAlternative(thing, KIRI.RDF.TYPE, type -> getLabelViaProperty(thing, type)); 109 if (label != null) return label; 110 111 label = getAlternative(thing, KIRI.RDFS.RANGE, range -> getLabel_(range)); 112 if (label != null) return label; 113 114 if (!curie.equals(fallbackIRI)) return curie; 115 116 try { 117 var fallbackURI = new URI(fallbackIRI); 118 var filename = URIs.getFilenameOrLastPathSegmentOrHost(fallbackURI); 119 if (filename == null) return fallbackIRI; 120 121 // TODO Should we consider any ?query=arg as part of a "label"?! 122 var fragment = fallbackURI.getFragment(); 123 if (fragment != null) return filename + "#" + fragment; 124 else return filename; 125 } catch (URISyntaxException e) { 126 return fallbackIRI; 127 } 128 } 129 130 private @Nullable String getLabelViaProperty(@Nullable Thing thing, Thing type) { 131 if (thing == null) return null; 132 var typesLabelProperty = type.getString(KIRI.E.LABEL_PROPERTY); 133 if (typesLabelProperty == null) return null; 134 return thing.getString(typesLabelProperty); 135 } 136 137 private @Nullable String getLabel_(@Nullable Thing thing) { 138 var label = getString(thing, KIRI.E.LABEL); 139 if (label != null) return label; 140 141 label = getString(thing, KIRI.RDFS.LABEL); 142 if (label != null) return label; 143 144 var name = getString(thing, KIRI.SCHEMA.NAME); 145 if (name != null) return name; 146 147 var title = getString(thing, KIRI.DC.TITLE); 148 return title; 149 } 150 151 /** Returns the Thing's {@link KIRI.SCHEMA#DESC}, if any. */ 152 private String getDescriptionHTML(Thing thing) { 153 var description = getString(thing, KIRI.E.DESCRIPTION); 154 if (description != null) return description; 155 156 description = getString(thing, KIRI.SCHEMA.DESC); 157 if (description != null) return description; 158 159 description = getString(thing, KIRI.SCHEMA.ABSTRACT); 160 if (description != null) return description; 161 162 description = getString(thing, KIRI.DC.DESCRIPTION); 163 if (description != null) return description; 164 165 description = getString(thing, KIRI.RDFS.COMMENT); 166 if (description != null) return description; 167 168 return ""; 169 } 170 171 /** 172 * Returns the Thing's {@link KIRI.E#EMOJI}, if any; otherwise an HTML IMG tag using the URL 173 * from {@link KIRI.SCHEMA#IMG}, if any; and if neither tries the same on the Thing's {@link 174 * KIRI.RDFS#CLASS}; if that also is not present, then gives up and just an empty String. 175 */ 176 private String getImageHTML(Thing thing) { 177 if (thing == null) return ""; 178 179 var thingImage = getImageHTML_(thing); 180 if (thingImage != null) return thingImage; 181 182 return ""; 183 } 184 185 private @Nullable String getImageHTML_(Thing thing) { 186 if (thing == null) return null; 187 188 var emoji = getEmoji_(thing); 189 if (emoji != null) return emoji; 190 191 var imageURL = getImageURL_(thing); 192 if (imageURL != null) return html(imageURL); 193 194 // TODO Also support (and test) https://schema.org/ImageObject 195 // for https://schema.org/thumbnail and https://schema.org/logo 196 197 return null; 198 } 199 200 private @Nullable String getEmoji_(Thing thing) { 201 var emoji = getString(thing, KIRI.E.EMOJI); 202 if (emoji != null) return emoji; 203 204 emoji = getAlternative(thing, KIRI.RDFS.RANGE, range -> getEmoji_(range)); 205 if (emoji != null) return emoji; 206 207 emoji = getAlternative(thing, KIRI.RDF.TYPE, type -> getEmoji_(type)); 208 return emoji; 209 } 210 211 private @Nullable String getImageURL_(Thing thing) { 212 var imageURL = getAlternative(thing, KIRI.RDFS.RANGE, range -> getImageURL__(range)); 213 if (imageURL != null) return imageURL; 214 215 imageURL = getAlternative(thing, KIRI.RDF.TYPE, type -> getImageURL__(type)); 216 return imageURL; 217 } 218 219 private @Nullable String getImageURL__(Thing thing) { 220 var imageURL = getString(thing, KIRI.SCHEMA.LOGO); 221 if (imageURL != null) return html(imageURL); 222 223 imageURL = getString(thing, KIRI.SCHEMA.THUMBNAIL_URL); 224 if (imageURL != null) return html(imageURL); 225 226 imageURL = getString(thing, KIRI.SCHEMA.IMG); 227 if (imageURL != null) return html(imageURL); 228 229 return null; 230 } 231 232 private String html(String imageURL) { 233 // TODO ImageMetadataProvider which can determine (and cache!) width & height 234 return "<img src=\"" + imageURL + "\" style=\"max-height: 1em;\">"; 235 } 236 237 private @Nullable String getAlternative( 238 Thing thing, String viaPropertyIRI, Function<Thing, String> alt) { 239 var alternativeThingIRI = getString(thing, viaPropertyIRI); 240 if (alternativeThingIRI != null && !alternativeThingIRI.equals(thing.iri())) { 241 var alternativeThing = tp.get(alternativeThingIRI); 242 if (alternativeThing != null) { 243 var alternativeSource = alt.apply(alternativeThing); 244 return alternativeSource; 245 } 246 } 247 return null; 248 } 249 250 private @Nullable String getString(@Nullable Thing thing, String propertyIRI) { 251 if (thing == null) return null; 252 String string = null; 253 if (!thing.isIterable(propertyIRI)) string = thing.getString(propertyIRI); 254 // TODO Implement supporting e.g. multiple types 255 if (string == null) { 256 log.trace("No {} on {}:\n{}", propertyIRI, thing.iri(), thing); 257 } 258 return string; 259 } 260}