package com.psddev.cms.rte;
import java.io.IOException;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import com.google.common.base.Preconditions;
import org.jsoup.Jsoup;
import org.jsoup.nodes.DataNode;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
import com.psddev.cms.db.RichTextElement;
import com.psddev.dari.db.ObjectType;
import com.psddev.dari.db.Reference;
import com.psddev.dari.db.ReferentialText;
import com.psddev.dari.util.HtmlWriter;
import com.psddev.dari.util.ObjectUtils;
/**
* Builder that can convert HTML into views.
*
* <p>Typical use within a {@link com.psddev.cms.view.ViewModel} implementation
* might look like:</p>
*
* <blockquote><pre>
* {@code List<MyRichTextItemView> views = RichTextViewBuilder.build(model.getBody(), rte -> createView(MyRichTextItemView.class, rte);}
* </pre></blockquote>
*/
public class RichTextViewBuilder<V> {
private final String html;
private Function<String, V> htmlToView;
private Function<RichTextElement, V> elementToView;
private boolean keepUnboundElements;
private final List<RichTextPreprocessor> preprocessors = new ArrayList<>();
/**
* Creates a new instance that will convert the given {@code html}.
*
* @param html Nonnull.
*/
public RichTextViewBuilder(String html) {
Preconditions.checkNotNull(html);
this.html = html;
}
/**
* Creates a new instance that will convert the given {@code text}.
*
* @param text Nonnull.
*/
public RichTextViewBuilder(ReferentialText text) {
Preconditions.checkNotNull(text);
this.html = toHtml(text);
}
// Converts ref text into an HTML string.
private static String toHtml(ReferentialText text) {
if (text == null) {
return null;
}
StringBuilder builder = new StringBuilder();
for (Object item : text) {
if (item instanceof Reference) {
Reference ref = (Reference) item;
StringWriter refString = new StringWriter();
HtmlWriter refHtml = new HtmlWriter(refString);
try {
refHtml.writeStart(
ReferenceRichTextElement.TAG_NAME,
ReferenceRichTextElement.VALUES_ATTRIBUTE,
ObjectUtils.toJson(ref.getState().getSimpleValues()));
refHtml.writeEnd();
} catch (IOException error) {
throw new IllegalStateException(error);
}
builder.append(refString);
} else {
builder.append(item);
}
}
return builder.toString();
}
/**
* Converts the given given {@code html} into a list of views using the
* given {@code elementToView} function.
*
* <p>This method sets the most commonly used options automatically.</p>
*
* <p>Note that this API deliberately breaks the generics contract of the
* returned list, such that it may contain instances of
* {@link com.psddev.cms.view.RawView}. Thus, explicitly iterating over
* the returned list with the type of the generic argument may result in
* a {@link java.lang.ClassCastException}.</p>
*
* <p>If explicit iteration is required for your application to function
* properly, cast the list to {@code List<Object>} and do an
* {@code instanceof} check before casting to the desired type, or use the
* more general {@link #RichTextViewBuilder(String)} API and call
* {@link #htmlToView(Function)} to supply a function that conforms to the
* generic type.</p>
*
* @param <V> The type of views to return.
* @param html If {@code null}, returns an empty list.
* @param elementToView Nonnull.
* @return Nonnull.
*/
public static <V> List<V> build(String html, Function<RichTextElement, V> elementToView) {
Preconditions.checkNotNull(elementToView);
if (html != null) {
return new RichTextViewBuilder<V>(html)
.addPreprocessor(new EditorialMarkupRichTextPreprocessor())
.addPreprocessor(new LineBreakRichTextPreprocessor())
.elementToView(elementToView)
.build();
} else {
return Collections.emptyList();
}
}
/**
* Converts the given given {@code text} into a list of views using the
* given {@code elementToView} function.
*
* @param <V> The type of views to return.
* @param text If {@code null}, returns an empty list.
* @param elementToView Nonnull.
* @return Nonnull.
* @see #build(String, Function)
*/
public static <V> List<V> build(ReferentialText text, Function<RichTextElement, V> elementToView) {
return build(toHtml(text), elementToView);
}
/**
* Sets the function that's used to convert raw HTML into a view.
*
* <p>Note that the raw HTML may be unbalanced.</p>
*
* @param htmlToView Nullable.
* @return Itself.
*/
public RichTextViewBuilder<V> htmlToView(Function<String, V> htmlToView) {
this.htmlToView = htmlToView;
return this;
}
/**
* Sets the handler that's used to convert a rich text element into a view.
*
* @param elementToView Nullable.
* @return Itself.
*/
public RichTextViewBuilder<V> elementToView(Function<RichTextElement, V> elementToView) {
this.elementToView = elementToView;
return this;
}
/**
* Sets whether the rich text element tags should remain in the output
* when there aren't any {@link com.psddev.cms.view.ViewBinding}s on them.
*
* @return Itself.
*/
public RichTextViewBuilder<V> keepUnboundElements(boolean keepUnboundElements) {
this.keepUnboundElements = keepUnboundElements;
return this;
}
/**
* Adds a rich text preprocessor to be applied to the rich text prior to the
* transformation into a set of views.
*
* @param preprocessor the rich text preprocessor to be applied.
* @return this builder.
*/
public RichTextViewBuilder<V> addPreprocessor(RichTextPreprocessor preprocessor) {
Preconditions.checkNotNull(preprocessor);
this.preprocessors.add(preprocessor);
return this;
}
/**
* Converts the HTML into a list of views.
*
* @return Nonnull.
*/
public List<V> build() {
List<V> views = new ArrayList<>();
Document document = Jsoup.parseBodyFragment(html);
document.outputSettings().prettyPrint(false);
for (RichTextPreprocessor preprocessor : preprocessors) {
preprocessor.preprocess(document.body());
}
toViewNodes(document.body().childNodes())
.stream()
.map(RichTextViewNode::toView)
.filter(Objects::nonNull)
.forEach(views::add);
return views;
}
// Traverses the siblings all the way down the tree, collapsing balanced
// blocks of HTML that do NOT contain any rich text elements into a single
// HTML string.
private List<RichTextViewNode<V>> toViewNodes(List<Node> siblings) {
List<RichTextViewNode<V>> viewNodes = new ArrayList<>();
for (Node sibling : siblings) {
if (sibling instanceof Element) {
Element element = (Element) sibling;
RichTextElement rte = RichTextElement.fromElement(element);
ObjectType tagType = rte != null ? rte.getState().getType() : null;
if (rte != null && elementToView != null) {
viewNodes.add(new ElementRichTextViewNode<>(rte, elementToView));
} else if (tagType == null || keepUnboundElements) {
List<RichTextViewNode<V>> childViewNodes = toViewNodes(element.childNodes());
String html = element.outerHtml();
if (element.tag().isSelfClosing()) {
viewNodes.add(new StringRichTextViewNode<>(html, htmlToView));
} else {
int firstGtAt = html.indexOf('>');
int lastLtAt = html.lastIndexOf('<');
// This deliberately does not validate the index values
// above, since non-self-closing element should always
// have those characters present in the HTML.
viewNodes.add(new StringRichTextViewNode<>(html.substring(0, firstGtAt + 1), htmlToView));
viewNodes.addAll(childViewNodes);
viewNodes.add(new StringRichTextViewNode<>(html.substring(lastLtAt), htmlToView));
}
}
} else if (sibling instanceof TextNode) {
viewNodes.add(new StringRichTextViewNode<>(((TextNode) sibling).text(), htmlToView));
} else if (sibling instanceof DataNode) {
viewNodes.add(new StringRichTextViewNode<>(((DataNode) sibling).getWholeData(), htmlToView));
}
}
// Collapse the nodes as much as possible.
List<RichTextViewNode<V>> collapsed = new ArrayList<>();
List<StringRichTextViewNode<V>> adjacent = new ArrayList<>();
for (RichTextViewNode<V> childBuilderNode : viewNodes) {
if (childBuilderNode instanceof StringRichTextViewNode) {
adjacent.add((StringRichTextViewNode<V>) childBuilderNode);
} else {
collapsed.add(new StringRichTextViewNode<>(
adjacent.stream()
.map(StringRichTextViewNode::getHtml)
.collect(Collectors.joining()), htmlToView));
adjacent.clear();
collapsed.add(childBuilderNode);
}
}
if (!adjacent.isEmpty()) {
collapsed.add(new StringRichTextViewNode<>(
adjacent.stream()
.map(StringRichTextViewNode::getHtml)
.collect(Collectors.joining()), htmlToView));
}
return collapsed;
}
}