tagging support (#96)

This commit is contained in:
Athou
2013-10-13 10:49:44 +02:00
parent 94f469a6b1
commit 431ab92a02
53 changed files with 840 additions and 234 deletions

View File

@@ -31,6 +31,8 @@ import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryContent_;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedEntryStatus_;
import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.FeedEntryTag_;
import com.commafeed.backend.model.FeedEntry_;
import com.commafeed.backend.model.FeedSubscription;
import com.commafeed.backend.model.Models;
@@ -47,6 +49,10 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
private static final String ALIAS_STATUS = "status";
private static final String ALIAS_ENTRY = "entry";
private static final String ALIAS_TAG = "tag";
@Inject
FeedEntryTagDAO feedEntryTagDAO;
private static final Comparator<FeedEntryStatus> STATUS_COMPARATOR_DESC = new Comparator<FeedEntryStatus>() {
@Override
@@ -63,7 +69,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
@Inject
ApplicationSettingsService applicationSettingsService;
public FeedEntryStatus getStatus(FeedSubscription sub, FeedEntry entry) {
public FeedEntryStatus getStatus(User user, FeedSubscription sub, FeedEntry entry) {
CriteriaQuery<FeedEntryStatus> query = builder.createQuery(getType());
Root<FeedEntryStatus> root = query.from(getType());
@@ -76,14 +82,14 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
List<FeedEntryStatus> statuses = em.createQuery(query).getResultList();
FeedEntryStatus status = Iterables.getFirst(statuses, null);
return handleStatus(status, sub, entry);
return handleStatus(user, status, sub, entry);
}
private FeedEntryStatus handleStatus(FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) {
private FeedEntryStatus handleStatus(User user, FeedEntryStatus status, FeedSubscription sub, FeedEntry entry) {
if (status == null) {
Date unreadThreshold = applicationSettingsService.getUnreadThreshold();
boolean read = unreadThreshold == null ? false : entry.getUpdated().before(unreadThreshold);
status = new FeedEntryStatus(sub.getUser(), sub, entry);
status = new FeedEntryStatus(user, sub, entry);
status.setRead(read);
status.setMarkable(!read);
} else {
@@ -92,6 +98,12 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
return status;
}
private FeedEntryStatus fetchTags(User user, FeedEntryStatus status) {
List<FeedEntryTag> tags = feedEntryTagDAO.findByEntry(user, status.getEntry());
status.setTags(tags);
return status;
}
public List<FeedEntryStatus> findStarred(User user, Date newerThan, int offset, int limit, ReadingOrder order, boolean includeContent) {
CriteriaQuery<FeedEntryStatus> query = builder.createQuery(getType());
@@ -114,13 +126,14 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
setTimeout(q);
List<FeedEntryStatus> statuses = q.getResultList();
for (FeedEntryStatus status : statuses) {
status = handleStatus(status, status.getSubscription(), status.getEntry());
status = handleStatus(user, status, status.getSubscription(), status.getEntry());
status = fetchTags(user, status);
}
return lazyLoadContent(includeContent, statuses);
}
private Criteria buildSearchCriteria(FeedSubscription sub, boolean unreadOnly, String keywords, Date newerThan, int offset, int limit,
ReadingOrder order, Date last) {
ReadingOrder order, Date last, String tag) {
Criteria criteria = getSession().createCriteria(FeedEntry.class, ALIAS_ENTRY);
criteria.add(Restrictions.eq(FeedEntry_.feed.getName(), sub.getFeed()));
@@ -138,7 +151,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
Criteria statusJoin = criteria.createCriteria(FeedEntry_.statuses.getName(), ALIAS_STATUS, JoinType.LEFT_OUTER_JOIN,
Restrictions.eq(FeedEntryStatus_.subscription.getName(), sub));
if (unreadOnly) {
if (unreadOnly && tag == null) {
Disjunction or = Restrictions.disjunction();
or.add(Restrictions.isNull(FeedEntryStatus_.read.getName()));
@@ -151,6 +164,11 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
}
}
if (tag != null) {
criteria.createCriteria(FeedEntry_.tags.getName(), ALIAS_TAG, JoinType.INNER_JOIN,
Restrictions.eq(FeedEntryTag_.name.getName(), tag));
}
if (newerThan != null) {
criteria.add(Restrictions.ge(FeedEntry_.inserted.getName(), newerThan));
}
@@ -185,14 +203,14 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
}
@SuppressWarnings("unchecked")
public List<FeedEntryStatus> findBySubscriptions(List<FeedSubscription> subs, boolean unreadOnly, String keywords, Date newerThan,
int offset, int limit, ReadingOrder order, boolean includeContent, boolean onlyIds) {
public List<FeedEntryStatus> findBySubscriptions(User user, List<FeedSubscription> subs, boolean unreadOnly, String keywords,
Date newerThan, int offset, int limit, ReadingOrder order, boolean includeContent, boolean onlyIds, String tag) {
int capacity = offset + limit;
Comparator<FeedEntryStatus> comparator = order == ReadingOrder.desc ? STATUS_COMPARATOR_DESC : STATUS_COMPARATOR_ASC;
FixedSizeSortedSet<FeedEntryStatus> set = new FixedSizeSortedSet<FeedEntryStatus>(capacity, comparator);
for (FeedSubscription sub : subs) {
Date last = (order != null && set.isFull()) ? set.last().getEntryUpdated() : null;
Criteria criteria = buildSearchCriteria(sub, unreadOnly, keywords, newerThan, -1, capacity, order, last);
Criteria criteria = buildSearchCriteria(sub, unreadOnly, keywords, newerThan, -1, capacity, order, last, tag);
ProjectionList projection = Projections.projectionList();
projection.add(Projections.property("id"), "id");
projection.add(Projections.property("updated"), "updated");
@@ -234,7 +252,10 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
for (FeedEntryStatus placeholder : placeholders) {
Long statusId = placeholder.getId();
FeedEntry entry = em.find(FeedEntry.class, placeholder.getEntry().getId());
statuses.add(handleStatus(statusId == null ? null : findById(statusId), placeholder.getSubscription(), entry));
FeedEntryStatus status = handleStatus(user, statusId == null ? null : findById(statusId), placeholder.getSubscription(),
entry);
status = fetchTags(user, status);
statuses.add(status);
}
statuses = lazyLoadContent(includeContent, statuses);
}
@@ -244,7 +265,7 @@ public class FeedEntryStatusDAO extends GenericDAO<FeedEntryStatus> {
@SuppressWarnings("unchecked")
public UnreadCount getUnreadCount(FeedSubscription subscription) {
UnreadCount uc = null;
Criteria criteria = buildSearchCriteria(subscription, true, null, null, -1, -1, null, null);
Criteria criteria = buildSearchCriteria(subscription, true, null, null, -1, -1, null, null, null);
ProjectionList projection = Projections.projectionList();
projection.add(Projections.rowCount(), "count");
projection.add(Projections.max(FeedEntry_.updated.getName()), "updated");

View File

@@ -0,0 +1,43 @@
package com.commafeed.backend.dao;
import java.util.List;
import javax.ejb.Stateless;
import javax.persistence.criteria.CriteriaQuery;
import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.FeedEntryTag_;
import com.commafeed.backend.model.FeedEntry_;
import com.commafeed.backend.model.User;
import com.commafeed.backend.model.User_;
@Stateless
public class FeedEntryTagDAO extends GenericDAO<FeedEntryTag> {
public List<String> findByUser(User user) {
CriteriaQuery<String> query = builder.createQuery(String.class);
Root<FeedEntryTag> root = query.from(getType());
query.select(root.get(FeedEntryTag_.name));
query.distinct(true);
Predicate p1 = builder.equal(root.get(FeedEntryTag_.user).get(User_.id), user.getId());
query.where(p1);
return cache(em.createQuery(query)).getResultList();
}
public List<FeedEntryTag> findByEntry(User user, FeedEntry entry) {
CriteriaQuery<FeedEntryTag> query = builder.createQuery(getType());
Root<FeedEntryTag> root = query.from(getType());
Predicate p1 = builder.equal(root.get(FeedEntryTag_.user).get(User_.id), user.getId());
Predicate p2 = builder.equal(root.get(FeedEntryTag_.entry).get(FeedEntry_.id), entry.getId());
query.where(p1, p2);
return cache(em.createQuery(query)).getResultList();
}
}

View File

@@ -55,5 +55,8 @@ public class FeedEntry extends AbstractModel {
@OneToMany(mappedBy = "entry", cascade = CascadeType.REMOVE)
private Set<FeedEntryStatus> statuses;
@OneToMany(mappedBy = "entry", cascade = CascadeType.REMOVE)
private Set<FeedEntryTag> tags;
}

View File

@@ -1,6 +1,7 @@
package com.commafeed.backend.model;
import java.util.Date;
import java.util.List;
import javax.persistence.Cacheable;
import javax.persistence.Column;
@@ -42,6 +43,9 @@ public class FeedEntryStatus extends AbstractModel {
@Transient
private boolean markable;
@Transient
private List<FeedEntryTag> tags;
/**
* Denormalization starts here

View File

@@ -0,0 +1,46 @@
package com.commafeed.backend.model;
import javax.persistence.Cacheable;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
@Entity
@Table(name = "FEEDENTRYTAGS")
@SuppressWarnings("serial")
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.TRANSACTIONAL)
@Getter
@Setter
public class FeedEntryTag extends AbstractModel {
@JoinColumn(name = "user_id")
@ManyToOne(fetch = FetchType.LAZY)
private User user;
@JoinColumn(name = "entry_id")
@ManyToOne(fetch = FetchType.LAZY)
private FeedEntry entry;
@Column(name = "name", length = 40)
private String name;
public FeedEntryTag() {
}
public FeedEntryTag(User user, FeedEntry entry, String name) {
this.name = name;
this.entry = entry;
this.user = user;
}
}

View File

@@ -43,7 +43,7 @@ public class FeedEntryService {
return;
}
FeedEntryStatus status = feedEntryStatusDAO.getStatus(sub, entry);
FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry);
if (status.isMarkable()) {
status.setRead(read);
feedEntryStatusDAO.saveOrUpdate(status);
@@ -64,14 +64,14 @@ public class FeedEntryService {
return;
}
FeedEntryStatus status = feedEntryStatusDAO.getStatus(sub, entry);
FeedEntryStatus status = feedEntryStatusDAO.getStatus(user, sub, entry);
status.setStarred(starred);
feedEntryStatusDAO.saveOrUpdate(status);
}
public void markSubscriptionEntries(User user, List<FeedSubscription> subscriptions, Date olderThan) {
List<FeedEntryStatus> statuses = feedEntryStatusDAO
.findBySubscriptions(subscriptions, true, null, null, -1, -1, null, false, false);
List<FeedEntryStatus> statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, null, null, -1, -1, null, false,
false, null);
markList(statuses, olderThan);
cache.invalidateUnreadCount(subscriptions.toArray(new FeedSubscription[0]));
cache.invalidateUserRootCategory(user);

View File

@@ -0,0 +1,61 @@
package com.commafeed.backend.services;
import java.util.List;
import java.util.Map;
import javax.ejb.Stateless;
import javax.inject.Inject;
import com.commafeed.backend.dao.FeedEntryDAO;
import com.commafeed.backend.dao.FeedEntryTagDAO;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.User;
import com.google.common.base.Function;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
@Stateless
public class FeedEntryTagService {
@Inject
FeedEntryDAO feedEntryDAO;
@Inject
FeedEntryTagDAO feedEntryTagDAO;
public void updateTags(User user, Long entryId, List<String> tagNames) {
FeedEntry entry = feedEntryDAO.findById(entryId);
if (entry == null) {
return;
}
List<FeedEntryTag> tags = feedEntryTagDAO.findByEntry(user, entry);
Map<String, FeedEntryTag> tagMap = Maps.uniqueIndex(tags, new Function<FeedEntryTag, String>() {
@Override
public String apply(FeedEntryTag input) {
return input.getName();
}
});
List<FeedEntryTag> addList = Lists.newArrayList();
List<FeedEntryTag> removeList = Lists.newArrayList();
for (String tagName : tagNames) {
FeedEntryTag tag = tagMap.get(tagName);
if (tag == null) {
addList.add(new FeedEntryTag(user, entry, tagName));
}
}
for (FeedEntryTag tag : tags) {
if (!tagNames.contains(tag.getName())) {
removeList.add(tag);
}
}
feedEntryTagDAO.saveOrUpdate(addList);
feedEntryTagDAO.delete(removeList);
}
}

View File

@@ -3,6 +3,7 @@ package com.commafeed.frontend.model;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import lombok.Data;
@@ -10,7 +11,9 @@ import com.commafeed.backend.feeds.FeedUtils;
import com.commafeed.backend.model.FeedEntry;
import com.commafeed.backend.model.FeedEntryContent;
import com.commafeed.backend.model.FeedEntryStatus;
import com.commafeed.backend.model.FeedEntryTag;
import com.commafeed.backend.model.FeedSubscription;
import com.google.common.collect.Lists;
import com.sun.syndication.feed.synd.SyndContentImpl;
import com.sun.syndication.feed.synd.SyndEntry;
import com.sun.syndication.feed.synd.SyndEntryImpl;
@@ -43,6 +46,12 @@ public class Entry implements Serializable {
entry.setFeedLink(sub.getFeed().getLink());
entry.setIconUrl(FeedUtils.getFaviconUrl(sub, publicUrl));
List<String> tags = Lists.newArrayList();
for (FeedEntryTag tag : status.getTags()) {
tags.add(tag.getName());
}
entry.setTags(tags);
if (content != null) {
entry.setRtl(FeedUtils.isRTL(feedEntry));
entry.setTitle(content.getTitle());
@@ -125,4 +134,7 @@ public class Entry implements Serializable {
@ApiProperty("wether the entry is still markable (old entry statuses are discarded)")
private boolean markable;
@ApiProperty("tags")
private List<String> tags;
}

View File

@@ -0,0 +1,22 @@
package com.commafeed.frontend.model.request;
import java.io.Serializable;
import java.util.List;
import lombok.Data;
import com.wordnik.swagger.annotations.ApiClass;
import com.wordnik.swagger.annotations.ApiProperty;
@SuppressWarnings("serial")
@ApiClass("Tag Request")
@Data
public class TagRequest implements Serializable {
@ApiProperty(value = "entry id", required = true)
private Long entryId;
@ApiProperty(value = "tags")
private List<String> tags;
}

View File

@@ -54,13 +54,13 @@ public class NextUnreadRedirectPage extends WebPage {
List<FeedEntryStatus> statuses = null;
if (StringUtils.isBlank(categoryId) || CategoryREST.ALL.equals(categoryId)) {
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(user);
statuses = feedEntryStatusDAO.findBySubscriptions(subs, true, null, null, 0, 1, order, true, false);
statuses = feedEntryStatusDAO.findBySubscriptions(user, subs, true, null, null, 0, 1, order, true, false, null);
} else {
FeedCategory category = feedCategoryDAO.findById(user, Long.valueOf(categoryId));
if (category != null) {
List<FeedCategory> children = feedCategoryDAO.findAllChildrenCategories(user, category);
List<FeedSubscription> subscriptions = feedSubscriptionDAO.findByCategories(user, children);
statuses = feedEntryStatusDAO.findBySubscriptions(subscriptions, true, null, null, 0, 1, order, true, false);
statuses = feedEntryStatusDAO.findBySubscriptions(user, subscriptions, true, null, null, 0, 1, order, true, false, null);
}
}

View File

@@ -106,7 +106,8 @@ public class CategoryREST extends AbstractREST {
@ApiParam(
value = "search for keywords in either the title or the content of the entries, separated by spaces, 3 characters minimum") @QueryParam("keywords") String keywords,
@ApiParam(value = "return only entry ids") @DefaultValue("false") @QueryParam("onlyIds") boolean onlyIds,
@ApiParam(value = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds) {
@ApiParam(value = "comma-separated list of excluded subscription ids") @QueryParam("excludedSubscriptionIds") String excludedSubscriptionIds,
@ApiParam(value = "keep only entries tagged with this tag") @QueryParam("tag") String tag) {
Preconditions.checkNotNull(readType);
@@ -138,8 +139,8 @@ public class CategoryREST extends AbstractREST {
entries.setName("All");
List<FeedSubscription> subs = feedSubscriptionDAO.findAll(getUser());
removeExcludedSubscriptions(subs, excludedIds);
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(subs, unreadOnly, keywords, newerThanDate, offset,
limit + 1, order, true, onlyIds);
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(getUser(), subs, unreadOnly, keywords, newerThanDate,
offset, limit + 1, order, true, onlyIds, tag);
for (FeedEntryStatus status : list) {
entries.getEntries().add(
@@ -161,8 +162,8 @@ public class CategoryREST extends AbstractREST {
List<FeedCategory> categories = feedCategoryDAO.findAllChildrenCategories(getUser(), parent);
List<FeedSubscription> subs = feedSubscriptionDAO.findByCategories(getUser(), categories);
removeExcludedSubscriptions(subs, excludedIds);
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(subs, unreadOnly, keywords, newerThanDate, offset,
limit + 1, order, true, onlyIds);
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(getUser(), subs, unreadOnly, keywords, newerThanDate,
offset, limit + 1, order, true, onlyIds, tag);
for (FeedEntryStatus status : list) {
entries.getEntries().add(
@@ -192,7 +193,9 @@ public class CategoryREST extends AbstractREST {
@Produces(MediaType.APPLICATION_XML)
@SecurityCheck(value = Role.USER, apiKeyAllowed = true)
public Response getCategoryEntriesAsFeed(
@ApiParam(value = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id) {
@ApiParam(value = "id of the category, 'all' or 'starred'", required = true) @QueryParam("id") String id, @ApiParam(
value = "tag",
required = true) @QueryParam("tag") String tag) {
Preconditions.checkNotNull(id);
@@ -201,7 +204,7 @@ public class CategoryREST extends AbstractREST {
int offset = 0;
int limit = 20;
Response response = getCategoryEntries(id, readType, null, offset, limit, order, null, false, null);
Response response = getCategoryEntries(id, readType, null, offset, limit, order, null, false, null, tag);
if (response.getStatus() != Status.OK.getStatusCode()) {
return response;
}

View File

@@ -1,17 +1,23 @@
package com.commafeed.frontend.rest.resources;
import java.util.List;
import javax.inject.Inject;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.Response;
import com.commafeed.backend.dao.FeedEntryStatusDAO;
import com.commafeed.backend.dao.FeedEntryTagDAO;
import com.commafeed.backend.dao.FeedSubscriptionDAO;
import com.commafeed.backend.services.ApplicationSettingsService;
import com.commafeed.backend.services.FeedEntryService;
import com.commafeed.backend.services.FeedEntryTagService;
import com.commafeed.frontend.model.request.MarkRequest;
import com.commafeed.frontend.model.request.MultipleMarkRequest;
import com.commafeed.frontend.model.request.StarRequest;
import com.commafeed.frontend.model.request.TagRequest;
import com.google.common.base.Preconditions;
import com.wordnik.swagger.annotations.Api;
import com.wordnik.swagger.annotations.ApiOperation;
@@ -30,6 +36,12 @@ public class EntryREST extends AbstractREST {
@Inject
FeedSubscriptionDAO feedSubscriptionDAO;
@Inject
FeedEntryTagDAO feedEntryTagDAO;
@Inject
FeedEntryTagService feedEntryTagService;
@Inject
ApplicationSettingsService applicationSettingsService;
@@ -71,4 +83,24 @@ public class EntryREST extends AbstractREST {
return Response.ok().build();
}
@Path("/tags")
@GET
@ApiOperation(value = "Get list of tags for the user", notes = "Get list of tags for the user")
public Response getTags() {
List<String> tags = feedEntryTagDAO.findByUser(getUser());
return Response.ok(tags).build();
}
@Path("/tag")
@POST
@ApiOperation(value = "Mark a feed entry", notes = "Mark a feed entry as read/unread")
public Response tagFeedEntry(@ApiParam(value = "Tag Request", required = true) TagRequest req) {
Preconditions.checkNotNull(req);
Preconditions.checkNotNull(req.getEntryId());
feedEntryTagService.updateTags(getUser(), req.getEntryId(), req.getTags());
return Response.ok().build();
}
}

View File

@@ -167,8 +167,8 @@ public class FeedREST extends AbstractREST {
entries.setErrorCount(subscription.getFeed().getErrorCount());
entries.setFeedLink(subscription.getFeed().getLink());
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(Arrays.asList(subscription), unreadOnly, keywords,
newerThanDate, offset, limit + 1, order, true, onlyIds);
List<FeedEntryStatus> list = feedEntryStatusDAO.findBySubscriptions(getUser(), Arrays.asList(subscription), unreadOnly,
keywords, newerThanDate, offset, limit + 1, order, true, onlyIds, null);
for (FeedEntryStatus status : list) {
entries.getEntries().add(