/** * Copyright (C) 2011 JTalks.org Team * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * This library is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA */ package org.jtalks.jcommune.model.entity; import javax.validation.Valid; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import com.google.common.base.Optional; import org.apache.solr.analysis.LowerCaseFilterFactory; import org.apache.solr.analysis.SnowballPorterFilterFactory; import org.apache.solr.analysis.StandardFilterFactory; import org.apache.solr.analysis.StandardTokenizerFactory; import org.apache.solr.analysis.StopFilterFactory; import org.hibernate.search.annotations.Analyzer; import org.hibernate.search.annotations.AnalyzerDef; import org.hibernate.search.annotations.AnalyzerDefs; import org.hibernate.search.annotations.DocumentId; import org.hibernate.search.annotations.Field; import org.hibernate.search.annotations.Fields; import org.hibernate.search.annotations.Indexed; import org.hibernate.search.annotations.IndexedEmbedded; import org.hibernate.search.annotations.Parameter; import org.hibernate.search.annotations.TokenFilterDef; import org.hibernate.search.annotations.TokenizerDef; import org.joda.time.DateTime; import org.jtalks.common.model.entity.Entity; import org.jtalks.jcommune.model.validation.annotations.NotBlankSized; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * Represents the topic of the forum. * Contains the list of related {@link Post}. * All Posts will be cascade deleted with the associated Topic. * The fields creationDate, topicStarter and Title are required and can't be <code>null</code> * * @author Pavel Vervenko * @author Vitaliy Kravchenko * @author Max Malakhov * @author Anuar Nurmakanov */ @AnalyzerDefs({ /* * Describes the analyzer for Russian. */ @AnalyzerDef(name = "russianJtalksAnalyzer", tokenizer = @TokenizerDef(factory = StandardTokenizerFactory.class), filters = { /* * All "terms" of the search text will be converted to lower case. */ @TokenFilterDef(factory = LowerCaseFilterFactory.class), /* * Several words in language doesn't have a significant value. * These filters exclude those words from the index. */ @TokenFilterDef(factory = StopFilterFactory.class, params = { @Parameter(name = "words", value = "org/jtalks/jcommune/lucene/english_stop.txt"), @Parameter(name = "ignoreCase", value = "true") }), @TokenFilterDef(factory = StopFilterFactory.class, params = { @Parameter(name = "words", value = "org/jtalks/jcommune/lucene/russian_stop.txt"), @Parameter(name = "ignoreCase", value = "true") }), /* * Provides the search by a root of a word. * If two words have the same root, then they are equal in the terminology of search. */ @TokenFilterDef(factory = SnowballPorterFilterFactory.class, params = @Parameter(name = "language", value = "Russian")) } ), /* * Describes the analyzer for default language(English). */ @AnalyzerDef(name = "defaultJtalksAnalyzer", tokenizer = @TokenizerDef(factory = StandardTokenizerFactory.class), filters = { @TokenFilterDef(factory = StandardFilterFactory.class), /* * All "terms" of the search text will be converted to lower case. */ @TokenFilterDef(factory = LowerCaseFilterFactory.class), /* * Several words in language don't have a significant value. * These filters exclude those words from the index. */ @TokenFilterDef(factory = StopFilterFactory.class, params = { @Parameter(name = "words", value = "org/jtalks/jcommune/lucene/english_stop.txt"), @Parameter(name = "ignoreCase", value = "true") }), @TokenFilterDef(factory = StopFilterFactory.class, params = { @Parameter(name = "words", value = "org/jtalks/jcommune/lucene/russian_stop.txt"), @Parameter(name = "ignoreCase", value = "true") }), /* * Provides the search by a root of a word. * If two words have the same root, then they are equal in the terminology of search. */ @TokenFilterDef(factory = SnowballPorterFilterFactory.class) } ) }) @Indexed public class Topic extends Entity implements SubscriptionAwareEntity { private static final Logger LOGGER = LoggerFactory.getLogger(Topic.class); public static final String URL_SUFFIX = "/posts/"; private DateTime creationDate; private DateTime modificationDate; private JCUser topicStarter; @NotBlankSized(min = MIN_NAME_SIZE, max = MAX_NAME_SIZE, message = "{javax.validation.constraints.Size.message}") private String title; private boolean sticked; private boolean announcement; private boolean closed; private Branch branch; private int views; @Valid private Poll poll; private String type; private Map<String, String> attributes = new HashMap<>(); private List<Post> posts = new ArrayList<>(); private List<PostDraft> drafts = new ArrayList<>(); private Set<JCUser> subscribers = new HashSet<>(); // transient, makes sense for current user only if set explicitly private transient DateTime lastReadPostDate; public static final int MIN_NAME_SIZE = 1; public static final int MAX_NAME_SIZE = 120; /** * Name of the field in the index for Russian. */ public static final String TOPIC_TITLE_FIELD_RU = "topicTitleRu"; /** * Name of the field in the index for default language(English). */ public static final String TOPIC_TITLE_FIELD_DEF = "topicTitle"; /** * Name of the prefix for collection of posts. */ public static final String TOPIC_POSTS_PREFIX = "topicPosts."; /** * Creates the Topic instance. */ public Topic() { } /** * Creates the Topic instance with required fields. * Creation and modification date is set to now. * * @param topicStarter user who create the topic * @param title topic title */ public Topic(JCUser topicStarter, String title) { this.topicStarter = topicStarter; this.title = title; this.creationDate = new DateTime(); this.modificationDate = new DateTime(); } public Topic(JCUser topicStarter, String title, String topicType) { this.topicStarter = topicStarter; this.title = title; this.creationDate = new DateTime(); this.modificationDate = new DateTime(); this.type = topicType; } /** * Add new {@link Post} to the topic. * The method sets Posts.topic field to this Topic. * * @param post post to add */ public void addPost(Post post) { setModificationDate(post.getCreationDate()); post.setTopic(this); this.posts.add(post); } /** * Remove the post from the topic. * * @param postToRemove post to remove */ public void removePost(Post postToRemove) { posts.remove(postToRemove); Topic topic = postToRemove.getTopic(); if (postToRemove.getCreationDate().withMillisOfSecond(0) .equals(topic.getModificationDate().withMillisOfSecond(0))) { topic.recalculateModificationDate(); } } /** * Check subscribed user on topic or not. * * @param user checked user * @return true if user subscribed on topic * false otherwise */ public boolean userSubscribed(JCUser user) { return subscribers.contains(user); } /** * Get the post creation date. * * @return the creationDate */ public DateTime getCreationDate() { return creationDate; } /** * Set the post creation date. * * @param creationDate the creationDate to set */ protected void setCreationDate(DateTime creationDate) { this.creationDate = creationDate; } /** * Get the user who created the post. * * @return the userCreated */ public JCUser getTopicStarter() { return topicStarter; } /** * The the author of the post. * * @param userCreated the user who create the post */ protected void setTopicStarter(JCUser userCreated) { this.topicStarter = userCreated; } /** * Gets the topic name. * * @return the topicName */ @Fields({ @Field(name = TOPIC_TITLE_FIELD_RU, analyzer = @Analyzer(definition = "russianJtalksAnalyzer")), @Field(name = TOPIC_TITLE_FIELD_DEF, analyzer = @Analyzer(definition = "defaultJtalksAnalyzer")) }) public String getTitle() { return title; } /** * @param newTitle new title for this topic */ public void setTitle(String newTitle) { this.title = newTitle; } /** * @return content of the first post of the topic */ public String getBodyText() { Post firstPost = getFirstPost(); return firstPost.getPostContent(); } /** * @return the list of posts in the topic, always not null and not empty */ @IndexedEmbedded(prefix = TOPIC_POSTS_PREFIX) public List<Post> getPosts() { return posts; } /** * @param posts the posts to set as topic contents, must not be empty or null */ protected void setPosts(List<Post> posts) { this.posts = posts; } /** * @return branch that contains the topic */ public Branch getBranch() { return branch; } /** * @param branch branch to be set as topics branch */ public void setBranch(Branch branch) { this.branch = branch; } /** * @return the firstPost in the topic, topics are guaranteed to have at least the first post */ public Post getFirstPost() { return posts.get(0); } /** * Get the last post in the topic. Topics are guaranteed to have at least the first post. * * @return last post in the topic. */ public Post getLastPost() { return posts.get(posts.size() - 1); } /** * Get next post to given post in topic. Following basic cases are possible: * <ol> * <li>In case of one post in topic it returns it back (not valid case from * end-user point of view).</li> * <li>In case if we pass post in the middle of the topic it returns next * post.</li> * <li>In case if we pass last post in the topic it returns previous one.</li> * </ol> * Used to find closest post which is good to be displayed after deletion of * post we pass as a parameter. * * @param post post to search next * @return Neighbor post */ public Post getNeighborPost(Post post) { for (int i = posts.size() - 1; i > 0; i--) { if (posts.get(i).equals(post)) { return (i == posts.size() - 1) ? posts.get(i - 1) : posts .get(i + 1); } } return getFirstPost(); } /** * @return date and time when theme was changed last time */ public DateTime getModificationDate() { return modificationDate; } /** * @param modificationDate date and time when theme was changed last time */ protected void setModificationDate(DateTime modificationDate) { this.modificationDate = modificationDate; } /** * Calculates modification date of topic taking it as last post in topic creation date. * Used after deletion of the post. It is necessary to save the sort order of topics in the future. */ public void recalculateModificationDate() { DateTime newTopicModificationDate = getFirstPost().getCreationDate(); for (Post post : getPosts()) { if (post.getCreationDate().isAfter(newTopicModificationDate.toInstant())) { newTopicModificationDate = post.getCreationDate(); } } modificationDate = newTopicModificationDate; } /** * Get the date of the last modification of posts in the current topic. */ public DateTime getLastModificationPostDate() { DateTime newTopicModificationDate = getFirstPost().getLastTouchedDate(); for (Post post : getPosts()) { if (post.getLastTouchedDate().isAfter(newTopicModificationDate.toInstant())) { newTopicModificationDate = post.getLastTouchedDate(); } } return newTopicModificationDate; } /** * @return flag og stickedness */ public boolean isSticked() { return this.sticked; } /** * @param sticked a flag of stickedness for a topic */ public void setSticked(boolean sticked) { this.sticked = sticked; } /** * @return flag og announcement */ public boolean isAnnouncement() { return this.announcement; } /** * @param announcement a flag of announcement for a topic */ public void setAnnouncement(boolean announcement) { this.announcement = announcement; } /** * Get count of post in topic. * * @return count of post */ public int getPostCount() { return posts.size(); } /** * @return topic page views */ public int getViews() { return views; } /** * @param views topic page views */ public void setViews(int views) { this.views = views; } /** * Get the poll for this topic. * * @return the poll for this topic */ public Poll getPoll() { return poll; } /** * Set the poll for this topic. * * @param poll the poll for this topic */ public void setPoll(Poll poll) { this.poll = poll; } /** * @param lastReadPostDate last read post creation date */ public void setLastReadPostDate(DateTime lastReadPostDate) { this.lastReadPostDate = lastReadPostDate; } /** * @return last read post creation date */ public DateTime getLastReadPostDate() { return lastReadPostDate; } /** * Returns first unread post for current user. If no unread post * information has been set explicitly this method will return * first topic's post id, considering all topic as unread. * * @return returns first unread post id for the current user */ public Long getFirstUnreadPostId() { if (isHasUpdates()) { return getFirstNewerPost(lastReadPostDate).getId(); } return getFirstPost().getId(); } /** * Returns first post that is newer then give time * @param time time to looking for newer post * @return first post that is newer then give time or first post if there is no post that is newer */ private Post getFirstNewerPost(DateTime time) { for (Post post : getPosts()) { if (post.getCreationDate().isAfter(time)) { return post; } } return getFirstPost(); } /** * This method will return true if there are unread posts in that topic * for the current user. This state is NOT persisted and must be * explicitly set by calling Topic.setLastReadPostDate(). * <p/> * If setter has not been called this method will always return <code>true</code> * * @return if current topic has posts still unread by the current user */ public boolean isHasUpdates() { return (lastReadPostDate == null) || (lastReadPostDate.isBefore(getLastPost().getCreationDate())); } /** * Determines a existence the poll in the topic. * * @return <tt>true</tt> if the poll exists * <tt>false</tt> if the poll doesn't exist */ public boolean isHasPoll() { return poll != null; } /** * {@inheritDoc} */ @DocumentId @Override public long getId() { return super.getId(); } /** * {@inheritDoc} */ @Override public Set<JCUser> getSubscribers() { return subscribers; } /** * {@inheritDoc} */ public void setSubscribers(Set<JCUser> subscribers) { this.subscribers = subscribers; } /** * {@inheritDoc} * * <p> * The target URL has the next format http://{forum root}/posts/{id} */ @Override public String getUrlSuffix() { return URL_SUFFIX + getLastPost().getId(); } /** * {@inheritDoc} */ @Override public <T extends SubscriptionAwareEntity> String getUnsubscribeLinkForSubscribersOf(Class<T> clazz) { if (Branch.class.isAssignableFrom(clazz)) { return getBranch().getUnsubscribeLinkForSubscribersOf(clazz); } else { //In case of post unsubscribe from topic too return String.format("/topics/%s/unsubscribe", getId()); } } /** * @return True if topic is closed */ public boolean isClosed() { return closed; } /** * @param closed If true then topic set to closed, else to open */ public void setClosed(boolean closed) { this.closed = closed; } /** * Gets type of the topic * * @return type of the topic */ public String getType() { return type; } /** * Sets specified type to the topic * * @param type type to set */ public void setType(String type) { this.type = type; } /** * Gets attributes of the topic * * @return attributes of the topic */ public Map<String, String> getAttributes() { return attributes; } /** * Sets specified attributes to the topic * For hibernate usage. Use Topic#putAttribute * * @param attributes attributes to set */ public void setAttributes(Map<String, String> attributes) { this.attributes = attributes; } /** * Adds new attribute or overrides existent attribute of the topic * * @param attributeName name of the attribute * @param attributeValue value of the attribute */ public void addOrOverrideAttribute(String attributeName, String attributeValue) { this.attributes.put(attributeName, attributeValue); } /** * Determines if topic is code review * * @return true if code review, otherwise false */ public boolean isCodeReview() { return type != null && type.equals(TopicTypeName.CODE_REVIEW.getName()); } /** * Determines if topic is provided by plugin. * NOTE: currently jcommune provides two topic types: "Code review" and "Discussion" all other * topic types are provided by plugins * * @return true if topic is provided by plugin otherwise false */ public boolean isPlugable() { return type != null && !(this.isCodeReview() || type.equals(TopicTypeName.DISCUSSION.getName())); } /** * Checks if this topic contains only posts added by topic Owner. * * @return <code>true</code> if condition is followed, otherwise <code>false</code> */ public boolean isContainsOwnerPostsOnly() { for (Post post : getPosts()) { if (!post.getUserCreated().equals(topicStarter)) { return false; } } return true; } /** * Gets list of drafts for current topic * * @return list of drafts */ public List<PostDraft> getDrafts() { return drafts; } /** * Sets list of drafts to current topic * * @param drafts list of drafts to set */ public void setDrafts(List<PostDraft> drafts) { this.drafts = drafts; } /** * Adds draft to current topic * * @param draft draft to add */ public void addDraft(PostDraft draft) { draft.setTopic(this); getDrafts().add(draft); } /** * Get draft of specified user in current topic * * @param user user to search draft * * @return draft of specified user or null if draft not found */ public PostDraft getDraftForUser(JCUser user) { for (PostDraft draft : getDrafts()) { if (draft.getAuthor().equals(user)) { return draft; } } return null; } /** * removes specified draft from topic * * @param draft draft to be removed */ public void removeDraft(PostDraft draft) { getDrafts().remove(draft); } /** * Removes draft of specified user if exist * * @param user user to search draft */ public void removeDraftOfUser(JCUser user) { PostDraft draft = getDraftForUser(user); if (draft != null) { removeDraft(draft); } } public int getUserPostCount(JCUser user) { int count = 0; for (Post post : getPosts()) { if (post.getUserCreated().equals(user)) { count ++; } } return count; } /** * Makes URL to mark topic page as read. * For anonymous user returns empty optional. * * @param user current user * @param page page to mark as read * @return Optional url string */ public Optional<String> getMarkAsReadUrl(JCUser user, String page) { if (user.isAnonymous()) { return Optional.absent(); } return Optional.of("{topicId}/page/{pageNum}/markread?userId={userId}&lastModified={lastModified}" .replace("{topicId}", String.valueOf(getId())) .replace("{pageNum}", page) .replace("{userId}", String.valueOf(user.getId())) .replace("{lastModified}", String.valueOf(getLastModificationPostDate().getMillis())) ); } }