873 lines
30 KiB
Python
873 lines
30 KiB
Python
# Copyright: Ankitects Pty Ltd and contributors
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
|
|
from __future__ import annotations
|
|
|
|
import random
|
|
import time
|
|
from heapq import *
|
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
|
|
|
import anki
|
|
from anki import hooks
|
|
from anki.cards import Card
|
|
from anki.consts import *
|
|
from anki.schedv2 import Scheduler as V2
|
|
from anki.utils import ids2str, intTime
|
|
|
|
# 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)
|
|
|
|
|
|
class Scheduler(V2):
|
|
name = "std"
|
|
haveCustomStudy = True
|
|
_spreadRev = True
|
|
_burySiblingsOnAnswer = True
|
|
|
|
def __init__( # pylint: disable=super-init-not-called
|
|
self, col: anki.collection.Collection
|
|
) -> None:
|
|
self.col = col.weakref()
|
|
self.queueLimit = 50
|
|
self.reportLimit = 1000
|
|
self.dynReportLimit = 99999
|
|
self.reps = 0
|
|
self.lrnCount = 0
|
|
self.revCount = 0
|
|
self.newCount = 0
|
|
self.today: Optional[int] = None
|
|
self._haveQueues = False
|
|
self._updateCutoff()
|
|
|
|
def answerCard(self, card: Card, ease: int) -> None:
|
|
self.col.log()
|
|
assert 1 <= ease <= 4
|
|
self.col.markReview(card)
|
|
if self._burySiblingsOnAnswer:
|
|
self._burySiblings(card)
|
|
card.reps += 1
|
|
# former is for logging new cards, latter also covers filt. decks
|
|
card.wasNew = card.type == CARD_TYPE_NEW # type: ignore
|
|
wasNewQ = card.queue == QUEUE_TYPE_NEW
|
|
|
|
new_delta = 0
|
|
review_delta = 0
|
|
|
|
if wasNewQ:
|
|
# came from the new queue, move to learning
|
|
card.queue = QUEUE_TYPE_LRN
|
|
# if it was a new card, it's now a learning card
|
|
if card.type == CARD_TYPE_NEW:
|
|
card.type = CARD_TYPE_LRN
|
|
# init reps to graduation
|
|
card.left = self._startingLeft(card)
|
|
# dynamic?
|
|
if card.odid and card.type == CARD_TYPE_REV:
|
|
if self._resched(card):
|
|
# reviews get their ivl boosted on first sight
|
|
card.ivl = self._dynIvlBoost(card)
|
|
card.odue = self.today + card.ivl
|
|
new_delta = +1
|
|
if card.queue in (QUEUE_TYPE_LRN, QUEUE_TYPE_DAY_LEARN_RELEARN):
|
|
self._answerLrnCard(card, ease)
|
|
elif card.queue == QUEUE_TYPE_REV:
|
|
self._answerRevCard(card, ease)
|
|
review_delta = +1
|
|
else:
|
|
raise Exception("Invalid queue '%s'" % card)
|
|
|
|
self.update_stats(
|
|
card.did,
|
|
new_delta=new_delta,
|
|
review_delta=review_delta,
|
|
milliseconds_delta=+card.timeTaken(),
|
|
)
|
|
|
|
card.mod = intTime()
|
|
card.usn = self.col.usn()
|
|
card.flush()
|
|
|
|
def counts(self, card: Optional[Card] = None) -> Tuple[int, int, int]:
|
|
counts = [self.newCount, self.lrnCount, self.revCount]
|
|
if card:
|
|
idx = self.countIdx(card)
|
|
if idx == QUEUE_TYPE_LRN:
|
|
counts[QUEUE_TYPE_LRN] += card.left // 1000
|
|
else:
|
|
counts[idx] += 1
|
|
|
|
new, lrn, rev = counts
|
|
return (new, lrn, rev)
|
|
|
|
def countIdx(self, card: Card) -> int:
|
|
if card.queue == QUEUE_TYPE_DAY_LEARN_RELEARN:
|
|
return QUEUE_TYPE_LRN
|
|
return card.queue
|
|
|
|
def answerButtons(self, card: Card) -> int:
|
|
if card.odue:
|
|
# normal review in dyn deck?
|
|
if card.odid and card.queue == QUEUE_TYPE_REV:
|
|
return 4
|
|
conf = self._lrnConf(card)
|
|
if card.type in (CARD_TYPE_NEW, CARD_TYPE_LRN) or len(conf["delays"]) > 1:
|
|
return 3
|
|
return 2
|
|
elif card.queue == QUEUE_TYPE_REV:
|
|
return 4
|
|
else:
|
|
return 3
|
|
|
|
def unburyCards(self) -> None:
|
|
"Unbury cards."
|
|
self.col.log(
|
|
self.col.db.list(
|
|
f"select id from cards where queue = {QUEUE_TYPE_SIBLING_BURIED}"
|
|
)
|
|
)
|
|
self.col.db.execute(
|
|
f"update cards set queue=type where queue = {QUEUE_TYPE_SIBLING_BURIED}"
|
|
)
|
|
|
|
def unburyCardsForDeck(self) -> None: # type: ignore[override]
|
|
sids = self._deckLimit()
|
|
self.col.log(
|
|
self.col.db.list(
|
|
f"select id from cards where queue = {QUEUE_TYPE_SIBLING_BURIED} and did in %s"
|
|
% sids
|
|
)
|
|
)
|
|
self.col.db.execute(
|
|
f"update cards set mod=?,usn=?,queue=type where queue = {QUEUE_TYPE_SIBLING_BURIED} and did in %s"
|
|
% sids,
|
|
intTime(),
|
|
self.col.usn(),
|
|
)
|
|
|
|
# Getting the next card
|
|
##########################################################################
|
|
|
|
def _getCard(self) -> Optional[Card]:
|
|
"Return the next due card id, or None."
|
|
# learning card due?
|
|
c = self._getLrnCard()
|
|
if c:
|
|
return c
|
|
# new first, or time for one?
|
|
if self._timeForNewCard():
|
|
c = self._getNewCard()
|
|
if c:
|
|
return c
|
|
# card due for review?
|
|
c = self._getRevCard()
|
|
if c:
|
|
return c
|
|
# day learning card due?
|
|
c = self._getLrnDayCard()
|
|
if c:
|
|
return c
|
|
# new cards left?
|
|
c = self._getNewCard()
|
|
if c:
|
|
return c
|
|
# collapse or finish
|
|
return self._getLrnCard(collapse=True)
|
|
|
|
# Learning queues
|
|
##########################################################################
|
|
|
|
def _resetLrnCount(self) -> None:
|
|
# sub-day
|
|
self.lrnCount = (
|
|
self.col.db.scalar(
|
|
f"""
|
|
select sum(left/1000) from (select left from cards where
|
|
did in %s and queue = {QUEUE_TYPE_LRN} and due < ? limit %d)"""
|
|
% (self._deckLimit(), self.reportLimit),
|
|
self.dayCutoff,
|
|
)
|
|
or 0
|
|
)
|
|
# day
|
|
self.lrnCount += self.col.db.scalar(
|
|
f"""
|
|
select count() from cards where did in %s and queue = {QUEUE_TYPE_DAY_LEARN_RELEARN}
|
|
and due <= ? limit %d"""
|
|
% (self._deckLimit(), self.reportLimit),
|
|
self.today,
|
|
)
|
|
|
|
def _resetLrn(self) -> None:
|
|
self._resetLrnCount()
|
|
self._lrnQueue: List[Any] = []
|
|
self._lrnDayQueue: List[Any] = []
|
|
self._lrnDids = self.col.decks.active()[:]
|
|
|
|
# sub-day learning
|
|
def _fillLrn(self) -> Union[bool, List[Any]]:
|
|
if not self.lrnCount:
|
|
return False
|
|
if self._lrnQueue:
|
|
return True
|
|
self._lrnQueue = self.col.db.all(
|
|
f"""
|
|
select due, id from cards where
|
|
did in %s and queue = {QUEUE_TYPE_LRN} and due < ?
|
|
limit %d"""
|
|
% (self._deckLimit(), self.reportLimit),
|
|
self.dayCutoff,
|
|
)
|
|
for i in range(len(self._lrnQueue)):
|
|
self._lrnQueue[i] = (self._lrnQueue[i][0], self._lrnQueue[i][1])
|
|
# as it arrives sorted by did first, we need to sort it
|
|
self._lrnQueue.sort()
|
|
return self._lrnQueue
|
|
|
|
def _getLrnCard(self, collapse: bool = False) -> Optional[Card]:
|
|
if self._fillLrn():
|
|
cutoff = time.time()
|
|
if collapse:
|
|
cutoff += self.col.conf["collapseTime"]
|
|
if self._lrnQueue[0][0] < cutoff:
|
|
id = heappop(self._lrnQueue)[1]
|
|
card = self.col.getCard(id)
|
|
self.lrnCount -= card.left // 1000
|
|
return card
|
|
return None
|
|
|
|
def _answerLrnCard(self, card: Card, ease: int) -> None:
|
|
# ease 1=no, 2=yes, 3=remove
|
|
conf = self._lrnConf(card)
|
|
if card.odid and not card.wasNew: # type: ignore
|
|
type = REVLOG_CRAM
|
|
elif card.type == CARD_TYPE_REV:
|
|
type = REVLOG_RELRN
|
|
else:
|
|
type = REVLOG_LRN
|
|
leaving = False
|
|
# lrnCount was decremented once when card was fetched
|
|
lastLeft = card.left
|
|
# immediate graduate?
|
|
if ease == BUTTON_THREE:
|
|
self._rescheduleAsRev(card, conf, True)
|
|
leaving = True
|
|
# graduation time?
|
|
elif ease == BUTTON_TWO and (card.left % 1000) - 1 <= 0:
|
|
self._rescheduleAsRev(card, conf, False)
|
|
leaving = True
|
|
else:
|
|
# one step towards graduation
|
|
if ease == BUTTON_TWO:
|
|
# decrement real left count and recalculate left today
|
|
left = (card.left % 1000) - 1
|
|
card.left = self._leftToday(conf["delays"], left) * 1000 + left
|
|
# failed
|
|
else:
|
|
card.left = self._startingLeft(card)
|
|
resched = self._resched(card)
|
|
if "mult" in conf and resched:
|
|
# review that's lapsed
|
|
card.ivl = max(1, conf["minInt"], int(card.ivl * conf["mult"]))
|
|
else:
|
|
# new card; no ivl adjustment
|
|
pass
|
|
if resched and card.odid:
|
|
card.odue = self.today + 1
|
|
delay = self._delayForGrade(conf, card.left)
|
|
if card.due < time.time():
|
|
# not collapsed; add some randomness
|
|
delay *= int(random.uniform(1, 1.25))
|
|
card.due = int(time.time() + delay)
|
|
# due today?
|
|
if card.due < self.dayCutoff:
|
|
self.lrnCount += card.left // 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.queue = QUEUE_TYPE_LRN
|
|
if self._lrnQueue and not self.revCount and not self.newCount:
|
|
smallestDue = self._lrnQueue[0][0]
|
|
card.due = max(card.due, smallestDue + 1)
|
|
heappush(self._lrnQueue, (card.due, card.id))
|
|
else:
|
|
# the card is due in one or more days, so we need to use the
|
|
# day learn queue
|
|
ahead = ((card.due - self.dayCutoff) // 86400) + 1
|
|
card.due = self.today + ahead
|
|
card.queue = QUEUE_TYPE_DAY_LEARN_RELEARN
|
|
self._logLrn(card, ease, conf, leaving, type, lastLeft)
|
|
|
|
def _lrnConf(self, card: Card) -> Dict[str, Any]:
|
|
if card.type == CARD_TYPE_REV:
|
|
return self._lapseConf(card)
|
|
else:
|
|
return self._newConf(card)
|
|
|
|
def _rescheduleAsRev(self, card: Card, conf: Dict[str, Any], early: bool) -> None:
|
|
lapse = card.type == CARD_TYPE_REV
|
|
if lapse:
|
|
if self._resched(card):
|
|
card.due = max(self.today + 1, card.odue)
|
|
else:
|
|
card.due = card.odue
|
|
card.odue = 0
|
|
else:
|
|
self._rescheduleNew(card, conf, early)
|
|
card.queue = QUEUE_TYPE_REV
|
|
card.type = CARD_TYPE_REV
|
|
# if we were dynamic, graduating means moving back to the old deck
|
|
resched = self._resched(card)
|
|
if card.odid:
|
|
card.did = card.odid
|
|
card.odue = 0
|
|
card.odid = 0
|
|
# if rescheduling is off, it needs to be set back to a new card
|
|
if not resched and not lapse:
|
|
card.queue = card.type = CARD_TYPE_NEW
|
|
card.due = self.col.nextID("pos")
|
|
|
|
def _startingLeft(self, card: Card) -> int:
|
|
if card.type == CARD_TYPE_REV:
|
|
conf = self._lapseConf(card)
|
|
else:
|
|
conf = self._lrnConf(card)
|
|
tot = len(conf["delays"])
|
|
tod = self._leftToday(conf["delays"], tot)
|
|
return tot + tod * 1000
|
|
|
|
def _graduatingIvl(
|
|
self, card: Card, conf: Dict[str, Any], early: bool, adj: bool = True
|
|
) -> int:
|
|
if card.type == CARD_TYPE_REV:
|
|
# lapsed card being relearnt
|
|
if card.odid:
|
|
if conf["resched"]:
|
|
return self._dynIvlBoost(card)
|
|
return card.ivl
|
|
if not early:
|
|
# graduate
|
|
ideal = conf["ints"][0]
|
|
else:
|
|
# early remove
|
|
ideal = conf["ints"][1]
|
|
if adj:
|
|
return self._adjRevIvl(card, ideal)
|
|
else:
|
|
return ideal
|
|
|
|
def _rescheduleNew(self, card: Card, conf: Dict[str, Any], early: bool) -> None:
|
|
"Reschedule a new card that's graduated for the first time."
|
|
card.ivl = self._graduatingIvl(card, conf, early)
|
|
card.due = self.today + card.ivl
|
|
card.factor = conf["initialFactor"]
|
|
|
|
def _logLrn(
|
|
self,
|
|
card: Card,
|
|
ease: int,
|
|
conf: Dict[str, Any],
|
|
leaving: bool,
|
|
type: int,
|
|
lastLeft: int,
|
|
) -> None:
|
|
lastIvl = -(self._delayForGrade(conf, lastLeft))
|
|
ivl = card.ivl if leaving else -(self._delayForGrade(conf, card.left))
|
|
|
|
def log():
|
|
self.col.db.execute(
|
|
"insert into revlog values (?,?,?,?,?,?,?,?,?)",
|
|
int(time.time() * 1000),
|
|
card.id,
|
|
self.col.usn(),
|
|
ease,
|
|
ivl,
|
|
lastIvl,
|
|
card.factor,
|
|
card.timeTaken(),
|
|
type,
|
|
)
|
|
|
|
try:
|
|
log()
|
|
except:
|
|
# duplicate pk; retry in 10ms
|
|
time.sleep(0.01)
|
|
log()
|
|
|
|
def removeLrn(self, ids: Optional[List[int]] = None) -> None:
|
|
"Remove cards from the learning queues."
|
|
if ids:
|
|
extra = " and id in " + 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 " + ids2str(
|
|
d.id for d in self.col.decks.all_names_and_ids()
|
|
)
|
|
# review cards in relearning
|
|
self.col.db.execute(
|
|
f"""
|
|
update cards set
|
|
due = odue, queue = {QUEUE_TYPE_REV}, mod = %d, usn = %d, odue = 0
|
|
where queue in ({QUEUE_TYPE_LRN},{QUEUE_TYPE_DAY_LEARN_RELEARN}) and type = {CARD_TYPE_REV}
|
|
%s
|
|
"""
|
|
% (intTime(), self.col.usn(), extra)
|
|
)
|
|
# new cards in learning
|
|
self.forgetCards(
|
|
self.col.db.list(
|
|
f"select id from cards where queue in ({QUEUE_TYPE_LRN},{QUEUE_TYPE_DAY_LEARN_RELEARN}) %s"
|
|
% extra
|
|
)
|
|
)
|
|
|
|
def _lrnForDeck(self, did: int) -> int:
|
|
cnt = (
|
|
self.col.db.scalar(
|
|
f"""
|
|
select sum(left/1000) from
|
|
(select left from cards where did = ? and queue = {QUEUE_TYPE_LRN} and due < ? limit ?)""",
|
|
did,
|
|
intTime() + self.col.conf["collapseTime"],
|
|
self.reportLimit,
|
|
)
|
|
or 0
|
|
)
|
|
return cnt + self.col.db.scalar(
|
|
f"""
|
|
select count() from
|
|
(select 1 from cards where did = ? and queue = {QUEUE_TYPE_DAY_LEARN_RELEARN}
|
|
and due <= ? limit ?)""",
|
|
did,
|
|
self.today,
|
|
self.reportLimit,
|
|
)
|
|
|
|
# Reviews
|
|
##########################################################################
|
|
|
|
def _deckRevLimit(self, did: int) -> int:
|
|
return self._deckNewLimit(did, self._deckRevLimitSingle)
|
|
|
|
def _deckRevLimitSingle(self, d: Dict[str, Any]) -> int: # type: ignore[override]
|
|
if d["dyn"]:
|
|
return self.reportLimit
|
|
c = self.col.decks.confForDid(d["id"])
|
|
limit = max(0, c["rev"]["perDay"] - self.counts_for_deck_today(d["id"]).review)
|
|
return hooks.scheduler_review_limit_for_single_deck(limit, d)
|
|
|
|
def _revForDeck(self, did: int, lim: int) -> int: # type: ignore[override]
|
|
lim = min(lim, self.reportLimit)
|
|
return self.col.db.scalar(
|
|
f"""
|
|
select count() from
|
|
(select 1 from cards where did = ? and queue = {QUEUE_TYPE_REV}
|
|
and due <= ? limit ?)""",
|
|
did,
|
|
self.today,
|
|
lim,
|
|
)
|
|
|
|
def _resetRev(self) -> None:
|
|
self._revQueue: List[Any] = []
|
|
self._revDids = self.col.decks.active()[:]
|
|
|
|
def _fillRev(self, recursing=False) -> bool:
|
|
"True if a review card can be fetched."
|
|
if self._revQueue:
|
|
return True
|
|
if not self.revCount:
|
|
return False
|
|
while self._revDids:
|
|
did = self._revDids[0]
|
|
lim = min(self.queueLimit, self._deckRevLimit(did))
|
|
if lim:
|
|
# fill the queue with the current did
|
|
self._revQueue = self.col.db.list(
|
|
f"""
|
|
select id from cards where
|
|
did = ? and queue = {QUEUE_TYPE_REV} and due <= ? limit ?""",
|
|
did,
|
|
self.today,
|
|
lim,
|
|
)
|
|
if self._revQueue:
|
|
# ordering
|
|
if self.col.decks.get(did)["dyn"]:
|
|
# dynamic decks need due order preserved
|
|
self._revQueue.reverse()
|
|
else:
|
|
# random order for regular reviews
|
|
r = random.Random()
|
|
r.seed(self.today)
|
|
r.shuffle(self._revQueue)
|
|
# is the current did empty?
|
|
if len(self._revQueue) < lim:
|
|
self._revDids.pop(0)
|
|
return True
|
|
# nothing left in the deck; move to next
|
|
self._revDids.pop(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
|
|
if recursing:
|
|
print("bug: fillRev()")
|
|
return False
|
|
self._resetRev()
|
|
return self._fillRev(recursing=True)
|
|
|
|
# Answering a review card
|
|
##########################################################################
|
|
|
|
def _answerRevCard(self, card: Card, ease: int) -> None:
|
|
delay: int = 0
|
|
if ease == BUTTON_ONE:
|
|
delay = self._rescheduleLapse(card)
|
|
else:
|
|
self._rescheduleRev(card, ease)
|
|
self._logRev(card, ease, delay, REVLOG_REV)
|
|
|
|
def _rescheduleLapse(self, card: Card) -> int:
|
|
conf = self._lapseConf(card)
|
|
card.lastIvl = card.ivl
|
|
if self._resched(card):
|
|
card.lapses += 1
|
|
card.ivl = self._nextLapseIvl(card, conf)
|
|
card.factor = max(1300, card.factor - 200)
|
|
card.due = self.today + card.ivl
|
|
# if it's a filtered deck, update odue as well
|
|
if card.odid:
|
|
card.odue = card.due
|
|
# if suspended as a leech, nothing to do
|
|
delay: int = 0
|
|
if self._checkLeech(card, conf) and card.queue == QUEUE_TYPE_SUSPENDED:
|
|
return delay
|
|
# if no relearning steps, nothing to do
|
|
if not conf["delays"]:
|
|
return delay
|
|
# record rev due date for later
|
|
if not card.odue:
|
|
card.odue = card.due
|
|
delay = self._delayForGrade(conf, 0)
|
|
card.due = int(delay + time.time())
|
|
card.left = self._startingLeft(card)
|
|
# queue 1
|
|
if card.due < self.dayCutoff:
|
|
self.lrnCount += card.left // 1000
|
|
card.queue = QUEUE_TYPE_LRN
|
|
heappush(self._lrnQueue, (card.due, card.id))
|
|
else:
|
|
# day learn queue
|
|
ahead = ((card.due - self.dayCutoff) // 86400) + 1
|
|
card.due = self.today + ahead
|
|
card.queue = QUEUE_TYPE_DAY_LEARN_RELEARN
|
|
return delay
|
|
|
|
def _nextLapseIvl(self, card: Card, conf: Dict[str, Any]) -> int:
|
|
return max(conf["minInt"], int(card.ivl * conf["mult"]))
|
|
|
|
def _rescheduleRev(self, card: Card, ease: int) -> None: # type: ignore[override]
|
|
# update interval
|
|
card.lastIvl = card.ivl
|
|
if self._resched(card):
|
|
self._updateRevIvl(card, ease)
|
|
# then the rest
|
|
card.factor = max(1300, card.factor + [-150, 0, 150][ease - 2])
|
|
card.due = self.today + card.ivl
|
|
else:
|
|
card.due = card.odue
|
|
if card.odid:
|
|
card.did = card.odid
|
|
card.odid = 0
|
|
card.odue = 0
|
|
|
|
# Interval management
|
|
##########################################################################
|
|
|
|
def _nextRevIvl(self, card: Card, ease: int) -> int: # type: ignore[override]
|
|
"Ideal next interval for CARD, given EASE."
|
|
delay = self._daysLate(card)
|
|
conf = self._revConf(card)
|
|
fct = card.factor / 1000
|
|
ivl2 = self._constrainedIvl((card.ivl + delay // 4) * 1.2, conf, card.ivl)
|
|
ivl3 = self._constrainedIvl((card.ivl + delay // 2) * fct, conf, ivl2)
|
|
ivl4 = self._constrainedIvl(
|
|
(card.ivl + delay) * fct * conf["ease4"], conf, ivl3
|
|
)
|
|
if ease == BUTTON_TWO:
|
|
interval = ivl2
|
|
elif ease == BUTTON_THREE:
|
|
interval = ivl3
|
|
elif ease == BUTTON_FOUR:
|
|
interval = ivl4
|
|
# interval capped?
|
|
return min(interval, conf["maxIvl"])
|
|
|
|
def _constrainedIvl(self, ivl: float, conf: Dict[str, Any], prev: int) -> int: # type: ignore[override]
|
|
"Integer interval after interval factor and prev+1 constraints applied."
|
|
new = ivl * conf.get("ivlFct", 1)
|
|
return int(max(new, prev + 1))
|
|
|
|
def _updateRevIvl(self, card: Card, ease: int) -> None:
|
|
idealIvl = self._nextRevIvl(card, ease)
|
|
card.ivl = min(
|
|
max(self._adjRevIvl(card, idealIvl), card.ivl + 1),
|
|
self._revConf(card)["maxIvl"],
|
|
)
|
|
|
|
def _adjRevIvl(self, card: Card, idealIvl: int) -> int:
|
|
if self._spreadRev:
|
|
idealIvl = self._fuzzedIvl(idealIvl)
|
|
return idealIvl
|
|
|
|
# Dynamic deck handling
|
|
##########################################################################
|
|
|
|
def rebuildDyn(self, did: Optional[int] = None) -> Optional[Sequence[int]]: # type: ignore[override]
|
|
"Rebuild a dynamic deck."
|
|
did = did or self.col.decks.selected()
|
|
deck = self.col.decks.get(did)
|
|
assert deck["dyn"]
|
|
# move any existing cards back first, then fill
|
|
self.emptyDyn(did)
|
|
ids = self._fillDyn(deck)
|
|
if not ids:
|
|
return None
|
|
# and change to our new deck
|
|
self.col.decks.select(did)
|
|
return ids
|
|
|
|
def _fillDyn(self, deck: Dict[str, Any]) -> Sequence[int]: # type: ignore[override]
|
|
search, limit, order = deck["terms"][0]
|
|
orderlimit = self._dynOrder(order, limit)
|
|
if search.strip():
|
|
search = "(%s)" % search
|
|
search = "%s -is:suspended -is:buried -deck:filtered -is:learn" % search
|
|
try:
|
|
ids = self.col.findCards(search, order=orderlimit)
|
|
except:
|
|
ids = []
|
|
return ids
|
|
# move the cards over
|
|
self.col.log(deck["id"], ids)
|
|
self._moveToDyn(deck["id"], ids)
|
|
return ids
|
|
|
|
def emptyDyn(self, did: Optional[int], lim: Optional[str] = None) -> None:
|
|
if not lim:
|
|
lim = "did = %s" % did
|
|
self.col.log(self.col.db.list("select id from cards where %s" % lim))
|
|
# move out of cram queue
|
|
self.col.db.execute(
|
|
f"""
|
|
update cards set did = odid, queue = (case when type = {CARD_TYPE_LRN} then {QUEUE_TYPE_NEW}
|
|
else type end), type = (case when type = {CARD_TYPE_LRN} then {CARD_TYPE_NEW} else type end),
|
|
due = odue, odue = 0, odid = 0, usn = ? where %s"""
|
|
% lim,
|
|
self.col.usn(),
|
|
)
|
|
|
|
def _moveToDyn(self, did: int, ids: Sequence[int]) -> None: # type: ignore[override]
|
|
deck = self.col.decks.get(did)
|
|
data = []
|
|
t = intTime()
|
|
u = self.col.usn()
|
|
for c, id in enumerate(ids):
|
|
# start at -100000 so that reviews are all due
|
|
data.append((did, -100000 + c, u, id))
|
|
# due reviews stay in the review queue. careful: can't use
|
|
# "odid or did", as sqlite converts to boolean
|
|
queue = f"""
|
|
(case when type={CARD_TYPE_REV} and (case when odue then odue <= %d else due <= %d end)
|
|
then {QUEUE_TYPE_REV} else {QUEUE_TYPE_NEW} end)"""
|
|
queue %= (self.today, self.today)
|
|
self.col.db.executemany(
|
|
"""
|
|
update cards set
|
|
odid = (case when odid then odid else did end),
|
|
odue = (case when odue then odue else due end),
|
|
did = ?, queue = %s, due = ?, usn = ? where id = ?"""
|
|
% queue,
|
|
data,
|
|
)
|
|
|
|
def _dynIvlBoost(self, card: Card) -> int:
|
|
assert card.odid and card.type == CARD_TYPE_REV
|
|
assert card.factor
|
|
elapsed = card.ivl - (card.odue - self.today)
|
|
factor = ((card.factor / 1000) + 1.2) / 2
|
|
ivl = int(max(card.ivl, elapsed * factor, 1))
|
|
conf = self._revConf(card)
|
|
return min(conf["maxIvl"], ivl)
|
|
|
|
# Leeches
|
|
##########################################################################
|
|
|
|
def _checkLeech(self, card: Card, conf: Dict[str, Any]) -> bool:
|
|
"Leech handler. True if card was a leech."
|
|
lf = conf["leechFails"]
|
|
if not lf:
|
|
return False
|
|
# if over threshold or every half threshold reps after that
|
|
if card.lapses >= lf and (card.lapses - lf) % (max(lf // 2, 1)) == 0:
|
|
# add a leech tag
|
|
f = card.note()
|
|
f.addTag("leech")
|
|
f.flush()
|
|
# handle
|
|
a = conf["leechAction"]
|
|
if a == LEECH_SUSPEND:
|
|
# if it has an old due, remove it from cram/relearning
|
|
if card.odue:
|
|
card.due = card.odue
|
|
if card.odid:
|
|
card.did = card.odid
|
|
card.odue = card.odid = 0
|
|
card.queue = QUEUE_TYPE_SUSPENDED
|
|
# notify UI
|
|
hooks.card_did_leech(card)
|
|
return True
|
|
else:
|
|
return False
|
|
|
|
# Tools
|
|
##########################################################################
|
|
|
|
def _newConf(self, card: Card) -> Dict[str, Any]:
|
|
conf = self._cardConf(card)
|
|
# normal deck
|
|
if not card.odid:
|
|
return conf["new"]
|
|
# dynamic deck; override some attributes, use original deck for others
|
|
oconf = self.col.decks.confForDid(card.odid)
|
|
delays = conf["delays"] or oconf["new"]["delays"]
|
|
return dict(
|
|
# original deck
|
|
ints=oconf["new"]["ints"],
|
|
initialFactor=oconf["new"]["initialFactor"],
|
|
bury=oconf["new"].get("bury", True),
|
|
# overrides
|
|
delays=delays,
|
|
order=NEW_CARDS_DUE,
|
|
perDay=self.reportLimit,
|
|
)
|
|
|
|
def _lapseConf(self, card: Card) -> Dict[str, Any]:
|
|
conf = self._cardConf(card)
|
|
# normal deck
|
|
if not card.odid:
|
|
return conf["lapse"]
|
|
# dynamic deck; override some attributes, use original deck for others
|
|
oconf = self.col.decks.confForDid(card.odid)
|
|
delays = conf["delays"] or oconf["lapse"]["delays"]
|
|
return dict(
|
|
# original deck
|
|
minInt=oconf["lapse"]["minInt"],
|
|
leechFails=oconf["lapse"]["leechFails"],
|
|
leechAction=oconf["lapse"]["leechAction"],
|
|
mult=oconf["lapse"]["mult"],
|
|
# overrides
|
|
delays=delays,
|
|
resched=conf["resched"],
|
|
)
|
|
|
|
def _resched(self, card: Card) -> bool:
|
|
conf = self._cardConf(card)
|
|
if not conf["dyn"]:
|
|
return True
|
|
return conf["resched"]
|
|
|
|
# Deck finished state
|
|
##########################################################################
|
|
|
|
def haveBuried(self) -> bool:
|
|
sdids = self._deckLimit()
|
|
cnt = self.col.db.scalar(
|
|
f"select 1 from cards where queue = {QUEUE_TYPE_SIBLING_BURIED} and did in %s limit 1"
|
|
% sdids
|
|
)
|
|
return not not cnt
|
|
|
|
# Next time reports
|
|
##########################################################################
|
|
|
|
def nextIvl(self, card: Card, ease: int) -> float:
|
|
"Return the next interval for CARD, in seconds."
|
|
if card.queue in (QUEUE_TYPE_NEW, QUEUE_TYPE_LRN, QUEUE_TYPE_DAY_LEARN_RELEARN):
|
|
return self._nextLrnIvl(card, ease)
|
|
elif ease == BUTTON_ONE:
|
|
# lapsed
|
|
conf = self._lapseConf(card)
|
|
if conf["delays"]:
|
|
return conf["delays"][0] * 60
|
|
return self._nextLapseIvl(card, conf) * 86400
|
|
else:
|
|
# review
|
|
return self._nextRevIvl(card, ease) * 86400
|
|
|
|
# this isn't easily extracted from the learn code
|
|
def _nextLrnIvl(self, card: Card, ease: int) -> float:
|
|
if card.queue == 0:
|
|
card.left = self._startingLeft(card)
|
|
conf = self._lrnConf(card)
|
|
if ease == BUTTON_ONE:
|
|
# fail
|
|
return self._delayForGrade(conf, len(conf["delays"]))
|
|
elif ease == BUTTON_THREE:
|
|
# early removal
|
|
if not self._resched(card):
|
|
return 0
|
|
return self._graduatingIvl(card, conf, True, adj=False) * 86400
|
|
else:
|
|
left = card.left % 1000 - 1
|
|
if left <= 0:
|
|
# graduate
|
|
if not self._resched(card):
|
|
return 0
|
|
return self._graduatingIvl(card, conf, False, adj=False) * 86400
|
|
else:
|
|
return self._delayForGrade(conf, left)
|
|
|
|
# Suspending
|
|
##########################################################################
|
|
|
|
def suspendCards(self, ids: List[int]) -> None:
|
|
"Suspend cards."
|
|
self.col.log(ids)
|
|
self.remFromDyn(ids)
|
|
self.removeLrn(ids)
|
|
self.col.db.execute(
|
|
f"update cards set queue={QUEUE_TYPE_SUSPENDED},mod=?,usn=? where id in "
|
|
+ ids2str(ids),
|
|
intTime(),
|
|
self.col.usn(),
|
|
)
|
|
|
|
def unsuspendCards(self, ids: List[int]) -> None:
|
|
"Unsuspend cards."
|
|
self.col.log(ids)
|
|
self.col.db.execute(
|
|
"update cards set queue=type,mod=?,usn=? "
|
|
f"where queue = {QUEUE_TYPE_SUSPENDED} and id in " + ids2str(ids),
|
|
intTime(),
|
|
self.col.usn(),
|
|
)
|
|
|
|
def buryCards(self, cids: List[int], manual: bool = False) -> None:
|
|
# v1 only supported automatic burying
|
|
assert not manual
|
|
self.col.log(cids)
|
|
self.remFromDyn(cids)
|
|
self.removeLrn(cids)
|
|
self.col.db.execute(
|
|
f"""
|
|
update cards set queue={QUEUE_TYPE_SIBLING_BURIED},mod=?,usn=? where id in """
|
|
+ ids2str(cids),
|
|
intTime(),
|
|
self.col.usn(),
|
|
)
|