// BlogBridge -- RSS feed reader, manager, and web based service
// Copyright (C) 2002-2006 by R. Pito Salas
//
// This program is free software; you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software Foundation;
// either version 2 of the License, or (at your option) any later version.
//
// This program 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License along with this program;
// if not, write to the Free Software Foundation, Inc., 59 Temple Place,
// Suite 330, Boston, MA 02111-1307 USA
//
// Contact: R. Pito Salas
// mailto:pitosalas@users.sourceforge.net
// More information: about BlogBridge
// http://www.blogbridge.com
// http://sourceforge.net/projects/blogbridge
//
// $Id: AbstractArticle.java,v 1.36 2008/03/17 17:16:17 spyromus Exp $
//
package com.salas.bb.domain;
import com.salas.bb.sentiments.Calculator;
import com.salas.bb.utils.CommonUtils;
import com.salas.bb.utils.Constants;
import com.salas.bb.utils.StringUtils;
import com.salas.bb.utils.i18n.Strings;
import com.salas.bb.utils.swinghtml.TextProcessor;
import java.lang.ref.SoftReference;
import java.net.URL;
import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Abstract article implementation.
*/
public abstract class AbstractArticle implements IArticle
{
/** ID property. */
public static final String PROP_ID = "id";
/** Pattern for looking for HTML links. */
private static final Pattern PAT_LINKS =
Pattern.compile("<a [^>]*href\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>", Pattern.CASE_INSENSITIVE);
private final List<IArticleListener> listeners;
private long id;
private String simpleMatchKey;
private IFeed feed;
private IFeed candidateFeed; // Used during checks to see if the article fits the feed
private boolean read;
private boolean pinned;
private String title;
private String author;
private String subject;
private Date publicationDate;
private URL link;
/** This flag is <code>TRUE</code> only once a life-cycle during the initial articleAdded even processing. */
private boolean isnew;
/** Brief mode settings. */
public static final int DEFAULT_BRIEF_SENTENCES = 3;
public static final int DEFAULT_BRIEF_MIN_LENGTH = 50;
public static final int DEFAULT_BRIEF_MAX_LENGTH = 1000;
private static int briefSentences = DEFAULT_BRIEF_SENTENCES;
private static int briefMinLength = DEFAULT_BRIEF_MIN_LENGTH;
private static int briefMaxLength = DEFAULT_BRIEF_MAX_LENGTH;
/**
* Links collected from the article text.
*/
private Collection<String> links;
/** Soft storage for plain text. */
private SoftReference<String> softPlainText;
/** The list of words the title is composed of. */
private String[] titleWords;
private final Object titleWordsLock = new Object();
/** Positive sentiments count. */
private int positiveSentimentsCount;
/** Negative sentiments count. */
private int negativeSentimentsCount;
private boolean positive;
private boolean negative;
private final ReadWriteLock sentimentsLock = new ReentrantReadWriteLock();
private final Lock readLock = sentimentsLock.readLock();
private final Lock writeLock = sentimentsLock.writeLock();
/**
* Creates abstract article.
*/
protected AbstractArticle()
{
listeners = new CopyOnWriteArrayList<IArticleListener>();
id = -1;
simpleMatchKey = null;
pinned = false;
}
/**
* Returns ID of the article in database. This ID is used by persistence layer.
*
* @return ID of the article in database.
*/
public long getID()
{
return id;
}
/**
* Sets ID of the article in database.
*
* @param aId ID of the article.
*/
public void setID(long aId)
{
long oldId = id;
id = aId;
firePropertyChanged(PROP_ID, oldId, id);
}
/**
* Adds listener to receive article's events.
*
* @param listener listener.
*
* @throws NullPointerException if listener isn't specified.
*/
public void addListener(IArticleListener listener)
{
if (listener == null) throw new NullPointerException(Strings.error("unspecified.listener"));
if (!listeners.contains(listener)) listeners.add(listener);
}
/**
* Removes listener.
*
* @param listener listener.
*
* @throws NullPointerException if listener isn't specified.
*/
public void removeListener(IArticleListener listener)
{
if (listener == null) throw new NullPointerException(Strings.error("unspecified.listener"));
listeners.remove(listener);
}
/**
* Fires <code>propertyChanged</code> event.
*
* @param property property name.
* @param oldValue old value.
* @param newValue new value.
*
* @throws NullPointerException if property name isn't specified.
*/
protected void firePropertyChanged(String property, Object oldValue, Object newValue)
{
if (property == null) throw new NullPointerException(Strings.error("unspecified.property"));
if (!CommonUtils.areDifferent(oldValue, newValue)) return;
for (IArticleListener listener : listeners)
{
listener.propertyChanged(this, property, oldValue, newValue);
}
}
/**
* Returns simple match key which is close to unique.
*
* @return simple match key.
*/
public synchronized String getSimpleMatchKey()
{
if (simpleMatchKey == null) computeSimpleMatchKey();
return simpleMatchKey;
}
/**
* Sets new simple match key to be used for matching. This is usually done during the loading from
* the database.
*
* @param key key.
*/
public void setSimpleMatchKey(String key)
{
simpleMatchKey = StringUtils.intern(key);
}
/**
* Resets the value of simple match key indicating that the key is no longer valid.
*/
protected synchronized void resetSimpleMatchKey()
{
simpleMatchKey = null;
}
/**
* Returns plain version of article text.
*
* @return plain version of text.
*/
public synchronized String getPlainText()
{
String plainText = getPlainFromCache();
if (plainText == null)
{
plainText = convertTextToPlain(getHtmlText());
putPlainInCache(plainText);
}
return plainText;
}
/**
* Returns the name of article author.
*
* @return author.
*/
public String getAuthor()
{
return author;
}
/**
* Sets the name of article author.
*
* @param aAuthor author.
*/
public void setAuthor(String aAuthor)
{
author = StringUtils.intern(aAuthor);
}
/**
* Returns the date of publication.
*
* @return publication date.
*/
public Date getPublicationDate()
{
return publicationDate;
}
/**
* Sets the date of article publication.
*
* @param aPublicationDate publication date.
*/
public void setPublicationDate(Date aPublicationDate)
{
publicationDate = aPublicationDate;
}
/**
* Returns TRUE if the article is read.
*
* @return TRUE if the article is read.
*/
public boolean isRead()
{
return read;
}
/**
* Sets the value of read flag for the article.
*
* @param aRead TRUE if the article is read.
*/
public void setRead(boolean aRead)
{
if (aRead != read)
{
read = aRead;
firePropertyChanged(PROP_READ, !read, read);
}
}
/**
* Returns the pin flag state.
*
* @return pin flag.
*/
public boolean isPinned()
{
return pinned;
}
/**
* Sets the pin flag state.
*
* @param pinned TRUE to pin.
*/
public void setPinned(boolean pinned)
{
this.pinned = pinned;
firePropertyChanged(PROP_PINNED, !pinned, pinned);
}
/**
* Returns the subject of article.
*
* @return subject.
*/
public String getSubject()
{
return subject;
}
/**
* Sets the subject of article.
*
* @param aSubject new subject.
*/
public void setSubject(String aSubject)
{
subject = StringUtils.intern(aSubject);
}
/**
* Returns title of the article.
*
* @return title.
*/
public synchronized String getTitle()
{
return title;
}
/**
* Sets the title of the article.
*
* @param aTitle title of the article.
*/
public synchronized void setTitle(String aTitle)
{
title = StringUtils.intern(aTitle);
// Simple match key depends on the title -- reset the key when text changes.
resetSimpleMatchKey();
}
/**
* Returns the words of the title in the order of appearance.
*
* @return words of the title.
*/
public String[] getTitleWords()
{
synchronized (titleWordsLock)
{
if (titleWords == null)
{
String[] wrds = StringUtils.split(title, "-+#$%^&_*,.()[]<>!?\"':;/\\ ");
// Count words with 3 or more chars
int s = 0;
for (String wrd : wrds) if (wrd.length() > 2) s++;
// Compose the resulting array
titleWords = new String[s];
s = 0;
for (String wrd : wrds) if (wrd.length() > 2) titleWords[s++] = wrd.intern();
}
}
return titleWords;
}
/**
* Returns URL of associated article page.
*
* @return URL of article page.
*/
public URL getLink()
{
return link;
}
/**
* Sets the link to associated HTML page.
*
* @param aLink link to HTML page.
*/
public void setLink(URL aLink)
{
// Recreate a link in a way that reuses strings
link = CommonUtils.intern(aLink);
resetSimpleMatchKey();
}
/**
* Returns parent feed.
*
* @return parent feed.
*/
public IFeed getFeed()
{
return feed;
}
/**
* Sets the parent feed.
*
* @param aFeed parent feed.
*/
public void setFeed(IFeed aFeed)
{
feed = aFeed;
}
/**
* Sets the candidate feed.
*
* @param feed parent feed.
*/
public void setCandidateFeed(IFeed feed)
{
candidateFeed = feed;
}
/**
* Returns all links (absolute and relative) found in the article text.
*
* @return links.
*/
public synchronized Collection<String> getLinks()
{
if (links == null) links = collectLinks(getHtmlText());
return links;
}
/**
* Looks through the article body and records all found links (<a href="link">).
*
* @param aText the text of the article.
*
* @return the list of links in the article.
*/
static Collection<String> collectLinks(String aText)
{
Set<String> linksSet = new HashSet<String>();
if (aText != null)
{
Matcher m = PAT_LINKS.matcher(aText);
while (m.find()) linksSet.add(m.group(1).intern());
}
return linksSet;
}
/**
* Returns hash code for the article.
*
* @return hash code.
*/
public int hashCode()
{
return getSimpleMatchKey().hashCode();
}
/**
* Compares this article to some different article. Uses simple match key for comparison.
*
* @param obj article object.
*
* @return TRUE if equal.
*/
public boolean equals(Object obj)
{
IArticle article = (IArticle)obj;
return getSimpleMatchKey().equals(article.getSimpleMatchKey());
}
// ---------------------------------------------------------------------------------------------
// Brief Mode
// ---------------------------------------------------------------------------------------------
/**
* Returns brief version of plain text.
*
* @return brief text.
*/
public String getBriefText()
{
return StringUtils.excerpt(getPlainText(), briefSentences, briefMinLength, briefMaxLength);
}
/**
* Returns the number of sentences in a brief mode.
*
* @return sentences.
*/
public static int getBriefSentences()
{
return briefSentences;
}
/**
* Sets new sentence limit for brief mode.
*
* @param limit limit.
*/
public static void setBriefSentences(int limit)
{
briefSentences = limit;
}
/**
* Returns the minimum number of characters to show in a brief mode (if available).
*
* @return minimum.
*/
public static int getBriefMinLength()
{
return briefMinLength;
}
/**
* Sets new minimum length for brief mode.
*
* @param min length.
*/
public static void setBriefMinLength(int min)
{
briefMinLength = min;
}
/**
* Returns the maximum number of characters to show in a brief mode (if available).
*
* @return max length.
*/
public static int getBriefMaxLength()
{
return briefMaxLength;
}
/**
* Sets new maximum length for brief mode.
*
* @param max length.
*/
public static void setBriefMaxLength(int max)
{
briefMaxLength = max;
}
// ---------------------------------------------------------------------------------------------
// Caching
// ---------------------------------------------------------------------------------------------
private synchronized void putPlainInCache(String plainText)
{
softPlainText = new SoftReference<String>(plainText);
}
private synchronized String getPlainFromCache()
{
return softPlainText == null ? null : softPlainText.get();
}
/**
* Computes the key.
*/
public void computeSimpleMatchKey()
{
FeedHandlingType handlingType = null;
if (feed != null)
{
handlingType = feed.getHandlingType();
} else if (candidateFeed != null)
{
handlingType = candidateFeed.getHandlingType();
}
if (handlingType == null) handlingType = FeedHandlingType.DEFAULT;
setSimpleMatchKey(handlingType.generateArticleMatchKey(this));
}
/**
* Converts text to plain version.
*
* @param text text to convert.
*
* @return plain text version.
*/
public static String convertTextToPlain(String text)
{
return text == null ? null : TextProcessor.processPlain(text, Constants.ARTICLE_SIZE_LIMIT);
}
/**
* Returns <code>TRUE</code> if article has just been added.
* This flag remains <code>TRUE</code> during the initial article
* added even processing. Right after that it's no longer new.
*
* @return <code>TRUE</code> if it's the first time the article is added to a feed.
*/
public boolean isNew()
{
return isnew;
}
/**
* Sets the new state.
*
* @param n new state.
*/
public void setNew(boolean n)
{
isnew = n;
}
/**
* Recalculates sentiment counts.
*/
public void recalculateSentimentCounts()
{
writeLock.lock();
try
{
String text = getPlainText();
setSentimentsCounts(Calculator.countPositiveOccurances(text), Calculator.countNegativeOccurances(text));
} finally
{
writeLock.unlock();
}
}
/**
* Sets sentiments counts.
*
* @param positive positive.
* @param negative negative.
*/
public void setSentimentsCounts(int positive, int negative)
{
writeLock.lock();
int oldPositive;
int oldNegative;
try
{
oldPositive = positiveSentimentsCount;
oldNegative = negativeSentimentsCount;
positiveSentimentsCount = positive;
negativeSentimentsCount = negative;
recalculateConnotation();
readLock.lock();
} finally
{
writeLock.unlock();
}
try
{
if (oldPositive != positive || oldNegative != negative)
{
firePropertyChanged(PROP_SENTIMENT_COUNTS, 0, 1);
}
} finally
{
readLock.unlock();
}
}
/**
* Returns positive sentiments count.
*
* @return count.
*/
public int getPositiveSentimentsCount()
{
int cnt;
readLock.lock();
try { cnt = positiveSentimentsCount; } finally { readLock.unlock(); }
return cnt;
}
/**
* Returns negative sentiments count.
*
* @return count.
*/
public int getNegativeSentimentsCount()
{
int cnt;
readLock.lock();
try { cnt = negativeSentimentsCount; } finally { readLock.unlock(); }
return cnt;
}
/**
* Returns TRUE if article is positive based on the sentiments count.
*
* @return TRUE if positive.
*/
public boolean isPositive()
{
boolean is;
readLock.lock();
try { is = positive; } finally { readLock.unlock(); }
return is;
}
/**
* Returns TRUE if article is negative based on the sentiments count.
*
* @return TRUE if negative.
*/
public boolean isNegative()
{
boolean is;
readLock.lock();
try { is = negative; } finally { readLock.unlock(); }
return is;
}
/**
* Recalculates connotation.
*/
public void recalculateConnotation()
{
boolean oldPositive;
boolean oldNegative;
writeLock.lock();
try
{
int vn = negativeSentimentsCount;
int vp = positiveSentimentsCount;
int tp = Calculator.getConfig().getPositiveThreshold();
int tn = Calculator.getConfig().getNegativeThreshold();
oldPositive = positive;
oldNegative = negative;
// positive = isDominating(vp, vn, tp);
// negative = isDominating(vn, vp, tn);
positive = negative = false;
// alternate formula
int excess_positives = vp - vn;
if (excess_positives > 0 && excess_positives > tp)
positive = true;
else if (excess_positives < 0 && excess_positives < tn )
negative = true;
readLock.lock();
} finally
{
writeLock.unlock();
}
try
{
firePropertyChanged(PROP_POSITIVE, oldPositive, positive);
firePropertyChanged(PROP_NEGATIVE, oldNegative, negative);
} finally
{
readLock.unlock();
}
}
/**
* Calculates the attitude of an article taking a threshold in account.
*
* @param v1 leading value of sentiments.
* @param v2 following value of sentiments.
* @param th threshold.
*
* @return TRUE if the domination of v1 over v2 is over th percent.
*/
private static boolean isDominating(int v1, int v2, int th)
{
if (v2 == 0) return v1 > 0;
return ((v1 - v2) * 100 / v2) > th;
}
}