1   package eu.fbk.knowledgestore.server.http.jaxrs;
2   
3   import java.io.IOException;
4   import java.util.List;
5   import java.util.Map;
6   import java.util.Set;
7   import java.util.concurrent.atomic.AtomicInteger;
8   
9   import javax.annotation.Nullable;
10  
11  import com.google.common.base.Function;
12  import com.google.common.base.Joiner;
13  import com.google.common.base.MoreObjects;
14  import com.google.common.base.Predicate;
15  import com.google.common.base.Splitter;
16  import com.google.common.base.Strings;
17  import com.google.common.collect.HashMultiset;
18  import com.google.common.collect.ImmutableList;
19  import com.google.common.collect.ImmutableSet;
20  import com.google.common.collect.Iterables;
21  import com.google.common.collect.Lists;
22  import com.google.common.collect.Maps;
23  import com.google.common.collect.Multiset;
24  import com.google.common.collect.Ordering;
25  import com.google.common.collect.Sets;
26  import com.google.common.escape.Escaper;
27  import com.google.common.html.HtmlEscapers;
28  import com.google.common.net.UrlEscapers;
29  
30  import org.openrdf.model.BNode;
31  import org.openrdf.model.Literal;
32  import org.openrdf.model.URI;
33  import org.openrdf.model.Value;
34  import org.openrdf.query.BindingSet;
35  
36  import eu.fbk.knowledgestore.data.Data;
37  import eu.fbk.knowledgestore.data.Record;
38  import eu.fbk.knowledgestore.server.http.UIConfig;
39  import eu.fbk.knowledgestore.vocabulary.KS;
40  import eu.fbk.knowledgestore.vocabulary.NIF;
41  import eu.fbk.knowledgestore.vocabulary.NWR;
42  
43  /**
44   * Collection of utility methods for rendering various kinds of object to HTML.
45   */
46  public final class RenderUtils {
47  
48      private static final boolean CHAR_OFFSET_HACK = Boolean.parseBoolean(System.getProperty(
49              "ks.charOffsetHack", "false"))
50              || Boolean.parseBoolean(MoreObjects.firstNonNull(System.getenv("KS_CHAR_OFFSET_HACK"),
51                      "false"));
52  
53      private static final AtomicInteger COUNTER = new AtomicInteger(0);
54  
55      /**
56       * Render a generic object, returning the corresponding HTML string. Works for null objects,
57       * RDF {@code Value}s, {@code Record}s, {@code BindingSet}s and {@code Iterable}s of the
58       * former.
59       *
60       * @param object
61       *            the object to render.
62       * @return the rendered HTML string
63       */
64      public static String render(final Object object) {
65          try {
66              final StringBuilder builder = new StringBuilder();
67              render(object, builder);
68              return builder.toString();
69          } catch (final IOException ex) {
70              throw new Error(ex); // should not happen
71          }
72      }
73  
74      /**
75       * Render a generic object, emitting the corresponding HTML string to the supplied
76       * {@code Appendable} object. Works for null objects, RDF {@code Value}s, {@code Record}s,
77       * {@code BindingSet}s and {@code Iterable}s of the former.
78       *
79       * @param object
80       *            the object to render.
81       */
82      @SuppressWarnings("unchecked")
83      public static <T extends Appendable> T render(final Object object, final T out)
84              throws IOException {
85  
86          if (object instanceof URI) {
87              render((URI) object, null, out);
88  
89          } else if (object instanceof Literal) {
90              final Literal literal = (Literal) object;
91              out.append("<span");
92              if (literal.getLanguage() != null) {
93                  out.append(" title=\"@").append(literal.getLanguage()).append("\"");
94              } else if (literal.getDatatype() != null) {
95                  out.append(" title=\"&lt;").append(literal.getDatatype().stringValue())
96                          .append("&gt;\"");
97              }
98              out.append(">").append(literal.stringValue()).append("</span>");
99  
100         } else if (object instanceof BNode) {
101             final BNode bnode = (BNode) object;
102             out.append("_:").append(bnode.getID());
103 
104         } else if (object instanceof Record) {
105             final Record record = (Record) object;
106             out.append("<table class=\"record table table-condensed\"><tbody>\n<tr><td>ID</td><td>");
107             render(record.getID(), out);
108             out.append("</td></tr>\n");
109             for (final URI property : Ordering.from(Data.getTotalComparator()).sortedCopy(
110                     record.getProperties())) {
111                 out.append("<tr><td>");
112                 render(property, out);
113                 out.append("</td><td>");
114                 final List<Object> values = record.get(property);
115                 if (values.size() == 1) {
116                     render(values.get(0), out);
117                 } else {
118                     out.append("<div class=\"scroll\">");
119                     String separator = "";
120                     for (final Object value : Ordering.from(Data.getTotalComparator()).sortedCopy(
121                             record.get(property))) {
122                         out.append(separator);
123                         render(value, out);
124                         separator = "<br/>";
125                     }
126                     out.append("</div>");
127                 }
128                 out.append("</td></tr>\n");
129             }
130             out.append("</tbody></table>");
131 
132         } else if (object instanceof BindingSet) {
133             render(ImmutableSet.of(object));
134 
135         } else if (object instanceof Iterable<?>) {
136             final Iterable<?> iterable = (Iterable<?>) object;
137             boolean isEmpty = true;
138             boolean isIterableOfSolutions = true;
139             for (final Object element : iterable) {
140                 isEmpty = false;
141                 if (!(element instanceof BindingSet)) {
142                     isIterableOfSolutions = false;
143                     break;
144                 }
145             }
146             if (!isEmpty) {
147                 if (!isIterableOfSolutions) {
148                     String separator = "";
149                     for (final Object element : (Iterable<?>) object) {
150                         out.append(separator);
151                         render(element, out);
152                         separator = "<br/>";
153                     }
154                 } else {
155                     Joiner.on("").appendTo(out,
156                             renderSolutionTable(null, (Iterable<BindingSet>) object).iterator());
157                 }
158             }
159 
160         } else if (object != null) {
161             out.append(object.toString());
162         }
163 
164         return out;
165     }
166 
167     public static <T extends Appendable> T render(final URI uri, @Nullable final URI selection,
168             final T out) throws IOException {
169         out.append("<a href=\"").append(RenderUtils.escapeHtml(uri.stringValue())).append("\"");
170         if (selection != null) {
171             out.append(" data-sel=\"").append(RenderUtils.escapeHtml(selection)).append("\"");
172         }
173         out.append(" class=\"uri\">").append(RenderUtils.shortenURI(uri)).append("</a>");
174         return out;
175     }
176 
177     public static <T extends Appendable> T renderText(final String text, final String contentType,
178             final T out) throws IOException {
179         if (contentType.equals("text/plain")) {
180             out.append("<div class=\"text\">\n").append(RenderUtils.escapeHtml(text))
181                     .append("\n</div>\n");
182         } else {
183             // TODO: only XML enabled by default - should be generalized / made more robust
184             out.append("<pre class=\"text-pre pre-scrollable prettyprint linenums lang-xml\">")
185                     .append(RenderUtils.escapeHtml(text)).append("</pre>");
186         }
187         return out;
188     }
189 
190     public static <T extends Appendable> T renderText(final String text,
191             final List<Record> mentions, @Nullable final URI selection, final boolean canSelect,
192             final boolean onlyMention, final UIConfig config, final T out) throws IOException {
193 
194         final List<String> lines = Lists.newArrayList(Splitter.on('\n').split(text));
195         if (CHAR_OFFSET_HACK) {
196             for (int i = 0; i < lines.size(); ++i) {
197                 lines.set(i, lines.get(i).replaceAll("\\s+", " ") + " ");
198             }
199         }
200 
201         int lineStart = CHAR_OFFSET_HACK ? 0 : -1;
202         int lineOffset = 0;
203         int mentionIndex = 0;
204 
205         boolean anchorAdded = false;
206 
207         out.append("<div class=\"text\">\n");
208         for (final String l : lines) {
209             final String line = CHAR_OFFSET_HACK ? l.trim() : l;
210             lineStart += CHAR_OFFSET_HACK ? 0 : 1;
211             boolean mentionFound = false;
212             while (mentionIndex < mentions.size()) {
213                 final Record mention = mentions.get(mentionIndex);
214                 final Integer begin = mention.getUnique(NIF.BEGIN_INDEX, Integer.class);
215                 final Integer end = mention.getUnique(NIF.END_INDEX, Integer.class);
216                 String cssStyle = null;
217                 for (final UIConfig.Category category : config.getMentionCategories()) {
218                     if (category.getCondition().evalBoolean(mention)) {
219                         cssStyle = category.getStyle();
220                         break;
221                     }
222                 }
223                 if (cssStyle == null || begin == null || end == null
224                         || begin < lineStart + lineOffset) {
225                     ++mentionIndex;
226                     continue;
227                 }
228                 if (end > lineStart + line.length()) {
229                     break;
230                 }
231                 final boolean selected = mention.getID().equals(selection)
232                         || mention.get(KS.REFERS_TO, URI.class).contains(selection);
233                 if (!mentionFound) {
234                     out.append("<p>");
235                 }
236                 out.append(RenderUtils.escapeHtml(line.substring(lineOffset, begin - lineStart)));
237                 out.append("<a href=\"#\"");
238                 if (selected && !anchorAdded) {
239                     out.append(" id=\"selection\"");
240                     anchorAdded = true;
241                 }
242                 if (canSelect) {
243                     out.append(" onclick=\"select('").append(RenderUtils.escapeJavaScriptString(mention.getID()))
244                             .append("')\"");
245                 }
246                 out.append(" class=\"mention").append(selected ? " selected" : "")
247                         .append("\" style=\"").append(cssStyle).append("\" title=\"");
248                 String separator = "";
249                 for (final URI property : config.getMentionOverviewProperties()) {
250                     final List<Value> values = mention.get(property, Value.class);
251                     if (!values.isEmpty()) {
252                         out.append(separator)
253                                 .append(Data.toString(property, Data.getNamespaceMap()))
254                                 .append(" = ");
255                         for (final Value value : values) {
256                             if (!KS.MENTION.equals(value)
257                                     && !NWR.TIME_OR_EVENT_MENTION.equals(value)
258                                     && !NWR.ENTITY_MENTION.equals(value)) {
259                                 out.append(" ").append(
260                                         Data.toString(value, Data.getNamespaceMap()));
261                             }
262                         }
263                         separator = "\n";
264                     }
265                 }
266                 out.append("\">");
267                 out.append(RenderUtils.escapeHtml(line.substring(begin - lineStart, end
268                         - lineStart)));
269                 out.append("</a>");
270                 lineOffset = end - lineStart;
271                 ++mentionIndex;
272                 mentionFound = true;
273             }
274             if (mentionFound || !onlyMention) {
275                 if (!mentionFound) {
276                     out.append("<p>\n");
277                 }
278                 out.append(RenderUtils.escapeHtml(line.substring(lineOffset, line.length())));
279                 out.append("</p>\n");
280             }
281             lineStart += line.length();
282             lineOffset = 0;
283         }
284         out.append("</div>\n");
285         return out;
286     }
287 
288     /**
289      * Render in a streaming-way the solutions of a SPARQL SELECT query to an HTML table, emitting
290      * an iterable with of HTML fragments.
291      *
292      * @param variables
293      *            the variables to render in the table, in the order they should be rendered; if
294      *            null, variables will be automatically extracted from the solutions and all the
295      *            variables in alphanumeric order will be emitted
296      * @param solutions
297      *            the solutions to render
298      */
299     public static Iterable<String> renderSolutionTable(final List<String> variables,
300             final Iterable<? extends BindingSet> solutions) {
301 
302         final List<String> actualVariables;
303         if (variables != null) {
304             actualVariables = ImmutableList.copyOf(variables);
305         } else {
306             final Set<String> variableSet = Sets.newHashSet();
307             for (final BindingSet solution : solutions) {
308                 variableSet.addAll(solution.getBindingNames());
309             }
310             actualVariables = Ordering.natural().sortedCopy(variableSet);
311         }
312 
313         final int width = 75 / actualVariables.size();
314         final StringBuilder builder = new StringBuilder();
315         builder.append("<table class=\"sparql table table-condensed tablesorter\"><thead>\n<tr>");
316         for (final String variable : actualVariables) {
317             builder.append("<th style=\"width: ").append(width).append("%\">")
318                     .append(escapeHtml(variable)).append("</th>");
319         }
320         final Iterable<String> header = ImmutableList.of(builder.toString());
321         final Iterable<String> footer = ImmutableList.of("</tbody></table>");
322         final Function<BindingSet, String> renderer = new Function<BindingSet, String>() {
323 
324             @Override
325             public String apply(final BindingSet bindings) {
326                 if (Thread.interrupted()) {
327                     throw new IllegalStateException("Interrupted");
328                 }
329                 final StringBuilder builder = new StringBuilder();
330                 builder.append("<tr>");
331                 for (final String variable : actualVariables) {
332                     builder.append("<td>");
333                     try {
334                         render(bindings.getValue(variable), builder);
335                     } catch (final IOException ex) {
336                         throw new Error(ex);
337                     }
338                     builder.append("</td>");
339                 }
340                 builder.append("</tr>\n");
341                 return builder.toString();
342             }
343 
344         };
345         return Iterables.concat(header, Iterables.transform(solutions, renderer), footer);
346     }
347 
348     public static <T extends Appendable> T renderMultisetTable(final T out,
349             final Multiset<?> multiset, final String elementHeader,
350             final String occurrencesHeader, @Nullable final String linkTemplate)
351             throws IOException {
352 
353         final String tableID = "table" + COUNTER.getAndIncrement();
354         out.append("<table id=\"").append(tableID).append("\" class=\"display datatable\">\n");
355         out.append("<thead>\n<tr><th>").append(MoreObjects.firstNonNull(elementHeader, "Value"))
356                 .append("</th><th>")
357                 .append(MoreObjects.firstNonNull(occurrencesHeader, "Occurrences"))
358                 .append("</th></tr>\n</thead>\n");
359         out.append("<tbody>\n");
360         for (final Object element : multiset.elementSet()) {
361             final int occurrences = multiset.count(element);
362             out.append("<tr><td>");
363             RenderUtils.render(element, out);
364             out.append("</td><td>");
365             if (linkTemplate == null) {
366                 out.append(Integer.toString(occurrences));
367             } else {
368                 final Escaper esc = UrlEscapers.urlFormParameterEscaper();
369                 final String e = esc.escape(Data.toString(element, Data.getNamespaceMap()));
370                 final String u = linkTemplate.replace("${element}", e);
371                 out.append("<a href=\"").append(u).append("\">")
372                         .append(Integer.toString(occurrences)).append("</a>");
373             }
374             out.append("</td></tr>\n");
375         }
376         out.append("</tbody>\n</table>\n");
377         out.append("<script>$(document).ready(function() { applyDataTable('").append(tableID)
378                 .append("', false, {}); });</script>");
379         return out;
380     }
381 
382     public static <T extends Appendable> T renderRecordsTable(final T out,
383             final Iterable<Record> records, @Nullable List<URI> propertyURIs,
384             @Nullable final String extraOptions) throws IOException {
385 
386         // Extract the properties to show if not explicitly supplied
387         if (propertyURIs == null) {
388             final Set<URI> uriSet = Sets.newHashSet();
389             for (final Record record : records) {
390                 uriSet.addAll(record.getProperties());
391             }
392             propertyURIs = Ordering.from(Data.getTotalComparator()).sortedCopy(uriSet);
393         }
394 
395         // Emit the table
396         final String tableID = "table" + COUNTER.getAndIncrement();
397         out.append("<table id=\"").append(tableID).append("\" class=\"display datatable\">\n");
398         out.append("<thead>\n<tr><th>URI</th>");
399         for (final URI propertyURI : propertyURIs) {
400             out.append("<th>").append(RenderUtils.shortenURI(propertyURI)).append("</th>");
401         }
402         out.append("</tr>\n</thead>\n<tbody>\n");
403         for (final Record record : records) {
404             out.append("<tr><td>").append(RenderUtils.render(record.getID())).append("</td>");
405             for (final URI propertyURI : propertyURIs) {
406                 out.append("<td>").append(RenderUtils.render(record.get(propertyURI)))
407                         .append("</td>");
408             }
409             out.append("</tr>\n");
410         }
411         out.append("</tbody>\n</table>\n");
412         out.append("<script>$(document).ready(function() { applyDataTable('").append(tableID)
413                 .append("', true, {").append(Strings.nullToEmpty(extraOptions))
414                 .append("}); });</script>");
415         return out;
416     }
417 
418     public static <T extends Appendable> T renderRecordsAggregateTable(final T out,
419             final Iterable<Record> records, @Nullable final Predicate<URI> propertyFilter,
420             @Nullable final String linkTemplate, @Nullable final String extraOptions)
421             throws IOException {
422 
423         // Aggregate properties and values
424         final Map<URI, Multiset<Value>> properties = Maps.newHashMap();
425         final Map<Object, URI> examples = Maps.newHashMap();
426         for (final Record record : records) {
427             for (final URI property : record.getProperties()) {
428                 if (propertyFilter == null || propertyFilter.apply(property)) {
429                     Multiset<Value> values = properties.get(property);
430                     if (values == null) {
431                         values = HashMultiset.create();
432                         properties.put(property, values);
433                     }
434                     for (final Value value : record.get(property, Value.class)) {
435                         values.add(value);
436                         examples.put(ImmutableList.of(property, value), record.getID());
437                     }
438                 }
439             }
440         }
441 
442         // Emit the table
443         final Ordering<Object> ordering = Ordering.from(Data.getTotalComparator());
444         final String tableID = "table" + COUNTER.getAndIncrement();
445         out.append("<table id=\"").append(tableID).append("\" class=\"display datatable\">\n");
446         out.append("<thead>\n<tr><th>Property</th><th>Value</th>"
447                 + "<th>Occurrences</th><th>Example</th></tr>\n</thead>\n");
448         out.append("<tbody>\n");
449         for (final URI property : ordering.sortedCopy(properties.keySet())) {
450             final Multiset<Value> values = properties.get(property);
451             for (final Value value : ordering.sortedCopy(values.elementSet())) {
452                 final int occurrences = values.count(value);
453                 final URI example = examples.get(ImmutableList.of(property, value));
454                 out.append("<tr><td>");
455                 render(property, out);
456                 out.append("</td><td>");
457                 render(value, out);
458                 out.append("</td><td>");
459                 if (linkTemplate == null) {
460                     out.append(Integer.toString(occurrences));
461                 } else {
462                     final Escaper e = UrlEscapers.urlFormParameterEscaper();
463                     final String p = e.escape(Data.toString(property, Data.getNamespaceMap()));
464                     final String v = e.escape(Data.toString(value, Data.getNamespaceMap()));
465                     final String u = linkTemplate.replace("${property}", p).replace("${value}", v);
466                     out.append("<a href=\"").append(u).append("\">")
467                             .append(Integer.toString(occurrences)).append("</a>");
468                 }
469                 out.append("</td><td>");
470                 render(example, out);
471                 out.append("</td></tr>\n");
472             }
473         }
474         out.append("</tbody>\n</table>\n");
475         out.append("<script>$(document).ready(function() { applyDataTable('").append(tableID)
476                 .append("', false, {").append(Strings.nullToEmpty(extraOptions))
477                 .append("}); });</script>");
478         return out;
479     }
480 
481     /**
482      * Returns a shortened version of the supplied RDF {@code URI}.
483      *
484      * @param uri
485      *            the uri to shorten
486      * @return the shortened URI string
487      */
488     @Nullable
489     public static String shortenURI(@Nullable final URI uri) {
490         if (uri == null) {
491             return null;
492         }
493         final String prefix = Data.namespaceToPrefix(uri.getNamespace(), Data.getNamespaceMap());
494         if (prefix != null) {
495             return prefix + ':' + uri.getLocalName();
496         }
497         final String ns = uri.getNamespace();
498         return "&lt;.." + uri.stringValue().substring(ns.length() - 1) + "&gt;";
499         // final int index = uri.stringValue().lastIndexOf('/');
500         // if (index >= 0) {
501         // return "&lt;.." + uri.stringValue().substring(index) + "&gt;";
502         // }
503         // return "&lt;" + uri.stringValue() + "&gt;";
504     }
505 
506     /**
507      * Transforms the supplied object to an escaped HTML string.
508      *
509      * @param object
510      *            the object
511      * @return the escaped HTML string
512      */
513     @Nullable
514     public static String escapeHtml(@Nullable final Object object) {
515         return object == null ? null : HtmlEscapers.htmlEscaper().escape(object.toString());
516     }
517 
518     public static String escapeJavaScriptString(@Nullable final Object object) {
519         return object == null ? null : object.toString().replaceAll("'", "\\\\'");
520     }
521 
522     private RenderUtils() {
523     }
524 
525 }