/**
* Copyright 2010 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.livingstories.server.rpcimpl;
import com.google.common.base.Function;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import com.google.common.collect.Sets;
import com.google.gwt.user.server.rpc.RemoteServiceServlet;
import com.google.livingstories.client.AssetContentItem;
import com.google.livingstories.client.BackgroundContentItem;
import com.google.livingstories.client.BaseContentItem;
import com.google.livingstories.client.ContentItemType;
import com.google.livingstories.client.ContentRpcService;
import com.google.livingstories.client.DisplayContentItemBundle;
import com.google.livingstories.client.EventContentItem;
import com.google.livingstories.client.FilterSpec;
import com.google.livingstories.client.Importance;
import com.google.livingstories.client.NarrativeContentItem;
import com.google.livingstories.client.PlayerContentItem;
import com.google.livingstories.client.PublishState;
import com.google.livingstories.client.contentmanager.SearchTerms;
import com.google.livingstories.client.util.GlobalUtil;
import com.google.livingstories.client.util.SnippetUtil;
import com.google.livingstories.client.util.dom.JavaNodeAdapter;
import com.google.livingstories.server.dataservices.entities.BaseContentEntity;
import com.google.livingstories.server.dataservices.entities.LivingStoryEntity;
import com.google.livingstories.server.dataservices.entities.UserLivingStoryEntity;
import com.google.livingstories.server.dataservices.impl.DataImplFactory;
import com.google.livingstories.server.dataservices.impl.PMF;
import com.google.livingstories.server.util.AlertSender;
import com.google.livingstories.server.util.StringUtil;
import com.google.livingstories.servlet.ExternalServiceKeyChain;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.jdo.JDOObjectNotFoundException;
import javax.jdo.PersistenceManager;
import javax.jdo.Query;
import javax.jdo.Transaction;
import javax.mail.internet.InternetAddress;
import javax.servlet.http.HttpServletRequest;
/**
* Implementation of the RPC service that is used for reading and writing {@link BaseContentEntity}
* objects to the AppEngine datastore. This service converts the {@link BaseContentEntity} data
* objects to {@link BaseContentItem} for the client use.
*/
public class ContentRpcImpl extends RemoteServiceServlet implements ContentRpcService {
public static final int CONTENT_ITEM_COUNT_LIMIT = 20;
public static final int JUMP_TO_CONTENT_ITEM_CONTEXT_COUNT = 3;
private static final int EMAIL_ALERT_SNIPPET_LENGTH = 500;
private static final Logger logger =
Logger.getLogger(ContentRpcImpl.class.getCanonicalName());
private InternetAddress cachedFromAddress = null;
private String cachedPublisherName = null;
@Override
public synchronized BaseContentItem createOrChangeContentItem(BaseContentItem contentItem) {
// Get the list of content items to link within the content first so that if there is an
// exception with the queries, it doesn't affect the saving of the content entity. Except for
// unassigned content items and player content items, because we don't auto-link from their
// content. Or if the content item doesn't have any content.
boolean runAutoLink = contentItem.getLivingStoryId() != null
&& contentItem.getContentItemType() != ContentItemType.PLAYER
&& !GlobalUtil.isContentEmpty(contentItem.getContent());
List<PlayerContentItem> playerContentItems = null;
List<BackgroundContentItem> concepts = null;
try {
if (runAutoLink) {
playerContentItems = getPlayers(contentItem.getLivingStoryId());
concepts = getConcepts(contentItem.getLivingStoryId());
}
} catch (Exception e) {
logger.warning("Skipping auto-linking. Error with retrieving players or concepts."
+ e.getMessage());
runAutoLink = false;
}
PersistenceManager pm = PMF.get().getPersistenceManager();
Transaction tx = null;
BaseContentEntity contentEntity;
PublishState oldPublishState = null;
Set<Long> newLinkedContentItemSuggestions = null;
try {
if (contentItem.getId() != null) {
contentEntity = pm.getObjectById(BaseContentEntity.class, contentItem.getId());
oldPublishState = contentEntity.getPublishState();
contentEntity.copyFields(contentItem);
} else {
contentEntity = BaseContentEntity.fromClientObject(contentItem);
}
if (runAutoLink) {
newLinkedContentItemSuggestions =
AutoLinkEntitiesInContent.createLinks(contentEntity, playerContentItems, concepts);
}
tx = pm.currentTransaction();
tx.begin();
pm.makePersistent(contentEntity);
tx.commit();
// If this was an event or a narrative and had a linked narrative, then the 'standalone'
// field on the narrative content item needs to be updated to 'false'.
// Note: this doesn't handle the case of unlinking a previously linked narrative content item.
// That would require checking the linked content items of every single other event
// content item to make sure it's not linked to from anywhere else, which would be an
// expensive operation.
Set<Long> linkedContentEntityIds = contentEntity.getLinkedContentEntityIds();
ContentItemType contentItemType = contentEntity.getContentItemType();
if ((contentItemType == ContentItemType.EVENT || contentItemType == ContentItemType.NARRATIVE)
&& !linkedContentEntityIds.isEmpty()) {
List<Object> oids = new ArrayList<Object>(linkedContentEntityIds.size());
for (Long id : linkedContentEntityIds) {
oids.add(pm.newObjectIdInstance(BaseContentEntity.class, id));
}
@SuppressWarnings("unchecked")
Collection<BaseContentEntity> linkedContentEntities = pm.getObjectsById(oids);
for (BaseContentEntity linkedContentEntity : linkedContentEntities) {
if (linkedContentEntity.getContentItemType() == ContentItemType.NARRATIVE) {
linkedContentEntity.setIsStandalone(false);
}
}
}
// TODO: may also want to invalidate linked content items if they changed
// and aren't from the same living story.
invalidateCache(contentItem.getLivingStoryId());
} finally {
if (tx != null && tx.isActive()) {
tx.rollback();
}
pm.close();
}
// Send email alerts if an event content item was changed from 'Draft' to 'Published'
if (contentEntity.getContentItemType() == ContentItemType.EVENT
&& contentEntity.getPublishState() == PublishState.PUBLISHED
&& oldPublishState != null && oldPublishState == PublishState.DRAFT) {
sendEmailAlerts((EventContentItem)contentItem);
}
// We pass suggested new linked content items back to the client by adding their ids to the
// client object before returning it. It's the client's responsibility to check the linked
// content item ids it passed in with those that came back, and to present appropriate
// UI for processing the suggestions. Note that we shouldn't add the suggestions directly
// to contentEntity! This will persist them to the datastore prematurely.
BaseContentItem ret = contentEntity.toClientObject();
if (newLinkedContentItemSuggestions != null) {
ret.addAllLinkedContentItemIds(newLinkedContentItemSuggestions);
}
return ret;
}
@Override
public List<PlayerContentItem> getUnassignedPlayers() {
return getPlayers(null);
}
private List<PlayerContentItem> getPlayers(Long livingStoryId) {
List<BaseContentEntity> playerEntities =
getPublishedContentEntitiesByType(livingStoryId, ContentItemType.PLAYER);
List<PlayerContentItem> playerContentItems = Lists.newArrayList();
for (BaseContentEntity playerEntity : playerEntities) {
playerContentItems.add((PlayerContentItem)(playerEntity.toClientObject()));
}
return playerContentItems;
}
private List<BackgroundContentItem> getConcepts(Long livingStoryId) {
List<BaseContentEntity> backgroundEntities =
getPublishedContentEntitiesByType(livingStoryId, ContentItemType.BACKGROUND);
List<BackgroundContentItem> backgroundContentItems = Lists.newArrayList();
for (BaseContentEntity backgroundEntity : backgroundEntities) {
if (!GlobalUtil.isContentEmpty(backgroundEntity.getName())) {
backgroundContentItems.add((BackgroundContentItem)(backgroundEntity.toClientObject()));
}
}
return backgroundContentItems;
}
private List<BaseContentEntity> getPublishedContentEntitiesByType(Long livingStoryId,
ContentItemType contentItemType) {
PersistenceManager pm = PMF.get().getPersistenceManager();
Query query = pm.newQuery(BaseContentEntity.class);
query.setFilter("livingStoryId == livingStoryIdParam " +
"&& publishState == com.google.livingstories.client.PublishState.PUBLISHED " +
"&& contentItemType == '" + contentItemType.name() + "'");
query.declareParameters("java.lang.Long livingStoryIdParam");
try {
@SuppressWarnings("unchecked")
List<BaseContentEntity> entities = (List<BaseContentEntity>) query.execute(livingStoryId);
pm.retrieveAll(entities);
return entities;
} finally {
query.closeAll();
pm.close();
}
}
private void sendEmailAlerts(EventContentItem eventContentItem) {
PersistenceManager pm = PMF.get().getPersistenceManager();
// Get list of users
Query query = pm.newQuery(UserLivingStoryEntity.class);
query.setFilter("livingStoryId == livingStoryIdParam && subscribedToEmails == true");
query.declareParameters("long livingStoryIdParam");
try {
@SuppressWarnings("unchecked")
List<UserLivingStoryEntity> userLivingStoryEntities =
(List<UserLivingStoryEntity>) query.execute(eventContentItem.getLivingStoryId());
Multimap<String, String> usersByLocale = HashMultimap.create();
for (UserLivingStoryEntity entity : userLivingStoryEntities) {
usersByLocale.put(entity.getSubscriptionLocale(), entity.getParentEmailAddress());
}
if (!usersByLocale.isEmpty()) {
// Determine what all the placeholder text should be for the per-locale e-mails.
// getServletContext() doesn't return a valid result at construction-time, so
// we initialize the external properties lazily.
if (cachedFromAddress == null && cachedPublisherName == null) {
ExternalServiceKeyChain externalKeys = new ExternalServiceKeyChain(getServletContext());
cachedPublisherName = externalKeys.getPublisherName();
cachedFromAddress = externalKeys.getFromAddress();
}
LivingStoryEntity livingStory = pm.getObjectById(LivingStoryEntity.class,
eventContentItem.getLivingStoryId());
String baseLspUrl = getBaseServerUrl() + "/lsps/" + livingStory.getUrl();
String eventSummary = eventContentItem.getEventSummary();
String eventDetails = eventContentItem.getContent();
if (GlobalUtil.isContentEmpty(eventSummary)
&& !GlobalUtil.isContentEmpty(eventDetails)) {
eventSummary = SnippetUtil.createSnippet(JavaNodeAdapter.fromHtml(eventDetails),
EMAIL_ALERT_SNIPPET_LENGTH);
}
ImmutableMap<String, String> placeholderMap = new ImmutableMap.Builder<String, String>()
.put("storyTitle", livingStory.getTitle())
.put("updateTitle", eventContentItem.getEventUpdate())
.put("publisherName", cachedPublisherName)
.put("snippet", StringUtil.stripForExternalSites(eventSummary))
.put("linkUrl", baseLspUrl + "#OVERVIEW:false,false,false,false,n,n,n:"
+ eventContentItem.getId())
.put("loginUrl", DataImplFactory.getUserLoginService().createLoginUrl(baseLspUrl))
.build();
for (String locale : usersByLocale.keySet()) {
sendEmailsForLocale(placeholderMap, locale, usersByLocale.get(locale));
}
}
} finally {
query.closeAll();
pm.close();
}
}
private String getBaseServerUrl() {
HttpServletRequest request = super.getThreadLocalRequest();
StringBuffer url = request.getRequestURL();
return url.substring(0, url.length() - request.getRequestURI().length());
}
private void sendEmailsForLocale(
Map<String, String> placeholderMap, String localeString, Collection<String> recipients) {
// We reconstruct the Locale from the locale string. This ignores the possibility that
// a language variant is being specified, a script is being specified, etc.
// TODO: fix that.
Locale locale = Locale.ENGLISH;
if (!localeString.isEmpty()) {
String[] splitRes = localeString.split("_");
locale = splitRes.length == 1 ? new Locale(splitRes[0])
: new Locale(splitRes[0], splitRes[1]);
}
ResourceBundle emailBundle = ResourceBundle.getBundle(
"com.google.livingstories.server.rpcimpl.emailTemplate", locale);
String subject = emailBundle.getString("updateEmailSubject")
.replace("{0}", placeholderMap.get("storyTitle"));
// get the template in the .properties file, converting to the format expected by
// java.util.Formatter. A simple replaceAll won't suffice here 'cause the source format
// is 0-indexed, but the target format is 1-indexed. We use a StringBuffer below rather
// than a StringBuilder because Matcher is only compatible with the former.
StringBuffer sb = new StringBuffer();
Pattern p = Pattern.compile("\\{(\\d+)\\}");
Matcher m = p.matcher(emailBundle.getString("updateEmailTemplate"));
while (m.find()) {
int num = Integer.parseInt(m.group(1));
m.appendReplacement(sb, "%" + (num + 1) + "\\$s");
}
m.appendTail(sb);
String template = sb.toString();
// Some parts of this template aren't necessary if certain placeholders are blank.
// Do some replacement logic to correct this. Note the reluctant quantifiers.
String publisherName = placeholderMap.get("publisherName");
if (GlobalUtil.isContentEmpty(publisherName)) {
template = template.replaceFirst("<span class=\"p_span\".*?</span>", "");
}
String snippet = placeholderMap.get("snippet");
if (snippet == null || snippet.isEmpty()) {
template = template.replaceFirst("<div class=\"s_div\".*?</div>", "");
}
// The transformations above may have taken some of these placeholders out of the
// template, but that's okay!
String body = String.format(template,
placeholderMap.get("updateTitle"),
publisherName,
snippet,
placeholderMap.get("linkUrl"),
placeholderMap.get("loginUrl"));
if (cachedFromAddress != null) {
AlertSender.sendEmail(cachedFromAddress, recipients, subject, body);
}
}
@SuppressWarnings("unchecked")
@Override
public synchronized List<BaseContentItem> getContentItemsForLivingStory(
Long livingStoryId, boolean onlyPublished) {
List<BaseContentItem> contentItems =
Caches.getLivingStoryContentItems(livingStoryId, onlyPublished);
if (contentItems != null) {
return contentItems;
}
PersistenceManager pm = PMF.get().getPersistenceManager();
Query query = pm.newQuery(BaseContentEntity.class);
query.setFilter("livingStoryId == livingStoryIdParam"
+ (onlyPublished ? "&& publishState == '" + PublishState.PUBLISHED.name() + "'" : ""));
query.setOrdering("timestamp desc");
query.declareParameters("java.lang.Long livingStoryIdParam");
try {
List<BaseContentItem> clientContentItems = new ArrayList<BaseContentItem>();
@SuppressWarnings("unchecked")
List<BaseContentEntity> results = (List<BaseContentEntity>) query.execute(livingStoryId);
for (BaseContentEntity result : results) {
clientContentItems.add(result.toClientObject());
}
Caches.setLivingStoryContentItems(livingStoryId, onlyPublished, clientContentItems);
return clientContentItems;
} finally {
query.closeAll();
pm.close();
}
}
/**
* Gets the eventBundle for a given date range within a living story.
* @param livingStoryId the relevant story's id.
* @param filterSpec a specification of how to filter the results
* @param focusedContentItemId optional; indicates that contentItem with this id should be
* included in the returned list. Specifying this parameter causes the method to return all
* content items from cutoff up until 3 items after the focused content item. Otherwise, the
* method just returns the first 20 content items after cutoff.
* @param cutoff Do not return content items that sort earlier/later than this date (exclusive)
* (Depends on order specified in filterSpec). Null if there is no bound.
* @return an appropriate DisplayContentItemBundle
*/
@Override
public synchronized DisplayContentItemBundle getDisplayContentItemBundle(Long livingStoryId,
FilterSpec filterSpec, Long focusedContentItemId, Date cutoff) {
if (filterSpec.contributorId != null || filterSpec.playerId != null) {
throw new IllegalArgumentException(
"filterSpec.contributorId and filterSpec.playerId should not be set by remote callers."
+ " contributorId = " + filterSpec.contributorId + " playerId = "+ filterSpec.playerId);
}
DisplayContentItemBundle result = Caches.getDisplayContentItemBundle(
livingStoryId, filterSpec, focusedContentItemId, cutoff);
if (result != null) {
return result;
}
FilterSpec localFilterSpec = new FilterSpec(filterSpec);
BaseContentItem focusedContentItem = null;
if (focusedContentItemId != null) {
focusedContentItem = getContentItem(focusedContentItemId, false);
if (focusedContentItem != null) {
if (adjustFilterSpecForContentItem(localFilterSpec, focusedContentItem)) {
// If we had to adjust the filter spec to accommodate the focused content item,
// we'll be switching filter views, so we want to clear the start date
// and reload the list from the beginning.
cutoff = null;
}
}
}
// Some preliminaries. Note that the present implementation just filters all content items for
// a story, which could be a bit expensive if there's a cache miss. By and large, though,
// we'd expect a lot more cache hits than cache misses, unlike the case with, say,
// a twitter "following" feed, which is more likely to be unique to that user.
List<BaseContentItem> allContentItems = getContentItemsForLivingStory(livingStoryId, true);
Map<Long, BaseContentItem> idToContentItemMap = Maps.newHashMap();
List<BaseContentItem> relevantContentItems = Lists.newArrayList();
for (BaseContentItem contentItem : allContentItems) {
idToContentItemMap.put(contentItem.getId(), contentItem);
Date sortKey = contentItem.getDateSortKey();
boolean matchesStartDate = (cutoff == null) ||
(localFilterSpec.oldestFirst ? !sortKey.before(cutoff) : !sortKey.after(cutoff));
if (matchesStartDate && localFilterSpec.doesContentItemMatch(contentItem)) {
relevantContentItems.add(contentItem);
}
}
sortContentItemList(relevantContentItems, localFilterSpec);
// Need to get the focused content item from the map instead of using the object directly.
// This is because we use indexOf() to find the location of the focused content item in the
// list and the original contentItem isn't the same object instance.
List<BaseContentItem> coreContentItems = getSublist(relevantContentItems,
focusedContentItem == null ? null : idToContentItemMap.get(focusedContentItemId), cutoff);
Set<Long> linkedContentItemIds = Sets.newHashSet();
for (BaseContentItem contentItem : coreContentItems) {
if (contentItem.displayTopLevel()) {
// If a content item isn't a top-level display content item, we can get away without
// returning its linked content items.
linkedContentItemIds.addAll(contentItem.getLinkedContentItemIds());
}
}
Set<BaseContentItem> linkedContentItems = Sets.newHashSet();
for (Long id : linkedContentItemIds) {
BaseContentItem linkedContentItem = idToContentItemMap.get(id);
if (linkedContentItem == null) {
System.err.println("Linked content item with id " + id + " is not found.");
} else {
linkedContentItems.add(linkedContentItem);
// For linked narratives, we want to get their own linked content items as well
if (linkedContentItem.getContentItemType() == ContentItemType.NARRATIVE) {
for (Long linkedToLinkedContentItemId : linkedContentItem.getLinkedContentItemIds()) {
BaseContentItem linkedToLinkedContentItem =
idToContentItemMap.get(linkedToLinkedContentItemId);
if (linkedToLinkedContentItem != null) {
linkedContentItems.add(linkedToLinkedContentItem);
}
}
}
}
}
Date nextDateInSequence = getNextDateInSequence(coreContentItems, relevantContentItems);
result = new DisplayContentItemBundle(coreContentItems, linkedContentItems, nextDateInSequence,
localFilterSpec);
Caches.setDisplayContentItemBundle(livingStoryId, filterSpec, focusedContentItemId, cutoff,
result);
return result;
}
/**
* Check if the contentItem matches the filterSpec. If not, this method adjusts the filter
* spec so that the contentItem will match.
* @return whether or not the filterSpec was adjusted.
*/
private boolean adjustFilterSpecForContentItem(FilterSpec filterSpec,
BaseContentItem contentItem) {
if (filterSpec.doesContentItemMatch(contentItem)) {
return false;
}
if (filterSpec.themeId != null && !contentItem.getThemeIds().contains(filterSpec.themeId)) {
filterSpec.themeId = null;
}
if (filterSpec.importantOnly && contentItem.getImportance() != Importance.HIGH) {
filterSpec.importantOnly = false;
}
if (filterSpec.contentItemType != contentItem.getContentItemType()) {
filterSpec.contentItemType = null;
} else if (contentItem.getContentItemType() == ContentItemType.ASSET
&& filterSpec.assetType != ((AssetContentItem) contentItem).getAssetType()) {
filterSpec.contentItemType = null;
filterSpec.assetType = null;
}
if (filterSpec.opinion && (contentItem.getContentItemType() != ContentItemType.NARRATIVE
|| !((NarrativeContentItem) contentItem).isOpinion())) {
filterSpec.opinion = false;
}
return true;
}
private void sortContentItemList(List<BaseContentItem> contentItems, FilterSpec filterSpec) {
Collections.sort(contentItems,
filterSpec.oldestFirst ? BaseContentItem.COMPARATOR : BaseContentItem.REVERSE_COMPARATOR);
}
private List<BaseContentItem> getSublist(List<BaseContentItem> allContentItems,
BaseContentItem focusedContentItem, Date cutoff) {
int contentItemLimit;
if (focusedContentItem == null) {
contentItemLimit = CONTENT_ITEM_COUNT_LIMIT;
} else {
contentItemLimit =
allContentItems.indexOf(focusedContentItem) + 1 + JUMP_TO_CONTENT_ITEM_CONTEXT_COUNT;
// If we are not appending content items and there are less than 20 results because of a
// focussed content item, bump the limit up to 20
if (cutoff == null && contentItemLimit < CONTENT_ITEM_COUNT_LIMIT) {
contentItemLimit = CONTENT_ITEM_COUNT_LIMIT;
}
}
contentItemLimit = Math.min(allContentItems.size(), contentItemLimit);
while (contentItemLimit < allContentItems.size() - 1) {
Date thisContentItemDate = allContentItems.get(contentItemLimit).getDateSortKey();
Date nextContentItemDate = allContentItems.get(contentItemLimit + 1).getDateSortKey();
if (!thisContentItemDate.equals(nextContentItemDate)) {
break;
}
contentItemLimit++;
}
// Copy the sublist into a new ArrayList since the sublist() method returns
// a view backed by the original list, which includes a bunch of content items we don't
// care about.
return new ArrayList<BaseContentItem>(allContentItems.subList(0, contentItemLimit));
}
/**
* We return the date of the content item after the last core content item returned
* as the 'next date in sequence', which we will use as the startDate in this method
* on the next call, when the user wants more content items.
* Very rare corner case:
* If the user loads up the page, a content item is added whose date falls between
* the date of the last content item returned and the next date in sequence, and then
* the user clicks 'view more', we'll miss displaying that new content item.
* We don't really care about this corner case though, since it will almost
* never happen.
*/
private Date getNextDateInSequence(List<BaseContentItem> coreContentItems,
List<BaseContentItem> relevantContentItems) {
return coreContentItems.size() < relevantContentItems.size()
? relevantContentItems.get(coreContentItems.size()).getDateSortKey() : null;
}
@Override
public synchronized BaseContentItem getContentItem(Long id, boolean getLinkedContentItems) {
PersistenceManager pm = PMF.get().getPersistenceManager();
try {
BaseContentItem contentItem = pm.getObjectById(BaseContentEntity.class, id).toClientObject();
if (getLinkedContentItems) {
contentItem.setLinkedContentItems(getContentItems(contentItem.getLinkedContentItemIds()));
}
return contentItem;
} catch (JDOObjectNotFoundException e) {
return null;
} finally {
pm.close();
}
}
@SuppressWarnings("unchecked")
@Override
public synchronized List<BaseContentItem> getContentItems(Collection<Long> ids) {
if (ids.isEmpty()) {
return new ArrayList<BaseContentItem>();
}
PersistenceManager pm = PMF.get().getPersistenceManager();
List<Object> oids = new ArrayList<Object>(ids.size());
for (Long id : ids) {
oids.add(pm.newObjectIdInstance(BaseContentEntity.class, id));
}
try {
Collection results = pm.getObjectsById(oids);
List<BaseContentItem> contentItems = new ArrayList<BaseContentItem>(results.size());
for (Object result : results) {
contentItems.add(((BaseContentEntity)result).toClientObject());
}
return contentItems;
} finally {
pm.close();
}
}
@Override
public synchronized DisplayContentItemBundle getRelatedContentItems(
Long contentItemId, boolean byContribution, Date cutoff) {
// translate contentItemId and byContribution into an appropriate FilterSpec, which we use
// to respond from cache instead of by making fresh queries.
FilterSpec filterSpec = new FilterSpec();
if (byContribution) {
filterSpec.contributorId = contentItemId;
} else {
filterSpec.playerId = contentItemId;
}
DisplayContentItemBundle result =
Caches.getDisplayContentItemBundle(null, filterSpec, null, cutoff);
if (result != null) {
return result;
}
PersistenceManager pm = PMF.get().getPersistenceManager();
Query query = pm.newQuery(BaseContentEntity.class);
String contentItemIdClause = byContribution
? "contributorIds == contentItemIdParam" : "linkedContentEntityIds == contentItemIdParam";
query.setFilter(contentItemIdClause
+ " && publishState == '" + PublishState.PUBLISHED.name() + "'");
// no need to explicitly set ordering, as we resort by display order.
query.declareParameters("java.lang.Long contentItemIdParam");
try {
List<BaseContentItem> relevantContentItems = new ArrayList<BaseContentItem>();
@SuppressWarnings("unchecked")
List<BaseContentEntity> contentEntities =
(List<BaseContentEntity>) query.execute(contentItemId);
for (BaseContentEntity contentEntity : contentEntities) {
BaseContentItem contentItem = contentEntity.toClientObject();
if (cutoff == null || !contentItem.getDateSortKey().after(cutoff)) {
relevantContentItems.add(contentItem);
}
}
// sort and put a window on the list, get the next date in the sequence
sortContentItemList(relevantContentItems, filterSpec);
List<BaseContentItem> coreContentItems = getSublist(relevantContentItems, null, cutoff);
Date nextDateInSequence = getNextDateInSequence(coreContentItems, relevantContentItems);
result = new DisplayContentItemBundle(coreContentItems,
Collections.<BaseContentItem>emptySet(), nextDateInSequence, filterSpec);
Caches.setDisplayContentItemBundle(null, filterSpec, null, cutoff, result);
return result;
} finally {
query.closeAll();
pm.close();
}
}
/**
* Performs a content entity query given a set of search filter terms.
*
* For all search combinations to be successful, we require the existence
* of several indexes:
* - LivingStoryId/PublishState/Timestamp (minimum, required fields)
* - LivingStoryId/PublishState/Timestamp/ContentItemType
* - LivingStoryId/PublishState/Timestamp/ContentItemType/PlayerType
* - LivingStoryId/PublishState/Timestamp/ContentItemType/AssetType
* - LivingStoryId/PublishState/Timestamp/ContentItemType/NarrativeType
* - LivingStoryId/PublishState/Timestamp/Importance/ContentItemType
* - LivingStoryId/PublishState/Timestamp/Importance/ContentItemType/PlayerType
* - LivingStoryId/PublishState/Timestamp/Importance/ContentItemType/AssetType
* - LivingStoryId/PublishState/Timestamp/Importance/ContentItemType/NarrativeType
*/
@Override
public List<BaseContentItem> executeSearch(SearchTerms searchTerms) {
PersistenceManager pm = PMF.get().getPersistenceManager();
Query query = pm.newQuery(BaseContentEntity.class);
StringBuilder queryFilters = new StringBuilder(
"livingStoryId == " + String.valueOf(searchTerms.livingStoryId)
+ " && publishState == '" + searchTerms.publishState.name() + "'");
// Optional filter: Date
if (searchTerms.beforeDate != null) {
queryFilters.append(" && timestamp < beforeDateParam");
}
if (searchTerms.afterDate != null) {
queryFilters.append(" && timestamp >= afterDateParam");
}
// Optional filter: Importance
if (searchTerms.importance != null) {
queryFilters.append(" && importance == '" + searchTerms.importance.name() + "'");
}
// Optional filter: contentItemType
if (searchTerms.contentItemType != null) {
queryFilters.append( "&& contentItemType == '" + searchTerms.contentItemType.name() + "'");
}
// Optional filter: content item subtype
if (searchTerms.contentItemType == ContentItemType.PLAYER && searchTerms.playerType != null) {
queryFilters.append(" && playerType == '" + searchTerms.playerType.name() + "'");
} else if (searchTerms.contentItemType == ContentItemType.ASSET
&& searchTerms.assetType != null) {
queryFilters.append(" && assetType == '" + searchTerms.assetType.name() + "'");
} else if (searchTerms.contentItemType == ContentItemType.NARRATIVE
&& searchTerms.narrativeType != null) {
queryFilters.append(" && narrativeType == '" + searchTerms.narrativeType.name() + "'");
}
query.setFilter(queryFilters.toString());
query.declareParameters("java.util.Date beforeDateParam, java.util.Date afterDateParam");
query.setOrdering("timestamp desc");
try {
List<BaseContentItem> clientContentItems = new ArrayList<BaseContentItem>();
@SuppressWarnings("unchecked")
List<BaseContentEntity> results =
(List<BaseContentEntity>) query.execute(searchTerms.beforeDate, searchTerms.afterDate);
for (BaseContentEntity result : results) {
clientContentItems.add(result.toClientObject());
}
return clientContentItems;
} finally {
query.closeAll();
pm.close();
}
}
@Override
public synchronized void deleteContentItem(final Long id) {
PersistenceManager pm = PMF.get().getPersistenceManager();
try {
BaseContentEntity contentEntity = pm.getObjectById(BaseContentEntity.class, id);
updateContentEntityReferencesHelper(pm, "linkedContentEntityIds", id,
new Function<BaseContentEntity, Void>() {
public Void apply(BaseContentEntity contentEntity) {
contentEntity.removeLinkedContentEntityId(id); return null;
}
});
// If deleting a contributor as well, update relevant contributor ids too.
if (contentEntity.getContentItemType() == ContentItemType.PLAYER) {
updateContentEntityReferencesHelper(pm, "contributorIds", id,
new Function<BaseContentEntity, Void>() {
public Void apply(BaseContentEntity contentEntity) {
contentEntity.removeContributorId(id); return null;
}
});
}
invalidateCache(contentEntity.getLivingStoryId());
pm.deletePersistent(contentEntity);
} finally {
pm.close();
}
}
private void invalidateCache(Long livingStoryId) {
Caches.clearLivingStoryContentItems(livingStoryId);
Caches.clearLivingStoryThemeInfo(livingStoryId);
Caches.clearStartPageBundle();
}
/**
* Helper method that updates content entities that refer to a content entity soon to be deleted.
* @param pm the persistence manager
* @param relevantField relevant field name for the query
* @param removeFunc a Function to apply to the results of the query
* @param id the id of the to-be-deleted content entity
*/
private void updateContentEntityReferencesHelper(PersistenceManager pm, String relevantField,
Long id, Function<BaseContentEntity, Void> removeFunc) {
Query query = pm.newQuery(BaseContentEntity.class);
query.setFilter(relevantField + " == contentItemIdParam");
query.declareParameters("java.lang.Long contentItemIdParam");
try {
@SuppressWarnings("unchecked")
List<BaseContentEntity> results = (List<BaseContentEntity>) query.execute(id);
for (BaseContentEntity result : results) {
removeFunc.apply(result);
}
pm.makePersistentAll(results);
} finally {
query.closeAll();
}
}
private List<Query> getUpdateQueries(PersistenceManager pm, Date timeParam, int range) {
String commonQueryFilter = "livingStoryId == livingStoryIdParam "
+ "&& publishState == com.google.livingstories.client.PublishState.PUBLISHED "
+ (timeParam == null ? "" : "&& timestamp > timeParam ");
Query eventsQuery = pm.newQuery(BaseContentEntity.class);
eventsQuery.setFilter(commonQueryFilter +
"&& contentItemType == com.google.livingstories.client.ContentItemType.EVENT");
eventsQuery.setOrdering("timestamp desc");
if (range != 0) {
eventsQuery.setRange(0, range);
}
eventsQuery.declareParameters("Long livingStoryIdParam"
+ (timeParam == null ? "" : ", java.util.Date timeParam"));
Query narrativesQuery = pm.newQuery(BaseContentEntity.class);
narrativesQuery.setFilter(commonQueryFilter +
"&& contentItemType == com.google.livingstories.client.ContentItemType.NARRATIVE " +
"&& isStandalone == true");
narrativesQuery.setOrdering("timestamp desc");
if (range != 0) {
narrativesQuery.setRange(0, range);
}
narrativesQuery.declareParameters("Long livingStoryIdParam"
+ (timeParam == null ? "" : ", java.util.Date timeParam"));
return ImmutableList.of(eventsQuery, narrativesQuery);
}
@Override
public synchronized Integer getUpdateCountSinceTime(Long livingStoryId, Date time) {
PersistenceManager pm = PMF.get().getPersistenceManager();
List<Query> updateQueries = getUpdateQueries(pm, time, 0);
try {
int result = 0;
for (Query query : updateQueries) {
query.setResult("count(id)");
result += (Integer) query.execute(livingStoryId, time);
}
return result;
} finally {
for (Query query : updateQueries) {
query.closeAll();
}
pm.close();
}
}
@Override
public List<BaseContentItem> getUpdatesSinceTime(Long livingStoryId, Date time) {
PersistenceManager pm = PMF.get().getPersistenceManager();
List<Query> updateQueries = getUpdateQueries(pm, time, 0);
try {
List<BaseContentItem> updates = new ArrayList<BaseContentItem>();
for (Query query : updateQueries) {
@SuppressWarnings("unchecked")
List<BaseContentEntity> results =
(List<BaseContentEntity>) query.execute(livingStoryId, time);
for (BaseContentEntity result : results) {
updates.add(result.toClientObject());
}
}
return updates;
} finally {
for (Query query : updateQueries) {
query.closeAll();
}
pm.close();
}
}
/**
* Return the latest 3 updates on top-level display items for a story, sorted in reverse-
* chronological order.
*/
public List<BaseContentItem> getUpdatesForStartPage(Long livingStoryId) {
PersistenceManager pm = PMF.get().getPersistenceManager();
List<Query> updateQueries = getUpdateQueries(pm, null, 3);
try {
List<BaseContentItem> updates = new ArrayList<BaseContentItem>();
// Get the latest 3 events and latest 3 narratives and then return the latest 3 items
// from those 6 because there is no way to do one appengine query for that
for (Query query : updateQueries) {
@SuppressWarnings("unchecked")
List<BaseContentEntity> results = (List<BaseContentEntity>) query.execute(livingStoryId);
for (BaseContentEntity result : results) {
updates.add(result.toClientObject());
}
}
Collections.sort(updates, BaseContentItem.REVERSE_COMPARATOR);
// Just return the latest 3 updates
return new ArrayList<BaseContentItem>(updates.subList(0, Math.min(3, updates.size())));
} finally {
for (Query query : updateQueries) {
query.closeAll();
}
pm.close();
}
}
@Override
public List<EventContentItem> getImportantEventsForLivingStory(Long livingStoryId) {
PersistenceManager pm = PMF.get().getPersistenceManager();
Query query = pm.newQuery(BaseContentEntity.class);
query.setFilter("livingStoryId == livingStoryIdParam " +
"&& contentItemType == com.google.livingstories.client.ContentItemType.EVENT " +
"&& importance == com.google.livingstories.client.Importance.HIGH " +
"&& publishState == com.google.livingstories.client.PublishState.PUBLISHED");
query.declareParameters("Long livingStoryIdParam");
try {
@SuppressWarnings("unchecked")
List<BaseContentEntity> results =
(List<BaseContentEntity>) query.execute(livingStoryId);
List<EventContentItem> events = new ArrayList<EventContentItem>();
for (BaseContentEntity result : results) {
EventContentItem event = (EventContentItem) result.toClientObject();
events.add(event);
}
Collections.sort(events, BaseContentItem.REVERSE_COMPARATOR);
return events;
} finally {
query.closeAll();
pm.close();
}
}
/**
* This method will return a list of all the players in the living story,
* sorted by importance. Our importance ranking is currently based solely
* on the number of content items in the living story that are linked to each player.
*/
@Override
public List<PlayerContentItem> getImportantPlayersForLivingStory(Long livingStoryId) {
PersistenceManager pm = PMF.get().getPersistenceManager();
Query query = pm.newQuery(BaseContentEntity.class);
query.setFilter("livingStoryId == livingStoryIdParam " +
"&& contentItemType == com.google.livingstories.client.ContentItemType.PLAYER " +
"&& importance == com.google.livingstories.client.Importance.HIGH " +
"&& publishState == com.google.livingstories.client.PublishState.PUBLISHED");
query.declareParameters("Long livingStoryIdParam");
List<PlayerContentItem> players = Lists.newArrayList();
try {
@SuppressWarnings("unchecked")
List<BaseContentEntity> results =
(List<BaseContentEntity>) query.execute(livingStoryId);
for (BaseContentEntity result : results) {
players.add((PlayerContentItem) result.toClientObject());
}
} finally {
query.closeAll();
pm.close();
}
return players;
}
/**
* Returns all the contributors for this living story.
*/
@Override
public Map<Long, PlayerContentItem> getContributorsByIdForLivingStory(Long livingStoryId) {
Map<Long, PlayerContentItem> result = Caches.getContributorsForLivingStory(livingStoryId);
if (result != null) {
return result;
}
List<BaseContentItem> allContentItems = getContentItemsForLivingStory(livingStoryId, true);
Set<Long> allContributorIds = new HashSet<Long>();
for (BaseContentItem contentItem : allContentItems) {
allContributorIds.addAll(contentItem.getContributorIds());
}
List<BaseContentItem> contributors = getContentItems(allContributorIds);
result = new HashMap<Long, PlayerContentItem>();
for (BaseContentItem contributor : contributors) {
if (contributor.getContentItemType() == ContentItemType.PLAYER) {
result.put(contributor.getId(), (PlayerContentItem) contributor);
} else {
logger.warning("Contributor id " + contributor.getId() + " does not map to a player");
}
}
Caches.setContributorsForLivingStory(livingStoryId, result);
return result;
}
}