/*
* jMemorize - Learning made easy (and fun) - A Leitner flashcards tool
* Copyright(C) 2004-2008 Riad Djemili and contributors
*
* 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 1, 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., 675 Mass Ave, Cambridge, MA 02139, USA.
*/
package jmemorize.core;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import jmemorize.util.NaturalOrderComparator;
/**
* A card category can hold a number of 0 to n decks. The cards in deck 0 are
* called <b>unlearned</b>. Cards in higher decks are called <b>learned</b> or
* <b>expired</b>, depending on the fact of wether their expiration timer
* has already passed by or not.
*
* A category can have a number of child categories. Fetching cards from a
* parent category will also fetch all cards of its child categories.
*
* For example category B is child of category A. A holds a card CA and B holds
* a card CB. Getting all cards of category A will then return CA <i>and</i>
* CB.
*
* Observers can be hooked to categories and will be notified when a card or
* category event in this category or one of its child categories happens.
*
* @author djemili
*/
public class Category implements Events
{
// TODO use CopyOnWriteArrayList in Java1.5
private List<CategoryObserver> m_observers = new ArrayList<CategoryObserver>();
private String m_name;
private int m_depth = 0; // is 0 for root category
private List<List<Card>> m_decks = new ArrayList<List<Card>>(); // list of card lists
private Category m_parent;
private List<Category> m_childCategories = new LinkedList<Category>();
/**
* Creates a new Category.
*
* @param name The name of the new category.
*/
public Category(String name)
{
m_name = name;
}
/*
* Card related methods.
*/
/**
* Adds a card to the level 0 deck of this category. The card will be
* considered as unlearned after being added to deck level 0.
*
* Fires a ADDED_EVENT.
*/
public void addCard(Card card)
{
addCard(card, 0);
}
/**
* Adds a card to the deck with given level and fires an event.
*
* Fires a ADDED_EVENT.
*/
public void addCard(Card card, int level)
{
addCardInternal(card, level);
fireCardEvent(ADDED_EVENT, card, card.getCategory(), level);
}
/**
* Removes a card from its associated deck and fires an event.
*/
public void removeCard(Card card)
{
int level = card.getLevel();
Category category = card.getCategory();
removeCardInternal(card);
fireCardEvent(REMOVED_EVENT, card, category, level);
}
/**
* Moves the card to a new category, preserving all its fields and its
* level. This is different from removing and then adding a card because it
* triggers a single MOVE event instead of a REMOVE and ADD event.
*/
public static void moveCard(Card card, Category newCategory)
{
int level = card.getLevel();
Category category = card.getCategory();
category.removeCardInternal(card);
newCategory.addCardInternal(card, level);
category.fireCardEvent(MOVED_EVENT, card, category, level);
newCategory.fireCardEvent(MOVED_EVENT, card, category, level);
}
/**
* Removes the card from its current deck and adds it to the next deck. The
* given date is used as new expiration date.
*
* Fires a DECK_EVENT.
*/
public static void raiseCardLevel(Card card, Date testDate, Date newExpirationDate)
{
card.incStats(1, 1);
changeCardLevel(card, card.getLevel() + 1, testDate, newExpirationDate);
}
/**
* Removes the card from its current deck and appends it to deck 0 (even if
* its already at level 0). TotalTests is increased by one.
*
* Fires a DECK_EVENT.
*/
public static void resetCardLevel(Card card, Date testDate)
{
card.incStats(0, 1);
changeCardLevel(card, 0, testDate, null); // CHECK use null for testdate!?
}
/**
* Removes the card from its current deck and reappends it to the same deck
* again. This doesnt change any values besides DateTouched.
*
* Fires a DECK_EVENT.
*/
public static void reappendCard(Card card)
{
card.setDateTouched(new Date());
card.getCategory().fireCardEvent(DECK_EVENT, card, card.getCategory(), card.getLevel());
}
/**
* Resets the card by moving it back to level 0 and deleting all its stats.
*
* Fires a DECK_EVENT.
*/
public void resetCard(Card card) //HACK
{
card.resetStats();
changeCardLevel(card, 0, null, null);
}
/*
* Card getter methods
*/
/**
* @return All cards of all decks in this category.
*/
public List<Card> getCards()
{
List<Card> cardList = new ArrayList<Card>();
//get cards from all decks
for (int i=0; i < m_decks.size(); i++)
{
cardList.addAll(getCards(i));
}
return cardList;
}
/**
* @param level the deck level.
*
* @return all cards in the given deck level in this category and its child
* categories. Returns all cards of all decks if -1 is given as level.
*/
public List<Card> getCards(int level)
{
if (level >= getNumberOfDecks())
{
return new ArrayList<Card>(); //HACK
}
if (level == -1)
{
return getCards();
}
//get cards in this category
List<Card> cardList = new ArrayList<Card>(m_decks.get(level));
//get cards in child categories
for (Category child : getChildCategories())
{
if (child.getNumberOfDecks() > level)
{
cardList.addAll(child.getCards(level));
}
}
return cardList;
}
/**
* @return all expired cards of all decks in this category and its child
* categories.
*/
public List<Card> getExpiredCards()
{
List<Card> expiredCards = getCards();
for (Iterator<Card> it = expiredCards.iterator(); it.hasNext();)
{
Card card = it.next();
if (!card.isExpired())
{
it.remove();
}
}
return expiredCards;
}
/**
* @return all expired cards of given deck in this category and its child
* categories.
*/
public List<Card> getExpiredCards(int level)
{
List<Card> expiredCards = getCards(level);
for (Iterator<Card> it = expiredCards.iterator(); it.hasNext();)
{
Card card = (Card)it.next();
if (!card.isExpired())
{
it.remove();
}
}
return expiredCards;
}
/**
* @return all learned cards of all decks in this category and its child
* categories.
*/
public List<Card> getLearnedCards()
{
List<Card> learnedCards = getCards();
for (Iterator<Card> it = learnedCards.iterator(); it.hasNext();)
{
Card card = (Card)it.next();
if (!card.isLearned())
{
it.remove();
}
}
return learnedCards;
}
/**
* Learned cards are cards that are learned and haven't expired yet.
*
* @param level the level of the deck of who's cards you want to get.
* @return all learned cards in deck with given level.
*/
public List<Card> getLearnedCards(int level)
{
// level 0 decks have no learned cards
if (level == 0)
{
return new ArrayList<Card>();
}
List<Card> learnedCards = getCards(level);
for (Iterator<Card> it = learnedCards.iterator(); it.hasNext();)
{
Card card = (Card)it.next();
if (!card.isLearned())
{
it.remove();
}
}
return learnedCards;
}
/**
* Unlearned cards (all cards in deck 0) and expired cards are learnable.
*
* @return all learnable cards in deck with given level.
*/
public List<Card> getLearnableCards(int level)
{
return level == 0 ? getCards(0) : getExpiredCards(level);
}
/**
* Unlearned cards (all cards in deck 0) and expired cards are learnable.
*
* @return all learnable cards in this category.
*
* @see #getLearnableCards(int)
*/
public List<Card> getLearnableCards()
{
List<Card> learnableCards = new LinkedList<Card>();
for (int i = 0; i < getNumberOfDecks(); i++)
{
learnableCards.addAll(getLearnableCards(i));
}
return learnableCards;
}
/**
* @return all unlearned cards of this category and its child categories.
*/
public List<Card> getUnlearnedCards()
{
return m_decks.size() > 0 ? getCards(0) : new ArrayList<Card>();
}
/**
* @return All cards that are local to this category. That is all cards
* that directly belong to this category and not to any of this child
* categories.
*/
public List<Card> getLocalCards()
{
List<Card> localCards = new ArrayList<Card>();
for (int i = 0; i < getNumberOfDecks(); i++)
{
localCards.addAll(getLocalCards(i));
}
return localCards;
}
/**
* @return All cards in the level that are local to this category. That is
* all cards that directly belong to this category and not to any of this
* child categories.
*/
public List<Card> getLocalCards(int level)
{
return m_decks.get(level);
}
/**
* @return The number of decks of this category and its child categories.
* That means that no child categoriy can have more number of decks then
* its parent category.
*/
public int getNumberOfDecks()
{
return m_decks.size();
}
/*
* Category related methods.
*/
/**
* @return Returns a unmodifiable list of the child categories.
*/
public List<Category> getChildCategories()
{
return Collections.unmodifiableList(m_childCategories);
}
/**
* @return the child category with given name. <code>null</code> if there
* is child category with given name.
*/
public Category getChildCategory(String name)
{
for (Category category : m_childCategories)
{
if (category.getName().equals(name))
return category;
}
return null;
}
/**
* Appends the category at end of category child list.
*/
public Category addCategoryChild(Category category)
{
category.m_parent = this;
category.m_depth = m_depth + 1;
Comparator comp = new NaturalOrderComparator();
int position = 0;
for (Category childCategory : m_childCategories)
{
if (comp.compare(category.getName(), childCategory.getName()) < 0)
break;
position++;
}
m_childCategories.add(position, category);
fireCategoryEvent(ADDED_EVENT, category);
return category;
}
/**
* Removes this category. Note that the root category can't be removed.
*
* Fires a REMOVED_EVENT.
*/
public void remove()
{
assert m_parent != null : "Root category can't be deleted"; //$NON-NLS-1$
m_parent.m_childCategories.remove(this);
fireCategoryEvent(REMOVED_EVENT, this);
m_parent = null; // have to release parent AFTER firing event
}
/**
* @return True if given category is a child of this category. False otherwise.
*/
public boolean contains(Category category)
{
if (this == category)
{
return true;
}
for (Category cat : m_childCategories)
{
if (cat.contains(category))
{
return true;
}
}
return false;
}
/**
* @return The parent of this category or <code>null</code> if it has no
* parent.
*/
public Category getParent()
{
return m_parent;
}
/**
* Sets a new name for this category.
*
* Fires a EDITED_EVENT.
*/
public void setName(String newName)
{
assert newName != null;
if (!m_name.equals(newName))
{
m_name = newName;
fireCategoryEvent(EDITED_EVENT, this);
}
}
/**
* @return The name of this category.
*/
public String getName()
{
return m_name;
}
/**
* @return a textual representation of the category path of this category.
* Starting from the root, every category in the path is separated with a
* slash. The path includes the name of this category as last part.
*/
public String getPath()
{
return m_parent != null ? m_parent.getPath() + "/" + getName() : getName(); //$NON-NLS-1$
}
/**
* @return Number of hops from this node to root.
*/
public int getDepth()
{
return m_depth;
}
/**
* @return A list of all child categories and their childs etc.
*/
public List<Category> getSubtreeList() // TODO rename to getChildCategoriesTree
{
List<Category> list = new ArrayList<Category>(m_childCategories.size() + 1);
list.add(this);
for (Category category : m_childCategories)
{
list.addAll(category.getSubtreeList());
}
return list;
}
/* (non-Javadoc)
* @see java.lang.Object#toString()
*/
public String toString()
{
return "Category("+m_name+")"; //$NON-NLS-1$ //$NON-NLS-2$
}
/*
* Event related methods
*/
public void addObserver(CategoryObserver observer)
{
m_observers.add(observer);
}
public void removeObserver(CategoryObserver observer)
{
m_observers.remove(observer);
}
/**
* @return a clone of this category. The clone contains the same child
* categories and the same cards as this category, but without any user
* dependent-stats(e.g. all cards are at level 0 and have no date_tested).
*/
public Category cloneWithoutProgress()
{
Category clonedCategory = new Category(m_name);
for (List<Card> cards : m_decks)
{
for (Card card : cards)
{
clonedCategory.addCard(card.cloneWithoutProgress());
}
}
for (Category childCategory : getChildCategories())
{
clonedCategory.addCategoryChild((Category)childCategory.cloneWithoutProgress());
}
return clonedCategory;
}
void fireCardEvent(int type, Card card, Category category, int deck)
{
if (type != EDITED_EVENT)
{
adjustNumberOfDecks();
}
if (m_parent != null)
{
m_parent.fireCardEvent(type, card, category, deck);
}
List<CategoryObserver> observersCopy = new ArrayList<CategoryObserver>(m_observers);
for (CategoryObserver observer : observersCopy)
{
observer.onCardEvent(type, card, category, deck);
}
}
void fireCategoryEvent(int type, Category category)
{
adjustNumberOfDecks();
if (m_parent != null)
{
m_parent.fireCategoryEvent(type, category);
}
List<CategoryObserver> observersCopy = new ArrayList<CategoryObserver>(m_observers);
for (CategoryObserver observer : observersCopy)
{
observer.onCategoryEvent(type, category);
}
}
/**
* Adds a card to this category without emitting a ADDED_EVENT.
*/
private void addCardInternal(Card card, int level)
{
// check boundary
while (m_decks.size() <= level)
{
m_decks.add(new ArrayList<Card>());
}
List<Card> cards = m_decks.get(level);
cards.add(card);
card.setCategory(this);
card.setLevel(level);
// sanity checks
if (level > 0 && card.getDateExpired() == null)
card.setDateExpired(new Date());
if (level == 0)
card.setDateExpired(null);
}
/**
* Removes a card from this category without emitting a REMOVED_EVENT.
*/
private void removeCardInternal(Card card)
{
Category cat = card.getCategory();
if (cat == this)
{
int level = card.getLevel();
List<Card> cards = m_decks.get(level);
cards.remove(card);
card.setCategory(null);
}
else
{
cat.removeCardInternal(card);
}
}
/**
* Changes the deck level of card and fires a DECK_EVENT.
*/
private static void changeCardLevel(Card card, int newLevel,
Date newTest, Date newExpiration)
{
Category category = card.getCategory();
int level = card.getLevel();
category.removeCardInternal(card);
card.setDateTested(newTest);
card.setDateExpired(newExpiration);
card.setDateTouched(new Date());
card.resetLearnedAmount();
// note also that new expiration date is set before adding again
category.addCardInternal(card, newLevel);
category.fireCardEvent(DECK_EVENT, card, category, level);
}
private void adjustNumberOfDecks()
{
// find child category with most decks
int maxChildDecks = 0;
for (Category child : m_childCategories)
{
if (child.getNumberOfDecks() > maxChildDecks)
{
maxChildDecks = child.getNumberOfDecks();
}
}
//grow decks
while (maxChildDecks > getNumberOfDecks())
{
m_decks.add(new ArrayList<Card>());
}
//trim decks
while (maxChildDecks < getNumberOfDecks()
&& (m_decks.get(getNumberOfDecks()-1)).isEmpty() )
{
m_decks.remove(getNumberOfDecks()-1);
}
}
}