/*
* Copyright (c) 2006-2013 by Public Library of Science
*
* http://plos.org
* http://ambraproject.org
*
* 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 org.ambraproject.service.taxonomy;
import org.ambraproject.ApplicationException;
import org.ambraproject.models.UserRole;
import org.ambraproject.service.cache.Cache;
import org.ambraproject.service.hibernate.HibernateServiceImpl;
import org.ambraproject.service.permission.PermissionsService;
import org.ambraproject.service.search.SearchService;
import org.ambraproject.util.CategoryUtils;
import org.ambraproject.views.CategoryView;
import org.ambraproject.views.SearchHit;
import org.ambraproject.views.article.FeaturedArticle;
import org.hibernate.HibernateException;
import org.hibernate.Session;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.orm.hibernate3.HibernateCallback;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
/**
* {@inheritDoc}
*/
public class TaxonomyServiceImpl extends HibernateServiceImpl implements TaxonomyService {
private static final int CACHE_TTL = 3600 * 24; // one day
private SearchService searchService;
private PermissionsService permissionsService;
private Cache cache;
/**
* {@inheritDoc}
*/
@SuppressWarnings("unchecked")
public FeaturedArticle getFeaturedArticleForSubjectArea(final String journalKey, final String subjectArea) {
//Find a "Featured Article" for the given subject area
//
//First query the database for a manually defined article for the term
//
// If database doesn't have article, query SOLR for:
// - Most shared in social media (using same roll-up/counting methods used in search sort options) over the last 7 days.
// - If no shares
// - Most viewed Article (using same roll-up/counting methods used in search sort options) over the last 7 days.
// - If no views over past 7 days
// - most viewed Article (over all time) (using same roll-up/counting methods used in search sort options)
List sqlResults = (List)hibernateTemplate.execute(new HibernateCallback() {
public Object doInHibernate(Session session) throws HibernateException, SQLException {
return session.createSQLQuery(
"select a.doi, a.title, a.strkImgURI from categoryFeaturedArticle cfa " +
"join article a on a.articleID = cfa.articleID " +
"join journal j on j.journalID = cfa.journalID " +
"where j.journalKey = :journalKey and " +
"lcase(cfa.category) = :category")
.setString("journalKey", journalKey)
.setString("category", subjectArea.toLowerCase())
.list();
}
});
if(sqlResults.size() != 0) {
Object[] row = (Object[])sqlResults.get(0);
return FeaturedArticle.builder()
.setDoi((String)row[0])
.setTitle((String) row[1])
.setStrkImgURI((String) row[2])
.setType("Featured Article")
.build();
} else {
try {
//Nothing defined, select an article from SOLR
return selectFeaturedArticleSOLR(journalKey, subjectArea);
} catch(ApplicationException ex) {
throw new RuntimeException(ex.getMessage(), ex);
}
}
}
/**
*
* Compute a featured article from SOLR for a journal / subject area by the following logic:
*
* Most shared in social media (using same roll-up/counting methods used in search sort options) over the last 7 days.
* - If no shares
* - Most viewed Article (using same roll-up/counting methods used in search sort options) over the last 7 days.
* - If no views over past 7 days
* - most viewed Article (over all time) (using same roll-up/counting methods used in search sort options)
*
* @param journalKey the given journal
* @param subjectArea the given subject area
*
* @return the computed articleInfo
*
* @throws ApplicationException
*/
private FeaturedArticle selectFeaturedArticleSOLR(final String journalKey, final String subjectArea)
throws ApplicationException {
//Only search for articles with shares
SearchHit hit = searchService.getMostSharedForJournalCategory(journalKey, subjectArea);
if(hit != null) {
return FeaturedArticle.builder()
.setDoi(hit.getUri())
.setTitle(hit.getTitle())
.setStrkImgURI(hit.getStrikingImage())
.setType("Most Shared Article")
.build();
} else {
//No articles with shares found for the given category. Lets try views over the past 30 days
//Only search for articles with views this month
hit = searchService.getMostViewedForJournalCategory(journalKey, subjectArea);
if(hit != null) {
return FeaturedArticle.builder()
.setDoi(hit.getUri())
.setTitle(hit.getTitle())
.setStrkImgURI(hit.getStrikingImage())
.setType("Most Viewed Article")
.build();
} else {
//No articles with views this month for the given category. Use all time views
hit = searchService.getMostViewedAllTimeForJournalCategory(journalKey, subjectArea);
if(hit != null) {
return FeaturedArticle.builder()
.setDoi(hit.getUri())
.setTitle(hit.getTitle())
.setStrkImgURI(hit.getStrikingImage())
.setType("Most Viewed Article")
.build();
} else {
//This is a very sad subject category :-(
return null;
}
}
}
}
/**
* {@inheritDoc}
*/
public Map<String, String> getFeaturedArticles(final String journalKey) {
Map<String, String> resultMap = new HashMap<String, String>();
List sqlResults = (List)hibernateTemplate.execute(new HibernateCallback() {
public Object doInHibernate(Session session) throws HibernateException, SQLException {
return session.createSQLQuery(
"select cfa.category, a.doi from categoryFeaturedArticle cfa " +
"join article a on a.articleID = cfa.articleID " +
"join journal j on j.journalID = cfa.journalID " +
"where j.journalKey = :journalKey")
.setString("journalKey", journalKey)
.list();
}
});
for(Object row : sqlResults) {
resultMap.put((String)((Object[])row)[0], (String)((Object[])row)[1]);
}
return resultMap;
}
/**
* {@inheritDoc}
*/
public void deleteFeaturedArticle(final String journalKey, final String subjectArea, final String authID) {
permissionsService.checkPermission(UserRole.Permission.MANAGE_FEATURED_ARTICLES, authID);
hibernateTemplate.execute(new HibernateCallback() {
public Object doInHibernate(Session session) throws HibernateException, SQLException {
return session.createSQLQuery(
"delete cfa from categoryFeaturedArticle cfa " +
"join article a on a.articleID = cfa.articleID " +
"join journal j on j.journalID = cfa.journalID " +
"where j.journalKey = :journalKey and " +
"lcase(cfa.category) = :category")
.setString("journalKey", journalKey)
.setString("category", subjectArea.toLowerCase())
.executeUpdate();
}
});
}
/**
* {@inheritDoc}
*/
public void createFeaturedArticle(final String journalKey, final String subjectArea, final String doi,
final String authID) {
permissionsService.checkPermission(UserRole.Permission.MANAGE_FEATURED_ARTICLES, authID);
int result = (Integer)hibernateTemplate.execute(new HibernateCallback() {
public Object doInHibernate(Session session) throws HibernateException, SQLException {
return session.createSQLQuery(
"insert into categoryFeaturedArticle(journalID, articleID, category, created, lastModified) " +
"select j.journalID, a.articleID, :category, NOW(), NOW() from article a, journal j " +
"where j.journalKey = :journalKey and a.doi = :doi")
.setString("journalKey", journalKey)
.setString("category", subjectArea)
.setString("doi", doi)
.executeUpdate();
}
});
if(result == 0) {
throw new RuntimeException("No records created, invalid journalKey or DOI specified.");
}
}
/**
* {@inheritDoc}
*/
public void flagTaxonomyTerm(final long articleID, final long categoryID, final String authID) {
//The style of query used is significantly different pending the authID is null or not
//I don't use a hibernate model here to save on precious CPU.
if(authID != null && authID.length() > 0) {
//This query will update on a duplicate
hibernateTemplate.execute(new HibernateCallback() {
public Object doInHibernate(Session session) throws HibernateException, SQLException {
session.createSQLQuery(
"insert into articleCategoryFlagged(articleID, categoryID, userProfileID, created, lastModified) select " +
":articleID, :categoryID, up.userProfileID, :created, :lastModified " +
"from userProfile up where up.authId = :authID on duplicate key update lastModified = :lastModified")
.setString("authID", authID)
.setLong("articleID", articleID)
.setLong("categoryID", categoryID)
.setCalendar("created", Calendar.getInstance())
.setCalendar("lastModified", Calendar.getInstance())
.executeUpdate();
return null;
}
});
} else {
//Insert userProfileID as a null value
hibernateTemplate.execute(new HibernateCallback() {
public Object doInHibernate(Session session) throws HibernateException, SQLException {
session.createSQLQuery(
"insert into articleCategoryFlagged(articleID, categoryID, userProfileID, created, lastModified) values(" +
":articleID, :categoryID, null, :created, :lastModified)")
.setLong("articleID", articleID)
.setLong("categoryID", categoryID)
.setCalendar("created", Calendar.getInstance())
.setCalendar("lastModified", Calendar.getInstance())
.executeUpdate();
return null;
}
});
}
}
/**
* {@inheritDoc}
*/
public void deflagTaxonomyTerm(final long articleID, final long categoryID, final String authID) {
//The style of query used is significantly different pending the authID is null or not
//I don't use a hibernate model here to save on precious CPU.
if(authID != null && authID.length() > 0) {
hibernateTemplate.execute(new HibernateCallback() {
public Object doInHibernate(Session session) throws HibernateException, SQLException {
session.createSQLQuery(
"delete acf.* from articleCategoryFlagged acf " +
"join userProfile up on up.userProfileID = acf.userProfileID " +
"where acf.articleID = :articleID and acf.categoryID = :categoryID and " +
"up.authId = :authID")
.setString("authID", authID)
.setLong("articleID", articleID)
.setLong("categoryID", categoryID)
.executeUpdate();
return null;
}
});
} else {
hibernateTemplate.execute(new HibernateCallback() {
public Object doInHibernate(Session session) throws HibernateException, SQLException {
//Remove one record from the database at random
session.createSQLQuery(
"delete from articleCategoryFlagged where articleID = :articleID and categoryID = :categoryID " +
"and userProfileID is null limit 1")
.setLong("articleID", articleID)
.setLong("categoryID", categoryID)
.executeUpdate();
return null;
}
});
}
}
/**
* {@inheritDoc}
*/
public SortedMap<String, List<String>> parseTopAndSecondLevelCategories(final String currentJournal)
throws ApplicationException {
if (cache == null) {
return parseTopAndSecondLevelCategoriesWithoutCache(currentJournal);
} else {
String key = ("topAndSecondLevelCategoriesCacheKey" + currentJournal).intern();
return cache.get(key, CACHE_TTL,
new Cache.SynchronizedLookup<SortedMap<String, List<String>>, ApplicationException>(key) {
@Override
public SortedMap<String, List<String>> lookup() throws ApplicationException {
return parseTopAndSecondLevelCategoriesWithoutCache(currentJournal);
}
});
}
}
private SortedMap<String, List<String>> parseTopAndSecondLevelCategoriesWithoutCache(String currentJournal)
throws ApplicationException {
List<String> fullCategoryPaths = searchService.getAllSubjects(currentJournal);
// Since there are lots of duplicates, we start by adding the second-level
// categories to a Set instead of a List.
Map<String, Set<String >> map = new HashMap<String, Set<String>>();
for (String category : fullCategoryPaths) {
// If the category doesn't start with a slash, it's one of the old-style
// categories where we didn't store the full path. Ignore these.
if (category.charAt(0) == '/') {
String[] fields = category.split("\\/");
if (fields.length >= 3) {
Set<String> subCats = map.get(fields[1]);
if (subCats == null) {
subCats = new HashSet<String>();
}
subCats.add(fields[2]);
map.put(fields[1], subCats);
}
}
}
// Now sort all the subcategory lists, and add them to the result.
SortedMap<String, List<String>> results = new TreeMap<String, List<String>>();
for (Map.Entry<String, Set<String>> entry : map.entrySet()) {
List<String> subCatList = new ArrayList<String>(entry.getValue());
Collections.sort(subCatList);
results.put(entry.getKey(), subCatList);
}
return results;
}
/**
* {@inheritDoc}
*/
public CategoryView parseCategories(final String currentJournal)
throws ApplicationException {
if (cache == null) {
return parseCategoriesWithoutCache(currentJournal);
} else {
String key = ("categoriesCacheKey" + ((currentJournal==null)?"":currentJournal)).intern();
return cache.get(key, CACHE_TTL,
new Cache.SynchronizedLookup<CategoryView, ApplicationException>(key) {
@Override
public CategoryView lookup() throws ApplicationException {
return parseCategoriesWithoutCache(currentJournal);
}
});
}
}
@SuppressWarnings("unchecked")
private CategoryView parseCategoriesWithoutCache(String currentJournal)
throws ApplicationException {
List<String> subjects = searchService.getAllSubjects(currentJournal);
return CategoryUtils.createMapFromStringList(subjects);
}
/**
* {@inheritDoc}
*/
@Override
public Map<String, Long> getCounts(CategoryView taxonomy, String currentJournal) throws ApplicationException {
Map<String, Long> counts = getAllCounts(currentJournal);
Map<String, Long> results = new HashMap<String, Long>();
for (CategoryView child : taxonomy.getChildren().values()) {
results.put(child.getName(), counts.get(child.getName()));
}
results.put(taxonomy.getName(), counts.get(taxonomy.getName()));
return results;
}
/**
* Returns article counts for a given journal for all subject terms in the taxonomy.
* The results will be cached for CACHE_TTL.
*
* @param currentJournal specifies the current journal
* @return map from subject term to article count
* @throws ApplicationException
*/
private Map<String, Long> getAllCounts(final String currentJournal) throws ApplicationException {
if (cache == null) {
return getAllCountsWithoutCache(currentJournal);
} else {
String key = ("categoryCountCacheKey" + ((currentJournal == null) ? "" : currentJournal)).intern();
return cache.get(key, CACHE_TTL,
new Cache.SynchronizedLookup<Map<String, Long>, ApplicationException>(key) {
@Override
public Map<String, Long> lookup() throws ApplicationException {
return getAllCountsWithoutCache(currentJournal);
}
});
}
}
private Map<String, Long> getAllCountsWithoutCache(String currentJournal) throws ApplicationException {
SearchService.SubjectCounts subjectCounts = searchService.getAllSubjectCounts(currentJournal);
Map<String, Long> counts = subjectCounts.subjectCounts;
counts.put(CategoryView.ROOT_NODE_NAME, subjectCounts.totalArticles);
return counts;
}
public void setCache(Cache cache) {
this.cache = cache;
}
@Required
public void setSearchService(final SearchService searchService) {
this.searchService = searchService;
}
@Required
public void setPermissionsService(final PermissionsService permissionsService) {
this.permissionsService = permissionsService;
}
}