Created
July 6, 2018 21:58
-
-
Save met/e10c058549475dbfcb063a0bf3d9ec49 to your computer and use it in GitHub Desktop.
Spaced Repetition Implementation from AnkiDroid project https://github.com/ankidroid/Anki-Android/blob/master/AnkiDroid/src/main/java/com/ichi2/libanki/Sched.java (look for "Answering a review card" and "Interval management")
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/**************************************************************************************** | |
* 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() { | |
unburyCardsForDeck(mCol.getDecks().active()); | |
} | |
private void unburyCardsForDeck(List<Long> allDecks) { | |
// Refactored to allow unburying an arbitrary deck | |
String sids = Utils.ids2str(allDecks); | |
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() { | |
return haveBuried(mCol.getDecks().active()); | |
} | |
private boolean haveBuried(List<Long> allDecks) { | |
// Refactored to allow querying an arbitrary deck | |
String sdids = Utils.ids2str(allDecks); | |
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) { | |
long 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 long 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 (long) (conf.getJSONArray("delays").getDouble(0) * 60.0); | |
} | |
return _nextLapseIvl(card, conf) * 86400L; | |
} else { | |
// review | |
return _nextRevIvl(card, ease) * 86400L; | |
} | |
} catch (JSONException e) { | |
throw new RuntimeException(e); | |
} | |
} | |
private long _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) * 86400L; | |
} else { | |
int left = card.getLeft() % 1000 - 1; | |
if (left <= 0) { | |
// graduate | |
if (!_resched(card)) { | |
return 0; | |
} | |
return _graduatingIvl(card, conf, false, false) * 86400L; | |
} 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) { | |
List<Long> all = new ArrayList<>(mCol.getDecks().children(did).values()); | |
all.add(did); | |
return haveBuried(all); | |
} | |
public void unburyCardsForDeck(long did) { | |
List<Long> all = new ArrayList<>(mCol.getDecks().children(did).values()); | |
all.add(did); | |
unburyCardsForDeck(all); | |
} | |
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); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment