// 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: StandardGuide.java,v 1.53 2007/11/07 17:16:48 spyromus Exp $ // package com.salas.bb.domain; import com.salas.bb.utils.IdentityList; import com.salas.bb.utils.i18n.Strings; import java.text.MessageFormat; import java.util.*; /** * <p>Standard feeds guide. The guide, which has the list of feeds and nothing more. * We can add new feeds to this guide and remove them from there.</p> * * <p>This implementation locks itself when working with feeds list. Every code working * with feeds list and counters is required to lock the instance to avoid concurrency * problems.</p> * * <p><strong>Feed Positions</strong></p> * * <p>We have two separate feed holders: a guide and a reading list. Each reading list * belongs to a single guide which makes our life easier. There are several facts we * base our work on:</p> * * <ul> * <li>We sort feeds initially when we finished loading them into the guide, reading * lists and added those reading lists to the guide. After the guide is added to * the model and the model is shown, the feeds will be in correct order.</li> * <li>The only way to change the positions of feeds is manual rearrangement. If some * feed is removed from the guide (or from one of assigned reading lists) we don't * need to recalculate the positions because deleting feeds doesn't affect the order.</li> * <li>We add feeds only to the end of the list. It guaranties that we do minimal * database updates.</li> * </ul> * * <p>All above shows that we:</p> * * <ul> * <li>Store position information only in the database.</li> * <li>Update position information when a feed is repositioned or inserted.</li> * <li>Sort feeds within the guide during initial loading from the database (once).</li> * </ul> */ public class StandardGuide extends AbstractGuide { /** The list of feeds added manually to this guide. */ private final FeedLinkInfoList directFeedLinks; /** The combined list of feeds (manual and from reading lists). */ private final List<IFeed> feeds; /** The sorted combined list of feeds. */ private final List<String> sortedFeeds; /** The listener for feed titles changes. */ private final FeedTitleChangeListener feedTitleChangeListener; /** The list of reading lists assigned to this guide. */ private final List<ReadingList> readingLists; /** The listener of changes in the assigned reading lists. */ private final IReadingListListener readingListsListener; /** * Creates empty guide. */ public StandardGuide() { directFeedLinks = new FeedLinkInfoList(); feeds = new IdentityList<IFeed>(); sortedFeeds = new ArrayList<String>(); feedTitleChangeListener = new FeedTitleChangeListener(); readingLists = new ArrayList<ReadingList>(); readingListsListener = new ReadingListsListener(); } /** * Returns the feed at given position. If the position is out of range [0;size) the IOOB * exception will be thrown. * * @param index index of the feed. * * @return feed at specified index. * * @throws IndexOutOfBoundsException if the feed index is out of range [0;size). */ public synchronized IFeed getFeedAt(int index) { return feeds.get(index); } /** * Returns number of feeds in the guide. * * @return number of feeds. */ public synchronized int getFeedsCount() { return feeds.size(); } /** * Adds feed to the guide. * * @param feed feed to add. * * @throws NullPointerException if feed isn't specified. * @throws IllegalStateException if feed is already assigned to some feed. */ public synchronized void add(IFeed feed) { if (feed == null) throw new NullPointerException(Strings.error("unspecified.feed")); if (addDirectFeedLink(feed)) addFeedToList(feed); } /** * Adds feed to the list of feeds. This method fires no events and used mostly as * a part of more complex composite actions. Please note that it is DANGEROUS to use * this method and should be avoided except as for being part of adding and moving * feeds. * * @param feed feed to add. * * @see #removeFeedFromList(IFeed, boolean) */ private void addFeedToList(IFeed feed) { if (!feeds.contains(feed)) { feeds.add(feed); addToSorted(feed.getTitle()); feed.addListener(feedTitleChangeListener); feed.addParentGuide(this); fireFeedAdded(feed); } } /** * Removes feed from the guide. * * @param feed feed to remove. * * @return TRUE if removed. * * @throws NullPointerException if feed isn't specified. */ public synchronized boolean remove(IFeed feed) { return remove(feed, true); } /** * Removes feed from the guide. * * @param feed feed to remove. * @param lastInBatch <code>TRUE</code> if this removal is last in batch. * * @return <code>TRUE</code> if removed. * * @throws NullPointerException if feed isn't specified. */ private boolean remove(IFeed feed, boolean lastInBatch) { if (feed == null) throw new NullPointerException(Strings.error("unspecified.feed")); boolean removed = false; if (hasDirectLinkWith(feed)) { boolean canRemove = true; if (feed instanceof DirectFeed) { DirectFeed dfeed = (DirectFeed)feed; ReadingList[] lists = dfeed.getReadingLists(); for (int i = 0; canRemove && i < lists.length; i++) { ReadingList list = lists[i]; canRemove = !readingLists.contains(list); } } removed = canRemove ? removeFeedFromList(feed, lastInBatch) : removeDirectFeedLink(feed); } return removed; } /** * Removes the feeds in list from this guide one by one. * * @param feeds feeds to remove. */ public synchronized void remove(IFeed[] feeds) { for (int i = 0; i < feeds.length; i++) { IFeed feed = feeds[i]; remove(feed, i == feeds.length - 1); } } /** * Removes feed from the list of feeds. This method makes no listener notifications which * makes it very DANGEROUS to use. Please make sure that you fire all necessary events * or put the feed back later to maintain the model in consistent state. * * @param feed feed to remove. * @param lastInBatch <code>TRUE</code> if it's the last feed in batch removal. * * @return <code>TRUE</code> if it was removed successfully. */ private boolean removeFeedFromList(IFeed feed, boolean lastInBatch) { int index = feeds.indexOf(feed); boolean removed = feeds.remove(feed); feed.removeListener(feedTitleChangeListener); removeFromSorted(feed.getTitle()); feed.removeParentGuide(this); removeDirectFeedLink(feed); if (removed) fireFeedRemoved(feed, index, lastInBatch); return removed; } /** Removes every reading list and feed associated with this guide. */ public void removeChildren() { ReadingList[] lists = getReadingLists(); for (ReadingList list : lists) remove(list, true); super.removeChildren(); } /** * Returns index of feed within the guide. * * @param feed feed to get index for. * * @return index of feed. * * @throws NullPointerException if feed isn't specified. * @throws IllegalStateException if feed is assigned to the other guide. */ public synchronized int indexOf(IFeed feed) { if (feed == null) throw new NullPointerException(Strings.error("unspecified.feed")); return feeds.indexOf(feed); } /** * Moves the feed to a different guide or different location within this guide. * * @param aFeed feed to move. * @param aDestination destination guide. * @param index new feed index within destination guide. * * @throws NullPointerException if destination guide isn't specified or * a feed isn't specified. */ public void moveFeed(IFeed aFeed, StandardGuide aDestination, int index) { if (aDestination == null) throw new NullPointerException(Strings.error("unspecified.destination.guide")); if (aFeed == null) throw new NullPointerException(Strings.error("unspecified.feed")); if (aDestination != this) { moveFeedToDifferentGuide(aFeed, aDestination); } else { repositionFeed(aFeed, index); } } private synchronized void repositionFeed(IFeed feed, int position) { int currentPosition = indexOf(feed); if (currentPosition != -1) { feeds.remove(feed); feeds.add(position, feed); fireFeedRepositioned(feed, currentPosition, position); } } /** * Moves feed to the other guide. * * @param feed feed to move. * @param dest destination guide. */ private synchronized void moveFeedToDifferentGuide(IFeed feed, StandardGuide dest) { dest.add(feed); remove(feed); } /** * Sets new title of the guide. * * @param aTitle title. */ public void setTitle(String aTitle) { super.setTitle(aTitle); // When the guide is renamed all previous syncronizations mean nothing // to the feeds and reading lists -- we reset their times as if they were // newly added. setSyncTime(-1, true); } /** * Compares this guide with the other guide object. * * @param o other guide. * * @return TRUE if equivalent. */ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; if (!super.equals(o)) return false; final StandardGuide that = (StandardGuide)o; return feeds.equals(that.feeds); } /** * Returns hash code of this guide object. * * @return hash code. */ public int hashCode() { // Reusing hash code of super return super.hashCode(); } /** * Returns the array of all feeds. * * @return array of feeds. */ public IFeed[] getFeeds() { return feeds.toArray(new IFeed[0]); } // --------------------------------------------------------------------------------------------- // Direct linking // --------------------------------------------------------------------------------------------- /** * Removes direct feed link and fires the event if the link was removed. * * @param feed feed to unlink. * * @return <code>TRUE</code> if was removed. */ private boolean removeDirectFeedLink(IFeed feed) { boolean removed; // FeedLinkInfo info = directFeedLinks.getInfo(feed); if (removed = directFeedLinks.remove(feed)) { // TODO: To modify the feedLinkRemoved event later to contain this (it's necessary for correct deleted feeds management) //boolean synched = info.getLastSyncTime(); fireFeedLinkRemoved(feed); } return removed; } /** * Adds direct feed link to the list and fires event. * * @param feed feed to add. * * @return <code>TRUE</code> if the feed was added to the list of directly linked feeds. */ private boolean addDirectFeedLink(IFeed feed) { boolean added = false; if (!hasDirectLinkWith(feed)) { directFeedLinks.add(new FeedLinkInfo(feed)); fireFeedLinkAdded(feed); added = true; } return added; } /** * Returns <code>TRUE</code> only if the feed was added directly to this guide. * * @param feed feed. * * @return <code>TRUE</code> only if the feed was added directly to this guide. */ public boolean hasDirectLinkWith(IFeed feed) { return directFeedLinks.contains(feed); } /** * Returns linking info for the given feed. * * @param feed feed. * * @return linking info or <code>NULL</code> if feed has no direct link to the guide. */ public FeedLinkInfo getFeedLinkInfo(IFeed feed) { return directFeedLinks.getInfo(feed); } // --------------------------------------------------------------------------------------------- // Reading lists // --------------------------------------------------------------------------------------------- /** * Adds reading list to the guide. * * @param list reading list. */ public void add(ReadingList list) { if (list == null) return; // Add the list if (!readingLists.contains(list)) { readingLists.add(list); list.setParentGuide(this); list.addListener(readingListsListener); fireReadingListAdded(list); // Add all feeds from the reading list DirectFeed[] feeds = list.getFeeds(); for (DirectFeed feed : feeds) onFeedAddedToReadingList(feed); } } /** * Removes reading list and all associated feeds. * * @param list reading list. * @param removeFeeds <code>TRUE</code> to remove associated feeds, * otherwise they will be converted to normal. */ public void remove(ReadingList list, boolean removeFeeds) { if (list == null) return; // Remove the list if (readingLists.remove(list)) { DirectFeed[] listFeeds = list.getFeeds(); for (DirectFeed feed : listFeeds) { if (!removeFeeds) add(feed); list.remove(feed); onFeedRemovedFromReadingList(feed); } list.setParentGuide(null); list.removeListener(readingListsListener); fireReadingListRemoved(list); } else throw new IllegalStateException(MessageFormat.format( Strings.error("reading.list.did.not.belong.to.this.guide"), list, this)); } /** * Returns the list of reading lists associated with this guide. * * @return the list of reading lists associated with this guide. */ public ReadingList[] getReadingLists() { return readingLists.toArray(new ReadingList[0]); } /** * Fires event about addition of a reading list. * * @param list reading list. */ protected void fireReadingListAdded(ReadingList list) { Iterator iterator = listeners.iterator(); while (iterator.hasNext()) { IGuideListener listener = (IGuideListener)iterator.next(); listener.readingListAdded(this, list); } } /** * Fires event about removal of a reading list. * * @param list reading list. */ protected void fireReadingListRemoved(ReadingList list) { Iterator iterator = listeners.iterator(); while (iterator.hasNext()) { IGuideListener listener = (IGuideListener)iterator.next(); listener.readingListRemoved(this, list); } } /** * Fires event about feed link property changes. * * @param info feed link info. * @param prop property name. * @param oldVal old value. * @param newVal new value. */ private void fireFeedLinkPropertyChanged(FeedLinkInfo info, String prop, long oldVal, long newVal) { Iterator iterator = listeners.iterator(); while (iterator.hasNext()) { IGuideListener listener = (IGuideListener)iterator.next(); listener.feedLinkPropertyChanged(this, info.getFeed(), prop, oldVal, newVal); } } // --------------------------------------------------------------------------------------------- // Feed positioning // --------------------------------------------------------------------------------------------- /** * Initializes positions of feeds using the given map of feeds to their positions. * * @param feedsToPositions the map of feeds to their positions. */ public void initPositions(final Map feedsToPositions) { Collections.sort(feeds, new Comparator<IFeed>() { public int compare(IFeed feed1, IFeed feed2) { int pos1 = getPosition(feed1); int pos2 = getPosition(feed2); boolean less; if (pos1 == pos2) { String title1 = feed1.getTitle(); String title2 = feed2.getTitle(); less = title1 != null ? (title2 != null && title1.compareTo(title2) == -1) : title2 != null; } else less = pos1 < pos2; return less ? -1 : 1; } private int getPosition(IFeed feed) { Integer pos = (Integer)feedsToPositions.get(feed); return pos == null ? Integer.MAX_VALUE : pos; } }); } // --------------------------------------------------------------------------------------------- // Feeds alpha sorting section // // What we do today to sort feed? // We store the titles of all feeds in special array, which is sorted in alphabetical order. // When we need to get alphabetical order of the feed we lookup its title in this array // and return the index. It allows several feeds with the same title occupy the same index, // which is necessary for correct primary/secondary sorting. // // WHEN NEW FEED ARRIVES we take its title and find the proper place in our sorted titles array. // WHEN FEED TITLE CHANGES we remove old title from array and rescan all feeds, adding the // co-titled feeds back. After that we add new title to our array. // WHEN FEED IS REMOVED we do exactly what is in the first phase of title change handline. // --------------------------------------------------------------------------------------------- /** * Invoked when title of contained feed changes. * * @param from title changed from this value. * @param to title changed to this value. */ private void feedTitleChanged(String from, String to) { synchronized (sortedFeeds) { removeFromSorted(from); addToSorted(to); } } /** * Invoked when new feed arrives and requires to be added to the array of sorted titles. * * @param feedTitle new feed title. */ private void addToSorted(String feedTitle) { if (feedTitle == null) return; feedTitle = feedTitle.toLowerCase().intern(); synchronized (sortedFeeds) { int alphaIndex = Collections.binarySearch(sortedFeeds, feedTitle); if (alphaIndex < 0) sortedFeeds.add(-(alphaIndex + 1), feedTitle); } } /** * Invoked when we no longer need to store the title for some feed. * It doesn't mean that we don't have any other feeds with this title -- we can. To restore * their titles we rescan them after removal. * * @param feedTitle title to remove. */ private void removeFromSorted(String feedTitle) { if (feedTitle == null) return; feedTitle = feedTitle.toLowerCase(); synchronized (sortedFeeds) { sortedFeeds.remove(feedTitle); IFeed[] feeds = getFeeds(); for (IFeed feed : feeds) addToSorted(feed.getTitle()); } } /** * Returns alphabetical index of feed within the guide. * * @param feed feed to get alpha-index for. * * @return alphabetical index of feed. * * @throws NullPointerException if feed isn't specified. * @throws IllegalStateException if feed is assigned to the other guide. */ public int alphaIndexOf(IFeed feed) { if (feed == null) throw new NullPointerException(Strings.error("unspecified.feed")); int index = 0; String feedTitle = feed.getTitle(); if (feedTitle != null) { feedTitle = feedTitle.toLowerCase(); synchronized (sortedFeeds) { index = sortedFeeds.indexOf(feedTitle); } } return index; } /** * Sets massively sync times and makes other housekeeping. * * @param syncTime time of sync. * @param syncOut <code>TRUE</code> if it was sync-out. */ void onSyncCompletion(long syncTime, boolean syncOut) { setSyncTime(syncTime, syncOut); } /** * Setting sync time of all children. * * @param syncTime sync time. * @param unconditional <code>TRUE</code> to set time unconditionaly, otherwise only to these * objects having some sync time already set. */ protected void setSyncTime(long syncTime, boolean unconditional) { for (ReadingList list : readingLists) { if (unconditional || list.getLastSyncTime() != -1) list.setLastSyncTime(syncTime); } for (FeedLinkInfo info : directFeedLinks) { if (unconditional || info.getLastSyncTime() != -1) info.setLastSyncTime(syncTime); } } /** * Invoked when a feed is added to reading list. * * @param feed feed. */ private void onFeedAddedToReadingList(IFeed feed) { if (!feeds.contains(feed)) addFeedToList(feed); } /** * Invoked when some feed is removed from associated reading list. * * @param feed feed. */ private void onFeedRemovedFromReadingList(DirectFeed feed) { if (!hasDirectLinkWith(feed)) { boolean readingListsContainFeed = false; ReadingList[] lists = feed.getReadingLists(); for (int i = 0; !readingListsContainFeed && i < lists.length; i++) { readingListsContainFeed = readingLists.contains(lists[i]); } if (!readingListsContainFeed) removeFeedFromList(feed, true); } } /** * Listener of changes in feed title. When title changes the feed should be repositioned * int alpha-sorted list. */ private class FeedTitleChangeListener extends FeedAdapter { /** * Called when information in feed changed. * * @param feed feed. * @param property property of the feed. * @param oldValue old property value. * @param newValue new property value. */ public void propertyChanged(IFeed feed, String property, Object oldValue, Object newValue) { if (property.equals(IFeed.PROP_TITLE)) { feedTitleChanged((String)oldValue, (String)newValue); } } } /** * Monitors the events in reading lists attached to this guide. */ private class ReadingListsListener extends ReadingListAdapter { /** * Invoked when new feed has been added to the reading list. * * @param list reading list the feed was added to. * @param feed added feed. */ public void feedAdded(ReadingList list, IFeed feed) { onFeedAddedToReadingList(feed); } /** * Invoked when the feed has been removed from the reading list. * * @param list reading list the feed was removed from. * @param feed removed feed. */ public void feedRemoved(ReadingList list, IFeed feed) { DirectFeed dfeed = (DirectFeed)feed; onFeedRemovedFromReadingList(dfeed); } } /** * Holder of guide-to-feed linking information. */ public class FeedLinkInfo { /** The time of last synchronization (saving). */ public static final String PROP_LAST_SYNC_TIME = "lastSyncTime"; private final IFeed feed; private long lastSyncTime = -1; /** * Creates link feed information for some feed. * * @param aFeed feed. */ public FeedLinkInfo(IFeed aFeed) { feed = aFeed; } /** * Returns linked feed. * * @return feed. */ public IFeed getFeed() { return feed; } /** * Returns time of last synchronization. * * @return time of last synchronization. */ public long getLastSyncTime() { return lastSyncTime; } /** * Sets time of last synchronization. * * @param time time of last synchronization. */ public void setLastSyncTime(long time) { long oldLastSyncTime = lastSyncTime; lastSyncTime = time; fireFeedLinkPropertyChanged(this, PROP_LAST_SYNC_TIME, oldLastSyncTime, time); } } /** * The list of feed linking information holders. */ private static class FeedLinkInfoList extends ArrayList<FeedLinkInfo> { /** * Returns linking info for the feed. * * @param feed feed. * * @return info or <code>NULL</code> if the feed is not linked to this guide directly. */ public FeedLinkInfo getInfo(IFeed feed) { int index = indexOf(feed); return index == -1 ? null : get(index); } /** * Searches for the first occurence of the given argument, testing for equality using the * <tt>equals</tt> method. * * @param elem an object. * * @return the index of the first occurrence of the argument in this list; * returns <tt>-1</tt> if the object is not found. * * @see Object#equals(Object) */ public int indexOf(Object elem) { int index = -1; if (elem == null) { index = super.indexOf(elem); } else { for (int i = 0; index == -1 && i < size(); i++) { FeedLinkInfo info = get(i); if (elem == info.getFeed()) index = i; } } return index; } /** * Returns the index of the last occurrence of the specified object in this list. * * @param elem the desired element. * * @return the index of the last occurrence of the specified object in this list; * returns -1 if the object is not found. */ public int lastIndexOf(Object elem) { int index = -1; if (elem == null) { index = super.lastIndexOf(elem); } else { for (int i = size() - 1; index == -1 && i >= 0; i--) { FeedLinkInfo info = get(i); if (elem == info.getFeed()) index = i; } } return index; } /** * Removes a single instance of the specified element from this * collection, if it is present (optional operation). * * @param feed feed info for which to be removed. * @return <tt>true</tt> if the collection contained the specified * element. */ public boolean remove(IFeed feed) { boolean removed = false; int index = indexOf(feed); if (index != -1) { removed = true; remove(index); } return removed; } /** * Returns <tt>true</tt> if this list contains the specified element. * * @param feed element whose presence in this List is to be tested. * * @return <code>true</code> if the specified element is present; * <code>false</code> otherwise. */ public boolean contains(IFeed feed) { return super.contains(feed); } } }