/*
* See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
* This is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package com.xpn.xwiki.plugin.feed;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.PrintWriter;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import org.apache.commons.io.output.NullWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Node;
import org.w3c.tidy.Tidy;
import org.xwiki.xml.XMLUtils;
import com.sun.syndication.feed.synd.SyndCategory;
import com.sun.syndication.feed.synd.SyndCategoryImpl;
import com.sun.syndication.feed.synd.SyndContent;
import com.sun.syndication.feed.synd.SyndContentImpl;
import com.sun.syndication.feed.synd.SyndEntry;
import com.xpn.xwiki.XWiki;
import com.xpn.xwiki.XWikiContext;
import com.xpn.xwiki.XWikiException;
import com.xpn.xwiki.api.Document;
import com.xpn.xwiki.doc.XWikiDocument;
import com.xpn.xwiki.util.TidyMessageLogger;
/**
* Concrete strategy for computing the field values of a feed entry from any {@link XWikiDocument} instance.
*/
public class SyndEntryDocumentSource implements SyndEntrySource
{
/**
* Utility class for selecting a property from a XWiki object.
*/
public static class PropertySelector
{
/**
* The name of a XWiki class.
*/
private String className;
/**
* The index of an object within the document that this selector is applied to.
*/
private int objectIndex;
/**
* The name of a property available for {@link #className}.
*/
private String propertyName;
/**
* Creates a new instance from a string representation.
*
* @param strRep a string like "ClassName_ObjectIndex_PropertyName", where class name and object index are
* optional
*/
public PropertySelector(String strRep)
{
int indexStartPos = strRep.indexOf('_');
if (indexStartPos < 0) {
// class name and object index are not specified
this.className = null;
this.objectIndex = 0;
this.propertyName = strRep;
} else {
int propStartPos = strRep.indexOf("_", indexStartPos + 1);
if (propStartPos < 0) {
// object index is not specified
this.className = strRep.substring(0, indexStartPos);
this.objectIndex = 0;
this.propertyName = strRep.substring(indexStartPos + 1);
} else {
// all three have been specified
this.className = strRep.substring(0, indexStartPos);
this.objectIndex = Integer.parseInt(strRep.substring(indexStartPos + 1, propStartPos));
this.propertyName = strRep.substring(propStartPos + 1);
}
}
}
/**
* @return the name of a XWiki class
*/
public String getClassName()
{
return this.className;
}
/**
* @return the index of an object within the document that this selector is applied to
*/
public int getObjectIndex()
{
return this.objectIndex;
}
/**
* @return the name of a property available for {@link #className}
*/
public String getPropertyName()
{
return this.propertyName;
}
}
protected static final Logger LOGGER = LoggerFactory.getLogger(SyndEntryDocumentSource.class);
protected static final TidyMessageLogger TIDY_LOGGER = new TidyMessageLogger(LOGGER);
public static final String CONTENT_TYPE = "ContentType";
public static final String CONTENT_LENGTH = "ContentLength";
public static final Properties TIDY_FEED_CONFIG;
public static final Properties TIDY_XML_CONFIG;
public static final Properties TIDY_HTML_CONFIG;
public static final String FIELD_URI = "uri";
public static final String FIELD_LINK = "link";
public static final String FIELD_TITLE = "title";
public static final String FIELD_DESCRIPTION = "description";
public static final String FIELD_CATEGORIES = "categories";
public static final String FIELD_PUBLISHED_DATE = "publishedDate";
public static final String FIELD_UPDATED_DATE = "updatedDate";
public static final String FIELD_AUTHOR = "author";
public static final String FIELD_CONTRIBUTORS = "contributors";
public static final Map<String, Object> DEFAULT_PARAMS;
static {
// general configuration
TIDY_FEED_CONFIG = new Properties();
TIDY_FEED_CONFIG.setProperty("force-output", "yes");
TIDY_FEED_CONFIG.setProperty("indent-attributes", "no");
TIDY_FEED_CONFIG.setProperty("indent", "no");
TIDY_FEED_CONFIG.setProperty("quiet", "yes");
TIDY_FEED_CONFIG.setProperty("trim-empty-elements", "yes");
// XML specific configuration
TIDY_XML_CONFIG = new Properties(TIDY_FEED_CONFIG);
TIDY_XML_CONFIG.setProperty("input-xml", "yes");
TIDY_XML_CONFIG.setProperty("output-xml", "yes");
TIDY_XML_CONFIG.setProperty("add-xml-pi", "no");
TIDY_XML_CONFIG.setProperty("input-encoding", "UTF8");
// HTML specific configuration
TIDY_HTML_CONFIG = new Properties(TIDY_FEED_CONFIG);
TIDY_HTML_CONFIG.setProperty("output-xhtml", "yes");
TIDY_HTML_CONFIG.setProperty("show-body-only", "yes");
TIDY_HTML_CONFIG.setProperty("drop-empty-paras", "yes");
TIDY_HTML_CONFIG.setProperty("enclose-text", "yes");
TIDY_HTML_CONFIG.setProperty("logical-emphasis", "yes");
TIDY_HTML_CONFIG.setProperty("input-encoding", "UTF8");
// default parameters for all instances of this class
DEFAULT_PARAMS = new HashMap<String, Object>();
DEFAULT_PARAMS.put(CONTENT_TYPE, "text/html");
DEFAULT_PARAMS.put(CONTENT_LENGTH, -1); // no limit by default
}
/**
* Strategy instance parameters. Each concrete strategy can define its own (paramName, paramValue) pairs. These
* parameters are overwritten by those used when calling
* {@link SyndEntrySource#source(SyndEntry, Object, Map, XWikiContext)} method
*/
private Map<String, Object> params;
public SyndEntryDocumentSource()
{
this(new HashMap<String, Object>());
}
/**
* Creates a new instance. The given parameters overwrite {@link #DEFAULT_PARAMS}.
*
* @param params parameters only for this instance
*/
public SyndEntryDocumentSource(Map<String, Object> params)
{
setParams(params);
}
/**
* @return instance parameters
*/
public Map<String, Object> getParams()
{
return this.params;
}
/**
* Sets instance parameters. Instance parameters overwrite {@link #DEFAULT_PARAMS}
*
* @param params instance parameters
*/
public void setParams(Map<String, Object> params)
{
this.params = joinParams(params, getDefaultParams());
}
/**
* Strategy class parameters
*/
protected Map<String, Object> getDefaultParams()
{
return DEFAULT_PARAMS;
}
@Override
public void source(SyndEntry entry, Object obj, Map<String, Object> params, XWikiContext context)
throws XWikiException
{
// cast source
Document doc = castDocument(obj, context);
// test access rights
if (!doc.hasAccessLevel("view")) {
throw new XWikiException(XWikiException.MODULE_XWIKI_ACCESS, XWikiException.ERROR_XWIKI_ACCESS_DENIED,
"Access denied in view mode!");
}
// prepare parameters (overwrite instance parameters)
Map<String, Object> trueParams = joinParams(params, getParams());
sourceDocument(entry, doc, trueParams, context);
}
/**
* Overwrites the current values of the given feed entry with new ones computed from the specified source document.
*
* @param entry the feed entry whose fields are going to be overwritten
* @param doc the source for the new values to be set on the fields of the feed entry
* @param params parameters to adjust the computation. Each concrete strategy may define its own (key, value) pairs
* @param context the XWiki context
* @throws XWikiException
*/
public void sourceDocument(SyndEntry entry, Document doc, Map<String, Object> params, XWikiContext context)
throws XWikiException
{
entry.setUri(getURI(doc, params, context));
entry.setLink(getLink(doc, params, context));
entry.setTitle(getTitle(doc, params, context));
entry.setDescription(getDescription(doc, params, context));
entry.setCategories(getCategories(doc, params, context));
entry.setPublishedDate(getPublishedDate(doc, params, context));
entry.setUpdatedDate(getUpdateDate(doc, params, context));
entry.setAuthor(getAuthor(doc, params, context));
entry.setContributors(getContributors(doc, params, context));
}
protected String getDefaultURI(Document doc, Map<String, Object> params, XWikiContext context)
throws XWikiException
{
return doc.getExternalURL("view", "language=" + doc.getRealLanguage());
}
protected String getURI(Document doc, Map<String, Object> params, XWikiContext context) throws XWikiException
{
String mapping = (String) params.get(FIELD_URI);
if (mapping == null) {
return getDefaultURI(doc, params, context);
} else if (isVelocityCode(mapping)) {
return parseString(mapping, doc, context);
} else {
return getStringValue(mapping, doc, context);
}
}
protected String getDefaultLink(Document doc, Map<String, Object> params, XWikiContext context)
throws XWikiException
{
return getDefaultURI(doc, params, context);
}
protected String getLink(Document doc, Map<String, Object> params, XWikiContext context) throws XWikiException
{
String mapping = (String) params.get(FIELD_LINK);
if (mapping == null) {
return getDefaultLink(doc, params, context);
} else if (isVelocityCode(mapping)) {
return parseString(mapping, doc, context);
} else {
return getStringValue(mapping, doc, context);
}
}
protected String getDefaultTitle(Document doc, Map<String, Object> params, XWikiContext context)
{
return doc.getDisplayTitle();
}
protected String getTitle(Document doc, Map<String, Object> params, XWikiContext context) throws XWikiException
{
String mapping = (String) params.get(FIELD_TITLE);
if (mapping == null) {
return getDefaultTitle(doc, params, context);
} else if (isVelocityCode(mapping)) {
return parseString(mapping, doc, context);
} else {
return getStringValue(mapping, doc, context);
}
}
protected String getDefaultDescription(Document doc, Map<String, Object> params, XWikiContext context)
{
XWiki xwiki = context.getWiki();
String author = xwiki.getUserName(doc.getAuthor(), null, false, context);
// the description format should be taken from a resource bundle, and thus localized
String descFormat = "Version %1$s edited by %2$s on %3$s";
return String.format(descFormat, new Object[] {doc.getVersion(), author, doc.getDate()});
}
protected SyndContent getDescription(Document doc, Map<String, Object> params, XWikiContext context)
throws XWikiException
{
String description;
String mapping = (String) params.get(FIELD_DESCRIPTION);
if (mapping == null) {
description = getDefaultDescription(doc, params, context);
} else if (isVelocityCode(mapping)) {
description = parseString(mapping, doc, context);
} else {
description = doc.getRenderedContent(getStringValue(mapping, doc, context), doc.getSyntaxId());
}
String contentType = (String) params.get(CONTENT_TYPE);
int contentLength = ((Number) params.get(CONTENT_LENGTH)).intValue();
if (contentLength >= 0) {
if ("text/plain".equals(contentType)) {
description = getPlainPreview(description, contentLength);
} else if ("text/html".equals(contentType)) {
description = getHTMLPreview(description, contentLength);
} else if ("text/xml".equals(contentType)) {
description = getXMLPreview(description, contentLength);
}
}
return getSyndContent(contentType, description);
}
protected List<SyndCategory> getDefaultCategories(Document doc, Map<String, Object> params, XWikiContext context)
{
return Collections.emptyList();
}
protected List<SyndCategory> getCategories(Document doc, Map<String, Object> params, XWikiContext context)
throws XWikiException
{
String mapping = (String) params.get(FIELD_CATEGORIES);
if (mapping == null) {
return getDefaultCategories(doc, params, context);
}
List<Object> categories;
if (isVelocityCode(mapping)) {
categories = parseList(mapping, doc, context);
} else {
categories = getListValue(mapping, doc, context);
}
List<SyndCategory> result = new ArrayList<SyndCategory>();
for (Object category : categories) {
if (category instanceof SyndCategory) {
result.add((SyndCategory) category);
} else if (category != null) {
SyndCategory scat = new SyndCategoryImpl();
scat.setName(category.toString());
result.add(scat);
}
}
return result;
}
protected Date getDefaultPublishedDate(Document doc, Map<String, Object> params, XWikiContext context)
{
return doc.getDate();
}
protected Date getPublishedDate(Document doc, Map<String, Object> params, XWikiContext context)
throws XWikiException
{
String mapping = (String) params.get(FIELD_PUBLISHED_DATE);
if (mapping == null) {
return getDefaultPublishedDate(doc, params, context);
} else if (isVelocityCode(mapping)) {
return parseDate(mapping, doc, context);
} else {
return getDateValue(mapping, doc, context);
}
}
protected Date getDefaultUpdateDate(Document doc, Map<String, Object> params, XWikiContext context)
{
return doc.getDate();
}
protected Date getUpdateDate(Document doc, Map<String, Object> params, XWikiContext context) throws XWikiException
{
String mapping = (String) params.get(FIELD_UPDATED_DATE);
if (mapping == null) {
return getDefaultUpdateDate(doc, params, context);
} else if (isVelocityCode(mapping)) {
return parseDate(mapping, doc, context);
} else {
return getDateValue(mapping, doc, context);
}
}
protected String getDefaultAuthor(Document doc, Map<String, Object> params, XWikiContext context)
{
return context.getWiki().getUserName(doc.getCreator(), null, false, context);
}
protected String getAuthor(Document doc, Map<String, Object> params, XWikiContext context) throws XWikiException
{
String mapping = (String) params.get(FIELD_AUTHOR);
if (mapping == null) {
return getDefaultAuthor(doc, params, context);
} else if (isVelocityCode(mapping)) {
return parseString(mapping, doc, context);
} else {
return getStringValue(mapping, doc, context);
}
}
protected List<String> getDefaultContributors(Document doc, Map<String, Object> params, XWikiContext context)
{
XWiki xwiki = context.getWiki();
List<String> contributors = new ArrayList<String>();
contributors.add(xwiki.getUserName(doc.getAuthor(), null, false, context));
return contributors;
}
protected List<String> getContributors(Document doc, Map<String, Object> params, XWikiContext context)
throws XWikiException
{
String mapping = (String) params.get(FIELD_CONTRIBUTORS);
if (mapping == null) {
return getDefaultContributors(doc, params, context);
}
List<Object> rawContributors;
if (isVelocityCode(mapping)) {
rawContributors = parseList(mapping, doc, context);
} else {
rawContributors = getListValue(mapping, doc, context);
}
List<String> contributors = new ArrayList<String>();
for (Object rawContributor : rawContributors) {
if (rawContributor instanceof String) {
contributors.add((String) rawContributor);
} else {
contributors.add(rawContributor.toString());
}
}
return contributors;
}
/**
* Distinguishes between mapping to a property and mapping to a velocity code.
*
* @param mapping
* @return true if the given string is a mapping to a velocity code
*/
protected boolean isVelocityCode(String mapping)
{
return mapping.charAt(0) == '{' && mapping.charAt(mapping.length() - 1) == '}';
}
protected String parseString(String mapping, Document doc, XWikiContext context) throws XWikiException
{
if (isVelocityCode(mapping)) {
return doc.getRenderedContent(mapping.substring(1, mapping.length() - 1), doc.getSyntax().toIdString());
} else {
return mapping;
}
}
/**
* Converts the given velocity string to a {@link Date} instance. The velocity code must be evaluated to a long
* representing a time stamp.
*/
protected Date parseDate(String mapping, Document doc, XWikiContext context) throws NumberFormatException,
XWikiException
{
if (isVelocityCode(mapping)) {
return new Date(Long.parseLong(parseString(mapping, doc, context).trim()));
} else {
return null;
}
}
/**
* Converts the given velocity code to a {@link List} instance. The velocity code must be evaluated to a string with
* the following format: "[item1,item2,...,itemN]".
*/
protected List<Object> parseList(String mapping, Document doc, XWikiContext context) throws XWikiException
{
if (!isVelocityCode(mapping)) {
return null;
}
String strRep = parseString(mapping, doc, context).trim();
if (strRep.charAt(0) != '[' || strRep.charAt(strRep.length() - 1) != ']') {
return null;
}
String[] array = strRep.substring(1, strRep.length() - 1).split(",");
if (array.length > 0) {
List<Object> list = new ArrayList<Object>();
for (int i = 0; i < array.length; i++) {
list.add(array[i]);
}
return list;
} else {
return Collections.emptyList();
}
}
/**
* Retrieves the value of a string property.
*/
protected String getStringValue(String mapping, Document doc, XWikiContext context) throws XWikiException
{
PropertySelector ps = new PropertySelector(mapping);
if (ps.getClassName() == null) {
return doc.display(ps.getPropertyName());
} else {
XWikiDocument xdoc = context.getWiki().getDocument(doc.getFullName(), context);
return xdoc.getObject(ps.getClassName(), ps.getObjectIndex()).getStringValue(ps.getPropertyName());
}
}
/**
* Retrieves the value of a date property.
*/
protected Date getDateValue(String mapping, Document doc, XWikiContext context) throws XWikiException
{
XWikiDocument xdoc = context.getWiki().getDocument(doc.getFullName(), context);
PropertySelector ps = new PropertySelector(mapping);
if (ps.getClassName() == null) {
return xdoc.getFirstObject(ps.getPropertyName(), context).getDateValue(ps.getPropertyName());
} else {
return xdoc.getObject(ps.getClassName(), ps.getObjectIndex()).getDateValue(ps.getPropertyName());
}
}
/**
* Retrieves the value of a list property.
*/
protected List<Object> getListValue(String mapping, Document doc, XWikiContext context) throws XWikiException
{
XWikiDocument xdoc = context.getWiki().getDocument(doc.getFullName(), context);
PropertySelector ps = new PropertySelector(mapping);
if (ps.getClassName() == null) {
return xdoc.getFirstObject(ps.getPropertyName(), context).getListValue(ps.getPropertyName());
} else {
return xdoc.getObject(ps.getClassName(), ps.getObjectIndex()).getListValue(ps.getPropertyName());
}
}
/**
* @return base + (extra - base)
*/
protected Map<String, Object> joinParams(Map<String, Object> base, Map<String, Object> extra)
{
Map<String, Object> params = new HashMap<String, Object>();
params.putAll(base);
for (Map.Entry<String, Object> entry : extra.entrySet()) {
if (params.get(entry.getKey()) == null) {
params.put(entry.getKey(), entry.getValue());
}
}
return params;
}
/**
* Cleans up the given XML fragment using the specified configuration.
*
* @param xmlFragment the XML fragment to be cleaned up
* @param config the configuration properties to use
* @return the DOM tree associated with the cleaned up XML fragment
*/
public static org.w3c.dom.Document tidy(String xmlFragment, Properties config)
{
Tidy tidy = new Tidy();
tidy.setConfigurationFromProps(config);
// We capture the logs and redirect them to the XWiki logging subsystem. Since we do this we don't want
// JTidy warnings and errors to be sent to stderr/stdout
tidy.setMessageListener(TIDY_LOGGER);
tidy.setErrout(new PrintWriter(new NullWriter()));
// Even if we add a message listener we still have to redirect the output. Otherwise all the messages will be
// written to the standard output (besides being logged by TIDY_LOGGER).
ByteArrayOutputStream out = new ByteArrayOutputStream();
ByteArrayInputStream in = new ByteArrayInputStream(xmlFragment.getBytes(Charset.forName(config.getProperty("input-encoding"))));
return tidy.parseDOM(in, out);
}
/**
* Computes the sum of lengths of all the text nodes within the given DOM sub-tree
*
* @param node the root of the DOM sub-tree containing the text
* @return the sum of lengths of text nodes within the given DOM sub-tree
*/
public static int innerTextLength(Node node)
{
switch (node.getNodeType()) {
case Node.TEXT_NODE:
return node.getNodeValue().length();
case Node.ELEMENT_NODE:
int length = 0;
Node child = node.getFirstChild();
while (child != null) {
length += innerTextLength(child);
child = child.getNextSibling();
}
return length;
case Node.DOCUMENT_NODE:
return innerTextLength(((org.w3c.dom.Document) node).getDocumentElement());
default:
return 0;
}
}
/**
* Extracts the first characters of the given XML fragment, up to the given length limit, adding only characters in
* XML text nodes. The XML fragment is cleaned up before extracting the prefix to be sure that the result is
* well-formed.
*
* @param xmlFragment the full XML text
* @param previewLength the maximum number of characters allowed in the preview, considering only the XML text nodes
* @return a prefix of the given XML fragment summing at most <code>previewLength</code> characters in its text
* nodes
*/
public static String getXMLPreview(String xmlFragment, int previewLength)
{
try {
return XMLUtils.extractXML(tidy(xmlFragment, TIDY_XML_CONFIG), 0, previewLength);
} catch (RuntimeException e) {
return getPlainPreview(xmlFragment, previewLength);
}
}
/**
* Extracts the first characters of the given HTML fragment, up to the given length limit, adding only characters in
* HTML text nodes. The HTML fragment is cleaned up before extracting the prefix to be sure the result is
* well-formed.
*
* @param htmlFragment the full HTML text
* @param previewLength the maximum number of characters allowed in the preview, considering only the HTML text
* nodes
* @return a prefix of the given HTML fragment summing at most <code>previewLength</code> characters in its text
* nodes
*/
public static String getHTMLPreview(String htmlFragment, int previewLength)
{
try {
org.w3c.dom.Document html = tidy(htmlFragment, TIDY_HTML_CONFIG);
Node body = html.getElementsByTagName("body").item(0);
return XMLUtils.extractXML(body.getFirstChild(), 0, previewLength);
} catch (RuntimeException e) {
return getPlainPreview(htmlFragment, previewLength);
}
}
/**
* Extracts the first characters of the given text, up to the last space within the given length limit.
*
* @param plainText the full text
* @param previewLength the maximum number of characters allowed in the preview
* @return a prefix of the <code>plainText</code> having at most <code>previewLength</code> characters
*/
public static String getPlainPreview(String plainText, int previewLength)
{
if (plainText.length() <= previewLength) {
return plainText;
}
// We remove the leading and trailing spaces from the given text to avoid interfering
// with the last space within the length limit
plainText = plainText.trim();
if (plainText.length() <= previewLength) {
return plainText;
}
int spaceIndex = plainText.lastIndexOf(" ", previewLength);
if (spaceIndex < 0) {
spaceIndex = previewLength;
}
plainText = plainText.substring(0, spaceIndex);
return plainText;
}
/**
* Creates a new {@link SyndContent} instance for the given content type and value.
*
* @param type content type
* @param value the content
* @return a new {@link SyndContent} instance
*/
protected SyndContent getSyndContent(String type, String value)
{
SyndContent content = new SyndContentImpl();
content.setType(type);
content.setValue(value);
return content;
}
/**
* Casts the given object to a {@link Document} instance. The given object must be either a {@link Document}
* instance already, a {@link XWikiDocument} instance or the full name of the document.
*
* @param obj object to be casted
* @param context the XWiki context
* @return the document associated with the given object
* @throws XWikiException if the given object is neither a {@link Document} instance, a {@link XWikiDocument}
* instance nor the full name of the document
*/
protected Document castDocument(Object obj, XWikiContext context) throws XWikiException
{
if (obj instanceof Document) {
return (Document) obj;
} else if (obj instanceof XWikiDocument) {
return ((XWikiDocument) obj).newDocument(context);
} else if (obj instanceof String) {
return context.getWiki().getDocument((String) obj, context).newDocument(context);
} else {
throw new XWikiException(XWikiException.MODULE_XWIKI_PLUGINS, XWikiException.ERROR_XWIKI_DOES_NOT_EXIST, "");
}
}
}