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}