/* * jMemorize - Learning made easy (and fun) - A Leitner flashcards tool * Copyright(C) 2004-2008 Riad Djemili * * 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.learn; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Random; import java.util.Set; import java.util.logging.ConsoleHandler; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import jmemorize.core.Card; import jmemorize.core.Category; import jmemorize.core.CategoryObserver; import jmemorize.util.EquivalenceClassSet; /** * A learn session is instantiated with a LearnSettings object which defines the * rules which the session should handle cards. * * The workflow for this class is as following: * * <ol> * <li>Learn Session fetches a card according to its LearnSettings. To get * notified of this card, use the {@link LearnCardObserver}.</li> * <li>The learn session waits for a call to either {{@link #cardChecked(boolean, * boolean)} or {@link #cardSkipped()}. This makes the learn session perform some * action on the card.</li> * <li>This action results in some category event, which the learn session gets * notified of, because it attached itself also as a category observer to the * category that is to be learned. This category event results in either * fetching the next card (see above) or all call to the * {@link LearnSessionProvider} to inform it that the session has ended. The * {@link LearnSessionProvider} then notifies all of its * {@link LearnSessionObserver}.</li> * </ol> * * Note that when a card is neither learned or skipped, but i.e. deleted or * resetted, step 2 is skipped and step 3 comes into play directly. * * The order of a card in the learn session depends on the shuffle and category order settings: * [Shuffle: Off, Category Order: Off] Deck, Last test date * [Shuffle: Off, Category Order: On ] Category, Deck, Last test date * [Shuffle: On, Category Order: Off] Deck, Random number * [Shuffle: On, Category Order: On ] Category, Deck, Random number * * @author djemili */ public class DefaultLearnSession implements CategoryObserver, LearnSession { /** * A Comparator that is used for sorting cards in learn sessions. * This is used to sort the cards into equivalence classes, from * which the next card will be drawn randomly. */ private class CardComparator implements Comparator<CardInfo> { private Map<Category, Integer> m_categoryGroupOrder; public CardComparator(Map<Category, Integer> categoryGroupOrder) { m_categoryGroupOrder = categoryGroupOrder; } /* * @see java.util.Comparator */ public int compare(CardInfo card0, CardInfo card1) { if (card0.getLevel() < card1.getLevel() ) { return -1; } else if (card0.getLevel() > card1.getLevel() ) { return 1; } // else card0.getLevel() == card1.getLevel() if (m_settings.isGroupByCategory()) { Integer cat0 = m_categoryGroupOrder.get(card0.getCategory()); Integer cat1 = m_categoryGroupOrder.get(card1.getCategory()); if (cat0 != null && cat1 != null) { if (cat0.intValue() < cat1.intValue()) { return -1; } else if (cat0.intValue() > cat1.intValue()) { return 1; } } } return 0; // if (m_bIgnoreDate) // { // return 0; // } // // Date date0 = card0.getDateTouched(); // Date date1 = card1.getDateTouched(); // // if (date0.equals(date1)) // { // return 0; // } // // return (date0.before(date1) ? -1 : 1); } } /** * This class is a wrapper for a card. It allows to associate additional * data to a card, that is only relevant during a single specifc learn * session. */ private class CardInfo { private Card m_card; /** * For learning this variable should be used instead of the real level * of the card. This allows for some special shuffling techniques. */ private int m_level; public CardInfo(Card card) { m_card = card; m_level = card.getLevel(); } public Card getCard() { return m_card; } public int getLevel() { return m_level; } public void setLevel(int level) { m_level = level; } public Category getCategory() { return m_card.getCategory(); } @Override public String toString() { return "CardInfo("+m_card.toString()+")"; } } // learn session settings private Category m_category; // the root category of the lesson private Category m_rootCategory; private LearnSettings m_settings; private LearnSessionProvider m_provider; // current learn session state private boolean m_quit; private boolean m_learningStarted = false; private CardInfo m_currentCardInfo; // The cards in the set are partitioned into the following exclusive // subsets. Cards move from reserve to active, from active to reserve or // learned, but do not move after reaching learned. private EquivalenceClassSet<CardInfo> m_cardsActive; private EquivalenceClassSet<CardInfo> m_cardsReserve; // the list of all cards that have been checked in the order last seen. Does // not include cards that were skipped and never passed/failed. private List<Card> m_cardsChecked = new ArrayList<Card>(); private Set<Card> m_cardsLearned = new HashSet<Card>(); private Map<Card, CardInfo> m_cardsInfoMap = new HashMap<Card, CardInfo>(); // NOTE - m_cardsLearned is the set of all cards successfully learned // this session, which is the union of "passed" and "relearned". // We don't track of those two categories internally. // "Passed" = Learned - EverFailed // "ReLearned" = Learned intersect EverFailed // "Failed" = EverFailed - Learned // These sets are non exclusive markers that indicate the status of a card // Note that these are Sets not Lists. Two reasons: // 1) The order is not important. // 2) Lookup efficiency is better. // Cards do not get removed from the EverFailed list. private Set<Card> m_cardsEverFailed = new HashSet<Card>(); private Set<Card> m_cardsSkipped = new HashSet<Card>(); // NOTE - this is only the *active* cards which are partially learned - // there may be others in the reserve set. private Set<Card> m_cardsActivePartiallyLearned = new HashSet<Card>(); // Further invariants: // - Learned intsersection Skipped = NULL // - partialPassed intersection Learned = NULL // etc private Random m_rand = new Random(); private List<LearnCardObserver> m_cardObservers = new LinkedList<LearnCardObserver>(); private Date m_start; private Date m_end; private Logger m_logger = Logger.getLogger("jmemorize.session"); /** * Creates a new learn session. Use {@link #startLearning()} to start the * learning. */ public DefaultLearnSession(Category category, LearnSettings settings, List<Card> selectedCards, boolean learnUnlearned, boolean learnExpired, LearnSessionProvider provider) { m_rootCategory = category; while (m_rootCategory.getParent() != null) m_rootCategory = m_rootCategory.getParent(); m_category = category; m_rootCategory.addObserver(this); m_settings = settings; m_provider = provider; setupLogger(); Map<Category, Integer> order = m_settings.isGroupByCategory() ? createCategoryGroupOrder() : null; m_cardsActive = fetchCards(selectedCards, learnUnlearned, learnExpired, order); m_cardsReserve = new EquivalenceClassSet<CardInfo>(m_cardsActive.getComparator()); // Note that EquivalenceClassSets always default to shuffle mode (any card // from the current class may be chosen next.) This is what we want here. } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public void startLearning() { if (m_learningStarted) throw new IllegalStateException("startLearning should only happen once!"); m_learningStarted = true; m_start = new Date(); // move all cards to cardsPastLimit, then fetch exactly as many as needed if (m_settings.isCardLimitEnabled() && m_cardsActive.size() > m_settings.getCardLimit()) { m_cardsReserve = m_cardsActive; m_cardsActive = m_cardsReserve.partition(m_settings.getCardLimit()); } gotoNextCard(); } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public void endLearning() { m_end = new Date(); m_rootCategory.removeObserver(this); m_provider.sessionEnded(this); } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public LearnSettings getSettings() { return m_settings; } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public Date getStart() { return m_start; } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public Date getEnd() { return m_end; } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public Card getCurrentCard() { return m_currentCardInfo.getCard(); } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public Set<Card> getCardsLeft() { return Collections.unmodifiableSet(toCardSet(m_cardsActive)); } public int getNCardsPartiallyLearned() { return m_cardsActivePartiallyLearned.size(); } public int getNCardsLearned() { return m_cardsLearned.size(); } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public Category getCategory() { return m_category; } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public void cardChecked(boolean passed, boolean shownFlipped) { Card currentCard = m_currentCardInfo.getCard(); m_logger.fine(String.format("cardChecked: %b %s", passed, currentCard.getFrontSide().getText())); assert !m_cardsLearned.contains(currentCard); assert !m_cardsReserve.contains(m_currentCardInfo); assert m_cardsActive.contains(m_currentCardInfo); m_cardsSkipped.remove(currentCard); m_cardsActivePartiallyLearned.remove(currentCard); if (passed) { boolean raiseLevel = true; // If we are using the 'Check both sides' strategy, we need to do // different calculations to work out whether the card is ready to // be raised a level if (m_settings.getSidesMode() == LearnSettings.SIDES_BOTH) { // Work out how much of it is learned int frontAmountLearned = currentCard.getLearnedAmount(true); int backAmountLearned = currentCard.getLearnedAmount(false); if (shownFlipped) backAmountLearned++; else frontAmountLearned++; if ((frontAmountLearned < m_settings.getAmountToTest(true)) || (backAmountLearned < m_settings.getAmountToTest(false))) { // It's partially learned. // increment the amount it has been learned by m_cardsActivePartiallyLearned.add(currentCard); m_logger.fine("...partially passed."); raiseLevel = false; // incremenLearnedAmount fires a DECK_EVENT currentCard.incrementLearnedAmount(!shownFlipped); } } if (raiseLevel) { m_logger.fine("...passed."); raiseCardLevel(currentCard); } } else { // TODO should this be renamed since currently only cards with // level > 0 are called failed in session summaries if (!m_settings.isRetestFailedCards()) m_cardsActive.remove(m_currentCardInfo); if (currentCard.getLevel() > 0) { m_cardsEverFailed.add(currentCard); m_logger.fine("...failed."); } /* NOTE - If the card is still active, the card may be in the wrong * equivalence class (i.e. the set is in an inconsistent state). * We can't fix it until after the reset, *but* the * resetCardLevel() method fires the event which results in the * observers reacting (checking for end of session, getting the * next card, etc). The card's equivalence class will be wrong, * but this should not be a problem for gotoNextCard. * We reset the equivalence class as soon as possible. */ Category.resetCardLevel(currentCard, m_start); m_currentCardInfo.setLevel(currentCard.getLevel()); m_cardsActive.resetEquivalenceClass(m_currentCardInfo); } m_logger.fine("...Cards remaining: " + m_cardsActive.size()); m_logger.fine("...Cards partially learned: " + getNCardsPartiallyLearned()); m_logger.fine("...num failed= " + m_cardsEverFailed.size()); // note that raising/reseting card level will be noticed by onCardEvent. // program flow continues there. } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public void cardSkipped() { Card currentCard = m_currentCardInfo.getCard(); // Note that we do not remove the card from m_cardsChecked. m_logger.fine("cardSkipped: " + currentCard.getFrontSide()); assert !m_cardsLearned.contains(currentCard); assert !m_cardsReserve.contains(m_currentCardInfo); assert m_cardsActive.contains(m_currentCardInfo); m_cardsSkipped.add(currentCard); if (m_cardsReserve != null && m_cardsReserve.size() > 0) { m_cardsActivePartiallyLearned.remove(m_currentCardInfo); CardInfo replacementCardInfo = m_cardsReserve.loopIterator().next(); Card replacementCard = replacementCardInfo.getCard(); if (replacementCard.getLearnedAmount(true) > 0 || replacementCard.getLearnedAmount(false) > 0) { m_cardsActivePartiallyLearned.add(replacementCard); } m_cardsActive.add(replacementCardInfo); m_cardsReserve.remove(replacementCardInfo); m_cardsReserve.addExpired(m_currentCardInfo); m_cardsActive.remove(m_currentCardInfo); m_logger.fine("Moving to reserve: " + currentCard.getFrontSide()); m_logger.fine("Moving to active: " + replacementCard.getFrontSide()); } m_logger.fine("...cards remaining: " + m_cardsActive.size()); Category.reappendCard(currentCard); // program flow continues in onCardEvent } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public Set<Card> getPassedCards() { // "passed" = Learned and not Failed Set<Card> tempSet = new HashSet<Card>(m_cardsLearned); tempSet.removeAll(m_cardsEverFailed); return Collections.unmodifiableSet(tempSet); } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public Set<Card> getFailedCards() { Set<Card> tempSet = new HashSet<Card>(m_cardsEverFailed); tempSet.removeAll(m_cardsLearned); return Collections.unmodifiableSet(tempSet); } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public Set<Card> getSkippedCards() { return Collections.unmodifiableSet(m_cardsSkipped); } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public Set<Card> getRelearnedCards() { Set<Card> tempSet = new HashSet<Card>(m_cardsEverFailed); tempSet.retainAll(m_cardsLearned); return Collections.unmodifiableSet(tempSet); } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public void onTimer() { m_quit = true; } /* (non-Javadoc) * @see jmemorize.core.CategoryObserver */ public void onCardEvent(int type, Card card, Category category, int deck) { CardInfo cardInfo = getCardInfo(card); if (cardInfo == null) // this happens when a new card is created; ignore return; switch (type) { case ADDED_EVENT: // if there is a reserve and we have enough cards, add to the reserve int allCards = m_cardsLearned.size() + m_cardsActive.size(); if (m_settings.isCardLimitEnabled() && allCards >= m_settings.getCardLimit()) { m_cardsReserve.add(cardInfo); } else { m_cardsActive.add(cardInfo); } break; case REMOVED_EVENT: // remove it from all sets m_cardsActive.remove(cardInfo); m_cardsReserve.remove(cardInfo); m_cardsLearned.remove(card); m_cardsActivePartiallyLearned.remove(card); m_cardsEverFailed.remove(card); m_cardsSkipped.remove(card); if (cardInfo == m_currentCardInfo) { gotoNextCard(); } m_cardsChecked.remove(card); break; case DECK_EVENT: if (cardInfo == m_currentCardInfo) { gotoNextCard(); } // TODO currently, resetting a learned card does not put it back in the // active set. Should it? break; } } /* (non-Javadoc) * @see jmemorize.core.CategoryObserver */ public void onCategoryEvent(int type, Category category) { // no category events should occure while learning. // ignore } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public List<Card> getCheckedCards() { // TODO the meaning of this collides with the naming of checkCard(..) // because it also includes skipped cards return Collections.unmodifiableList(m_cardsChecked); } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public boolean isRelevant() { return m_cardsEverFailed.size() > 0 || m_cardsLearned.size() > 0; } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public void addObserver(LearnCardObserver observer) { m_cardObservers.add(observer); } /* (non-Javadoc) * @see jmemorize.core.LearnSession */ public void removeObserver(LearnCardObserver observer) { m_cardObservers.remove(observer); } /** * Note that this method is specialy for DefaultLearnSession and not part of * the LearnSession interface. * * @return the shuffled 'fake' card level that is currently used for the * card. */ public int getCurrentShuffleLevel() { return m_currentCardInfo.getLevel(); } public boolean isQuit() { boolean noCardsLeft = m_cardsActive.size() == 0; boolean limitReached = m_settings.isCardLimitEnabled() && m_cardsLearned.size() >= m_settings.getCardLimit(); return m_quit || noCardsLeft || limitReached; } private void raiseCardLevel(Card card) { CardInfo cardInfo = getCardInfo(card); assert cardInfo != null; m_cardsActive.remove(cardInfo); m_cardsLearned.add(card); int level = card.getLevel(); Date expiration = m_settings.getExpirationDate(m_start, level); Category.raiseCardLevel(card, m_start, expiration); } private void gotoNextCard() { // check for end condition if (isQuit()) { endLearning(); } else { CardInfo lastCardInfo = m_currentCardInfo; m_currentCardInfo = m_cardsActive.loopIterator().next(); // prevent the same card from occuring twice in a row if (m_cardsActive.size() > 1 && lastCardInfo == m_currentCardInfo) { m_currentCardInfo = m_cardsActive.loopIterator().next(); } // add the new card to the checked list now so it can be edited as part of the set. // m_cardsChecked is ordered by last viewing, so remove prior to add Card currentCard = m_currentCardInfo.getCard(); m_cardsChecked.remove(currentCard); m_cardsChecked.add(currentCard); boolean flippedMode = checkIfFlipped(); for (LearnCardObserver observer : m_cardObservers) { observer.nextCardFetched(currentCard, flippedMode); } } } /** * Checks whether the card should be displayed as flipped or not. * * @return <code>true</code> if the card should be flipped. * <code>false</code> otherwise. */ private boolean checkIfFlipped() { if (m_settings.getSidesMode() == LearnSettings.SIDES_RANDOM) { return m_rand.nextInt(2) == 1; // 50% chance } else if (m_settings.getSidesMode() == LearnSettings.SIDES_BOTH) { // allocate the side proportionally to the amount they have left to learn Card currentCard = m_currentCardInfo.getCard(); int timesToLearnFront = m_settings.getAmountToTest(true) - currentCard.getLearnedAmount(true); int timesToLearnBack = m_settings.getAmountToTest(false) - currentCard.getLearnedAmount(false); if (timesToLearnBack < 0) timesToLearnBack = 0; if (timesToLearnFront < 0) timesToLearnFront = 0; if (timesToLearnFront + timesToLearnBack == 0) return false; int rand = m_rand.nextInt(timesToLearnFront + timesToLearnBack); return rand < timesToLearnBack; } else { return (m_settings.getSidesMode() == LearnSettings.SIDES_FLIPPED); } } /** * Fetch the cards that should be learned in this session according to given * params. */ private EquivalenceClassSet<CardInfo> fetchCards(List<Card> selectedCards, boolean learnUnlearnedCards, boolean learnExpiredCards, Map<Category, Integer> categoryGroupOrder) { List<Card> cards = new ArrayList<Card>(); if (learnUnlearnedCards) cards.addAll(m_category.getUnlearnedCards()); if (learnExpiredCards) cards.addAll(m_category.getExpiredCards()); if (!learnUnlearnedCards && !learnExpiredCards) cards.addAll(selectedCards); List<Integer> levels = new LinkedList<Integer>(); List<CardInfo> cardInfos = new ArrayList<CardInfo>(cards.size()); m_cardsInfoMap.clear(); for (Card card : cards) { CardInfo cardInfo = new CardInfo(card); cardInfos.add(cardInfo); m_cardsInfoMap.put(card, cardInfo); if (!levels.contains(card.getLevel())) levels.add(card.getLevel()); } // shuffle random cards float shuffleRatio = m_settings.getShuffleRatio(); int shuffledCardsCount = (int)(shuffleRatio * cards.size()); List<CardInfo> shuffledCardInfos = new ArrayList<CardInfo>(shuffledCardsCount); if (levels.size() > 1) { for (int i = 0; i < shuffledCardsCount; i++) { int randIndex = m_rand.nextInt(cardInfos.size()); CardInfo cardInfo = cardInfos.remove(randIndex); shuffledCardInfos.add(cardInfo); // randomly find a new level, which ISN'T our current level int randLevel = m_rand.nextInt(levels.size() - 1); if (randLevel >= cardInfo.getLevel()) randLevel++; cardInfo.setLevel(levels.get(randLevel)); } } // create equivalence set EquivalenceClassSet<CardInfo> cardSet = new EquivalenceClassSet<CardInfo>(new CardComparator(categoryGroupOrder)); cardSet.addAll(cardInfos); cardSet.addAll(shuffledCardInfos); return cardSet; } private Set<Card> toCardSet(Collection<CardInfo> cardInfos) { HashSet<Card> set = new HashSet<Card>(); for (CardInfo cardInfo : cardInfos) { set.add(cardInfo.getCard()); } return set; } private CardInfo getCardInfo(Card card) { return m_cardsInfoMap.get(card); } /** * @return Cards that are being learned can be grouped by categories. In * this case the map holds for every category the position when it should * appear. */ private Map<Category, Integer> createCategoryGroupOrder() { List<Category> categories = m_category.getSubtreeList(); if (m_settings.getCategoryOrder() == LearnSettings.CATEGORY_ORDER_RANDOM) { Collections.shuffle(categories); } HashMap<Category, Integer> map = new HashMap<Category, Integer>(); int i = 0; for (Category category : categories) { map.put(category, new Integer(i++)); } // cards that have no category will be last in order map.put(null, new Integer(i)); return map; } private void setupLogger() { // TODO move to main? m_logger.setLevel(Level.FINE); Handler ch = new ConsoleHandler(); ch.setLevel(Level.WARNING); Logger.getLogger("").addHandler(ch); } }