package eu.fbk.knowledgestore.server.http.jaxrs; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import javax.annotation.Nullable; import com.google.common.base.Function; import com.google.common.base.Joiner; import com.google.common.base.MoreObjects; import com.google.common.base.Predicate; import com.google.common.base.Splitter; import com.google.common.base.Strings; import com.google.common.collect.HashMultiset; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.collect.Multiset; import com.google.common.collect.Ordering; import com.google.common.collect.Sets; import com.google.common.escape.Escaper; import com.google.common.html.HtmlEscapers; import com.google.common.net.UrlEscapers; import org.openrdf.model.BNode; import org.openrdf.model.Literal; import org.openrdf.model.URI; import org.openrdf.model.Value; import org.openrdf.query.BindingSet; import eu.fbk.knowledgestore.data.Data; import eu.fbk.knowledgestore.data.Record; import eu.fbk.knowledgestore.server.http.UIConfig; import eu.fbk.knowledgestore.vocabulary.KS; import eu.fbk.knowledgestore.vocabulary.NIF; import eu.fbk.knowledgestore.vocabulary.NWR; /** * Collection of utility methods for rendering various kinds of object to HTML. */ public final class RenderUtils { private static final boolean CHAR_OFFSET_HACK = Boolean.parseBoolean(System.getProperty( "ks.charOffsetHack", "false")) || Boolean.parseBoolean(MoreObjects.firstNonNull(System.getenv("KS_CHAR_OFFSET_HACK"), "false")); private static final AtomicInteger COUNTER = new AtomicInteger(0); /** * Render a generic object, returning the corresponding HTML string. Works for null objects, * RDF {@code Value}s, {@code Record}s, {@code BindingSet}s and {@code Iterable}s of the * former. * * @param object * the object to render. * @return the rendered HTML string */ public static String render(final Object object) { try { final StringBuilder builder = new StringBuilder(); render(object, builder); return builder.toString(); } catch (final IOException ex) { throw new Error(ex); // should not happen } } /** * Render a generic object, emitting the corresponding HTML string to the supplied * {@code Appendable} object. Works for null objects, RDF {@code Value}s, {@code Record}s, * {@code BindingSet}s and {@code Iterable}s of the former. * * @param object * the object to render. */ @SuppressWarnings("unchecked") public static <T extends Appendable> T render(final Object object, final T out) throws IOException { if (object instanceof URI) { render((URI) object, null, out); } else if (object instanceof Literal) { final Literal literal = (Literal) object; out.append("<span"); if (literal.getLanguage() != null) { out.append(" title=\"@").append(literal.getLanguage()).append("\""); } else if (literal.getDatatype() != null) { out.append(" title=\"<").append(literal.getDatatype().stringValue()) .append(">\""); } out.append(">").append(literal.stringValue()).append("</span>"); } else if (object instanceof BNode) { final BNode bnode = (BNode) object; out.append("_:").append(bnode.getID()); } else if (object instanceof Record) { final Record record = (Record) object; out.append("<table class=\"record table table-condensed\"><tbody>\n<tr><td>ID</td><td>"); render(record.getID(), out); out.append("</td></tr>\n"); for (final URI property : Ordering.from(Data.getTotalComparator()).sortedCopy( record.getProperties())) { out.append("<tr><td>"); render(property, out); out.append("</td><td>"); final List<Object> values = record.get(property); if (values.size() == 1) { render(values.get(0), out); } else { out.append("<div class=\"scroll\">"); String separator = ""; for (final Object value : Ordering.from(Data.getTotalComparator()).sortedCopy( record.get(property))) { out.append(separator); render(value, out); separator = "<br/>"; } out.append("</div>"); } out.append("</td></tr>\n"); } out.append("</tbody></table>"); } else if (object instanceof BindingSet) { render(ImmutableSet.of(object)); } else if (object instanceof Iterable<?>) { final Iterable<?> iterable = (Iterable<?>) object; boolean isEmpty = true; boolean isIterableOfSolutions = true; for (final Object element : iterable) { isEmpty = false; if (!(element instanceof BindingSet)) { isIterableOfSolutions = false; break; } } if (!isEmpty) { if (!isIterableOfSolutions) { String separator = ""; for (final Object element : (Iterable<?>) object) { out.append(separator); render(element, out); separator = "<br/>"; } } else { Joiner.on("").appendTo(out, renderSolutionTable(null, (Iterable<BindingSet>) object).iterator()); } } } else if (object != null) { out.append(object.toString()); } return out; } public static <T extends Appendable> T render(final URI uri, @Nullable final URI selection, final T out) throws IOException { out.append("<a href=\"").append(RenderUtils.escapeHtml(uri.stringValue())).append("\""); if (selection != null) { out.append(" data-sel=\"").append(RenderUtils.escapeHtml(selection)).append("\""); } out.append(" class=\"uri\">").append(RenderUtils.shortenURI(uri)).append("</a>"); return out; } public static <T extends Appendable> T renderText(final String text, final String contentType, final T out) throws IOException { if (contentType.equals("text/plain")) { out.append("<div class=\"text\">\n").append(RenderUtils.escapeHtml(text)) .append("\n</div>\n"); } else { // TODO: only XML enabled by default - should be generalized / made more robust out.append("<pre class=\"text-pre pre-scrollable prettyprint linenums lang-xml\">") .append(RenderUtils.escapeHtml(text)).append("</pre>"); } return out; } public static <T extends Appendable> T renderText(final String text, final List<Record> mentions, @Nullable final URI selection, final boolean canSelect, final boolean onlyMention, final UIConfig config, final T out) throws IOException { final List<String> lines = Lists.newArrayList(Splitter.on('\n').split(text)); if (CHAR_OFFSET_HACK) { for (int i = 0; i < lines.size(); ++i) { lines.set(i, lines.get(i).replaceAll("\\s+", " ") + " "); } } int lineStart = CHAR_OFFSET_HACK ? 0 : -1; int lineOffset = 0; int mentionIndex = 0; boolean anchorAdded = false; out.append("<div class=\"text\">\n"); for (final String l : lines) { final String line = CHAR_OFFSET_HACK ? l.trim() : l; lineStart += CHAR_OFFSET_HACK ? 0 : 1; boolean mentionFound = false; while (mentionIndex < mentions.size()) { final Record mention = mentions.get(mentionIndex); final Integer begin = mention.getUnique(NIF.BEGIN_INDEX, Integer.class); final Integer end = mention.getUnique(NIF.END_INDEX, Integer.class); String cssStyle = null; for (final UIConfig.Category category : config.getMentionCategories()) { if (category.getCondition().evalBoolean(mention)) { cssStyle = category.getStyle(); break; } } if (cssStyle == null || begin == null || end == null || begin < lineStart + lineOffset) { ++mentionIndex; continue; } if (end > lineStart + line.length()) { break; } final boolean selected = mention.getID().equals(selection) || mention.get(KS.REFERS_TO, URI.class).contains(selection); if (!mentionFound) { out.append("<p>"); } out.append(RenderUtils.escapeHtml(line.substring(lineOffset, begin - lineStart))); out.append("<a href=\"#\""); if (selected && !anchorAdded) { out.append(" id=\"selection\""); anchorAdded = true; } if (canSelect) { out.append(" onclick=\"select('").append(mention.getID().toString()) .append("')\""); } out.append(" class=\"mention").append(selected ? " selected" : "") .append("\" style=\"").append(cssStyle).append("\" title=\""); String separator = ""; for (final URI property : config.getMentionOverviewProperties()) { final List<Value> values = mention.get(property, Value.class); if (!values.isEmpty()) { out.append(separator) .append(Data.toString(property, Data.getNamespaceMap())) .append(" = "); for (final Value value : values) { if (!KS.MENTION.equals(value) && !NWR.TIME_OR_EVENT_MENTION.equals(value) && !NWR.ENTITY_MENTION.equals(value)) { out.append(" ").append( Data.toString(value, Data.getNamespaceMap())); } } separator = "\n"; } } out.append("\">"); out.append(RenderUtils.escapeHtml(line.substring(begin - lineStart, end - lineStart))); out.append("</a>"); lineOffset = end - lineStart; ++mentionIndex; mentionFound = true; } if (mentionFound || !onlyMention) { if (!mentionFound) { out.append("<p>\n"); } out.append(RenderUtils.escapeHtml(line.substring(lineOffset, line.length()))); out.append("</p>\n"); } lineStart += line.length(); lineOffset = 0; } out.append("</div>\n"); return out; } /** * Render in a streaming-way the solutions of a SPARQL SELECT query to an HTML table, emitting * an iterable with of HTML fragments. * * @param variables * the variables to render in the table, in the order they should be rendered; if * null, variables will be automatically extracted from the solutions and all the * variables in alphanumeric order will be emitted * @param solutions * the solutions to render */ public static Iterable<String> renderSolutionTable(final List<String> variables, final Iterable<? extends BindingSet> solutions) { final List<String> actualVariables; if (variables != null) { actualVariables = ImmutableList.copyOf(variables); } else { final Set<String> variableSet = Sets.newHashSet(); for (final BindingSet solution : solutions) { variableSet.addAll(solution.getBindingNames()); } actualVariables = Ordering.natural().sortedCopy(variableSet); } final int width = 75 / actualVariables.size(); final StringBuilder builder = new StringBuilder(); builder.append("<table class=\"sparql table table-condensed tablesorter\"><thead>\n<tr>"); for (final String variable : actualVariables) { builder.append("<th style=\"width: ").append(width).append("%\">") .append(escapeHtml(variable)).append("</th>"); } final Iterable<String> header = ImmutableList.of(builder.toString()); final Iterable<String> footer = ImmutableList.of("</tbody></table>"); final Function<BindingSet, String> renderer = new Function<BindingSet, String>() { @Override public String apply(final BindingSet bindings) { if (Thread.interrupted()) { throw new IllegalStateException("Interrupted"); } final StringBuilder builder = new StringBuilder(); builder.append("<tr>"); for (final String variable : actualVariables) { builder.append("<td>"); try { render(bindings.getValue(variable), builder); } catch (final IOException ex) { throw new Error(ex); } builder.append("</td>"); } builder.append("</tr>\n"); return builder.toString(); } }; return Iterables.concat(header, Iterables.transform(solutions, renderer), footer); } public static <T extends Appendable> T renderMultisetTable(final T out, final Multiset<?> multiset, final String elementHeader, final String occurrencesHeader, @Nullable final String linkTemplate) throws IOException { final String tableID = "table" + COUNTER.getAndIncrement(); out.append("<table id=\"").append(tableID).append("\" class=\"display datatable\">\n"); out.append("<thead>\n<tr><th>").append(MoreObjects.firstNonNull(elementHeader, "Value")) .append("</th><th>") .append(MoreObjects.firstNonNull(occurrencesHeader, "Occurrences")) .append("</th></tr>\n</thead>\n"); out.append("<tbody>\n"); for (final Object element : multiset.elementSet()) { final int occurrences = multiset.count(element); out.append("<tr><td>"); RenderUtils.render(element, out); out.append("</td><td>"); if (linkTemplate == null) { out.append(Integer.toString(occurrences)); } else { final Escaper esc = UrlEscapers.urlFormParameterEscaper(); final String e = esc.escape(Data.toString(element, Data.getNamespaceMap())); final String u = linkTemplate.replace("${element}", e); out.append("<a href=\"").append(u).append("\">") .append(Integer.toString(occurrences)).append("</a>"); } out.append("</td></tr>\n"); } out.append("</tbody>\n</table>\n"); out.append("<script>$(document).ready(function() { applyDataTable('").append(tableID) .append("', false, {}); });</script>"); return out; } public static <T extends Appendable> T renderRecordsTable(final T out, final Iterable<Record> records, @Nullable List<URI> propertyURIs, @Nullable final String extraOptions) throws IOException { // Extract the properties to show if not explicitly supplied if (propertyURIs == null) { final Set<URI> uriSet = Sets.newHashSet(); for (final Record record : records) { uriSet.addAll(record.getProperties()); } propertyURIs = Ordering.from(Data.getTotalComparator()).sortedCopy(uriSet); } // Emit the table final String tableID = "table" + COUNTER.getAndIncrement(); out.append("<table id=\"").append(tableID).append("\" class=\"display datatable\">\n"); out.append("<thead>\n<tr><th>URI</th>"); for (final URI propertyURI : propertyURIs) { out.append("<th>").append(RenderUtils.shortenURI(propertyURI)).append("</th>"); } out.append("</tr>\n</thead>\n<tbody>\n"); for (final Record record : records) { out.append("<tr><td>").append(RenderUtils.render(record.getID())).append("</td>"); for (final URI propertyURI : propertyURIs) { out.append("<td>").append(RenderUtils.render(record.get(propertyURI))) .append("</td>"); } out.append("</tr>\n"); } out.append("</tbody>\n</table>\n"); out.append("<script>$(document).ready(function() { applyDataTable('").append(tableID) .append("', true, {").append(Strings.nullToEmpty(extraOptions)) .append("}); });</script>"); return out; } public static <T extends Appendable> T renderRecordsAggregateTable(final T out, final Iterable<Record> records, @Nullable final Predicate<URI> propertyFilter, @Nullable final String linkTemplate, @Nullable final String extraOptions) throws IOException { // Aggregate properties and values final Map<URI, Multiset<Value>> properties = Maps.newHashMap(); final Map<Object, URI> examples = Maps.newHashMap(); for (final Record record : records) { for (final URI property : record.getProperties()) { if (propertyFilter == null || propertyFilter.apply(property)) { Multiset<Value> values = properties.get(property); if (values == null) { values = HashMultiset.create(); properties.put(property, values); } for (final Value value : record.get(property, Value.class)) { values.add(value); examples.put(ImmutableList.of(property, value), record.getID()); } } } } // Emit the table final Ordering<Object> ordering = Ordering.from(Data.getTotalComparator()); final String tableID = "table" + COUNTER.getAndIncrement(); out.append("<table id=\"").append(tableID).append("\" class=\"display datatable\">\n"); out.append("<thead>\n<tr><th>Property</th><th>Value</th>" + "<th>Occurrences</th><th>Example</th></tr>\n</thead>\n"); out.append("<tbody>\n"); for (final URI property : ordering.sortedCopy(properties.keySet())) { final Multiset<Value> values = properties.get(property); for (final Value value : ordering.sortedCopy(values.elementSet())) { final int occurrences = values.count(value); final URI example = examples.get(ImmutableList.of(property, value)); out.append("<tr><td>"); render(property, out); out.append("</td><td>"); render(value, out); out.append("</td><td>"); if (linkTemplate == null) { out.append(Integer.toString(occurrences)); } else { final Escaper e = UrlEscapers.urlFormParameterEscaper(); final String p = e.escape(Data.toString(property, Data.getNamespaceMap())); final String v = e.escape(Data.toString(value, Data.getNamespaceMap())); final String u = linkTemplate.replace("${property}", p).replace("${value}", v); out.append("<a href=\"").append(u).append("\">") .append(Integer.toString(occurrences)).append("</a>"); } out.append("</td><td>"); render(example, out); out.append("</td></tr>\n"); } } out.append("</tbody>\n</table>\n"); out.append("<script>$(document).ready(function() { applyDataTable('").append(tableID) .append("', false, {").append(Strings.nullToEmpty(extraOptions)) .append("}); });</script>"); return out; } /** * Returns a shortened version of the supplied RDF {@code URI}. * * @param uri * the uri to shorten * @return the shortened URI string */ @Nullable public static String shortenURI(@Nullable final URI uri) { if (uri == null) { return null; } final String prefix = Data.namespaceToPrefix(uri.getNamespace(), Data.getNamespaceMap()); if (prefix != null) { return prefix + ':' + uri.getLocalName(); } final String ns = uri.getNamespace(); return "<.." + uri.stringValue().substring(ns.length() - 1) + ">"; // final int index = uri.stringValue().lastIndexOf('/'); // if (index >= 0) { // return "<.." + uri.stringValue().substring(index) + ">"; // } // return "<" + uri.stringValue() + ">"; } /** * Transforms the supplied object to an escaped HTML string. * * @param object * the object * @return the escaped HTML string */ @Nullable public static String escapeHtml(@Nullable final Object object) { return object == null ? null : HtmlEscapers.htmlEscaper().escape(object.toString()); } private RenderUtils() { } }