diff --git a/dspace/modules/additions/src/main/java/org/dspace/app/util/SyndicationFeed.java b/dspace/modules/additions/src/main/java/org/dspace/app/util/SyndicationFeed.java new file mode 100644 index 0000000000000000000000000000000000000000..eb88ff704f8171347897d244abcbfa6e6eb1b88c --- /dev/null +++ b/dspace/modules/additions/src/main/java/org/dspace/app/util/SyndicationFeed.java @@ -0,0 +1,574 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +package org.dspace.app.util; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import javax.servlet.http.HttpServletRequest; + +import org.apache.commons.lang.ArrayUtils; +import org.apache.commons.lang.StringUtils; +import org.w3c.dom.Document; + +import org.dspace.content.Bitstream; +import org.dspace.content.Collection; +import org.dspace.content.Community; +import org.dspace.content.DCDate; +import org.dspace.content.DCValue; +import org.dspace.content.DSpaceObject; +import org.dspace.content.Item; +import org.dspace.core.ConfigurationManager; +import org.dspace.core.Constants; +import org.dspace.handle.HandleManager; + +import com.sun.syndication.feed.synd.SyndFeed; +import com.sun.syndication.feed.synd.SyndFeedImpl; +import com.sun.syndication.feed.synd.SyndEntry; +import com.sun.syndication.feed.synd.SyndEntryImpl; +import com.sun.syndication.feed.synd.SyndEnclosure; +import com.sun.syndication.feed.synd.SyndEnclosureImpl; +import com.sun.syndication.feed.synd.SyndImage; +import com.sun.syndication.feed.synd.SyndImageImpl; +import com.sun.syndication.feed.synd.SyndPerson; +import com.sun.syndication.feed.synd.SyndPersonImpl; +import com.sun.syndication.feed.synd.SyndContent; +import com.sun.syndication.feed.synd.SyndContentImpl; +import com.sun.syndication.feed.module.DCModuleImpl; +import com.sun.syndication.feed.module.DCModule; +import com.sun.syndication.feed.module.Module; +import com.sun.syndication.feed.module.itunes.*; +import com.sun.syndication.feed.module.itunes.types.Duration; +import com.sun.syndication.io.SyndFeedOutput; +import com.sun.syndication.io.FeedException; + +import org.apache.log4j.Logger; +import org.dspace.content.Bundle; + +/** + * Invoke ROME library to assemble a generic model of a syndication + * for the given list of Items and scope. Consults configuration for the + * metadata bindings to feed elements. Uses ROME's output drivers to + * return any of the implemented formats, e.g. RSS 1.0, RSS 2.0, ATOM 1.0. + * + * The feed generator and OpenSearch call on this class so feed contents are + * uniform for both. + * + * @author Larry Stone + */ +public class SyndicationFeed +{ + private static final Logger log = Logger.getLogger(SyndicationFeed.class); + + + /** i18n key values */ + public static final String MSG_UNTITLED = "notitle"; + public static final String MSG_LOGO_TITLE = "logo.title"; + public static final String MSG_FEED_TITLE = "feed.title"; + public static final String MSG_FEED_DESCRIPTION = "general-feed.description"; + public static final String MSG_METADATA = "metadata."; + public static final String MSG_UITYPE = "ui.type"; + + // UI keywords + public static final String UITYPE_XMLUI = "xmlui"; + public static final String UITYPE_JSPUI = "jspui"; + + // default DC fields for entry + private static String defaultTitleField = "dc.title"; + private static String defaultAuthorField = "dc.contributor.author"; + private static String defaultDateField = "dc.date.issued"; + private static String defaultDescriptionFields = "dc.description.abstract, dc.description, dc.title.alternative, dc.title"; + private static String defaultExternalMedia = "dc.source.uri"; + + // metadata field for Item title in entry: + private static String titleField = + getDefaultedConfiguration("webui.feed.item.title", defaultTitleField); + + // metadata field for Item publication date in entry: + private static String dateField = + getDefaultedConfiguration("webui.feed.item.date", defaultDateField); + + // metadata field for Item description in entry: + private static String descriptionFields[] = + getDefaultedConfiguration("webui.feed.item.description", defaultDescriptionFields).split("\\s*,\\s*"); + + private static String authorField = + getDefaultedConfiguration("webui.feed.item.author", defaultAuthorField); + + // metadata field for Podcast external media source url + private static String externalSourceField = getDefaultedConfiguration("webui.feed.podcast.sourceuri", defaultExternalMedia); + + // metadata field for Item dc:creator field in entry's DCModule (no default) + private static String dcCreatorField = ConfigurationManager.getProperty("webui.feed.item.dc.creator"); + + // metadata field for Item dc:date field in entry's DCModule (no default) + private static String dcDateField = ConfigurationManager.getProperty("webui.feed.item.dc.date"); + + // metadata field for Item dc:author field in entry's DCModule (no default) + private static String dcDescriptionField = ConfigurationManager.getProperty("webui.feed.item.dc.description"); + + // List of available mimetypes that we'll add to podcast feed. Multiple values separated by commas + private static String podcastableMIMETypes = getDefaultedConfiguration("webui.feed.podcast.mimetypes", "audio/x-mpeg"); + + // -------- Instance variables: + + // the feed object we are building + private SyndFeed feed = null; + + // memory of UI that called us, "xmlui" or "jspui" + // affects Bitstream retrieval URL and I18N keys + private String uiType = null; + + private HttpServletRequest request = null; + + /** + * Constructor. + * @param ui either "xmlui" or "jspui" + */ + public SyndicationFeed(String ui) + { + feed = new SyndFeedImpl(); + uiType = ui; + } + + /** + * Returns list of metadata selectors used to compose the description element + * + * @return selector list - format 'schema.element[.qualifier]' + */ + public static String[] getDescriptionSelectors() + { + return (String[]) ArrayUtils.clone(descriptionFields); + } + + + /** + * Fills in the feed and entry-level metadata from DSpace objects. + */ + public void populate(HttpServletRequest request, DSpaceObject dso, + DSpaceObject items[], Map<String, String> labels) + { + String logoURL = null; + String objectURL = null; + String defaultTitle = null; + boolean podcastFeed = false; + this.request = request; + + // dso is null for the whole site, or a search without scope + if (dso == null) + { + defaultTitle = ConfigurationManager.getProperty("dspace.name"); + feed.setDescription(localize(labels, MSG_FEED_DESCRIPTION)); + objectURL = resolveURL(request, null); + logoURL = ConfigurationManager.getProperty("webui.feed.logo.url"); + } + else + { + Bitstream logo = null; + if (dso.getType() == Constants.COLLECTION) + { + Collection col = (Collection)dso; + defaultTitle = col.getMetadata("name"); + feed.setDescription(col.getMetadata("short_description")); + logo = col.getLogo(); + String cols = ConfigurationManager.getProperty("webui.feed.podcast.collections"); + if(cols != null && cols.length() > 1 && cols.contains(col.getHandle()) ) { + podcastFeed = true; + } + } + else if (dso.getType() == Constants.COMMUNITY) + { + Community comm = (Community)dso; + defaultTitle = comm.getMetadata("name"); + feed.setDescription(comm.getMetadata("short_description")); + logo = comm.getLogo(); + String comms = ConfigurationManager.getProperty("webui.feed.podcast.communities"); + if(comms != null && comms.length() > 1 && comms.contains(comm.getHandle()) ){ + podcastFeed = true; + } + } + objectURL = resolveURL(request, dso); + if (logo != null) + { + logoURL = urlOfBitstream(request, logo); + } + } + feed.setTitle(labels.containsKey(MSG_FEED_TITLE) ? + localize(labels, MSG_FEED_TITLE) : defaultTitle); + feed.setLink(objectURL); + feed.setPublishedDate(new Date()); + feed.setUri(objectURL); + + // add logo if we found one: + if (logoURL != null) + { + // we use the path to the logo for this, the logo itself cannot + // be contained in the rdf. Not all RSS-viewers show this logo. + SyndImage image = new SyndImageImpl(); + image.setLink(objectURL); + if (StringUtils.isNotBlank(feed.getTitle())) { + image.setTitle(feed.getTitle()); + } else { + image.setTitle(localize(labels, MSG_LOGO_TITLE)); + } + image.setUrl(logoURL); + feed.setImage(image); + } + + // add entries for items + if (items != null) + { + List<SyndEntry> entries = new ArrayList<SyndEntry>(); + for (DSpaceObject itemDSO : items) + { + if (itemDSO.getType() != Constants.ITEM) + { + continue; + } + Item item = (Item)itemDSO; + boolean hasDate = false; + SyndEntry entry = new SyndEntryImpl(); + entries.add(entry); + + String entryURL = resolveURL(request, item); + entry.setLink(entryURL); + entry.setUri(entryURL); + + String title = getOneDC(item, titleField); + entry.setTitle(title == null ? localize(labels, MSG_UNTITLED) : title); + + // "published" date -- should be dc.date.issued + String pubDate = getOneDC(item, dateField); + if (pubDate != null) + { + entry.setPublishedDate((new DCDate(pubDate)).toDate()); + hasDate = true; + } + // date of last change to Item + entry.setUpdatedDate(item.getLastModified()); + + StringBuffer db = new StringBuffer(); + for (String df : descriptionFields) + { + // Special Case: "(date)" in field name means render as date + boolean isDate = df.indexOf("(date)") > 0; + if (isDate) + { + df = df.replaceAll("\\(date\\)", ""); + } + + DCValue dcv[] = item.getMetadata(df); + if (dcv.length > 0) + { + String fieldLabel = labels.get(MSG_METADATA + df); + if (fieldLabel != null && fieldLabel.length()>0) + { + db.append(fieldLabel).append(": "); + } + boolean first = true; + for (DCValue v : dcv) + { + if (first) + { + first = false; + } + else + { + db.append("; "); + } + db.append(isDate ? new DCDate(v.value).toString() : v.value); + } + db.append("\n"); + } + } + if (db.length() > 0) + { + SyndContent desc = new SyndContentImpl(); + desc.setType("text/plain"); + desc.setValue(db.toString()); + entry.setDescription(desc); + } + + // This gets the authors into an ATOM feed + DCValue authors[] = item.getMetadata(authorField); + if (authors.length > 0) + { + List<SyndPerson> creators = new ArrayList<SyndPerson>(); + for (DCValue author : authors) + { + SyndPerson sp = new SyndPersonImpl(); + sp.setName(author.value); + creators.add(sp); + } + entry.setAuthors(creators); + } + + // only add DC module if any DC fields are configured + if (dcCreatorField != null || dcDateField != null || + dcDescriptionField != null) + { + DCModule dc = new DCModuleImpl(); + if (dcCreatorField != null) + { + DCValue dcAuthors[] = item.getMetadata(dcCreatorField); + if (dcAuthors.length > 0) + { + List<String> creators = new ArrayList<String>(); + for (DCValue author : dcAuthors) + { + creators.add(author.value); + } + dc.setCreators(creators); + } + } + if (dcDateField != null && !hasDate) + { + DCValue v[] = item.getMetadata(dcDateField); + if (v.length > 0) + { + dc.setDate((new DCDate(v[0].value)).toDate()); + } + } + if (dcDescriptionField != null) + { + DCValue v[] = item.getMetadata(dcDescriptionField); + if (v.length > 0) + { + StringBuffer descs = new StringBuffer(); + for (DCValue d : v) + { + if (descs.length() > 0) + { + descs.append("\n\n"); + } + descs.append(d.value); + } + dc.setDescription(descs.toString()); + } + } + entry.getModules().add(dc); + } + + //iTunes Podcast Support - START + if (podcastFeed) + { + // Add enclosure(s) + List<SyndEnclosure> enclosures = new ArrayList(); + try { + Bundle[] bunds = item.getBundles("ORIGINAL"); + if (bunds[0] != null) { + Bitstream[] bits = bunds[0].getBitstreams(); + for (int i = 0; (i < bits.length); i++) { + String mime = bits[i].getFormat().getMIMEType(); + if(podcastableMIMETypes.contains(mime)) { + SyndEnclosure enc = new SyndEnclosureImpl(); + enc.setType(bits[i].getFormat().getMIMEType()); + enc.setLength(bits[i].getSize()); + enc.setUrl(urlOfBitstream(request, bits[i])); + enclosures.add(enc); + } else { + continue; + } + } + } + //Also try to add an external value from dc.identifier.other + // We are assuming that if this is set, then it is a media file + DCValue[] externalMedia = item.getMetadata(externalSourceField); + if(externalMedia.length > 0) + { + for(int i = 0; i< externalMedia.length; i++) + { + SyndEnclosure enc = new SyndEnclosureImpl(); + enc.setType("audio/x-mpeg"); //We can't determine MIME of external file, so just picking one. + enc.setLength(1); + enc.setUrl(externalMedia[i].value); + enclosures.add(enc); + } + } + + } catch (Exception e) { + System.out.println(e.getMessage()); + } + entry.setEnclosures(enclosures); + + // Get iTunes specific fields: author, subtitle, summary, duration, keywords + EntryInformation itunes = new EntryInformationImpl(); + + String author = getOneDC(item, authorField); + if (author != null && author.length() > 0) { + itunes.setAuthor(author); // <itunes:author> + } + + itunes.setSubtitle(title == null ? localize(labels, MSG_UNTITLED) : title); // <itunes:subtitle> + + if (db.length() > 0) { + itunes.setSummary(db.toString()); // <itunes:summary> + } + + String extent = getOneDC(item, "dc.format.extent"); // assumed that user will enter this field with length of song in seconds + if (extent != null && extent.length() > 0) { + extent = extent.split(" ")[0]; + Integer duration = Integer.parseInt(extent); + itunes.setDuration(new Duration(duration)); // <itunes:duration> + } + + String subject = getOneDC(item, "dc.subject"); + if (subject != null && subject.length() > 0) { + String[] subjects = new String[1]; + subjects[0] = subject; + itunes.setKeywords(subjects); // <itunes:keywords> + } + + entry.getModules().add(itunes); + } + } + feed.setEntries(entries); + } + } + + /** + * Sets the feed type for XML delivery, e.g. "rss_1.0", "atom_1.0" + * Must match one of ROME's configured generators, see rome.properties + * (currently rss_1.0, rss_2.0, atom_1.0, atom_0.3) + */ + public void setType(String feedType) + { + feed.setFeedType(feedType); + // XXX FIXME: workaround ROME 1.0 bug, it puts invalid image element in rss1.0 + if ("rss_1.0".equals(feedType)) + { + feed.setImage(null); + } + } + + /** + * @return the feed we built as DOM Document + */ + public Document outputW3CDom() + throws FeedException + { + try + { + SyndFeedOutput feedWriter = new SyndFeedOutput(); + return feedWriter.outputW3CDom(feed); + } + catch (FeedException e) + { + log.error(e); + throw e; + } + } + + /** + * @return the feed we built as serialized XML string + */ + public String outputString() + throws FeedException + { + SyndFeedOutput feedWriter = new SyndFeedOutput(); + return feedWriter.outputString(feed); + } + + /** + * send the output to designated Writer + */ + public void output(java.io.Writer writer) + throws FeedException, IOException + { + SyndFeedOutput feedWriter = new SyndFeedOutput(); + feedWriter.output(feed, writer); + } + + /** + * Add a ROME plugin module (e.g. for OpenSearch) at the feed level. + */ + public void addModule(Module m) + { + feed.getModules().add(m); + } + + // utility to get config property with default value when not set. + private static String getDefaultedConfiguration(String key, String dfl) + { + String result = ConfigurationManager.getProperty(key); + return (result == null) ? dfl : result; + } + + // returns absolute URL to download content of bitstream (which might not belong to any Item) + private String urlOfBitstream(HttpServletRequest request, Bitstream logo) + { + String name = logo.getName(); + return resolveURL(request,null) + + (uiType.equalsIgnoreCase(UITYPE_XMLUI) ?"/bitstream/id/":"/retrieve/") + + logo.getID()+"/"+(name == null?"":name); + } + + /** + * Return a url to the DSpace object, either use the official + * handle for the item or build a url based upon the current server. + * + * If the dspaceobject is null then a local url to the repository is generated. + * + * @param dso The object to reference, null if to the repository. + * @return + */ + private String baseURL = null; // cache the result for null + + private String resolveURL(HttpServletRequest request, DSpaceObject dso) + { + // If no object given then just link to the whole repository, + // since no offical handle exists so we have to use local resolution. + if (dso == null) + { + if (baseURL == null) + { + if (request == null) + { + baseURL = ConfigurationManager.getProperty("dspace.url"); + } + else + { + baseURL = (request.isSecure()) ? "https://" : "http://"; + baseURL += ConfigurationManager.getProperty("dspace.hostname"); + baseURL += ":" + request.getServerPort(); + baseURL += request.getContextPath(); + } + } + return baseURL; + } + + // return a link to handle in repository + else if (ConfigurationManager.getBooleanProperty("webui.feed.localresolve")) + { + return resolveURL(request, null) + "/handle/" + dso.getHandle(); + } + + // link to the Handle server or other persistent URL source + else + { + return HandleManager.getCanonicalForm(dso.getHandle()); + } + } + + // retrieve text for localization key, or mark untranslated + private String localize(Map<String, String> labels, String s) + { + return labels.containsKey(s) ? labels.get(s) : ("Untranslated:"+s); + } + + // spoonful of syntactic sugar when we only need first value + private String getOneDC(Item item, String field) + { + DCValue dcv[] = item.getMetadata(field); + return (dcv.length > 0) ? dcv[0].value : null; + } +} +