/**************************************************************************************** * Copyright (c) 2011 Norbert Nagold <norbert.nagold@gmail.com> * * Copyright (c) 2012 Kostas Spyropoulos <inigo.aldana@gmail.com> * * Copyright (c) 2013 Houssam Salem <houssam.salem.au@gmail.com> * * * * This program is free software; you can redistribute it and/or modify it under * * the terms of the GNU General private License as published by the Free Software * * Foundation; either version 3 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 private License for more details. * * * * You should have received a copy of the GNU General private License along with * * this program. If not, see <http://www.gnu.org/licenses/>. * ****************************************************************************************/ package com.ichi2.libanki; import android.app.Activity; import android.content.Context; import android.database.Cursor; import android.database.SQLException; import android.database.sqlite.SQLiteConstraintException; import android.graphics.Typeface; import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.text.style.StyleSpan; import com.ichi2.anki.R; import com.ichi2.libanki.hooks.Hooks; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.lang.ref.WeakReference; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.ListIterator; import java.util.Locale; import java.util.Random; import timber.log.Timber; public class Sched { // Not in libanki private static final int[] FACTOR_ADDITION_VALUES = { -150, 0, 150 }; private String mName = "std"; private boolean mHaveCustomStudy = true; private boolean mSpreadRev = true; private boolean mBurySiblingsOnAnswer = true; private Collection mCol; private int mQueueLimit; private int mReportLimit; private int mReps; private boolean mHaveQueues; private int mToday; public long mDayCutoff; private int mNewCount; private int mLrnCount; private int mRevCount; private int mNewCardModulus; private double[] mEtaCache = new double[] { -1, -1, -1, -1 }; // Queues private final LinkedList<Long> mNewQueue = new LinkedList<>(); private final LinkedList<long[]> mLrnQueue = new LinkedList<>(); private final LinkedList<Long> mLrnDayQueue = new LinkedList<>(); private final LinkedList<Long> mRevQueue = new LinkedList<>(); private LinkedList<Long> mNewDids; private LinkedList<Long> mLrnDids; private LinkedList<Long> mRevDids; // Not in libanki private WeakReference<Activity> mContextReference; /** * queue types: 0=new/cram, 1=lrn, 2=rev, 3=day lrn, -1=suspended, -2=buried * revlog types: 0=lrn, 1=rev, 2=relrn, 3=cram * positive revlog intervals are in days (rev), negative in seconds (lrn) */ public Sched(Collection col) { mCol = col; mQueueLimit = 50; mReportLimit = 1000; mReps = 0; mHaveQueues = false; _updateCutoff(); } /** * Pop the next card from the queue. None if finished. */ public Card getCard() { _checkDay(); if (!mHaveQueues) { reset(); } Card card = _getCard(); if (card != null) { mCol.log(card); if (!mBurySiblingsOnAnswer) { _burySiblings(card); } mReps += 1; card.startTimer(); return card; } return null; } public void reset() { _updateCutoff(); _resetLrn(); _resetRev(); _resetNew(); mHaveQueues = true; } public void answerCard(Card card, int ease) { mCol.log(); mCol.markReview(card); if (mBurySiblingsOnAnswer) { _burySiblings(card); } card.setReps(card.getReps() + 1); // former is for logging new cards, latter also covers filt. decks card.setWasNew((card.getType() == 0)); boolean wasNewQ = (card.getQueue() == 0); if (wasNewQ) { // came from the new queue, move to learning card.setQueue(1); // if it was a new card, it's now a learning card if (card.getType() == 0) { card.setType(1); } // init reps to graduation card.setLeft(_startingLeft(card)); // dynamic? if (card.getODid() != 0 && card.getType() == 2) { if (_resched(card)) { // reviews get their ivl boosted on first sight card.setIvl(_dynIvlBoost(card)); card.setODue(mToday + card.getIvl()); } } _updateStats(card, "new"); } if (card.getQueue() == 1 || card.getQueue() == 3) { _answerLrnCard(card, ease); if (!wasNewQ) { _updateStats(card, "lrn"); } } else if (card.getQueue() == 2) { _answerRevCard(card, ease); _updateStats(card, "rev"); } else { throw new RuntimeException("Invalid queue"); } _updateStats(card, "time", card.timeTaken()); card.setMod(Utils.intNow()); card.setUsn(mCol.usn()); card.flushSched(); } public int[] counts() { return counts(null); } public int[] counts(Card card) { int[] counts = {mNewCount, mLrnCount, mRevCount}; if (card != null) { int idx = countIdx(card); if (idx == 1) { counts[1] += card.getLeft() / 1000; } else { counts[idx] += 1; } } return counts; } /** * Return counts over next DAYS. Includes today. */ public int dueForecast() { return dueForecast(7); } public int dueForecast(int days) { // TODO:... return 0; } public int countIdx(Card card) { if (card.getQueue() == 3) { return 1; } return card.getQueue(); } public int answerButtons(Card card) { if (card.getODue() != 0) { // normal review in dyn deck? if (card.getODid() != 0 && card.getQueue() == 2) { return 4; } JSONObject conf = _lrnConf(card); try { if (card.getType() == 0 || card.getType() == 1 || conf.getJSONArray("delays").length() > 1) { return 3; } } catch (JSONException e) { throw new RuntimeException(e); } return 2; } else if (card.getQueue() == 2) { return 4; } else { return 3; } } /* * Unbury cards. */ public void unburyCards() { try { mCol.getConf().put("lastUnburied", mToday); mCol.log(mCol.getDb().queryColumn(Long.class, "select id from cards where queue = -2", 0)); } catch (JSONException e) { throw new RuntimeException(e); } mCol.getDb().execute("update cards set queue=type where queue = -2"); } public void unburyCardsForDeck() { String sids = Utils.ids2str(mCol.getDecks().active()); mCol.log(mCol.getDb().queryColumn(Long.class, "select id from cards where queue = -2 and did in " + sids, 0)); mCol.getDb().execute("update cards set mod=?,usn=?,queue=type where queue = -2 and did in " + sids, new Object[] { Utils.intNow(), mCol.usn() }); } /** * Rev/lrn/time daily stats ************************************************* * ********************************************** */ private void _updateStats(Card card, String type) { _updateStats(card, type, 1); } public void _updateStats(Card card, String type, long cnt) { String key = type + "Today"; long did = card.getDid(); List<JSONObject> list = mCol.getDecks().parents(did); list.add(mCol.getDecks().get(did)); for (JSONObject g : list) { try { JSONArray a = g.getJSONArray(key); // add a.put(1, a.getLong(1) + cnt); } catch (JSONException e) { throw new RuntimeException(e); } mCol.getDecks().save(g); } } public void extendLimits(int newc, int rev) { JSONObject cur = mCol.getDecks().current(); ArrayList<JSONObject> decks = new ArrayList<>(); decks.add(cur); try { decks.addAll(mCol.getDecks().parents(cur.getLong("id"))); for (long did : mCol.getDecks().children(cur.getLong("id")).values()) { decks.add(mCol.getDecks().get(did)); } for (JSONObject g : decks) { // add JSONArray ja = g.getJSONArray("newToday"); ja.put(1, ja.getInt(1) - newc); g.put("newToday", ja); ja = g.getJSONArray("revToday"); ja.put(1, ja.getInt(1) - rev); g.put("revToday", ja); mCol.getDecks().save(g); } } catch (JSONException e) { throw new RuntimeException(e); } } private int _walkingCount(Method limFn, Method cntFn) { int tot = 0; HashMap<Long, Integer> pcounts = new HashMap<>(); // for each of the active decks try { for (long did : mCol.getDecks().active()) { // get the individual deck's limit int lim = (Integer)limFn.invoke(Sched.this, mCol.getDecks().get(did)); if (lim == 0) { continue; } // check the parents List<JSONObject> parents = mCol.getDecks().parents(did); for (JSONObject p : parents) { // add if missing long id = p.getLong("id"); if (!pcounts.containsKey(id)) { pcounts.put(id, (Integer)limFn.invoke(Sched.this, p)); } // take minimum of child and parent lim = Math.min(pcounts.get(id), lim); } // see how many cards we actually have int cnt = (Integer)cntFn.invoke(Sched.this, did, lim); // if non-zero, decrement from parents counts for (JSONObject p : parents) { long id = p.getLong("id"); pcounts.put(id, pcounts.get(id) - cnt); } // we may also be a parent pcounts.put(did, lim - cnt); // and add to running total tot += cnt; } } catch (JSONException | IllegalAccessException | InvocationTargetException e) { throw new RuntimeException(e); } return tot; } /** * Deck list **************************************************************** ******************************* */ /** * Returns [deckname, did, rev, lrn, new] */ public List<DeckDueTreeNode> deckDueList() { _checkDay(); mCol.getDecks().recoverOrphans(); ArrayList<JSONObject> decks = mCol.getDecks().allSorted(); HashMap<String, Integer[]> lims = new HashMap<>(); ArrayList<DeckDueTreeNode> data = new ArrayList<>(); try { for (JSONObject deck : decks) { // if we've already seen the exact same deck name, remove the // invalid duplicate and reload if (lims.containsKey(deck.getString("name"))) { mCol.getDecks().rem(deck.getLong("id"), false, true); return deckDueList(); } String p; List<String> parts = Arrays.asList(deck.getString("name").split("::", -1)); if (parts.size() < 2) { p = null; } else { parts = parts.subList(0, parts.size() - 1); p = TextUtils.join("::", parts); } // new int nlim = _deckNewLimitSingle(deck); if (!TextUtils.isEmpty(p)) { if (!lims.containsKey(p)) { // if parent was missing, this deck is invalid, and we need to reload the deck list mCol.getDecks().rem(deck.getLong("id"), false, true); return deckDueList(); } nlim = Math.min(nlim, lims.get(p)[0]); } int _new = _newForDeck(deck.getLong("id"), nlim); // learning int lrn = _lrnForDeck(deck.getLong("id")); // reviews int rlim = _deckRevLimitSingle(deck); if (!TextUtils.isEmpty(p)) { rlim = Math.min(rlim, lims.get(p)[1]); } int rev = _revForDeck(deck.getLong("id"), rlim); // save to list data.add(new DeckDueTreeNode(deck.getString("name"), deck.getLong("id"), rev, lrn, _new)); // add deck as a parent lims.put(deck.getString("name"), new Integer[]{nlim, rlim}); } } catch (JSONException e) { throw new RuntimeException(e); } return data; } public List<DeckDueTreeNode> deckDueTree() { return _groupChildren(deckDueList()); } private List<DeckDueTreeNode> _groupChildren(List<DeckDueTreeNode> grps) { // first, split the group names into components for (DeckDueTreeNode g : grps) { g.names = g.names[0].split("::", -1); } // and sort based on those components Collections.sort(grps); // then run main function return _groupChildrenMain(grps); } private List<DeckDueTreeNode> _groupChildrenMain(List<DeckDueTreeNode> grps) { List<DeckDueTreeNode> tree = new ArrayList<>(); // group and recurse ListIterator<DeckDueTreeNode> it = grps.listIterator(); while (it.hasNext()) { DeckDueTreeNode node = it.next(); String head = node.names[0]; // Compose the "tail" node list. The tail is a list of all the nodes that proceed // the current one that contain the same name[0]. I.e., they are subdecks that stem // from this node. This is our version of python's itertools.groupby. List<DeckDueTreeNode> tail = new ArrayList<>(); tail.add(node); while (it.hasNext()) { DeckDueTreeNode next = it.next(); if (head.equals(next.names[0])) { // Same head - add to tail of current head. tail.add(next); } else { // We've iterated past this head, so step back in order to use this node as the // head in the next iteration of the outer loop. it.previous(); break; } } Long did = null; int rev = 0; int _new = 0; int lrn = 0; List<DeckDueTreeNode> children = new ArrayList<>(); for (DeckDueTreeNode c : tail) { if (c.names.length == 1) { // current node did = c.did; rev += c.revCount; lrn += c.lrnCount; _new += c.newCount; } else { // set new string to tail String[] newTail = new String[c.names.length-1]; System.arraycopy(c.names, 1, newTail, 0, c.names.length-1); c.names = newTail; children.add(c); } } children = _groupChildrenMain(children); // tally up children counts for (DeckDueTreeNode ch : children) { rev += ch.revCount; lrn += ch.lrnCount; _new += ch.newCount; } // limit the counts to the deck's limits JSONObject conf = mCol.getDecks().confForDid(did); JSONObject deck = mCol.getDecks().get(did); try { if (conf.getInt("dyn") == 0) { rev = Math.max(0, Math.min(rev, conf.getJSONObject("rev").getInt("perDay") - deck.getJSONArray("revToday").getInt(1))); _new = Math.max(0, Math.min(_new, conf.getJSONObject("new").getInt("perDay") - deck.getJSONArray("newToday").getInt(1))); } } catch (JSONException e) { throw new RuntimeException(e); } tree.add(new DeckDueTreeNode(head, did, rev, lrn, _new, children)); } return tree; } /** * Getting the next card **************************************************** * ******************************************* */ /** * Return the next due card, or null. */ private Card _getCard() { // learning card due? Card c = _getLrnCard(); if (c != null) { return c; } // new first, or time for one? if (_timeForNewCard()) { c = _getNewCard(); if (c != null) { return c; } } // Card due for review? c = _getRevCard(); if (c != null) { return c; } // day learning card due? c = _getLrnDayCard(); if (c != null) { return c; } // New cards left? c = _getNewCard(); if (c != null) { return c; } // collapse or finish return _getLrnCard(true); } /** * New cards **************************************************************** ******************************* */ private void _resetNewCount() { try { mNewCount = _walkingCount(Sched.class.getDeclaredMethod("_deckNewLimitSingle", JSONObject.class), Sched.class.getDeclaredMethod("_cntFnNew", long.class, int.class)); } catch (NoSuchMethodException e) { throw new RuntimeException(e); } } // Used as an argument for _walkingCount() in _resetNewCount() above @SuppressWarnings("unused") private int _cntFnNew(long did, int lim) { return mCol.getDb().queryScalar( "SELECT count() FROM (SELECT 1 FROM cards WHERE did = " + did + " AND queue = 0 LIMIT " + lim + ")"); } private void _resetNew() { _resetNewCount(); mNewDids = new LinkedList<>(mCol.getDecks().active()); mNewQueue.clear(); _updateNewCardRatio(); } private boolean _fillNew() { if (mNewQueue.size() > 0) { return true; } if (mNewCount == 0) { return false; } while (!mNewDids.isEmpty()) { long did = mNewDids.getFirst(); int lim = Math.min(mQueueLimit, _deckNewLimit(did)); Cursor cur = null; if (lim != 0) { mNewQueue.clear(); try { // fill the queue with the current did cur = mCol .getDb() .getDatabase() .rawQuery("SELECT id FROM cards WHERE did = " + did + " AND queue = 0 order by due LIMIT " + lim, null); while (cur.moveToNext()) { mNewQueue.add(cur.getLong(0)); } } finally { if (cur != null && !cur.isClosed()) { cur.close(); } } if (!mNewQueue.isEmpty()) { // Note: libanki reverses mNewQueue and returns the last element in _getNewCard(). // AnkiDroid differs by leaving the queue intact and returning the *first* element // in _getNewCard(). return true; } } // nothing left in the deck; move to next mNewDids.remove(); } if (mNewCount != 0) { // if we didn't get a card but the count is non-zero, // we need to check again for any cards that were // removed from the queue but not buried _resetNew(); return _fillNew(); } return false; } private Card _getNewCard() { if (_fillNew()) { mNewCount -= 1; return mCol.getCard(mNewQueue.remove()); } return null; } private void _updateNewCardRatio() { try { if (mCol.getConf().getInt("newSpread") == Consts.NEW_CARDS_DISTRIBUTE) { if (mNewCount != 0) { mNewCardModulus = (mNewCount + mRevCount) / mNewCount; // if there are cards to review, ensure modulo >= 2 if (mRevCount != 0) { mNewCardModulus = Math.max(2, mNewCardModulus); } return; } } mNewCardModulus = 0; } catch (JSONException e) { throw new RuntimeException(e); } } /** * @return True if it's time to display a new card when distributing. */ private boolean _timeForNewCard() { if (mNewCount == 0) { return false; } int spread; try { spread = mCol.getConf().getInt("newSpread"); } catch (JSONException e) { throw new RuntimeException(e); } if (spread == Consts.NEW_CARDS_LAST) { return false; } else if (spread == Consts.NEW_CARDS_FIRST) { return true; } else if (mNewCardModulus != 0) { return (mReps != 0 && (mReps % mNewCardModulus == 0)); } else { return false; } } private int _deckNewLimit(long did) { return _deckNewLimit(did, null); } private int _deckNewLimit(long did, Method fn) { try { if (fn == null) { fn = Sched.class.getDeclaredMethod("_deckNewLimitSingle", JSONObject.class); } List<JSONObject> decks = mCol.getDecks().parents(did); decks.add(mCol.getDecks().get(did)); int lim = -1; // for the deck and each of its parents int rem = 0; for (JSONObject g : decks) { rem = (Integer) fn.invoke(Sched.this, g); if (lim == -1) { lim = rem; } else { lim = Math.min(rem, lim); } } return lim; } catch (IllegalArgumentException | NoSuchMethodException | InvocationTargetException | IllegalAccessException e) { throw new RuntimeException(e); } } /* New count for a single deck. */ public int _newForDeck(long did, int lim) { if (lim == 0) { return 0; } lim = Math.min(lim, mReportLimit); return mCol.getDb().queryScalar("SELECT count() FROM (SELECT 1 FROM cards WHERE did = " + did + " AND queue = 0 LIMIT " + lim + ")"); } /* Limit for deck without parent limits. */ public int _deckNewLimitSingle(JSONObject g) { try { if (g.getInt("dyn") != 0) { return mReportLimit; } JSONObject c = mCol.getDecks().confForDid(g.getLong("id")); return Math.max(0, c.getJSONObject("new").getInt("perDay") - g.getJSONArray("newToday").getInt(1)); } catch (JSONException e) { throw new RuntimeException(e); } } public int totalNewForCurrentDeck() { return mCol.getDb().queryScalar("SELECT count() FROM cards WHERE id IN (SELECT id FROM cards WHERE did IN " + Utils.ids2str(mCol.getDecks().active()) + " AND queue = 0 LIMIT " + mReportLimit + ")"); } /** * Learning queues *********************************************************** ************************************ */ private void _resetLrnCount() { // sub-day mLrnCount = mCol.getDb().queryScalar( "SELECT sum(left / 1000) FROM (SELECT left FROM cards WHERE did IN " + _deckLimit() + " AND queue = 1 AND due < " + mDayCutoff + " LIMIT " + mReportLimit + ")"); // day mLrnCount += mCol.getDb().queryScalar( "SELECT count() FROM cards WHERE did IN " + _deckLimit() + " AND queue = 3 AND due <= " + mToday + " LIMIT " + mReportLimit); } private void _resetLrn() { _resetLrnCount(); mLrnQueue.clear(); mLrnDayQueue.clear(); mLrnDids = mCol.getDecks().active(); } // sub-day learning private boolean _fillLrn() { if (mLrnCount == 0) { return false; } if (!mLrnQueue.isEmpty()) { return true; } Cursor cur = null; mLrnQueue.clear(); try { cur = mCol .getDb() .getDatabase() .rawQuery( "SELECT due, id FROM cards WHERE did IN " + _deckLimit() + " AND queue = 1 AND due < " + mDayCutoff + " LIMIT " + mReportLimit, null); while (cur.moveToNext()) { mLrnQueue.add(new long[] { cur.getLong(0), cur.getLong(1) }); } // as it arrives sorted by did first, we need to sort it Collections.sort(mLrnQueue, new Comparator<long[]>() { @Override public int compare(long[] lhs, long[] rhs) { return Long.valueOf(lhs[0]).compareTo(rhs[0]); } }); return !mLrnQueue.isEmpty(); } finally { if (cur != null && !cur.isClosed()) { cur.close(); } } } private Card _getLrnCard() { return _getLrnCard(false); } private Card _getLrnCard(boolean collapse) { if (_fillLrn()) { double cutoff = Utils.now(); if (collapse) { try { cutoff += mCol.getConf().getInt("collapseTime"); } catch (JSONException e) { throw new RuntimeException(e); } } if (mLrnQueue.getFirst()[0] < cutoff) { long id = mLrnQueue.remove()[1]; Card card = mCol.getCard(id); mLrnCount -= card.getLeft() / 1000; return card; } } return null; } // daily learning private boolean _fillLrnDay() { if (mLrnCount == 0) { return false; } if (!mLrnDayQueue.isEmpty()) { return true; } while (mLrnDids.size() > 0) { long did = mLrnDids.getFirst(); // fill the queue with the current did mLrnDayQueue.clear(); Cursor cur = null; try { cur = mCol .getDb() .getDatabase() .rawQuery( "SELECT id FROM cards WHERE did = " + did + " AND queue = 3 AND due <= " + mToday + " LIMIT " + mQueueLimit, null); while (cur.moveToNext()) { mLrnDayQueue.add(cur.getLong(0)); } } finally { if (cur != null && !cur.isClosed()) { cur.close(); } } if (mLrnDayQueue.size() > 0) { // order Random r = new Random(); r.setSeed(mToday); Collections.shuffle(mLrnDayQueue, r); // is the current did empty? if (mLrnDayQueue.size() < mQueueLimit) { mLrnDids.remove(); } return true; } // nothing left in the deck; move to next mLrnDids.remove(); } return false; } private Card _getLrnDayCard() { if (_fillLrnDay()) { mLrnCount -= 1; return mCol.getCard(mLrnDayQueue.remove()); } return null; } /** * @param ease 1=no, 2=yes, 3=remove */ private void _answerLrnCard(Card card, int ease) { JSONObject conf = _lrnConf(card); int type; if (card.getODid() != 0 && !card.getWasNew()) { type = 3; } else if (card.getType() == 2) { type = 2; } else { type = 0; } boolean leaving = false; // lrnCount was decremented once when card was fetched int lastLeft = card.getLeft(); // immediate graduate? if (ease == 3) { _rescheduleAsRev(card, conf, true); leaving = true; // graduation time? } else if (ease == 2 && (card.getLeft() % 1000) - 1 <= 0) { _rescheduleAsRev(card, conf, false); leaving = true; } else { // one step towards graduation if (ease == 2) { // decrement real left count and recalculate left today int left = (card.getLeft() % 1000) - 1; try { card.setLeft(_leftToday(conf.getJSONArray("delays"), left) * 1000 + left); } catch (JSONException e) { throw new RuntimeException(e); } // failed } else { card.setLeft(_startingLeft(card)); boolean resched = _resched(card); if (conf.has("mult") && resched) { // review that's lapsed try { card.setIvl(Math.max(Math.max(1, (int) (card.getIvl() * conf.getDouble("mult"))), conf.getInt("minInt"))); } catch (JSONException e) { throw new RuntimeException(e); } } else { // new card; no ivl adjustment // pass } if (resched && card.getODid() != 0) { card.setODue(mToday + 1); } } int delay = _delayForGrade(conf, card.getLeft()); if (card.getDue() < Utils.now()) { // not collapsed; add some randomness delay *= Utils.randomFloatInRange(1f, 1.25f); } card.setDue((int) (Utils.now() + delay)); // due today? if (card.getDue() < mDayCutoff) { mLrnCount += card.getLeft() / 1000; // if the queue is not empty and there's nothing else to do, make // sure we don't put it at the head of the queue and end up showing // it twice in a row card.setQueue(1); if (!mLrnQueue.isEmpty() && mRevCount == 0 && mNewCount == 0) { long smallestDue = mLrnQueue.getFirst()[0]; card.setDue(Math.max(card.getDue(), smallestDue + 1)); } _sortIntoLrn(card.getDue(), card.getId()); } else { // the card is due in one or more days, so we need to use the day learn queue long ahead = ((card.getDue() - mDayCutoff) / 86400) + 1; card.setDue(mToday + ahead); card.setQueue(3); } } _logLrn(card, ease, conf, leaving, type, lastLeft); } private int _delayForGrade(JSONObject conf, int left) { left = left % 1000; try { double delay; JSONArray ja = conf.getJSONArray("delays"); int len = ja.length(); try { delay = ja.getDouble(len - left); } catch (JSONException e) { if (conf.getJSONArray("delays").length() > 0) { delay = conf.getJSONArray("delays").getDouble(0); } else { // user deleted final step; use dummy value delay = 1.0; } } return (int) (delay * 60.0); } catch (JSONException e) { throw new RuntimeException(e); } } private JSONObject _lrnConf(Card card) { if (card.getType() == 2) { return _lapseConf(card); } else { return _newConf(card); } } private void _rescheduleAsRev(Card card, JSONObject conf, boolean early) { boolean lapse = (card.getType() == 2); if (lapse) { if (_resched(card)) { card.setDue(Math.max(mToday + 1, card.getODue())); } else { card.setDue(card.getODue()); } card.setODue(0); } else { _rescheduleNew(card, conf, early); } card.setQueue(2); card.setType(2); // if we were dynamic, graduating means moving back to the old deck boolean resched = _resched(card); if (card.getODid() != 0) { card.setDid(card.getODid()); card.setODue(0); card.setODid(0); // if rescheduling is off, it needs to be set back to a new card if (!resched && !lapse) { card.setType(0); card.setQueue(card.getType()); card.setDue(mCol.nextID("pos")); } } } private int _startingLeft(Card card) { try { JSONObject conf; if (card.getType() == 2) { conf = _lapseConf(card); } else { conf = _lrnConf(card); } int tot = conf.getJSONArray("delays").length(); int tod = _leftToday(conf.getJSONArray("delays"), tot); return tot + tod * 1000; } catch (JSONException e) { throw new RuntimeException(e); } } /* the number of steps that can be completed by the day cutoff */ private int _leftToday(JSONArray delays, int left) { return _leftToday(delays, left, 0); } private int _leftToday(JSONArray delays, int left, long now) { if (now == 0) { now = Utils.intNow(); } int ok = 0; int offset = Math.min(left, delays.length()); for (int i = 0; i < offset; i++) { try { now += (int) (delays.getDouble(delays.length() - offset + i) * 60.0); } catch (JSONException e) { throw new RuntimeException(e); } if (now > mDayCutoff) { break; } ok = i; } return ok + 1; } private int _graduatingIvl(Card card, JSONObject conf, boolean early) { return _graduatingIvl(card, conf, early, true); } private int _graduatingIvl(Card card, JSONObject conf, boolean early, boolean adj) { if (card.getType() == 2) { // lapsed card being relearnt if (card.getODid() != 0) { try { if (conf.getBoolean("resched")) { return _dynIvlBoost(card); } } catch (JSONException e) { throw new RuntimeException(e); } } return card.getIvl(); } int ideal; JSONArray ja; try { ja = conf.getJSONArray("ints"); if (!early) { // graduate ideal = ja.getInt(0); } else { ideal = ja.getInt(1); } if (adj) { return _adjRevIvl(card, ideal); } else { return ideal; } } catch (JSONException e) { throw new RuntimeException(e); } } /* Reschedule a new card that's graduated for the first time. */ private void _rescheduleNew(Card card, JSONObject conf, boolean early) { card.setIvl(_graduatingIvl(card, conf, early)); card.setDue(mToday + card.getIvl()); try { card.setFactor(conf.getInt("initialFactor")); } catch (JSONException e) { throw new RuntimeException(e); } } private void _logLrn(Card card, int ease, JSONObject conf, boolean leaving, int type, int lastLeft) { int lastIvl = -(_delayForGrade(conf, lastLeft)); int ivl = leaving ? card.getIvl() : -(_delayForGrade(conf, card.getLeft())); log(card.getId(), mCol.usn(), ease, ivl, lastIvl, card.getFactor(), card.timeTaken(), type); } private void log(long id, int usn, int ease, int ivl, int lastIvl, int factor, int timeTaken, int type) { try { mCol.getDb().execute("INSERT INTO revlog VALUES (?,?,?,?,?,?,?,?,?)", new Object[]{Utils.now() * 1000, id, usn, ease, ivl, lastIvl, factor, timeTaken, type}); } catch (SQLiteConstraintException e) { try { Thread.sleep(10); } catch (InterruptedException e1) { throw new RuntimeException(e1); } log(id, usn, ease, ivl, lastIvl, factor, timeTaken, type); } } public void removeLrn() { removeLrn(null); } /* Remove cards from the learning queues. */ private void removeLrn(long[] ids) { String extra; if (ids != null && ids.length > 0) { extra = " AND id IN " + Utils.ids2str(ids); } else { // benchmarks indicate it's about 10x faster to search all decks with the index than scan the table extra = " AND did IN " + Utils.ids2str(mCol.getDecks().allIds()); } // review cards in relearning mCol.getDb().execute( "update cards set due = odue, queue = 2, mod = " + Utils.intNow() + ", usn = " + mCol.usn() + ", odue = 0 where queue IN (1,3) and type = 2 " + extra); // new cards in learning forgetCards(Utils.arrayList2array(mCol.getDb().queryColumn(Long.class, "SELECT id FROM cards WHERE queue IN (1,3) " + extra, 0))); } private int _lrnForDeck(long did) { try { int cnt = mCol.getDb().queryScalar( "SELECT sum(left / 1000) FROM (SELECT left FROM cards WHERE did = " + did + " AND queue = 1 AND due < " + (Utils.intNow() + mCol.getConf().getInt("collapseTime")) + " LIMIT " + mReportLimit + ")"); return cnt + mCol.getDb().queryScalar( "SELECT count() FROM (SELECT 1 FROM cards WHERE did = " + did + " AND queue = 3 AND due <= " + mToday + " LIMIT " + mReportLimit + ")"); } catch (SQLException | JSONException e) { throw new RuntimeException(e); } } /** * Reviews ****************************************************************** ***************************** */ private int _deckRevLimit(long did) { try { return _deckNewLimit(did, Sched.class.getDeclaredMethod("_deckRevLimitSingle", JSONObject.class)); } catch (NoSuchMethodException e) { throw new RuntimeException(e); } } private int _deckRevLimitSingle(JSONObject d) { try { if (d.getInt("dyn") != 0) { return mReportLimit; } JSONObject c = mCol.getDecks().confForDid(d.getLong("id")); return Math.max(0, c.getJSONObject("rev").getInt("perDay") - d.getJSONArray("revToday").getInt(1)); } catch (JSONException e) { throw new RuntimeException(e); } } public int _revForDeck(long did, int lim) { lim = Math.min(lim, mReportLimit); return mCol.getDb().queryScalar("SELECT count() FROM (SELECT 1 FROM cards WHERE did = " + did + " AND queue = 2 AND due <= " + mToday + " LIMIT " + lim + ")"); } private void _resetRevCount() { try { mRevCount = _walkingCount(Sched.class.getDeclaredMethod("_deckRevLimitSingle", JSONObject.class), Sched.class.getDeclaredMethod("_cntFnRev", long.class, int.class)); } catch (NoSuchMethodException e) { throw new RuntimeException(e); } } // Dynamically invoked in _walkingCount, passed as a parameter in _resetRevCount @SuppressWarnings("unused") private int _cntFnRev(long did, int lim) { return mCol.getDb().queryScalar( "SELECT count() FROM (SELECT id FROM cards WHERE did = " + did + " AND queue = 2 and due <= " + mToday + " LIMIT " + lim + ")"); } private void _resetRev() { _resetRevCount(); mRevQueue.clear(); mRevDids = mCol.getDecks().active(); } private boolean _fillRev() { if (!mRevQueue.isEmpty()) { return true; } if (mRevCount == 0) { return false; } while (mRevDids.size() > 0) { long did = mRevDids.getFirst(); int lim = Math.min(mQueueLimit, _deckRevLimit(did)); Cursor cur = null; if (lim != 0) { mRevQueue.clear(); // fill the queue with the current did try { cur = mCol .getDb() .getDatabase() .rawQuery( "SELECT id FROM cards WHERE did = " + did + " AND queue = 2 AND due <= " + mToday + " LIMIT " + lim, null); while (cur.moveToNext()) { mRevQueue.add(cur.getLong(0)); } } finally { if (cur != null && !cur.isClosed()) { cur.close(); } } if (!mRevQueue.isEmpty()) { // ordering try { if (mCol.getDecks().get(did).getInt("dyn") != 0) { // dynamic decks need due order preserved // Note: libanki reverses mRevQueue and returns the last element in _getRevCard(). // AnkiDroid differs by leaving the queue intact and returning the *first* element // in _getRevCard(). } else { Random r = new Random(); r.setSeed(mToday); Collections.shuffle(mRevQueue, r); } } catch (JSONException e) { throw new RuntimeException(e); } // is the current did empty? if (mRevQueue.size() < lim) { mRevDids.remove(); } return true; } } // nothing left in the deck; move to next mRevDids.remove(); } if (mRevCount != 0) { // if we didn't get a card but the count is non-zero, // we need to check again for any cards that were // removed from the queue but not buried _resetRev(); return _fillRev(); } return false; } private Card _getRevCard() { if (_fillRev()) { mRevCount -= 1; return mCol.getCard(mRevQueue.remove()); } else { return null; } } public int totalRevForCurrentDeck() { return mCol.getDb().queryScalar(String.format(Locale.US, "SELECT count() FROM cards WHERE id IN (SELECT id FROM cards WHERE did IN %s AND queue = 2 AND due <= %d LIMIT %s)", Utils.ids2str(mCol.getDecks().active()), mToday, mReportLimit)); } /** * Answering a review card ************************************************** * ********************************************* */ private void _answerRevCard(Card card, int ease) { int delay = 0; if (ease == 1) { delay = _rescheduleLapse(card); } else { _rescheduleRev(card, ease); } _logRev(card, ease, delay); } private int _rescheduleLapse(Card card) { JSONObject conf; try { conf = _lapseConf(card); card.setLastIvl(card.getIvl()); if (_resched(card)) { card.setLapses(card.getLapses() + 1); card.setIvl(_nextLapseIvl(card, conf)); card.setFactor(Math.max(1300, card.getFactor() - 200)); card.setDue(mToday + card.getIvl()); // if it's a filtered deck, update odue as well if (card.getODid() != 0) { card.setODue(card.getDue()); } } // if suspended as a leech, nothing to do int delay = 0; if (_checkLeech(card, conf) && card.getQueue() == -1) { return delay; } // if no relearning steps, nothing to do if (conf.getJSONArray("delays").length() == 0) { return delay; } // record rev due date for later if (card.getODue() == 0) { card.setODue(card.getDue()); } delay = _delayForGrade(conf, 0); card.setDue((long) (delay + Utils.now())); card.setLeft(_startingLeft(card)); // queue 1 if (card.getDue() < mDayCutoff) { mLrnCount += card.getLeft() / 1000; card.setQueue(1); _sortIntoLrn(card.getDue(), card.getId()); } else { // day learn queue long ahead = ((card.getDue() - mDayCutoff) / 86400) + 1; card.setDue(mToday + ahead); card.setQueue(3); } return delay; } catch (JSONException e) { throw new RuntimeException(e); } } private int _nextLapseIvl(Card card, JSONObject conf) { try { return Math.max(conf.getInt("minInt"), (int)(card.getIvl() * conf.getDouble("mult"))); } catch (JSONException e) { throw new RuntimeException(e); } } private void _rescheduleRev(Card card, int ease) { // update interval card.setLastIvl(card.getIvl()); if (_resched(card)) { _updateRevIvl(card, ease); // then the rest card.setFactor(Math.max(1300, card.getFactor() + FACTOR_ADDITION_VALUES[ease - 2])); card.setDue(mToday + card.getIvl()); } else { card.setDue(card.getODue()); } if (card.getODid() != 0) { card.setDid(card.getODid()); card.setODid(0); card.setODue(0); } } private void _logRev(Card card, int ease, int delay) { log(card.getId(), mCol.usn(), ease, ((delay != 0) ? (-delay) : card.getIvl()), card.getLastIvl(), card.getFactor(), card.timeTaken(), 1); } /** * Interval management ****************************************************** * ***************************************** */ /** * Ideal next interval for CARD, given EASE. */ private int _nextRevIvl(Card card, int ease) { try { long delay = _daysLate(card); int interval = 0; JSONObject conf = _revConf(card); double fct = card.getFactor() / 1000.0; int ivl2 = _constrainedIvl((int)((card.getIvl() + delay/4) * 1.2), conf, card.getIvl()); int ivl3 = _constrainedIvl((int)((card.getIvl() + delay/2) * fct), conf, ivl2); int ivl4 = _constrainedIvl((int)((card.getIvl() + delay) * fct * conf.getDouble("ease4")), conf, ivl3); if (ease == 2) { interval = ivl2; } else if (ease == 3) { interval = ivl3; } else if (ease == 4) { interval = ivl4; } // interval capped? return Math.min(interval, conf.getInt("maxIvl")); } catch (JSONException e) { throw new RuntimeException(e); } } private int _fuzzedIvl(int ivl) { int[] minMax = _fuzzedIvlRange(ivl); // Anki's python uses random.randint(a, b) which returns x in [a, b] while the eq Random().nextInt(a, b) // returns x in [0, b-a), hence the +1 diff with libanki return (new Random().nextInt(minMax[1] - minMax[0] + 1)) + minMax[0]; } public int[] _fuzzedIvlRange(int ivl) { int fuzz; if (ivl < 2) { return new int[]{1, 1}; } else if (ivl == 2) { return new int[]{2, 3}; } else if (ivl < 7) { fuzz = (int)(ivl * 0.25); } else if (ivl < 30) { fuzz = Math.max(2, (int)(ivl * 0.15)); } else { fuzz = Math.max(4, (int)(ivl * 0.05)); } // fuzz at least a day fuzz = Math.max(fuzz, 1); return new int[]{ivl - fuzz, ivl + fuzz}; } /** Integer interval after interval factor and prev+1 constraints applied */ private int _constrainedIvl(int ivl, JSONObject conf, double prev) { double newIvl = ivl; newIvl = ivl * conf.optDouble("ivlFct",1.0); return (int) Math.max(newIvl, prev + 1); } /** * Number of days later than scheduled. */ private long _daysLate(Card card) { long due = card.getODid() != 0 ? card.getODue() : card.getDue(); return Math.max(0, mToday - due); } private void _updateRevIvl(Card card, int ease) { int idealIvl = _nextRevIvl(card, ease); card.setIvl(_adjRevIvl(card, idealIvl)); } private int _adjRevIvl(Card card, int idealIvl) { if (mSpreadRev) { idealIvl = _fuzzedIvl(idealIvl); } return idealIvl; } /** * Dynamic deck handling ****************************************************************** * ***************************** */ /* Rebuild a dynamic deck. */ public void rebuildDyn() { rebuildDyn(0); } public List<Long> rebuildDyn(long did) { if (did == 0) { did = mCol.getDecks().selected(); } JSONObject deck = mCol.getDecks().get(did); try { if (deck.getInt("dyn") == 0) { Timber.e("error: deck is not a filtered deck"); return null; } } catch (JSONException e1) { throw new RuntimeException(e1); } // move any existing cards back first, then fill emptyDyn(did); List<Long> ids = _fillDyn(deck); if (ids.isEmpty()) { return null; } // and change to our new deck mCol.getDecks().select(did); return ids; } private List<Long> _fillDyn(JSONObject deck) { JSONArray terms; List<Long> ids; try { terms = deck.getJSONArray("terms").getJSONArray(0); String search = terms.getString(0); int limit = terms.getInt(1); int order = terms.getInt(2); String orderlimit = _dynOrder(order, limit); if (!TextUtils.isEmpty(search.trim())) { search = String.format(Locale.US, "(%s)", search); } search = String.format(Locale.US, "%s -is:suspended -is:buried -deck:filtered", search); ids = mCol.findCards(search, orderlimit); if (ids.isEmpty()) { return ids; } // move the cards over mCol.log(deck.getLong("id"), ids); _moveToDyn(deck.getLong("id"), ids); } catch (JSONException e) { throw new RuntimeException(e); } return ids; } public void emptyDyn(long did) { emptyDyn(did, null); } public void emptyDyn(long did, String lim) { if (lim == null) { lim = "did = " + did; } mCol.log(mCol.getDb().queryColumn(Long.class, "select id from cards where " + lim, 0)); // move out of cram queue mCol.getDb().execute( "update cards set did = odid, queue = (case when type = 1 then 0 " + "else type end), type = (case when type = 1 then 0 else type end), " + "due = odue, odue = 0, odid = 0, usn = ? where " + lim, new Object[] { mCol.usn() }); } public void remFromDyn(long[] cids) { emptyDyn(0, "id IN " + Utils.ids2str(cids) + " AND odid"); } /** * Generates the required SQL for order by and limit clauses, for dynamic decks. * * @param o deck["order"] * @param l deck["limit"] * @return The generated SQL to be suffixed to "select ... from ... order by " */ private String _dynOrder(int o, int l) { String t; switch (o) { case Consts.DYN_OLDEST: t = "c.mod"; break; case Consts.DYN_RANDOM: t = "random()"; break; case Consts.DYN_SMALLINT: t = "ivl"; break; case Consts.DYN_BIGINT: t = "ivl desc"; break; case Consts.DYN_LAPSES: t = "lapses desc"; break; case Consts.DYN_ADDED: t = "n.id"; break; case Consts.DYN_REVADDED: t = "n.id desc"; break; case Consts.DYN_DUE: t = "c.due"; break; case Consts.DYN_DUEPRIORITY: t = String.format(Locale.US, "(case when queue=2 and due <= %d then (ivl / cast(%d-due+0.001 as real)) else 100000+due end)", mToday, mToday); break; default: // if we don't understand the term, default to due order t = "c.due"; } return t + " limit " + l; } private void _moveToDyn(long did, List<Long> ids) { ArrayList<Object[]> data = new ArrayList<>(); long t = Utils.intNow(); int u = mCol.usn(); for (long c = 0; c < ids.size(); c++) { // start at -100000 so that reviews are all due data.add(new Object[] { did, -100000 + c, u, ids.get((int) c) }); } // due reviews stay in the review queue. careful: can't use "odid or did", as sqlite converts to boolean String queue = "(CASE WHEN type = 2 AND (CASE WHEN odue THEN odue <= " + mToday + " ELSE due <= " + mToday + " END) THEN 2 ELSE 0 END)"; mCol.getDb().executeMany( "UPDATE cards SET odid = (CASE WHEN odid THEN odid ELSE did END), " + "odue = (CASE WHEN odue THEN odue ELSE due END), did = ?, queue = " + queue + ", due = ?, usn = ? WHERE id = ?", data); } private int _dynIvlBoost(Card card) { if (card.getODid() == 0 || card.getType() != 2 || card.getFactor() == 0) { Timber.e("error: deck is not a filtered deck"); return 0; } long elapsed = card.getIvl() - (card.getODue() - mToday); double factor = ((card.getFactor() / 1000.0) + 1.2) / 2.0; int ivl = Math.max(1, Math.max(card.getIvl(), (int) (elapsed * factor))); JSONObject conf = _revConf(card); try { return Math.min(conf.getInt("maxIvl"), ivl); } catch (JSONException e) { throw new RuntimeException(e); } } /** * Leeches ****************************************************************** ***************************** */ /** Leech handler. True if card was a leech. */ private boolean _checkLeech(Card card, JSONObject conf) { int lf; try { lf = conf.getInt("leechFails"); if (lf == 0) { return false; } // if over threshold or every half threshold reps after that if (card.getLapses() >= lf && (card.getLapses() - lf) % Math.max(lf / 2, 1) == 0) { // add a leech tag Note n = card.note(); n.addTag("leech"); n.flush(); // handle if (conf.getInt("leechAction") == 0) { // if it has an old due, remove it from cram/relearning if (card.getODue() != 0) { card.setDue(card.getODue()); } if (card.getODid() != 0) { card.setDid(card.getODid()); } card.setODue(0); card.setODid(0); card.setQueue(-1); } // notify UI if (mContextReference != null) { Context context = mContextReference.get(); Hooks.getInstance(context).runHook("leech", card, context); } return true; } } catch (JSONException e) { throw new RuntimeException(e); } return false; } /** * Tools ******************************************************************** *************************** */ public JSONObject _cardConf(Card card) { return mCol.getDecks().confForDid(card.getDid()); } private JSONObject _newConf(Card card) { try { JSONObject conf = _cardConf(card); // normal deck if (card.getODid() == 0) { return conf.getJSONObject("new"); } // dynamic deck; override some attributes, use original deck for others JSONObject oconf = mCol.getDecks().confForDid(card.getODid()); JSONArray delays = conf.optJSONArray("delays"); if (delays == null) { delays = oconf.getJSONObject("new").getJSONArray("delays"); } JSONObject dict = new JSONObject(); // original deck dict.put("ints", oconf.getJSONObject("new").getJSONArray("ints")); dict.put("initialFactor", oconf.getJSONObject("new").getInt("initialFactor")); dict.put("bury", oconf.getJSONObject("new").optBoolean("bury", true)); // overrides dict.put("delays", delays); dict.put("separate", conf.getBoolean("separate")); dict.put("order", Consts.NEW_CARDS_DUE); dict.put("perDay", mReportLimit); return dict; } catch (JSONException e) { throw new RuntimeException(e); } } private JSONObject _lapseConf(Card card) { try { JSONObject conf = _cardConf(card); // normal deck if (card.getODid() == 0) { return conf.getJSONObject("lapse"); } // dynamic deck; override some attributes, use original deck for others JSONObject oconf = mCol.getDecks().confForDid(card.getODid()); JSONArray delays = conf.optJSONArray("delays"); if (delays == null) { delays = oconf.getJSONObject("lapse").getJSONArray("delays"); } JSONObject dict = new JSONObject(); // original deck dict.put("minInt", oconf.getJSONObject("lapse").getInt("minInt")); dict.put("leechFails", oconf.getJSONObject("lapse").getInt("leechFails")); dict.put("leechAction", oconf.getJSONObject("lapse").getInt("leechAction")); dict.put("mult", oconf.getJSONObject("lapse").getDouble("mult")); // overrides dict.put("delays", delays); dict.put("resched", conf.getBoolean("resched")); return dict; } catch (JSONException e) { throw new RuntimeException(e); } } private JSONObject _revConf(Card card) { try { JSONObject conf = _cardConf(card); // normal deck if (card.getODid() == 0) { return conf.getJSONObject("rev"); } // dynamic deck return mCol.getDecks().confForDid(card.getODid()).getJSONObject("rev"); } catch (JSONException e) { throw new RuntimeException(e); } } public String _deckLimit() { return Utils.ids2str(mCol.getDecks().active()); } private boolean _resched(Card card) { JSONObject conf = _cardConf(card); try { if (conf.getInt("dyn") == 0) { return true; } return conf.getBoolean("resched"); } catch (JSONException e) { throw new RuntimeException(e); } } /** * Daily cutoff ************************************************************* ********************************** * This function uses GregorianCalendar so as to be sensitive to leap years, daylight savings, etc. */ private void _updateCutoff() { int oldToday = mToday; // days since col created mToday = (int) ((Utils.now() - mCol.getCrt()) / 86400); // end of day cutoff mDayCutoff = mCol.getCrt() + ((mToday + 1) * 86400); if (oldToday != mToday) { mCol.log(mToday, mDayCutoff); } // update all daily counts, but don't save decks to prevent needless conflicts. we'll save on card answer // instead for (JSONObject deck : mCol.getDecks().all()) { update(deck); } // unbury if the day has rolled over int unburied = mCol.getConf().optInt("lastUnburied", 0); if (unburied < mToday) { unburyCards(); } } private void update(JSONObject g) { for (String t : new String[] { "new", "rev", "lrn", "time" }) { String key = t + "Today"; try { if (g.getJSONArray(key).getInt(0) != mToday) { JSONArray ja = new JSONArray(); ja.put(mToday); ja.put(0); g.put(key, ja); } } catch (JSONException e) { throw new RuntimeException(e); } } } public void _checkDay() { // check if the day has rolled over if (Utils.now() > mDayCutoff) { reset(); } } /** * Deck finished state ****************************************************** * ***************************************** */ public CharSequence finishedMsg(Context context) { SpannableStringBuilder sb = new SpannableStringBuilder(); sb.append(context.getString(R.string.studyoptions_congrats_finished)); StyleSpan boldSpan = new StyleSpan(Typeface.BOLD); sb.setSpan(boldSpan, 0, sb.length(), 0); sb.append(_nextDueMsg(context)); // sb.append("\n\n"); // sb.append(_tomorrowDueMsg(context)); return sb; } public String _nextDueMsg(Context context) { StringBuilder sb = new StringBuilder(); if (revDue()) { sb.append("\n\n"); sb.append(context.getString(R.string.studyoptions_congrats_more_rev)); } if (newDue()) { sb.append("\n\n"); sb.append(context.getString(R.string.studyoptions_congrats_more_new)); } if (haveBuried()) { String now; if (mHaveCustomStudy) { now = " " + context.getString(R.string.sched_unbury_action); } else { now = ""; } sb.append("\n\n"); sb.append("" + context.getString(R.string.sched_has_buried) + now); } try { if (mHaveCustomStudy && mCol.getDecks().current().getInt("dyn") == 0) { sb.append("\n\n"); sb.append(context.getString(R.string.studyoptions_congrats_custom)); } } catch (JSONException e) { throw new RuntimeException(e); } return sb.toString(); } /** true if there are any rev cards due. */ public boolean revDue() { return mCol.getDb() .queryScalar( "SELECT 1 FROM cards WHERE did IN " + _deckLimit() + " AND queue = 2 AND due <= " + mToday + " LIMIT 1") != 0; } /** true if there are any new cards due. */ public boolean newDue() { return mCol.getDb().queryScalar("SELECT 1 FROM cards WHERE did IN " + _deckLimit() + " AND queue = 0 LIMIT 1") != 0; } public boolean haveBuried() { String sdids = Utils.ids2str(mCol.getDecks().active()); int cnt = mCol.getDb().queryScalar(String.format(Locale.US, "select 1 from cards where queue = -2 and did in %s limit 1", sdids)); return cnt != 0; } /** * Next time reports ******************************************************** * *************************************** */ /** * Return the next interval for a card and ease as a string. * * For a given card and ease, this returns a string that shows when the card will be shown again when the * specific ease button (AGAIN, GOOD etc.) is touched. This uses unit symbols like “s” rather than names * (“second”), like Anki desktop. * * @param context The app context, used for localization * @param card The card being reviewed * @param ease The button number (easy, good etc.) * @return A string like “1 min” or “1.7 mo” */ public String nextIvlStr(Context context, Card card, int ease) { int ivl = nextIvl(card, ease); if (ivl == 0) { return context.getString(R.string.sched_end); } String s = Utils.timeQuantity(context, ivl); try { if (ivl < mCol.getConf().getInt("collapseTime")) { s = context.getString(R.string.less_than_time, s); } } catch (JSONException e) { throw new RuntimeException(e); } return s; } /** * Return the next interval for CARD, in seconds. */ public int nextIvl(Card card, int ease) { try { if (card.getQueue() == 0 || card.getQueue() == 1 || card.getQueue() == 3) { return _nextLrnIvl(card, ease); } else if (ease == 1) { // lapsed JSONObject conf = _lapseConf(card); if (conf.getJSONArray("delays").length() > 0) { return (int) (conf.getJSONArray("delays").getDouble(0) * 60.0); } return _nextLapseIvl(card, conf) * 86400; } else { // review return _nextRevIvl(card, ease) * 86400; } } catch (JSONException e) { throw new RuntimeException(e); } } private int _nextLrnIvl(Card card, int ease) { // this isn't easily extracted from the learn code if (card.getQueue() == 0) { card.setLeft(_startingLeft(card)); } JSONObject conf = _lrnConf(card); try { if (ease == 1) { // fail return _delayForGrade(conf, conf.getJSONArray("delays").length()); } else if (ease == 3) { // early removal if (!_resched(card)) { return 0; } return _graduatingIvl(card, conf, true, false) * 86400; } else { int left = card.getLeft() % 1000 - 1; if (left <= 0) { // graduate if (!_resched(card)) { return 0; } return _graduatingIvl(card, conf, false, false) * 86400; } else { return _delayForGrade(conf, left); } } } catch (JSONException e) { throw new RuntimeException(e); } } /** * Suspending *************************************************************** ******************************** */ /** * Suspend cards. */ public void suspendCards(long[] ids) { mCol.log(ids); remFromDyn(ids); removeLrn(ids); mCol.getDb().execute( "UPDATE cards SET queue = -1, mod = " + Utils.intNow() + ", usn = " + mCol.usn() + " WHERE id IN " + Utils.ids2str(ids)); } /** * Unsuspend cards */ public void unsuspendCards(long[] ids) { mCol.log(ids); mCol.getDb().execute( "UPDATE cards SET queue = type, mod = " + Utils.intNow() + ", usn = " + mCol.usn() + " WHERE queue = -1 AND id IN " + Utils.ids2str(ids)); } public void buryCards(long[] cids) { mCol.log(cids); remFromDyn(cids); removeLrn(cids); mCol.getDb().execute("update cards set queue=-2,mod=?,usn=? where id in " + Utils.ids2str(cids), new Object[]{Utils.now(), mCol.usn()}); } /** * Bury all cards for note until next session. * @param nid The id of the targeted note. */ public void buryNote(long nid) { long[] cids = Utils.arrayList2array(mCol.getDb().queryColumn(Long.class, "SELECT id FROM cards WHERE nid = " + nid + " AND queue >= 0", 0)); buryCards(cids); } /** * Sibling spacing * ******************** */ private void _burySiblings(Card card) { LinkedList<Long> toBury = new LinkedList<>(); JSONObject nconf = _newConf(card); boolean buryNew = nconf.optBoolean("bury", true); JSONObject rconf = _revConf(card); boolean buryRev = rconf.optBoolean("bury", true); // loop through and remove from queues Cursor cur = null; try { cur = mCol.getDb().getDatabase().rawQuery(String.format(Locale.US, "select id, queue from cards where nid=%d and id!=%d "+ "and (queue=0 or (queue=2 and due<=%d))", card.getNid(), card.getId(), mToday), null); while (cur.moveToNext()) { long cid = cur.getLong(0); int queue = cur.getInt(1); if (queue == 2) { if (buryRev) { toBury.add(cid); } // if bury disabled, we still discard to give same-day spacing mRevQueue.remove(cid); } else { // if bury is disabled, we still discard to give same-day spacing if (buryNew) { toBury.add(cid); } mNewQueue.remove(cid); } } } finally { if (cur != null && !cur.isClosed()) { cur.close(); } } // then bury if (toBury.size() > 0) { mCol.getDb().execute("update cards set queue=-2,mod=?,usn=? where id in " + Utils.ids2str(toBury), new Object[] { Utils.now(), mCol.usn() }); mCol.log(toBury); } } /** * Resetting **************************************************************** ******************************* */ /** Put cards at the end of the new queue. */ public void forgetCards(long[] ids) { remFromDyn(ids); mCol.getDb().execute("update cards set type=0,queue=0,ivl=0,due=0,odue=0,factor=2500" + " where id in " + Utils.ids2str(ids)); int pmax = mCol.getDb().queryScalar("SELECT max(due) FROM cards WHERE type=0"); // takes care of mod + usn sortCards(ids, pmax + 1); mCol.log(ids); } /** * Put cards in review queue with a new interval in days (min, max). * * @param ids The list of card ids to be affected * @param imin the minimum interval (inclusive) * @param imax The maximum interval (inclusive) */ public void reschedCards(long[] ids, int imin, int imax) { ArrayList<Object[]> d = new ArrayList<>(); int t = mToday; long mod = Utils.intNow(); Random rnd = new Random(); for (long id : ids) { int r = rnd.nextInt(imax - imin + 1) + imin; d.add(new Object[] { Math.max(1, r), r + t, mCol.usn(), mod, 2500, id }); } remFromDyn(ids); mCol.getDb().executeMany( "update cards set type=2,queue=2,ivl=?,due=?,odue=0, " + "usn=?,mod=?,factor=? where id=?", d); mCol.log(ids); } /** * Completely reset cards for export. */ public void resetCards(Long[] ids) { long[] nonNew = Utils.arrayList2array(mCol.getDb().queryColumn(Long.class, String.format(Locale.US, "select id from cards where id in %s and (queue != 0 or type != 0)", Utils.ids2str(ids)), 0)); mCol.getDb().execute("update cards set reps=0, lapses=0 where id in " + Utils.ids2str(nonNew)); forgetCards(nonNew); mCol.log((Object[]) ids); } /** * Repositioning new cards ************************************************** * ********************************************* */ public void sortCards(long[] cids, int start) { sortCards(cids, start, 1, false, false); } public void sortCards(long[] cids, int start, int step, boolean shuffle, boolean shift) { String scids = Utils.ids2str(cids); long now = Utils.intNow(); ArrayList<Long> nids = new ArrayList<>(); for (long id : cids) { long nid = mCol.getDb().queryLongScalar("SELECT nid FROM cards WHERE id = " + id); if (!nids.contains(nid)) { nids.add(nid); } } if (nids.size() == 0) { // no new cards return; } // determine nid ordering HashMap<Long, Long> due = new HashMap<>(); if (shuffle) { Collections.shuffle(nids); } for (int c = 0; c < nids.size(); c++) { due.put(nids.get(c), (long) (start + c * step)); } int high = start + step * (nids.size() - 1); // shift? if (shift) { int low = mCol.getDb().queryScalar( "SELECT min(due) FROM cards WHERE due >= " + start + " AND type = 0 AND id NOT IN " + scids); if (low != 0) { int shiftby = high - low + 1; mCol.getDb().execute( "UPDATE cards SET mod = " + now + ", usn = " + mCol.usn() + ", due = due + " + shiftby + " WHERE id NOT IN " + scids + " AND due >= " + low + " AND queue = 0"); } } // reorder cards ArrayList<Object[]> d = new ArrayList<>(); Cursor cur = null; try { cur = mCol.getDb().getDatabase() .rawQuery("SELECT id, nid FROM cards WHERE type = 0 AND id IN " + scids, null); while (cur.moveToNext()) { long nid = cur.getLong(1); d.add(new Object[] { due.get(nid), now, mCol.usn(), cur.getLong(0) }); } } finally { if (cur != null && !cur.isClosed()) { cur.close(); } } mCol.getDb().executeMany("UPDATE cards SET due = ?, mod = ?, usn = ? WHERE id = ?", d); } public void randomizeCards(long did) { List<Long> cids = mCol.getDb().queryColumn(Long.class, "select id from cards where did = " + did, 0); sortCards(Utils.toPrimitive(cids), 1, 1, true, false); } public void orderCards(long did) { List<Long> cids = mCol.getDb().queryColumn(Long.class, "SELECT id FROM cards WHERE did = " + did + " ORDER BY id", 0); sortCards(Utils.toPrimitive(cids), 1, 1, false, false); } public void resortConf(JSONObject conf) { List<Long> dids = mCol.getDecks().didsForConf(conf); try { for (long did : dids) { if (conf.getJSONObject("new").getLong("order") == 0) { randomizeCards(did); } else { orderCards(did); } } } catch (JSONException e) { throw new RuntimeException(e); } } /** * for post-import */ public void maybeRandomizeDeck() { maybeRandomizeDeck(null); } public void maybeRandomizeDeck(Long did) { if (did == null) { did = mCol.getDecks().selected(); } JSONObject conf = mCol.getDecks().confForDid(did); // in order due? try { if (conf.getJSONObject("new").getInt("order") == Consts.NEW_CARDS_RANDOM) { randomizeCards(did); } } catch (JSONException e) { throw new RuntimeException(e); } } /* * *********************************************************** * The methods below are not in LibAnki. * *********************************************************** */ public boolean haveBuried(long did) { long odid = mCol.getDecks().selected(); mCol.getDecks().select(did); boolean buried = haveBuried(); mCol.getDecks().select(odid); return buried; } public void unburyCardsForDeck(long did) { long odid = mCol.getDecks().selected(); mCol.getDecks().select(did); unburyCardsForDeck(); mCol.getDecks().select(odid); } public String getName() { return mName; } public int getToday() { return mToday; } public void setToday(int today) { mToday = today; } public long getDayCutoff() { return mDayCutoff; } public int getReps(){ return mReps; } public void setReps(int reps){ mReps = reps; } /** * Counts */ public int cardCount() { String dids = _deckLimit(); return mCol.getDb().queryScalar("SELECT count() FROM cards WHERE did IN " + dids); } public int eta(int[] counts) { return eta(counts, true); } /** estimates remaining time for learning (based on last seven days) */ public int eta(int[] counts, boolean reload) { double revYesRate; double revTime; double lrnYesRate; double lrnTime; if (reload || mEtaCache[0] == -1) { Cursor cur = null; try { cur = mCol .getDb() .getDatabase() .rawQuery( "SELECT avg(CASE WHEN ease > 1 THEN 1.0 ELSE 0.0 END), avg(time) FROM revlog WHERE type = 1 AND id > " + ((mCol.getSched().getDayCutoff() - (7 * 86400)) * 1000), null); if (!cur.moveToFirst()) { return -1; } revYesRate = cur.getDouble(0); revTime = cur.getDouble(1); if (!cur.isClosed()) { cur.close(); } cur = mCol .getDb() .getDatabase() .rawQuery( "SELECT avg(CASE WHEN ease = 3 THEN 1.0 ELSE 0.0 END), avg(time) FROM revlog WHERE type != 1 AND id > " + ((mCol.getSched().getDayCutoff() - (7 * 86400)) * 1000), null); if (!cur.moveToFirst()) { return -1; } lrnYesRate = cur.getDouble(0); lrnTime = cur.getDouble(1); } finally { if (cur != null && !cur.isClosed()) { cur.close(); } } mEtaCache[0] = revYesRate; mEtaCache[1] = revTime; mEtaCache[2] = lrnYesRate; mEtaCache[3] = lrnTime; } else { revYesRate = mEtaCache[0]; revTime = mEtaCache[1]; lrnYesRate = mEtaCache[2]; lrnTime = mEtaCache[3]; } // rev cards double eta = revTime * counts[2]; // lrn cards double factor = Math.min(1 / (1 - lrnYesRate), 10); double lrnAnswers = (counts[0] + counts[1] + counts[2] * (1 - revYesRate)) * factor; eta += lrnAnswers * lrnTime; return (int) (eta / 60000); } public void decrementCounts(Card card) { int type = card.getQueue(); switch (type) { case 0: mNewCount--; break; case 1: mLrnCount -= card.getLeft() / 1000; break; case 2: mRevCount--; break; case 3: mLrnCount--; break; } } /** * Sorts a card into the lrn queue LIBANKI: not in libanki */ private void _sortIntoLrn(long due, long id) { Iterator i = mLrnQueue.listIterator(); int idx = 0; while (i.hasNext()) { if (((long[]) i.next())[0] > due) { break; } else { idx++; } } mLrnQueue.add(idx, new long[] { due, id }); } public boolean leechActionSuspend(Card card) { JSONObject conf; try { conf = _cardConf(card).getJSONObject("lapse"); return conf.getInt("leechAction") == 0; } catch (JSONException e) { throw new RuntimeException(e); } } public void setContext(WeakReference<Activity> contextReference) { mContextReference = contextReference; } /** * Holds the data for a single node (row) in the deck due tree (the user-visible list * of decks and their counts). A node also contains a list of nodes that refer to the * next level of sub-decks for that particular deck (which can be an empty list). * * The names field is an array of names that build a deck name from a hierarchy (i.e., a nested * deck will have an entry for every level of nesting). While the python version interchanges * between a string and a list of strings throughout processing, we always use an array for * this field and use names[0] for those cases. */ public class DeckDueTreeNode implements Comparable { public String[] names; public long did; public int depth; public int revCount; public int lrnCount; public int newCount; public List<DeckDueTreeNode> children = new ArrayList<>(); public DeckDueTreeNode(String[] names, long did, int revCount, int lrnCount, int newCount) { this.names = names; this.did = did; this.revCount = revCount; this.lrnCount = lrnCount; this.newCount = newCount; } public DeckDueTreeNode(String name, long did, int revCount, int lrnCount, int newCount) { this(new String[]{name}, did, revCount, lrnCount, newCount); } public DeckDueTreeNode(String name, long did, int revCount, int lrnCount, int newCount, List<DeckDueTreeNode> children) { this(new String[]{name}, did, revCount, lrnCount, newCount); this.children = children; } /** * Sort on the head of the node. */ @Override public int compareTo(Object other) { DeckDueTreeNode rhs = (DeckDueTreeNode) other; // Consider each subdeck name in the ordering for (int i = 0; i < names.length && i < rhs.names.length; i++) { int cmp = names[i].compareTo(rhs.names[i]); if (cmp == 0) { continue; } return cmp; } // If we made it this far then the arrays are of different length. The longer one should // always come after since it contains all of the sections of the shorter one inside it // (i.e., the short one is an ancestor of the longer one). if (rhs.names.length > names.length) { return -1; } else { return 1; } } @Override public String toString() { return String.format(Locale.US, "%s, %d, %d, %d, %d, %d, %s", Arrays.toString(names), did, depth, revCount, lrnCount, newCount, children); } } }