Java tutorial
/** * 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; } }