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.gen.graphviz;
019
020import com.google.common.escape.Escaper;
021import com.google.common.html.HtmlEscapers;
022
023import dev.enola.common.context.Context;
024import dev.enola.common.context.TLC;
025import dev.enola.common.convert.ConversionException;
026import dev.enola.thing.KIRI;
027import dev.enola.thing.PredicatesObjects;
028import dev.enola.thing.Thing;
029import dev.enola.thing.gen.Orphanage;
030import dev.enola.thing.gen.ThingsIntoAppendableConverter;
031import dev.enola.thing.impl.OnlyIRIThing;
032import dev.enola.thing.metadata.ThingMetadataProvider;
033import dev.enola.thing.repo.StackedThingProvider;
034import dev.enola.thing.repo.ThingProvider;
035
036import org.jspecify.annotations.Nullable;
037
038import java.io.IOException;
039import java.util.HashSet;
040import java.util.Set;
041
042public class GraphvizGenerator implements ThingsIntoAppendableConverter {
043
044    // TODO Coalesce e.g. enola:parent & enola:children (schema:inverseOf) into single dir=both link
045    // This would be useful e.g. for the TikaMediaTypesThingConverter produced graph diagram
046    // Note that strict digraph graphName { concentrate=true does not do this (because of labels;
047    // see https://stackoverflow.com/a/3463332/421602).
048
049    // TODO Subgraphs? https://graphviz.org/doc/info/lang.html#subgraphs-and-clusters Classes?
050
051    // TODO Links to other Things (not external HTTP) from within nested blank nodes? With ports??
052
053    private static final int MAX_TEXT_LENGTH = 23;
054
055    public enum Flags implements Context.Key<Boolean> {
056        /**
057         * In "full" mode, we print a table with properties; in "lite" mode we do not.
058         *
059         * <p>Note that many of <a href="https://graphviz.org/docs/layouts/">Graphviz's Layout
060         * Engines</a> don't seem to work well with full mode.
061         */
062        FULL
063    }
064
065    // NB: RosettaTest#testGraphviz() is the test coverage for this code
066
067    // NB: http://magjac.com/graphviz-visual-editor/ is handy for testing!
068
069    // NB: Because Graphviz (v12) does NOT support rendering A/HREF inside TABLE of LABEL,
070    // we cannot make the propertyIRIs clickable, unfortunately. (Nor any object values which are
071    // e.g. http://... (of ^schema:URL Datatype).
072
073    // NB: We're intentionally *NOT* showing the Datatype of properties (it's "too much").
074
075    private final ThingMetadataProvider metadataProvider;
076
077    public GraphvizGenerator(ThingMetadataProvider metadataProvider) {
078        this.metadataProvider = metadataProvider;
079    }
080
081    @Override
082    public boolean convertInto(Iterable<Thing> from, Appendable out)
083            throws ConversionException, IOException {
084        Set<String> thingIRIs = new HashSet<>();
085        var orphanage = new Orphanage();
086        out.append("digraph {\n");
087        try (var ctx = TLC.open()) {
088            ctx.push(ThingProvider.class, new StackedThingProvider(from));
089            for (Thing thing : from) {
090                orphanage.nonOrphan(thing.iri());
091                printThing(thing, out, orphanage);
092            }
093            for (String orphanIRI : orphanage.orphans()) {
094                var orphanThing = new OnlyIRIThing(orphanIRI);
095                printThing(orphanThing, out, orphanage);
096            }
097        }
098        out.append("}\n");
099        return true;
100    }
101
102    private void printThing(Thing thing, Appendable out, Orphanage orphanage) throws IOException {
103        boolean full = TLC.optional(Flags.FULL).orElse(false);
104
105        out.append("  \"");
106        out.append(thing.iri());
107        out.append("\" [");
108        if (full) out.append("shape=plain ");
109
110        // Nota bene: This is just an approximate heuristic; if there are multiple types,
111        // then we don't know which of them has colors, if any.
112        // TODO We could do better and find the first one with a color, if any....
113        var types = thing.getThings(KIRI.RDF.TYPE).iterator();
114        if (types.hasNext()) {
115            printColors(types.next(), out);
116        } else {
117            printColors(thing, out);
118        }
119
120        out.append("URL=\"");
121        out.append(thing.iri());
122        out.append("\" label=");
123        var metadata = metadataProvider.get(thing, thing.iri());
124        var label = label(metadata);
125        if (full) {
126            out.append("<");
127            printNonLinkPropertiesTable(label, thing, out);
128            out.append(">");
129        } else {
130            out.append("\"");
131            out.append(label);
132            out.append("\"");
133        }
134        out.append("]\n");
135
136        for (var p : thing.predicateIRIs()) {
137            for (var link : thing.getLinks(p)) {
138                out.append("  \"");
139                out.append(thing.iri());
140                out.append("\" -> \"");
141                var linkIRI = link.toString();
142                var linkLabel = label(metadataProvider.get(p));
143                out.append(linkIRI);
144                out.append("\" [URL=\"");
145                out.append(p); // NOT linkIRI
146                out.append("\" label=\"");
147                out.append(html(linkLabel));
148                out.append("\"]\n");
149                orphanage.candidate(linkIRI);
150            }
151        }
152        out.append('\n');
153    }
154
155    private void printColors(Thing thing, Appendable out) throws IOException {
156        var color = thing.get(KIRI.E.COLOR, String.class);
157        if (color != null) {
158            out.append("style=filled fillcolor=");
159            out.append(color);
160            out.append(' ');
161        }
162        var textColor = thing.get(KIRI.E.TEXT_COLOR, String.class);
163        if (textColor != null) {
164            out.append("fontcolor=");
165            out.append(textColor);
166            out.append(' ');
167        }
168    }
169
170    // NB: This is Graphviz and not an HTML table syntax!
171    // See https://graphviz.org/doc/info/shapes.html#html
172    private void printNonLinkPropertiesTable(
173            @Nullable String thingLabel, PredicatesObjects thing, Appendable out)
174            throws IOException {
175        out.append("<TABLE BORDER=\"0\" CELLBORDER=\"1\" CELLSPACING=\"0\">\n");
176        if (thingLabel != null) {
177            out.append("    <TR><TD COLSPAN=\"2\">");
178            out.append(html(thingLabel));
179            out.append("</TD></TR>\n");
180        }
181        for (var p : thing.predicateIRIs()) {
182            if (!thing.getLinks(p).isEmpty()) continue;
183            var pLabel = label(metadataProvider.get(p));
184            out.append("    <TR><TD ALIGN=\"left\">");
185            out.append(html(pLabel));
186            out.append("</TD><TD>");
187            if (thing.isIterable(p)) {
188                var iterable = thing.get(p, Iterable.class);
189                if (iterable != null) {
190                    for (var element : iterable) {
191                        // TODO Convert using datatype; needs thing.get(p, n, String.class)
192                        out.append(html(brief(element.toString())));
193                        out.append("<BR/>");
194                    }
195                }
196            } else if (thing.isStruct(p)) {
197                out.append("..."); // TODO Dig in, or fine as is?
198            } else {
199                var value = thing.getString(p);
200                if (value != null) out.append(html(brief(value)));
201            }
202            out.append("</TD></TR>\n");
203        }
204        out.append("  </TABLE>");
205    }
206
207    private String brief(String text) {
208        var trim = text.trim();
209        if (trim.length() > MAX_TEXT_LENGTH) return trim.substring(0, MAX_TEXT_LENGTH) + "...";
210        else return trim;
211    }
212
213    private static final Escaper htmlEscaper = HtmlEscapers.htmlEscaper();
214
215    private String html(String text) {
216        return htmlEscaper.escape(text);
217    }
218}