/* ********************************************************************** **
** Copyright notice **
** **
** (c) 2005-2011 RSSOwl Development Team **
** http://www.rssowl.org/ **
** **
** All rights reserved **
** **
** This program and the accompanying materials are made available under **
** the terms of the Eclipse Public License v1.0 which accompanies this **
** distribution, and is available at: **
** http://www.rssowl.org/legal/epl-v10.html **
** **
** A copy is found in the file epl-v10.html and important notices to the **
** license from the team is found in the textfile LICENSE.txt distributed **
** in this package. **
** **
** This copyright notice MUST APPEAR in all copies of the file! **
** **
** Contributors: **
** RSSOwl Development Team - initial API and implementation **
** **
** ********************************************************************** */
package org.rssowl.ui.internal.editors.feed;
import org.rssowl.core.persist.INews;
import org.rssowl.core.persist.dao.DynamicDAO;
import org.rssowl.core.util.Pair;
import org.rssowl.core.util.Triple;
import org.rssowl.ui.internal.EntityGroup;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* The news view model is a representation of the visible news in the
* {@link NewsBrowserViewer}. This includes groups (if enabled), sorting and
* other UI related state.
* <p>
* The model is safe to be used from multiple threads.
* </p>
*
* @author bpasero
*/
public class NewsBrowserViewModel {
private final List<Item> fItemList = new ArrayList<NewsBrowserViewModel.Item>();
private final Map<Long, Item> fNewsMap = new HashMap<Long, NewsBrowserViewModel.Item>();
private final Map<Long, Group> fGroupMap = new HashMap<Long, NewsBrowserViewModel.Group>();
private final Map<Long, List<Long>> fEntityGroupToNewsMap = new HashMap<Long, List<Long>>();
private final Set<Long> fExpandedNews = new HashSet<Long>();
private final Set<Long> fCollapsedGroups = new HashSet<Long>();
private final Set<Long> fHiddenNews = new HashSet<Long>();
private final Set<Long> fHiddenGroups = new HashSet<Long>();
private final Object fLock = new Object();
private final NewsBrowserViewer fViewer;
public NewsBrowserViewModel(NewsBrowserViewer viewer) {
fViewer = viewer;
}
/* Base Class of all Items in the Model */
private static class Item {
private final long fId;
public Item(long id) {
fId = id;
}
public long getId() {
return fId;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + (int) (fId ^ (fId >>> 32));
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Item other = (Item) obj;
if (fId != other.fId)
return false;
return true;
}
}
/* Special Item that contains other Items in the View */
private static class Group extends Item {
public Group(long id) {
super(id);
}
}
/**
* Updates this view model with the contents of the provided elements.
*
* @param elements the elements to create the view model from.
* @param pageSize the number of elements per page or <code>0</code> in case
* paging is disabled.
*/
public void setInput(Object[] elements, int pageSize) {
synchronized (fLock) {
/* Clear Caches */
fItemList.clear();
fNewsMap.clear();
fGroupMap.clear();
fEntityGroupToNewsMap.clear();
fExpandedNews.clear();
fCollapsedGroups.clear();
fHiddenNews.clear();
fHiddenGroups.clear();
/* Build the Model based on the Elements */
if (elements != null && elements.length > 0) {
/* Build Model */
int newsCounter = 0;
List<Long> currentGroupEntryList = null;
for (Object element : elements) {
Item entry = null;
/* Entity Group */
if (element instanceof EntityGroup) {
EntityGroup group = (EntityGroup) element;
entry = new Group(group.getId());
fGroupMap.put(entry.getId(), (Group) entry);
currentGroupEntryList = new ArrayList<Long>();
fEntityGroupToNewsMap.put(group.getId(), currentGroupEntryList);
/*
* We use ">=" here to check if the group is visible or not to avoid the case
* of a group being made visible that contains no visible news at all.
*/
if (pageSize != 0 && newsCounter >= pageSize)
setGroupVisible(group.getId(), false);
}
/* News Item */
else if (element instanceof INews) {
newsCounter++;
INews news = (INews) element;
entry = new Item(news.getId());
fNewsMap.put(entry.getId(), entry);
if (currentGroupEntryList != null)
currentGroupEntryList.add(news.getId());
if (pageSize != 0 && newsCounter > pageSize)
setNewsVisible(news, false);
}
/* Add Entry into Collection */
if (entry != null)
fItemList.add(entry);
}
}
}
}
/**
* @return the {@link Map} of groups if grouping is enabled.
*/
public Map<Long, List<Long>> getGroups() {
synchronized (fLock) {
return new HashMap<Long, List<Long>>(fEntityGroupToNewsMap);
}
}
/**
* @param groupId the group identifier to look for
* @return <code>true</code> if a group with the given identifier exists and
* <code>false</code> otherwise.
*/
public boolean hasGroup(long groupId) {
synchronized (fLock) {
return fEntityGroupToNewsMap.containsKey(groupId);
}
}
/**
* @param newsId the news identifier to look for
* @return <code>true</code> if a news with the given identifier exists and
* <code>false</code> otherwise.
*/
public boolean hasNews(long newsId) {
synchronized (fLock) {
return fNewsMap.containsKey(newsId);
}
}
/**
* @param groupId the group identifier to use
* @return the number of elements inside the group with the given identifier
* or 0 if none.
*/
public int getGroupSize(long groupId) {
synchronized (fLock) {
List<Long> entries = fEntityGroupToNewsMap.get(groupId);
return entries != null ? entries.size() : 0;
}
}
/**
* @param groupId the group identifier to use
* @return the list of news ids being held by the given group.
*/
@SuppressWarnings("unchecked")
public List<Long> getNewsIds(long groupId) {
synchronized (fLock) {
List<Long> newsIds = fEntityGroupToNewsMap.get(groupId);
return newsIds != null ? new ArrayList<Long>(newsIds) : Collections.EMPTY_LIST;
}
}
/**
* @param news the news item to check for expanded state
* @return <code>true</code> if the news is expanded and <code>false</code>
* otherwise.
*/
public boolean isNewsExpanded(INews news) {
synchronized (fLock) {
return fExpandedNews.contains(news.getId());
}
}
/**
* @param news the news item to check for being visible or not
* @return <code>true</code> if the news is visible and <code>false</code>
* otherwise.
*/
public boolean isNewsVisible(INews news) {
return news.getId() != null && isNewsVisible(news.getId());
}
/**
* @param newsId the news item to check for being visible or not
* @return <code>true</code> if the news is visible and <code>false</code>
* otherwise.
*/
public boolean isNewsVisible(long newsId) {
synchronized (fLock) {
return !fHiddenNews.contains(newsId);
}
}
/**
* @param groupId
* @return <code>true</code> if the group is expanded and <code>false</code>
* otherwise.
*/
public boolean isGroupExpanded(long groupId) {
synchronized (fLock) {
return !fCollapsedGroups.contains(groupId);
}
}
/**
* @param groupId
* @return <code>true</code> if the group is visible and <code>false</code>
* otherwise.
*/
public boolean isGroupVisible(long groupId) {
synchronized (fLock) {
return !fHiddenGroups.contains(groupId);
}
}
/**
* @return the identifier of the currently expanded news or -1 if none.
*/
public long getExpandedNews() {
synchronized (fLock) {
if (!fExpandedNews.isEmpty())
return fExpandedNews.iterator().next();
return -1L;
}
}
/**
* @param news the news to expand or collapse
* @param expanded <code>true</code> if expanded and <code>false</code> if
* collapsed
*/
public void setNewsExpanded(INews news, boolean expanded) {
synchronized (fLock) {
if (expanded)
fExpandedNews.add(news.getId());
else
fExpandedNews.remove(news.getId());
}
}
/**
* @param news the news to hide or show
* @param visible <code>true</code> if visible and <code>false</code> if
* hidden
*/
public void setNewsVisible(INews news, boolean visible) {
if (news.getId() != null)
setNewsVisible(news.getId(), visible);
}
/**
* @param newsId the news to hide or show
* @param visible <code>true</code> if visible and <code>false</code> if
* hidden
*/
public void setNewsVisible(long newsId, boolean visible) {
synchronized (fLock) {
if (visible)
fHiddenNews.remove(newsId);
else
fHiddenNews.add(newsId);
}
}
/**
* @param newsId the news to get the index of
* @return the index of the provided news or <code>-1</code> if none.
*/
public int indexOfNewsItem(long newsId) {
synchronized (fLock) {
for (int i = 0; i < fItemList.size(); i++) {
Item item = fItemList.get(i);
if (!(item instanceof Group) && item.getId() == newsId)
return i;
}
}
return -1;
}
/**
* @param groupId the group to hide or show
* @param visible <code>true</code> if visible and <code>false</code> if
* hidden
*/
public void setGroupVisible(long groupId, boolean visible) {
synchronized (fLock) {
if (visible)
fHiddenGroups.remove(groupId);
else
fHiddenGroups.add(groupId);
}
}
/**
* @param groupId the group to expand or collapse
* @param expanded <code>true</code> if expanded and <code>false</code> if
* collapsed
*/
public void setGroupExpanded(long groupId, boolean expanded) {
synchronized (fLock) {
if (expanded)
fCollapsedGroups.remove(groupId);
else
fCollapsedGroups.add(groupId);
}
}
/**
* @param newsId the identifier of the news to find the group for
* @return the identifier of the group for the given news or -1 if none
*/
public long findGroup(long newsId) {
synchronized (fLock) {
Set<java.util.Map.Entry<Long, List<Long>>> entries = fEntityGroupToNewsMap.entrySet();
for (java.util.Map.Entry<Long, List<Long>> entry : entries) {
List<Long> newsInGroup = entry.getValue();
if (newsInGroup.contains(newsId))
return entry.getKey();
}
}
return -1L;
}
/**
* @return <code>true</code> if the first item showing in the browser is
* unread and <code>false</code>otherwise. Will always return false if
* grouping is enabled as the first item then will be a group.
*/
public boolean isFirstItemUnread() {
synchronized (fLock) {
if (!fItemList.isEmpty()) {
Item item = fItemList.get(0);
return isUnread(item);
}
}
return false;
}
/**
* @return <code>true</code> if the browser viewer is displaying items and
* <code>false</code> otherwise.
*/
public boolean hasItems() {
synchronized (fLock) {
return !fItemList.isEmpty();
}
}
/**
* @return <code>true</code> if the browser viewer is containing hidden news
* and <code>false</code> otherwise.
*/
public boolean hasHiddenNews() {
synchronized (fLock) {
return !fHiddenNews.isEmpty();
}
}
/**
* @return the number of news in the model (both visible and hidden).
*/
public int getNewsCount() {
synchronized (fLock) {
return fItemList.size() - fEntityGroupToNewsMap.keySet().size();
}
}
/**
* @return the number of visible news displayed.
*/
public int getVisibleNewsCount() {
synchronized (fLock) {
return fItemList.size() - fEntityGroupToNewsMap.keySet().size() - fHiddenNews.size();
}
}
/**
* @return a list of ids of news that are unread and visible.
*/
public List<Long> getVisibleUnreadNews() {
List<Long> visibleUnreadNewsIds = new ArrayList<Long>();
synchronized (fLock) {
for (Item item : fItemList) {
if (item instanceof Group)
continue;
if (!isNewsVisible(item.getId()))
break;
if (isNewsInCollapsedGroup(item.getId()))
continue;
if (isUnread(item))
visibleUnreadNewsIds.add(item.getId());
}
}
return visibleUnreadNewsIds;
}
private boolean isNewsInCollapsedGroup(long newsId) {
synchronized (fLock) {
for (Long groupId : fCollapsedGroups) {
if (fEntityGroupToNewsMap.get(groupId).contains(newsId))
return true;
}
return false;
}
}
/**
* Returns the first news that is hidden and optionally unread or
* <code>-1</code> if none.
*
* @param onlyUnread if set to <code>true</code>, only unread news will be
* considered.
* @return the identifier of the first hidden news or <code>-1</code> if none.
*/
public long getFirstHiddenNews(boolean onlyUnread) {
synchronized (fLock) {
if (fHiddenNews.isEmpty())
return -1;
for (int i = 0; i < fItemList.size(); i++) {
Item item = fItemList.get(i);
if (item instanceof Group || isNewsVisible(item.getId()))
continue;
if (!onlyUnread || isUnread(item))
return item.getId();
}
}
return -1;
}
/**
* Returns the last news that is visible or <code>-1</code> if none.
*
* @return the identifier of the last hidden news or <code>-1</code> if none.
*/
public long getLastVisibleNews() {
Item lastVisibleNews = null;
synchronized (fLock) {
for (int i = 0; i < fItemList.size(); i++) {
Item item = fItemList.get(i);
if (item instanceof Group)
continue;
if (isNewsVisible(item.getId()))
lastVisibleNews = item;
else
break;
}
}
return lastVisibleNews != null ? lastVisibleNews.getId() : -1;
}
/**
* @return the last news item of this model or -1 if none.
*/
public long getLastNews() {
synchronized (fLock) {
for (int i = fItemList.size() - 1; i >= 0; i--) {
Item item = fItemList.get(i);
if (item instanceof Group)
continue;
return item.getId();
}
}
return -1;
}
/**
* @param news the news to remove from the view model.
* @return the identifier of a group that needs an update now that the news
* has been removed or -1 if none.
*/
public long removeNews(INews news) {
synchronized (fLock) {
/* Remove from generic Item Collections */
fHiddenNews.remove(news.getId());
Item item = fNewsMap.get(news.getId());
if (item != null) {
fItemList.remove(item);
fNewsMap.remove(item.getId());
}
/* Remove from Collection of expanded Elements */
fExpandedNews.remove(news.getId());
/* Remove from Group Mapping */
Set<java.util.Map.Entry<Long, List<Long>>> entries = fEntityGroupToNewsMap.entrySet();
for (java.util.Map.Entry<Long, List<Long>> entry : entries) {
Long groupId = entry.getKey();
List<Long> newsInGroup = entry.getValue();
if (newsInGroup.contains(news.getId())) {
newsInGroup.remove(news.getId());
/* In case the group is now empty, remove it as well */
if (newsInGroup.isEmpty()) {
fEntityGroupToNewsMap.remove(groupId);
fCollapsedGroups.remove(groupId);
fHiddenGroups.remove(groupId);
Group group = fGroupMap.get(groupId);
if (group != null) {
fItemList.remove(group);
fGroupMap.remove(group.getId());
}
}
return groupId; //News can only be part of one group
}
}
return -1L;
}
}
/**
* @param unread if the next news should be unread or not
* @param offset the offset to start navigating from
* @return the identifier of the next news or -1 if none
*/
public long nextNews(boolean unread, long offset) {
synchronized (fLock) {
/* Get the next news using provided one as starting location or from beginning if no location provided */
Item item = new Item(offset);
int nextIndex = (offset != -1 && fItemList.contains(item)) ? fItemList.indexOf(item) + 1 : 0;
/* More Elements available */
for (int i = nextIndex; i < fItemList.size(); i++) {
Item nextItem = fItemList.get(i);
if (nextItem instanceof Group)
continue; //We only want to navigate to News Items
/* Return Item if it matches the criteria */
if (!unread || isUnread(nextItem))
return nextItem.getId();
}
}
return -1L;
}
/**
* @param unread if the previous news should be unread or not
* @param offset the offset to start navigating from
* @return the identifier of the previous news or -1 if none
*/
public long previousNews(boolean unread, long offset) {
synchronized (fLock) {
/* Get the next news using provided one as starting location or from end if no location provided */
Item item = new Item(offset);
int previousIndex = (offset != -1 && fItemList.contains(item)) ? fItemList.indexOf(item) - 1 : fItemList.size() - 1;
/* More Elements available */
for (int i = previousIndex; i >= 0 && i < fItemList.size(); i--) {
Item previousItem = fItemList.get(i);
if (previousItem instanceof Group)
continue; //We only want to navigate to News Items
/* Return Item if it matches the criteria */
if (!unread || isUnread(previousItem))
return previousItem.getId();
}
}
return -1L;
}
private boolean isUnread(Item item) {
if (item instanceof Group)
return false;
INews news;
if (fViewer != null)
news = fViewer.resolve(item.getId());
else
news = DynamicDAO.load(INews.class, item.getId());
if (news == null)
return false;
switch (news.getState()) {
case NEW:
case UNREAD:
case UPDATED:
return true;
}
return false;
}
/**
* Retrieves the identifiers of the elements for the next page.
*
* @param pageSize the number of elements per page.
* @return a {@link Triple} of lists. The first contains the identifiers of
* groups revealed and the second the list of news identifiers.
*/
public Pair<List<Long> /* Groups */, List<Long> /* News Items */> getNextPage(int pageSize) {
List<Long> groups = new ArrayList<Long>(1);
List<Long> news = new ArrayList<Long>(pageSize);
/* Get the next page if paging is enabled */
if (pageSize != 0) {
synchronized (fLock) {
int indexOfFirstHiddenItem = indexOfFirstHiddenItem(-1);
if (indexOfFirstHiddenItem != -1) {
int newsCounter = 0;
for (int i = indexOfFirstHiddenItem; i < fItemList.size(); i++) {
Item item = fItemList.get(i);
/* Group */
if (item instanceof Group) {
groups.add(item.getId());
}
/* News */
else {
newsCounter++;
news.add(item.getId());
}
if (newsCounter == pageSize)
break; //Reached the next page, so stop looping
}
}
}
}
return Pair.create(groups, news);
}
/**
* Will return lists of groups (if any) and news that are hidden up to the
* provided news and including the entire page the news is in. This allows to
* reveal the actual page the provided news is in if hidden.
*
* @param newsId the identifier of the news item that is being revealed.
* @param pageSize the number of elements per page.
* @return a {@link Pair} of lists. The first contains the identifiers of
* groups revealed and the second the list of news identifiers.
*/
public Pair<List<Long> /* Groups */, List<Long> /* News Items */> revealPage(long newsId, int pageSize) {
List<Long> groups = new ArrayList<Long>(1);
List<Long> news = new ArrayList<Long>();
/* Find all pages until target news is found */
if (pageSize != 0) {
synchronized (fLock) {
int indexOfFirstHiddenItem = indexOfFirstHiddenItem(newsId);
if (indexOfFirstHiddenItem != -1) {
int newsCounter = 0;
for (int i = indexOfFirstHiddenItem; i < fItemList.size(); i++) {
Item item = fItemList.get(i);
/* Group */
if (item instanceof Group) {
groups.add(item.getId());
}
/* News */
else {
newsCounter++;
news.add(item.getId());
}
if (news.contains(newsId) && newsCounter % pageSize == 0)
break; //Reached the page that contains the target news, so stop
}
}
}
}
return Pair.create(groups, news);
}
private int indexOfFirstHiddenItem(long toId) {
synchronized (fLock) {
if (fHiddenNews.isEmpty() && fHiddenGroups.isEmpty())
return -1;
for (int i = 0; i < fItemList.size(); i++) {
Item item = fItemList.get(i);
if (!isVisible(item))
return i; //Hidden Item found
if (toId != -1 && toId == item.getId())
break; //Limit reached
}
}
return -1;
}
private boolean isVisible(Item item) {
if (item instanceof Group)
return isGroupVisible(item.getId());
return isNewsVisible(item.getId());
}
}