diff --git a/Cargo.lock b/Cargo.lock index a1a6adf9b..65d7858dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,6 +62,7 @@ dependencies = [ "flate2", "fluent", "fluent-syntax", + "fnv", "futures", "hex", "htmlescape", diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index a1535ad79..e6fbd759a 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -139,7 +139,7 @@ class Collection: if ver == 1: self.sched = V1Scheduler(self) elif ver == 2: - if os.getenv("TEST_SCHEDULER"): + if self.is_2021_test_scheduler_enabled(): self.sched = V2TestScheduler(self) # type: ignore else: self.sched = V2Scheduler(self) @@ -149,6 +149,14 @@ class Collection: self.clearUndo() self._loadScheduler() + def is_2021_test_scheduler_enabled(self) -> bool: + return self.get_config_bool(Config.Bool.SCHED_2021) + + def set_2021_test_scheduler_enabled(self, enabled: bool) -> None: + if self.is_2021_test_scheduler_enabled() != enabled: + self.set_config_bool(Config.Bool.SCHED_2021, enabled) + self._loadScheduler() + # DB-related ########################################################################## @@ -774,11 +782,14 @@ table.review-log {{ {revlog_style} }} c.nid, ) # and finally, update daily counts - n = c.queue - if c.queue in (QUEUE_TYPE_DAY_LEARN_RELEARN, QUEUE_TYPE_PREVIEW): - n = QUEUE_TYPE_LRN - type = ("new", "lrn", "rev")[n] - self.sched._updateStats(c, type, -1) + if self.sched.is_2021: + self._backend.requeue_undone_card(c.id) + else: + n = c.queue + if c.queue in (QUEUE_TYPE_DAY_LEARN_RELEARN, QUEUE_TYPE_PREVIEW): + n = QUEUE_TYPE_LRN + type = ("new", "lrn", "rev")[n] + self.sched._updateStats(c, type, -1) self.sched.reps -= 1 return c.id diff --git a/pylib/anki/scheduler.py b/pylib/anki/scheduler.py index bf989c005..5ec1fde1c 100644 --- a/pylib/anki/scheduler.py +++ b/pylib/anki/scheduler.py @@ -8,458 +8,113 @@ used by Anki. from __future__ import annotations -import pprint -import random -import time from heapq import * -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union +from typing import Any, List, Optional, Sequence, Tuple, Union import anki # pylint: disable=unused-import import anki._backend.backend_pb2 as _pb from anki import hooks from anki.cards import Card from anki.consts import * -from anki.decks import Deck, DeckConfig, DeckManager, DeckTreeNode, QueueConfig +from anki.decks import DeckConfig, DeckTreeNode, QueueConfig from anki.notes import Note from anki.types import assert_exhaustive from anki.utils import from_json_bytes, ids2str, intTime +QueuedCards = _pb.GetQueuedCardsOut.QueuedCards CongratsInfo = _pb.CongratsInfoOut -CountsForDeckToday = _pb.CountsForDeckTodayOut SchedTimingToday = _pb.SchedTimingTodayOut UnburyCurrentDeck = _pb.UnburyCardsInCurrentDeckIn BuryOrSuspend = _pb.BuryOrSuspendCardsIn +# fixme: reviewer.cardQueue/editCurrent/undo handling/retaining current card +# fixme: .reps + class Scheduler: - _burySiblingsOnAnswer = True + is_2021 = True def __init__(self, col: anki.collection.Collection) -> None: self.col = col.weakref() - self.queueLimit = 50 - self.reportLimit = 1000 - self.dynReportLimit = 99999 + # fixme: only used by the timeboxing code, and was double-incremented + # for ages - just move to gui? self.reps = 0 - self.today: Optional[int] = None - self._haveQueues = False - self._lrnCutoff = 0 - self._updateCutoff() - # Daily cutoff + # Timing ########################################################################## - def _updateCutoff(self) -> None: - timing = self._timing_today() - self.today = timing.days_elapsed - self.dayCutoff = timing.next_day_at - - def _checkDay(self) -> None: - # check if the day has rolled over - if time.time() > self.dayCutoff: - self.reset() - - def _timing_today(self) -> SchedTimingToday: + def timing_today(self) -> SchedTimingToday: return self.col._backend.sched_timing_today() + @property + def today(self) -> int: + return self.timing_today().days_elapsed + + @property + def dayCutoff(self) -> int: + return self.timing_today().next_day_at + # Fetching the next card ########################################################################## def reset(self) -> None: - self.col.decks.update_active() - self._updateCutoff() - self._reset_counts() - self._resetLrn() - self._resetRev() - self._resetNew() - self._haveQueues = True + self.col._backend.clear_card_queues() - def _reset_counts(self) -> None: - tree = self.deck_due_tree(self.col.decks.selected()) - node = self.col.decks.find_deck_in_tree(tree, int(self.col.conf["curDeck"])) - if not node: - # current deck points to a missing deck - self.newCount = 0 - self.revCount = 0 - self._immediate_learn_count = 0 + def get_queued_cards( + self, + *, + fetch_limit: int = 1, + intraday_learning_only: bool = False, + ) -> Union[QueuedCards, CongratsInfo]: + info = self.col._backend.get_queued_cards( + fetch_limit=fetch_limit, intraday_learning_only=intraday_learning_only + ) + kind = info.WhichOneof("value") + if kind == "queued_cards": + return info.queued_cards + elif kind == "congrats_info": + return info.congrats_info else: - self.newCount = node.new_count - self.revCount = node.review_count - self._immediate_learn_count = node.learn_count + assert_exhaustive(kind) + assert False def getCard(self) -> Optional[Card]: - """Pop the next card from the queue. None if finished.""" - self._checkDay() - if not self._haveQueues: - self.reset() - card = self._getCard() - if card: - self.col.log(card) - if not self._burySiblingsOnAnswer: - self._burySiblings(card) - self.reps += 1 + """Fetch the next card from the queue. None if finished.""" + response = self.get_queued_cards() + if isinstance(response, QueuedCards): + backend_card = response.cards[0].card + card = Card(self.col) + card._load_from_backend_card(backend_card) card.startTimer() return card - return None - - def _getCard(self) -> Optional[Card]: - """Return the next due card, 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 - - # day learning first and card due? - dayLearnFirst = self.col.conf.get("dayLearnFirst", False) - if dayLearnFirst: - c = self._getLrnDayCard() - if c: - return c - - # card due for review? - c = self._getRevCard() - if c: - return c - - # day learning card due? - if not dayLearnFirst: - 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) - - # Fetching new cards - ########################################################################## - - def _resetNew(self) -> None: - self._newDids = self.col.decks.active()[:] - self._newQueue: List[int] = [] - self._updateNewCardRatio() - - def _fillNew(self, recursing: bool = False) -> bool: - if self._newQueue: - return True - if not self.newCount: - return False - while self._newDids: - did = self._newDids[0] - lim = min(self.queueLimit, self._deckNewLimit(did)) - if lim: - # fill the queue with the current did - self._newQueue = self.col.db.list( - f""" - select id from cards where did = ? and queue = {QUEUE_TYPE_NEW} order by due,ord limit ?""", - did, - lim, - ) - if self._newQueue: - self._newQueue.reverse() - return True - # nothing left in the deck; move to next - self._newDids.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: fillNew()") - return False - self._reset_counts() - self._resetNew() - return self._fillNew(recursing=True) - - def _getNewCard(self) -> Optional[Card]: - if self._fillNew(): - self.newCount -= 1 - return self.col.getCard(self._newQueue.pop()) - return None - - def _updateNewCardRatio(self) -> None: - if self.col.conf["newSpread"] == NEW_CARDS_DISTRIBUTE: - if self.newCount: - self.newCardModulus = (self.newCount + self.revCount) // self.newCount - # if there are cards to review, ensure modulo >= 2 - if self.revCount: - self.newCardModulus = max(2, self.newCardModulus) - return - self.newCardModulus = 0 - - def _timeForNewCard(self) -> Optional[bool]: - "True if it's time to display a new card when distributing." - if not self.newCount: - return False - if self.col.conf["newSpread"] == NEW_CARDS_LAST: - return False - elif self.col.conf["newSpread"] == NEW_CARDS_FIRST: - return True - elif self.newCardModulus: - return self.reps != 0 and self.reps % self.newCardModulus == 0 else: - # shouldn't reach return None - def _deckNewLimit( - self, did: int, fn: Optional[Callable[[Deck], int]] = None - ) -> int: - if not fn: - fn = self._deckNewLimitSingle - sel = self.col.decks.get(did) - lim = -1 - # for the deck and each of its parents - for g in [sel] + self.col.decks.parents(did): - rem = fn(g) - if lim == -1: - lim = rem - else: - lim = min(rem, lim) - return lim + def _is_finished(self) -> bool: + "Don't use this, it is a stop-gap until this code is refactored." + info = self.get_queued_cards() + return isinstance(info, CongratsInfo) - def _newForDeck(self, did: int, lim: int) -> int: - "New count for a single deck." - if not lim: - return 0 - lim = min(lim, self.reportLimit) - return self.col.db.scalar( - f""" -select count() from -(select 1 from cards where did = ? and queue = {QUEUE_TYPE_NEW} limit ?)""", - did, - lim, - ) + def counts(self, card: Optional[Card] = None) -> Tuple[int, int, int]: + info = self.get_queued_cards() + if isinstance(info, CongratsInfo): + counts = [0, 0, 0] + else: + counts = [info.new_count, info.learning_count, info.review_count] - def _deckNewLimitSingle(self, g: DeckConfig) -> int: - "Limit for deck without parent limits." - if g["dyn"]: - return self.dynReportLimit - c = self.col.decks.confForDid(g["id"]) - limit = max(0, c["new"]["perDay"] - self.counts_for_deck_today(g["id"]).new) - return hooks.scheduler_new_limit_for_single_deck(limit, g) + return tuple(counts) # type: ignore - def totalNewForCurrentDeck(self) -> int: - return self.col.db.scalar( - f""" -select count() from cards where id in ( -select id from cards where did in %s and queue = {QUEUE_TYPE_NEW} limit ?)""" - % self._deckLimit(), - self.reportLimit, - ) + @property + def newCount(self) -> int: + return self.counts()[0] - # Fetching learning cards - ########################################################################## + @property + def lrnCount(self) -> int: + return self.counts()[1] - # scan for any newly due learning cards every minute - def _updateLrnCutoff(self, force: bool) -> bool: - nextCutoff = intTime() + self.col.conf["collapseTime"] - if nextCutoff - self._lrnCutoff > 60 or force: - self._lrnCutoff = nextCutoff - return True - return False - - def _maybeResetLrn(self, force: bool) -> None: - if self._updateLrnCutoff(force): - self._resetLrn() - - def _resetLrnCount(self) -> None: - # sub-day - self.lrnCount = ( - self.col.db.scalar( - f""" -select count() from cards where did in %s and queue = {QUEUE_TYPE_LRN} -and due < ?""" - % (self._deckLimit()), - self._lrnCutoff, - ) - 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 <= ?""" - % (self._deckLimit()), - self.today, - ) - # previews - self.lrnCount += self.col.db.scalar( - f""" -select count() from cards where did in %s and queue = {QUEUE_TYPE_PREVIEW} -""" - % (self._deckLimit()) - ) - - def _resetLrn(self) -> None: - self._updateLrnCutoff(force=True) - self._resetLrnCount() - self._lrnQueue: List[Tuple[int, int]] = [] - self._lrnDayQueue: List[int] = [] - 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 - cutoff = intTime() + self.col.conf["collapseTime"] - self._lrnQueue = self.col.db.all( # type: ignore - f""" -select due, id from cards where -did in %s and queue in ({QUEUE_TYPE_LRN},{QUEUE_TYPE_PREVIEW}) and due < ? -limit %d""" - % (self._deckLimit(), self.reportLimit), - cutoff, - ) - 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]: - self._maybeResetLrn(force=collapse and self.lrnCount == 0) - 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 -= 1 - return card - return None - - # daily learning - def _fillLrnDay(self) -> Optional[bool]: - if not self.lrnCount: - return False - if self._lrnDayQueue: - return True - while self._lrnDids: - did = self._lrnDids[0] - # fill the queue with the current did - self._lrnDayQueue = self.col.db.list( - f""" -select id from cards where -did = ? and queue = {QUEUE_TYPE_DAY_LEARN_RELEARN} and due <= ? limit ?""", - did, - self.today, - self.queueLimit, - ) - if self._lrnDayQueue: - # order - r = random.Random() - r.seed(self.today) - r.shuffle(self._lrnDayQueue) - # is the current did empty? - if len(self._lrnDayQueue) < self.queueLimit: - self._lrnDids.pop(0) - return True - # nothing left in the deck; move to next - self._lrnDids.pop(0) - # shouldn't reach here - return False - - def _getLrnDayCard(self) -> Optional[Card]: - if self._fillLrnDay(): - self.lrnCount -= 1 - return self.col.getCard(self._lrnDayQueue.pop()) - return None - - # Fetching reviews - ########################################################################## - - def _currentRevLimit(self) -> int: - d = self.col.decks.get(self.col.decks.selected(), default=False) - return self._deckRevLimitSingle(d) - - def _deckRevLimitSingle( - self, d: Dict[str, Any], parentLimit: Optional[int] = None - ) -> int: - # invalid deck selected? - if not d: - return 0 - - if d["dyn"]: - return self.dynReportLimit - - c = self.col.decks.confForDid(d["id"]) - lim = max(0, c["rev"]["perDay"] - self.counts_for_deck_today(d["id"]).review) - - if parentLimit is not None: - lim = min(parentLimit, lim) - elif "::" in d["name"]: - for parent in self.col.decks.parents(d["id"]): - # pass in dummy parentLimit so we don't do parent lookup again - lim = min(lim, self._deckRevLimitSingle(parent, parentLimit=lim)) - return hooks.scheduler_review_limit_for_single_deck(lim, d) - - def _revForDeck( - self, did: int, lim: int, childMap: DeckManager.childMapNode - ) -> Any: - dids = [did] + self.col.decks.childDids(did, childMap) - lim = min(lim, self.reportLimit) - return self.col.db.scalar( - f""" -select count() from -(select 1 from cards where did in %s and queue = {QUEUE_TYPE_REV} -and due <= ? limit ?)""" - % ids2str(dids), - self.today, - lim, - ) - - def _resetRev(self) -> None: - self._revQueue: List[int] = [] - - def _fillRev(self, recursing: bool = False) -> bool: - "True if a review card can be fetched." - if self._revQueue: - return True - if not self.revCount: - return False - - lim = min(self.queueLimit, self._currentRevLimit()) - if lim: - self._revQueue = self.col.db.list( - f""" -select id from cards where -did in %s and queue = {QUEUE_TYPE_REV} and due <= ? -order by due, random() -limit ?""" - % self._deckLimit(), - self.today, - lim, - ) - - if self._revQueue: - # preserve order - self._revQueue.reverse() - return True - - if recursing: - print("bug: fillRev2()") - return False - self._reset_counts() - self._resetRev() - return self._fillRev(recursing=True) - - def _getRevCard(self) -> Optional[Card]: - if self._fillRev(): - self.revCount -= 1 - return self.col.getCard(self._revQueue.pop()) - return None + @property + def reviewCount(self) -> int: + return self.counts()[2] # Answering a card ########################################################################## @@ -470,13 +125,11 @@ limit ?""" self.col.markReview(card) - if self._burySiblingsOnAnswer: - self._burySiblings(card) - new_state = self._answerCard(card, ease) - if not self._handle_leech(card, new_state): - self._maybe_requeue_card(card) + self._handle_leech(card, new_state) + + self.reps += 1 def _answerCard(self, card: Card, ease: int) -> _pb.SchedulingState: states = self.col._backend.get_next_card_states(card.id) @@ -523,45 +176,6 @@ limit ?""" else: return False - def _maybe_requeue_card(self, card: Card) -> None: - # preview cards - if card.queue == QUEUE_TYPE_PREVIEW: - # adjust the count immediately, and rely on the once a minute - # checks to requeue it - self.lrnCount += 1 - return - - # learning cards - if not card.queue == QUEUE_TYPE_LRN: - return - if card.due >= (intTime() + self.col.conf["collapseTime"]): - return - - # card is due within collapse time, so we'll want to add it - # back to the learning queue - self.lrnCount += 1 - - # 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 - 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)) - - def _cardConf(self, card: Card) -> DeckConfig: - return self.col.decks.confForDid(card.did) - - def _home_config(self, card: Card) -> DeckConfig: - return self.col.decks.confForDid(card.odid or card.did) - - def _deckLimit(self) -> str: - return ids2str(self.col.decks.active()) - - def counts_for_deck_today(self, deck_id: int) -> CountsForDeckToday: - return self.col._backend.counts_for_deck_today(deck_id) - # Next times ########################################################################## # fixme: move these into tests_schedv2 in the future @@ -618,52 +232,9 @@ limit ?""" return self._interval_for_state(new_state) - # Sibling spacing - ########################################################################## - - def _burySiblings(self, card: Card) -> None: - toBury: List[int] = [] - conf = self._home_config(card) - bury_new = conf["new"].get("bury", True) - bury_rev = conf["rev"].get("bury", True) - # loop through and remove from queues - for cid, queue in self.col.db.execute( - f""" -select id, queue from cards where nid=? and id!=? -and (queue={QUEUE_TYPE_NEW} or (queue={QUEUE_TYPE_REV} and due<=?))""", - card.nid, - card.id, - self.today, - ): - if queue == QUEUE_TYPE_REV: - queue_obj = self._revQueue - if bury_rev: - toBury.append(cid) - else: - queue_obj = self._newQueue - if bury_new: - toBury.append(cid) - - # even if burying disabled, we still discard to give same-day spacing - try: - queue_obj.remove(cid) - except ValueError: - pass - # then bury - if toBury: - self.bury_cards(toBury, manual=False) - # Review-related UI helpers ########################################################################## - def counts(self, card: Optional[Card] = None) -> Tuple[int, int, int]: - counts = [self.newCount, self.lrnCount, self.revCount] - if card: - idx = self.countIdx(card) - counts[idx] += 1 - new, lrn, rev = counts - return (new, lrn, rev) - def countIdx(self, card: Card) -> int: if card.queue in (QUEUE_TYPE_DAY_LEARN_RELEARN, QUEUE_TYPE_PREVIEW): return QUEUE_TYPE_LRN @@ -708,18 +279,14 @@ and (queue={QUEUE_TYPE_NEW} or (queue={QUEUE_TYPE_REV} and due<=?))""", did = self.col.decks.current()["id"] self.col._backend.extend_limits(deck_id=did, new_delta=new, review_delta=rev) - def _is_finished(self) -> bool: - "Don't use this, it is a stop-gap until this code is refactored." - return not any((self.newCount, self.revCount, self._immediate_learn_count)) - + # fixme: used by custom study def totalRevForCurrentDeck(self) -> int: return self.col.db.scalar( f""" select count() from cards where id in ( -select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? limit ?)""" +select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? limit 9999)""" % self._deckLimit(), self.today, - self.reportLimit, ) # Filtered deck handling @@ -832,11 +399,6 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l ########################################################################## - def __repr__(self) -> str: - d = dict(self.__dict__) - del d["col"] - return f"{super().__repr__()} {pprint.pformat(d, width=300)}" - # unit tests def _fuzzIvlRange(self, ivl: int) -> Tuple[int, int]: return (ivl, ivl) @@ -844,6 +406,11 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l # Legacy aliases and helpers ########################################################################## + # fixme: only used by totalRevForCurrentDeck and old deck stats + def _deckLimit(self) -> str: + self.col.decks.update_active() + return ids2str(self.col.decks.active()) + def reschedCards( self, card_ids: List[int], min_interval: int, max_interval: int ) -> None: @@ -943,6 +510,12 @@ due = (case when odue>0 then odue else due end), odue = 0, odid = 0, usn = ? whe ) return from_json_bytes(self.col._backend.deck_tree_legacy())[5] + def _cardConf(self, card: Card) -> DeckConfig: + return self.col.decks.confForDid(card.did) + + def _home_config(self, card: Card) -> DeckConfig: + return self.col.decks.confForDid(card.odid or card.did) + def _newConf(self, card: Card) -> QueueConfig: return self._home_config(card)["new"] diff --git a/pylib/anki/schedv2.py b/pylib/anki/schedv2.py index c152f7acc..46c740563 100644 --- a/pylib/anki/schedv2.py +++ b/pylib/anki/schedv2.py @@ -39,6 +39,7 @@ class Scheduler: haveCustomStudy = True _burySiblingsOnAnswer = True revCount: int + is_2021 = False def __init__(self, col: anki.collection.Collection) -> None: self.col = col.weakref() @@ -102,7 +103,6 @@ class Scheduler: self.col.log(card) if not self._burySiblingsOnAnswer: self._burySiblings(card) - self.reps += 1 card.startTimer() return card return None diff --git a/pylib/tests/test_sched2021.py b/pylib/tests/test_sched2021.py new file mode 120000 index 000000000..e0aedf8f1 --- /dev/null +++ b/pylib/tests/test_sched2021.py @@ -0,0 +1 @@ +test_schedv2.py \ No newline at end of file diff --git a/pylib/tests/test_schedv2.py b/pylib/tests/test_schedv2.py index 04c46ff33..bf84a8834 100644 --- a/pylib/tests/test_schedv2.py +++ b/pylib/tests/test_schedv2.py @@ -2,6 +2,9 @@ import copy import time +from typing import Tuple + +import pytest from anki import hooks from anki.consts import * @@ -10,10 +13,19 @@ from anki.schedv2 import UnburyCurrentDeck from anki.utils import intTime from tests.shared import getEmptyCol as getEmptyColOrig +# This file is used to exercise both the legacy Python 2.1 scheduler, +# and the experimental new one in Rust. Most tests run on both, but a few +# tests have been implemented separately where the behaviour differs. +is_2021 = "2021" in __file__ +new_sched_only = pytest.mark.skipif(not is_2021, reason="2021 only") +old_sched_only = pytest.mark.skipif(is_2021, reason="old only") + def getEmptyCol(): col = getEmptyColOrig() col.upgrade_to_v2_scheduler() + if is_2021: + col.set_2021_test_scheduler_enabled(True) return col @@ -183,6 +195,7 @@ def test_learn(): c.type = CARD_TYPE_NEW c.queue = QUEUE_TYPE_LRN c.flush() + col.sched.reset() col.sched.answerCard(c, 4) assert c.type == CARD_TYPE_REV assert c.queue == QUEUE_TYPE_REV @@ -274,6 +287,9 @@ def test_learn_day(): note = col.newNote() note["Front"] = "one" col.addNote(note) + note = col.newNote() + note["Front"] = "two" + col.addNote(note) col.sched.reset() c = col.sched.getCard() conf = col.sched._cardConf(c) @@ -283,11 +299,14 @@ def test_learn_day(): col.sched.answerCard(c, 3) # two reps to graduate, 1 more today assert c.left % 1000 == 3 - assert col.sched.counts() == (0, 1, 0) - c = col.sched.getCard() + assert col.sched.counts() == (1, 1, 0) + c.load() ni = col.sched.nextIvl assert ni(c, 3) == 86400 - # answering it will place it in queue 3 + # answer the other dummy card + col.sched.answerCard(col.sched.getCard(), 4) + # answering the first one will place it in queue 3 + c = col.sched.getCard() col.sched.answerCard(c, 3) assert c.due == col.sched.today + 1 assert c.queue == QUEUE_TYPE_DAY_LEARN_RELEARN @@ -296,7 +315,11 @@ def test_learn_day(): c.due -= 1 c.flush() col.reset() - assert col.sched.counts() == (0, 1, 0) + if is_2021: + # it appears in the review queue + assert col.sched.counts() == (0, 0, 1) + else: + assert col.sched.counts() == (0, 1, 0) c = col.sched.getCard() # nextIvl should work assert ni(c, 3) == 86400 * 2 @@ -408,7 +431,7 @@ def test_reviews(): assert "leech" in c.note().tags -def test_review_limits(): +def review_limits_setup() -> Tuple[anki.collection.Collection, Dict]: col = getEmptyCol() parent = col.decks.get(col.decks.id("parent")) @@ -442,6 +465,13 @@ def test_review_limits(): c.due = 0 c.flush() + return col, child + + +@old_sched_only +def test_review_limits(): + col, child = review_limits_setup() + tree = col.sched.deck_due_tree().children # (('parent', 1514457677462, 5, 0, 0, (('child', 1514457677463, 5, 0, 0, ()),))) assert tree[0].review_count == 5 # parent @@ -462,6 +492,29 @@ def test_review_limits(): assert tree[0].children[0].review_count == 9 # child +@new_sched_only +def test_review_limits_new(): + col, child = review_limits_setup() + + tree = col.sched.deck_due_tree().children + assert tree[0].review_count == 5 # parent + assert tree[0].children[0].review_count == 5 # child capped by parent + + # child .counts() are bound by parents + col.decks.select(child["id"]) + col.sched.reset() + assert col.sched.counts() == (0, 0, 5) + + # answering a card in the child should decrement both child and parent count + c = col.sched.getCard() + col.sched.answerCard(c, 3) + assert col.sched.counts() == (0, 0, 4) + + tree = col.sched.deck_due_tree().children + assert tree[0].review_count == 4 # parent + assert tree[0].children[0].review_count == 4 # child + + def test_button_spacing(): col = getEmptyCol() note = col.newNote() @@ -851,13 +904,20 @@ def test_ordcycle(): note["Back"] = "1" col.addNote(note) assert col.cardCount() == 3 + + conf = col.decks.get_config(1) + conf["new"]["bury"] = False + col.decks.save(conf) col.reset() + # ordinals should arrive in order - assert col.sched.getCard().ord == 0 - assert col.sched.getCard().ord == 1 - assert col.sched.getCard().ord == 2 + for i in range(3): + c = col.sched.getCard() + assert c.ord == i + col.sched.answerCard(c, 4) +@old_sched_only def test_counts_idx(): col = getEmptyCol() note = col.newNote() @@ -882,57 +942,87 @@ def test_counts_idx(): assert col.sched.counts() == (0, 1, 0) +@new_sched_only +def test_counts_idx_new(): + col = getEmptyCol() + note = col.newNote() + note["Front"] = "one" + note["Back"] = "two" + col.addNote(note) + note = col.newNote() + note["Front"] = "two" + note["Back"] = "two" + col.addNote(note) + col.reset() + assert col.sched.counts() == (2, 0, 0) + c = col.sched.getCard() + # getCard does not decrement counts + assert col.sched.counts() == (2, 0, 0) + assert col.sched.countIdx(c) == 0 + # answer to move to learn queue + col.sched.answerCard(c, 1) + assert col.sched.counts() == (1, 1, 0) + assert col.sched.countIdx(c) == 1 + # fetching next will not decrement the count + c = col.sched.getCard() + assert col.sched.counts() == (1, 1, 0) + assert col.sched.countIdx(c) == 0 + + def test_repCounts(): col = getEmptyCol() note = col.newNote() note["Front"] = "one" col.addNote(note) - col.reset() - # lrnReps should be accurate on pass/fail - assert col.sched.counts() == (1, 0, 0) - col.sched.answerCard(col.sched.getCard(), 1) - assert col.sched.counts() == (0, 1, 0) - col.sched.answerCard(col.sched.getCard(), 1) - assert col.sched.counts() == (0, 1, 0) - col.sched.answerCard(col.sched.getCard(), 3) - assert col.sched.counts() == (0, 1, 0) - col.sched.answerCard(col.sched.getCard(), 1) - assert col.sched.counts() == (0, 1, 0) - col.sched.answerCard(col.sched.getCard(), 3) - assert col.sched.counts() == (0, 1, 0) - col.sched.answerCard(col.sched.getCard(), 3) - assert col.sched.counts() == (0, 0, 0) note = col.newNote() note["Front"] = "two" col.addNote(note) col.reset() - # initial pass should be correct too - col.sched.answerCard(col.sched.getCard(), 3) - assert col.sched.counts() == (0, 1, 0) + # lrnReps should be accurate on pass/fail + assert col.sched.counts() == (2, 0, 0) col.sched.answerCard(col.sched.getCard(), 1) + assert col.sched.counts() == (1, 1, 0) + col.sched.answerCard(col.sched.getCard(), 1) + assert col.sched.counts() == (0, 2, 0) + col.sched.answerCard(col.sched.getCard(), 3) + assert col.sched.counts() == (0, 2, 0) + col.sched.answerCard(col.sched.getCard(), 1) + assert col.sched.counts() == (0, 2, 0) + col.sched.answerCard(col.sched.getCard(), 3) assert col.sched.counts() == (0, 1, 0) col.sched.answerCard(col.sched.getCard(), 4) assert col.sched.counts() == (0, 0, 0) - # immediate graduate should work note = col.newNote() note["Front"] = "three" col.addNote(note) + note = col.newNote() + note["Front"] = "four" + col.addNote(note) col.reset() + # initial pass and immediate graduate should be correct too + assert col.sched.counts() == (2, 0, 0) + col.sched.answerCard(col.sched.getCard(), 3) + assert col.sched.counts() == (1, 1, 0) + col.sched.answerCard(col.sched.getCard(), 4) + assert col.sched.counts() == (0, 1, 0) col.sched.answerCard(col.sched.getCard(), 4) assert col.sched.counts() == (0, 0, 0) # and failing a review should too note = col.newNote() - note["Front"] = "three" + note["Front"] = "five" col.addNote(note) c = note.cards()[0] c.type = CARD_TYPE_REV c.queue = QUEUE_TYPE_REV c.due = col.sched.today c.flush() + note = col.newNote() + note["Front"] = "six" + col.addNote(note) col.reset() - assert col.sched.counts() == (0, 0, 1) + assert col.sched.counts() == (1, 0, 1) col.sched.answerCard(col.sched.getCard(), 1) - assert col.sched.counts() == (0, 1, 0) + assert col.sched.counts() == (1, 1, 0) def test_timing(): @@ -968,12 +1058,25 @@ def test_collapse(): note = col.newNote() note["Front"] = "one" col.addNote(note) + # and another, so we don't get the same twice in a row + note = col.newNote() + note["Front"] = "two" + col.addNote(note) col.reset() - # test collapsing + # first note c = col.sched.getCard() col.sched.answerCard(c, 1) - c = col.sched.getCard() - col.sched.answerCard(c, 4) + # second note + c2 = col.sched.getCard() + assert c2.nid != c.nid + col.sched.answerCard(c2, 1) + # first should become available again, despite it being due in the future + c3 = col.sched.getCard() + assert c3.due > intTime() + col.sched.answerCard(c3, 4) + # answer other + c4 = col.sched.getCard() + col.sched.answerCard(c4, 4) assert not col.sched.getCard() @@ -1049,13 +1152,20 @@ def test_deckFlow(): note["Front"] = "three" default1 = note.model()["did"] = col.decks.id("Default::1") col.addNote(note) - # should get top level one first, then ::1, then ::2 col.reset() assert col.sched.counts() == (3, 0, 0) - for i in "one", "three", "two": - c = col.sched.getCard() - assert c.note()["Front"] == i - col.sched.answerCard(c, 3) + if is_2021: + # cards arrive in position order by default + for i in "one", "two", "three": + c = col.sched.getCard() + assert c.note()["Front"] == i + col.sched.answerCard(c, 3) + else: + # should get top level one first, then ::1, then ::2 + for i in "one", "three", "two": + c = col.sched.getCard() + assert c.note()["Front"] == i + col.sched.answerCard(c, 3) def test_reorder(): @@ -1120,13 +1230,13 @@ def test_resched(): note["Front"] = "one" col.addNote(note) c = note.cards()[0] - col.sched.reschedCards([c.id], 0, 0) + col.sched.set_due_date([c.id], "0") c.load() assert c.due == col.sched.today assert c.ivl == 1 assert c.queue == QUEUE_TYPE_REV and c.type == CARD_TYPE_REV # make it due tomorrow - col.sched.reschedCards([c.id], 1, 1) + col.sched.set_due_date([c.id], "1") c.load() assert c.due == col.sched.today + 1 assert c.ivl == 1 diff --git a/pylib/tests/test_undo.py b/pylib/tests/test_undo.py index eae507fec..fac70af27 100644 --- a/pylib/tests/test_undo.py +++ b/pylib/tests/test_undo.py @@ -50,31 +50,29 @@ def test_review(): note = col.newNote() note["Front"] = "one" col.addNote(note) + note = col.newNote() + note["Front"] = "two" + col.addNote(note) col.reset() assert not col.undoName() # answer - assert col.sched.counts() == (1, 0, 0) + assert col.sched.counts() == (2, 0, 0) c = col.sched.getCard() assert c.queue == QUEUE_TYPE_NEW col.sched.answerCard(c, 3) assert c.left % 1000 == 1 - assert col.sched.counts() == (0, 1, 0) + assert col.sched.counts() == (1, 1, 0) assert c.queue == QUEUE_TYPE_LRN # undo assert col.undoName() col.undo() col.reset() - assert col.sched.counts() == (1, 0, 0) + assert col.sched.counts() == (2, 0, 0) c.load() assert c.queue == QUEUE_TYPE_NEW assert c.left % 1000 != 1 assert not col.undoName() # we should be able to undo multiple answers too - note = col.newNote() - note["Front"] = "two" - col.addNote(note) - col.reset() - assert col.sched.counts() == (2, 0, 0) c = col.sched.getCard() col.sched.answerCard(c, 3) c = col.sched.getCard() diff --git a/rslib/BUILD.bazel b/rslib/BUILD.bazel index 52a886621..c2919a83c 100644 --- a/rslib/BUILD.bazel +++ b/rslib/BUILD.bazel @@ -84,6 +84,7 @@ rust_library( "//rslib/cargo:failure", "//rslib/cargo:flate2", "//rslib/cargo:fluent", + "//rslib/cargo:fnv", "//rslib/cargo:futures", "//rslib/cargo:hex", "//rslib/cargo:htmlescape", diff --git a/rslib/Cargo.toml b/rslib/Cargo.toml index 657cb372c..d970a316c 100644 --- a/rslib/Cargo.toml +++ b/rslib/Cargo.toml @@ -81,3 +81,4 @@ async-trait = "0.1.42" proc-macro-nested = "=0.1.6" ammonia = "3.1.0" pulldown-cmark = "0.8.0" +fnv = "1.0.7" diff --git a/rslib/backend.proto b/rslib/backend.proto index 4fe09b5fd..860b223d3 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -120,6 +120,9 @@ service BackendService { rpc StateIsLeech(SchedulingState) returns (Bool); rpc AnswerCard(AnswerCardIn) returns (Empty); rpc UpgradeScheduler(Empty) returns (Empty); + rpc GetQueuedCards(GetQueuedCardsIn) returns (GetQueuedCardsOut); + rpc ClearCardQueues(Empty) returns (Empty); + rpc RequeueUndoneCard(CardID) returns (Empty); // stats @@ -252,7 +255,17 @@ message DeckConfigInner { NEW_CARD_ORDER_DUE = 0; NEW_CARD_ORDER_RANDOM = 1; } - + enum ReviewCardOrder { + REVIEW_CARD_ORDER_SHUFFLED_BY_DAY = 0; + REVIEW_CARD_ORDER_SHUFFLED = 1; + REVIEW_CARD_ORDER_INTERVALS_ASCENDING = 2; + REVIEW_CARD_ORDER_INTERVALS_DESCENDING = 3; + } + enum ReviewMix { + REVIEW_MIX_MIX_WITH_REVIEWS = 0; + REVIEW_MIX_AFTER_REVIEWS = 1; + REVIEW_MIX_BEFORE_REVIEWS = 2; + } enum LeechAction { LEECH_ACTION_SUSPEND = 0; LEECH_ACTION_TAG_ONLY = 1; @@ -265,6 +278,7 @@ message DeckConfigInner { uint32 new_per_day = 9; uint32 reviews_per_day = 10; + uint32 new_per_day_minimum = 29; float initial_ease = 11; float easy_multiplier = 12; @@ -279,6 +293,10 @@ message DeckConfigInner { uint32 graduating_interval_easy = 19; NewCardOrder new_card_order = 20; + ReviewCardOrder review_order = 32; + + ReviewMix new_mix = 30; + ReviewMix interday_learning_mix = 31; LeechAction leech_action = 21; uint32 leech_threshold = 22; @@ -1243,6 +1261,7 @@ message Config { COLLAPSE_TODAY = 6; COLLAPSE_CARD_STATE = 7; COLLAPSE_FLAGS = 8; + SCHED_2021 = 9; } Key key = 1; } @@ -1341,3 +1360,34 @@ message AnswerCardIn { int64 answered_at_millis = 5; uint32 milliseconds_taken = 6; } + +message GetQueuedCardsIn { + uint32 fetch_limit = 1; + bool intraday_learning_only = 2; +} + +message GetQueuedCardsOut { + enum Queue { + New = 0; + Learning = 1; + Review = 2; + } + + message QueuedCard { + Card card = 1; + Queue queue = 5; + NextCardStates next_states = 6; + } + + message QueuedCards { + repeated QueuedCard cards = 1; + uint32 new_count = 2; + uint32 learning_count = 3; + uint32 review_count = 4; + } + + oneof value { + QueuedCards queued_cards = 1; + CongratsInfoOut congrats_info = 2; + } +} diff --git a/rslib/cargo/BUILD.bazel b/rslib/cargo/BUILD.bazel index 3e0b3dae5..e74595146 100644 --- a/rslib/cargo/BUILD.bazel +++ b/rslib/cargo/BUILD.bazel @@ -129,6 +129,15 @@ alias( ], ) +alias( + name = "fnv", + actual = "@raze__fnv__1_0_7//:fnv", + tags = [ + "cargo-raze", + "manual", + ], +) + alias( name = "futures", actual = "@raze__futures__0_3_12//:futures", diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 9dfece2c5..37faf752c 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -698,6 +698,25 @@ impl BackendService for Backend { .map(Into::into) } + fn get_queued_cards( + &self, + input: pb::GetQueuedCardsIn, + ) -> BackendResult { + self.with_col(|col| col.get_queued_cards(input.fetch_limit, input.intraday_learning_only)) + } + + fn clear_card_queues(&self, _input: pb::Empty) -> BackendResult { + self.with_col(|col| { + col.clear_queues(); + Ok(().into()) + }) + } + + fn requeue_undone_card(&self, input: pb::CardId) -> BackendResult { + self.with_col(|col| col.requeue_undone_card(input.into())) + .map(Into::into) + } + // statistics //----------------------------------------------- @@ -2059,8 +2078,8 @@ fn pbcard_to_native(c: pb::Card) -> Result { }) } -impl From for pb::SchedTimingTodayOut { - fn from(t: crate::scheduler::cutoff::SchedTimingToday) -> pb::SchedTimingTodayOut { +impl From for pb::SchedTimingTodayOut { + fn from(t: crate::scheduler::timing::SchedTimingToday) -> pb::SchedTimingTodayOut { pb::SchedTimingTodayOut { days_elapsed: t.days_elapsed, next_day_at: t.next_day_at, diff --git a/rslib/src/card.rs b/rslib/src/card.rs index dc8062a9a..0386404a5 100644 --- a/rslib/src/card.rs +++ b/rslib/src/card.rs @@ -118,6 +118,10 @@ impl Card { pub fn ease_factor(&self) -> f32 { (self.ease_factor as f32) / 1000.0 } + + pub fn is_intraday_learning(&self) -> bool { + matches!(self.queue, CardQueue::Learn | CardQueue::PreviewRepeat) + } } #[derive(Debug)] pub(crate) struct UpdateCardUndo(Card); diff --git a/rslib/src/collection.rs b/rslib/src/collection.rs index 7b652a7b8..13b046ce0 100644 --- a/rslib/src/collection.rs +++ b/rslib/src/collection.rs @@ -1,7 +1,6 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::err::Result; use crate::i18n::I18n; use crate::log::Logger; use crate::types::Usn; @@ -11,6 +10,7 @@ use crate::{ storage::SqliteStorage, undo::UndoManager, }; +use crate::{err::Result, scheduler::queue::CardQueues}; use std::{collections::HashMap, path::PathBuf, sync::Arc}; pub fn open_collection>( @@ -63,6 +63,7 @@ pub struct CollectionState { pub(crate) undo: UndoManager, pub(crate) notetype_cache: HashMap>, pub(crate) deck_cache: HashMap>, + pub(crate) card_queues: Option, } pub struct Collection { diff --git a/rslib/src/config.rs b/rslib/src/config.rs index 14ebf3fef..d88294b70 100644 --- a/rslib/src/config.rs +++ b/rslib/src/config.rs @@ -5,8 +5,8 @@ use crate::{ backend_proto as pb, collection::Collection, decks::DeckID, err::Result, notetype::NoteTypeID, timestamp::TimestampSecs, }; -use pb::config::bool::Key as BoolKey; -use pb::config::string::Key as StringKey; +pub use pb::config::bool::Key as BoolKey; +pub use pb::config::string::Key as StringKey; use serde::{de::DeserializeOwned, Serialize}; use serde_aux::field_attributes::deserialize_bool_from_anything; use serde_derive::Deserialize; @@ -63,6 +63,7 @@ pub(crate) enum ConfigKey { NormalizeNoteText, PreviewBothSides, Rollover, + Sched2021, SchedulerVersion, SetDueBrowser, SetDueReviewer, @@ -104,6 +105,7 @@ impl From for &'static str { ConfigKey::NormalizeNoteText => "normalize_note_text", ConfigKey::PreviewBothSides => "previewBothSides", ConfigKey::Rollover => "rollover", + ConfigKey::Sched2021 => "sched2021", ConfigKey::SchedulerVersion => "schedVer", ConfigKey::SetDueBrowser => "setDueBrowser", ConfigKey::SetDueReviewer => "setDueReviewer", @@ -126,6 +128,7 @@ impl From for ConfigKey { BoolKey::CollapseTags => ConfigKey::CollapseTags, BoolKey::CollapseToday => ConfigKey::CollapseToday, BoolKey::PreviewBothSides => ConfigKey::PreviewBothSides, + BoolKey::Sched2021 => ConfigKey::Sched2021, } } } @@ -365,7 +368,12 @@ impl Collection { #[allow(clippy::match_single_binding)] pub(crate) fn get_bool(&self, config: pb::config::Bool) -> bool { - match config.key() { + self.get_bool_key(config.key()) + } + + #[allow(clippy::match_single_binding)] + pub(crate) fn get_bool_key(&self, key: BoolKey) -> bool { + match key { // all options default to false at the moment other => self.get_config_default(ConfigKey::from(other)), } @@ -421,12 +429,19 @@ impl Default for SortKind { } } +// 2021 scheduler moves this into deck config pub(crate) enum NewReviewMix { Mix = 0, ReviewsFirst = 1, NewFirst = 2, } +impl Default for NewReviewMix { + fn default() -> Self { + NewReviewMix::Mix + } +} + #[derive(PartialEq, Serialize_repr, Deserialize_repr, Clone, Copy)] #[repr(u8)] pub(crate) enum Weekday { diff --git a/rslib/src/deckconf/mod.rs b/rslib/src/deckconf/mod.rs index f15203309..23c13abeb 100644 --- a/rslib/src/deckconf/mod.rs +++ b/rslib/src/deckconf/mod.rs @@ -11,7 +11,7 @@ use crate::{ }; pub use crate::backend_proto::{ - deck_config_inner::{LeechAction, NewCardOrder}, + deck_config_inner::{LeechAction, NewCardOrder, ReviewCardOrder, ReviewMix}, DeckConfigInner, }; pub use schema11::{DeckConfSchema11, NewCardOrderSchema11}; @@ -41,14 +41,9 @@ impl Default for DeckConf { inner: DeckConfigInner { learn_steps: vec![1.0, 10.0], relearn_steps: vec![10.0], - disable_autoplay: false, - cap_answer_time_to_secs: 60, - visible_timer_secs: 0, - skip_question_when_replaying_answer: false, new_per_day: 20, reviews_per_day: 200, - bury_new: false, - bury_reviews: false, + new_per_day_minimum: 0, initial_ease: 2.5, easy_multiplier: 1.3, hard_multiplier: 1.2, @@ -59,8 +54,17 @@ impl Default for DeckConf { graduating_interval_good: 1, graduating_interval_easy: 4, new_card_order: NewCardOrder::Due as i32, + review_order: ReviewCardOrder::ShuffledByDay as i32, + new_mix: ReviewMix::MixWithReviews as i32, + interday_learning_mix: ReviewMix::MixWithReviews as i32, leech_action: LeechAction::TagOnly as i32, leech_threshold: 8, + disable_autoplay: false, + cap_answer_time_to_secs: 60, + visible_timer_secs: 0, + skip_question_when_replaying_answer: false, + bury_new: false, + bury_reviews: false, other: vec![], }, } diff --git a/rslib/src/deckconf/schema11.rs b/rslib/src/deckconf/schema11.rs index 057dba532..1c56fcb90 100644 --- a/rslib/src/deckconf/schema11.rs +++ b/rslib/src/deckconf/schema11.rs @@ -32,6 +32,18 @@ pub struct DeckConfSchema11 { pub(crate) lapse: LapseConfSchema11, #[serde(rename = "dyn", default, deserialize_with = "default_on_invalid")] dynamic: bool, + + // 2021 scheduler options: these were not in schema 11, but we need to persist them + // so the settings are not lost on upgrade/downgrade + #[serde(default)] + new_mix: i32, + #[serde(default)] + new_per_day_minimum: u32, + #[serde(default)] + interday_learning_mix: i32, + #[serde(default)] + review_order: i32, + #[serde(flatten)] other: HashMap, } @@ -191,6 +203,10 @@ impl Default for DeckConfSchema11 { rev: Default::default(), lapse: Default::default(), other: Default::default(), + new_mix: 0, + new_per_day_minimum: 0, + interday_learning_mix: 0, + review_order: 0, } } } @@ -229,14 +245,9 @@ impl From for DeckConf { inner: DeckConfigInner { learn_steps: c.new.delays, relearn_steps: c.lapse.delays, - disable_autoplay: !c.autoplay, - cap_answer_time_to_secs: c.max_taken.max(0) as u32, - visible_timer_secs: c.timer as u32, - skip_question_when_replaying_answer: !c.replayq, new_per_day: c.new.per_day, reviews_per_day: c.rev.per_day, - bury_new: c.new.bury, - bury_reviews: c.rev.bury, + new_per_day_minimum: c.new_per_day_minimum, initial_ease: (c.new.initial_factor as f32) / 1000.0, easy_multiplier: c.rev.ease4, hard_multiplier: c.rev.hard_factor, @@ -250,8 +261,17 @@ impl From for DeckConf { NewCardOrderSchema11::Random => NewCardOrder::Random, NewCardOrderSchema11::Due => NewCardOrder::Due, } as i32, + review_order: c.review_order, + new_mix: c.new_mix, + interday_learning_mix: c.interday_learning_mix, leech_action: c.lapse.leech_action as i32, leech_threshold: c.lapse.leech_fails, + disable_autoplay: !c.autoplay, + cap_answer_time_to_secs: c.max_taken.max(0) as u32, + visible_timer_secs: c.timer as u32, + skip_question_when_replaying_answer: !c.replayq, + bury_new: c.new.bury, + bury_reviews: c.rev.bury, other: other_bytes, }, } @@ -332,6 +352,10 @@ impl From for DeckConfSchema11 { other: lapse_other, }, other: top_other, + new_mix: i.new_mix, + new_per_day_minimum: i.new_per_day_minimum, + interday_learning_mix: i.interday_learning_mix, + review_order: i.review_order, } } } diff --git a/rslib/src/decks/tree.rs b/rslib/src/decks/tree.rs index 4280dd647..0c8065b40 100644 --- a/rslib/src/decks/tree.rs +++ b/rslib/src/decks/tree.rs @@ -5,7 +5,7 @@ use super::{Deck, DeckKind, DueCounts}; use crate::{ backend_proto::DeckTreeNode, collection::Collection, - config::SchedulerVersion, + config::{BoolKey, SchedulerVersion}, deckconf::{DeckConf, DeckConfID}, decks::DeckID, err::Result, @@ -123,12 +123,11 @@ fn apply_limits( node.review_count = (node.review_count + child_rev_total).min(remaining_rev); } -/// Apply parent new limits to children, and add child counts to parents. -/// Unlike v1, reviews are not capped by their parents, and we return the -/// uncapped review amount to add to the parent. This is a bit of a hack, and -/// just tides us over until the v2 queue building code can be reworked. +/// Apply parent new limits to children, and add child counts to parents. Unlike +/// v1 and the 2021 scheduler, reviews are not capped by their parents, and we +/// return the uncapped review amount to add to the parent. /// Counts are (new, review). -fn apply_limits_v2( +fn apply_limits_v2_old( node: &mut DeckTreeNode, today: u32, decks: &HashMap, @@ -148,7 +147,7 @@ fn apply_limits_v2( let mut child_rev_total = 0; for child in &mut node.children { child_rev_total += - apply_limits_v2(child, today, decks, dconf, (remaining_new, remaining_rev)); + apply_limits_v2_old(child, today, decks, dconf, (remaining_new, remaining_rev)); child_new_total += child.new_count; // no limit on learning cards node.learn_count += child.learn_count; @@ -283,8 +282,10 @@ impl Collection { let counts = self.due_counts(days_elapsed, learn_cutoff, limit)?; let dconf = self.storage.get_deck_config_map()?; add_counts(&mut tree, &counts); - if self.scheduler_version() == SchedulerVersion::V2 { - apply_limits_v2( + if self.scheduler_version() == SchedulerVersion::V2 + && !self.get_bool_key(BoolKey::Sched2021) + { + apply_limits_v2_old( &mut tree, days_elapsed, &decks_map, diff --git a/rslib/src/preferences.rs b/rslib/src/preferences.rs index 1ec03c9e5..fcd82ffde 100644 --- a/rslib/src/preferences.rs +++ b/rslib/src/preferences.rs @@ -8,7 +8,7 @@ use crate::{ }, collection::Collection, err::Result, - scheduler::cutoff::local_minutes_west_for_stamp, + scheduler::timing::local_minutes_west_for_stamp, }; impl Collection { diff --git a/rslib/src/scheduler/answering/mod.rs b/rslib/src/scheduler/answering/mod.rs index 46c4d924e..2b0d2e1d8 100644 --- a/rslib/src/scheduler/answering/mod.rs +++ b/rslib/src/scheduler/answering/mod.rs @@ -20,11 +20,11 @@ use crate::{ use revlog::RevlogEntryPartial; use super::{ - cutoff::SchedTimingToday, states::{ steps::LearningSteps, CardState, FilteredState, NextCardStates, NormalState, StateContext, }, timespan::answer_button_time_collapsible, + timing::SchedTimingToday, }; #[derive(Copy, Clone)] @@ -239,6 +239,7 @@ impl Collection { self.add_partial_revlog(revlog_partial, usn, &answer)?; } self.update_deck_stats_from_answer(usn, &answer, &updater)?; + let timing = updater.timing; let mut card = updater.into_card(); self.update_card(&mut card, &original, usn)?; @@ -246,6 +247,8 @@ impl Collection { self.add_leech_tag(card.note_id)?; } + self.update_queues_after_answering_card(&card, timing)?; + Ok(()) } diff --git a/rslib/src/scheduler/bury_and_suspend.rs b/rslib/src/scheduler/bury_and_suspend.rs index 432754eeb..4fc93fa57 100644 --- a/rslib/src/scheduler/bury_and_suspend.rs +++ b/rslib/src/scheduler/bury_and_suspend.rs @@ -10,7 +10,7 @@ use crate::{ search::SortMode, }; -use super::cutoff::SchedTimingToday; +use super::timing::SchedTimingToday; use pb::unbury_cards_in_current_deck_in::Mode as UnburyDeckMode; impl Card { diff --git a/rslib/src/scheduler/mod.rs b/rslib/src/scheduler/mod.rs index 4a1bfc941..c5e678d43 100644 --- a/rslib/src/scheduler/mod.rs +++ b/rslib/src/scheduler/mod.rs @@ -6,20 +6,21 @@ use crate::{collection::Collection, config::SchedulerVersion, err::Result, prelu pub mod answering; pub mod bury_and_suspend; pub(crate) mod congrats; -pub mod cutoff; mod learning; pub mod new; +pub(crate) mod queue; mod reviews; pub mod states; pub mod timespan; +pub mod timing; mod upgrade; use chrono::FixedOffset; -use cutoff::{ +pub use reviews::parse_due_date_str; +use timing::{ sched_timing_today, v1_creation_date_adjusted_to_hour, v1_rollover_from_creation_stamp, SchedTimingToday, }; -pub use reviews::parse_due_date_str; impl Collection { pub fn timing_today(&self) -> Result { diff --git a/rslib/src/scheduler/queue/builder/gathering.rs b/rslib/src/scheduler/queue/builder/gathering.rs new file mode 100644 index 000000000..fdafa8b36 --- /dev/null +++ b/rslib/src/scheduler/queue/builder/gathering.rs @@ -0,0 +1,174 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::{super::limits::RemainingLimits, DueCard, NewCard, QueueBuilder}; +use crate::{card::CardQueue, prelude::*}; + +impl QueueBuilder { + /// Assumes cards will arrive sorted in (queue, due) order, so learning + /// cards come first, and reviews come before day-learning and preview cards. + pub(in super::super) fn add_due_card( + &mut self, + limit: &mut RemainingLimits, + queue: CardQueue, + card: DueCard, + ) -> bool { + let should_add = self.should_add_review_card(card.note_id); + + match queue { + CardQueue::Learn | CardQueue::PreviewRepeat => self.learning.push(card), + CardQueue::DayLearn => { + self.day_learning.push(card); + } + CardQueue::Review => { + if should_add { + self.review.push(card); + limit.review -= 1; + } + } + CardQueue::New + | CardQueue::Suspended + | CardQueue::SchedBuried + | CardQueue::UserBuried => { + unreachable!() + } + } + + limit.review != 0 + } + + pub(in super::super) fn add_new_card( + &mut self, + limit: &mut RemainingLimits, + card: NewCard, + ) -> bool { + let already_seen = self.have_seen_note_id(card.note_id); + if !already_seen { + self.new.push(card); + limit.new -= 1; + return limit.new != 0; + } + + // Cards will be arriving in (due, card_id) order, with all + // siblings sharing the same due number by default. In the + // common case, card ids will match template order, and nothing + // special is required. But if some cards have been generated + // after the initial note creation, they will have higher card + // ids, and the siblings will thus arrive in the wrong order. + // Sorting by ordinal in the DB layer is fairly costly, as it + // doesn't allow us to exit early when the daily limits have + // been met, so we want to enforce ordering as we add instead. + let previous_card_was_sibling_with_higher_ordinal = self + .new + .last() + .map(|previous| previous.note_id == card.note_id && previous.extra > card.extra) + .unwrap_or(false); + + if previous_card_was_sibling_with_higher_ordinal { + if self.bury_new { + // When burying is enabled, we replace the existing sibling + // with the lower ordinal one. + *self.new.last_mut().unwrap() = card; + } else { + // When burying disabled, we'll want to add this card as well, but + // not at the end of the list. + let target_idx = self + .new + .iter() + .enumerate() + .rev() + .filter_map(|(idx, queued_card)| { + if queued_card.note_id != card.note_id || queued_card.extra < card.extra { + Some(idx + 1) + } else { + None + } + }) + .next() + .unwrap_or(0); + self.new.insert(target_idx, card); + limit.new -= 1; + } + } else { + // card has arrived in expected order - add if burying disabled + if !self.bury_new { + self.new.push(card); + limit.new -= 1; + } + } + + limit.new != 0 + } + + fn should_add_review_card(&mut self, note_id: NoteID) -> bool { + !self.have_seen_note_id(note_id) || !self.bury_reviews + } + + /// Mark note seen, and return true if seen before. + fn have_seen_note_id(&mut self, note_id: NoteID) -> bool { + !self.seen_note_ids.insert(note_id) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn new_siblings() { + let mut builder = QueueBuilder::default(); + builder.bury_new = true; + let mut limits = RemainingLimits { + review: 0, + new: 100, + }; + + let cards = vec![ + NewCard { + id: CardID(1), + note_id: NoteID(1), + extra: 0, + ..Default::default() + }, + NewCard { + id: CardID(2), + note_id: NoteID(2), + extra: 1, + ..Default::default() + }, + NewCard { + id: CardID(3), + note_id: NoteID(2), + extra: 2, + ..Default::default() + }, + NewCard { + id: CardID(4), + note_id: NoteID(2), + extra: 0, + ..Default::default() + }, + ]; + + for card in &cards { + builder.add_new_card(&mut limits, card.clone()); + } + + assert_eq!(builder.new[0].id, CardID(1)); + assert_eq!(builder.new[1].id, CardID(4)); + assert_eq!(builder.new.len(), 2); + + // with burying disabled, we should get all siblings in order + builder.bury_new = false; + builder.new.truncate(0); + + for card in &cards { + builder.add_new_card(&mut limits, card.clone()); + } + + assert_eq!(builder.new[0].id, CardID(1)); + assert_eq!(builder.new[1].id, CardID(4)); + assert_eq!(builder.new[2].id, CardID(2)); + assert_eq!(builder.new[3].id, CardID(3)); + } +} diff --git a/rslib/src/scheduler/queue/builder/intersperser.rs b/rslib/src/scheduler/queue/builder/intersperser.rs new file mode 100644 index 000000000..4252f31ac --- /dev/null +++ b/rslib/src/scheduler/queue/builder/intersperser.rs @@ -0,0 +1,123 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/// Adapter to evenly mix two iterators of varying lengths into one. +pub(crate) struct Intersperser +where + I: Iterator + ExactSizeIterator, +{ + one: I, + two: I2, + one_idx: usize, + two_idx: usize, + one_len: usize, + two_len: usize, + ratio: f32, +} + +impl Intersperser +where + I: ExactSizeIterator, + I2: ExactSizeIterator, +{ + pub fn new(one: I, two: I2) -> Self { + let one_len = one.len(); + let two_len = two.len(); + let ratio = one_len as f32 / two_len as f32; + Intersperser { + one, + two, + one_idx: 0, + two_idx: 0, + one_len, + two_len, + ratio, + } + } + + fn one_idx(&self) -> Option { + if self.one_idx == self.one_len { + None + } else { + Some(self.one_idx) + } + } + + fn two_idx(&self) -> Option { + if self.two_idx == self.two_len { + None + } else { + Some(self.two_idx) + } + } + + fn next_one(&mut self) -> Option { + self.one_idx += 1; + self.one.next() + } + + fn next_two(&mut self) -> Option { + self.two_idx += 1; + self.two.next() + } +} + +impl Iterator for Intersperser +where + I: ExactSizeIterator, + I2: ExactSizeIterator, +{ + type Item = I::Item; + + fn next(&mut self) -> Option { + match (self.one_idx(), self.two_idx()) { + (Some(idx1), Some(idx2)) => { + let relative_idx2 = idx2 as f32 * self.ratio; + if relative_idx2 < idx1 as f32 { + self.next_two() + } else { + self.next_one() + } + } + (Some(_), None) => self.next_one(), + (None, Some(_)) => self.next_two(), + (None, None) => None, + } + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = (self.one_len + self.two_len) - (self.one_idx + self.two_idx); + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for Intersperser +where + I: ExactSizeIterator, + I2: ExactSizeIterator, +{ +} + +#[cfg(test)] +mod test { + use super::Intersperser; + + fn intersperse(a: &[u32], b: &[u32]) -> Vec { + Intersperser::new(a.iter().cloned(), b.iter().cloned()).collect() + } + + #[test] + fn interspersing() { + let a = &[1, 2, 3]; + let b = &[11, 22, 33]; + assert_eq!(&intersperse(a, b), &[1, 11, 2, 22, 3, 33]); + + let b = &[11, 22]; + assert_eq!(&intersperse(a, b), &[1, 11, 2, 22, 3]); + + // when both lists have the same relative position, we add from + // list 1 even if list 2 has more elements + let b = &[11, 22, 33, 44, 55, 66]; + assert_eq!(&intersperse(a, b), &[1, 11, 22, 2, 33, 44, 3, 55, 66]); + } +} diff --git a/rslib/src/scheduler/queue/builder/mod.rs b/rslib/src/scheduler/queue/builder/mod.rs new file mode 100644 index 000000000..5ad845215 --- /dev/null +++ b/rslib/src/scheduler/queue/builder/mod.rs @@ -0,0 +1,224 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +mod gathering; +pub(crate) mod intersperser; +pub(crate) mod sized_chain; +mod sorting; + +use std::{ + cmp::Reverse, + collections::{BinaryHeap, HashSet, VecDeque}, +}; + +use super::{ + limits::{remaining_limits_capped_to_parents, RemainingLimits}, + CardQueues, LearningQueueEntry, QueueEntry, QueueEntryKind, +}; +use crate::deckconf::{NewCardOrder, ReviewCardOrder, ReviewMix}; +use crate::prelude::*; +use {intersperser::Intersperser, sized_chain::SizedChain}; + +/// Temporary holder for review cards that will be built into a queue. +#[derive(Debug, Default, Clone)] +pub(crate) struct DueCard { + pub id: CardID, + pub note_id: NoteID, + pub mtime: TimestampSecs, + pub due: i32, + /// Used to store interval, and for shuffling + pub extra: u64, +} + +/// Temporary holder for new cards that will be built into a queue. +#[derive(Debug, Default, Clone)] +pub(crate) struct NewCard { + pub id: CardID, + pub note_id: NoteID, + pub mtime: TimestampSecs, + pub due: i32, + /// Used to store template_idx, and for shuffling + pub extra: u64, +} + +impl From for QueueEntry { + fn from(c: DueCard) -> Self { + QueueEntry { + id: c.id, + mtime: c.mtime, + kind: QueueEntryKind::Review, + } + } +} + +impl From for QueueEntry { + fn from(c: NewCard) -> Self { + QueueEntry { + id: c.id, + mtime: c.mtime, + kind: QueueEntryKind::New, + } + } +} + +impl From for LearningQueueEntry { + fn from(c: DueCard) -> Self { + LearningQueueEntry { + due: TimestampSecs(c.due as i64), + id: c.id, + mtime: c.mtime, + } + } +} + +#[derive(Default)] +pub(super) struct QueueBuilder { + pub(super) new: Vec, + pub(super) review: Vec, + pub(super) learning: Vec, + pub(super) day_learning: Vec, + pub(super) seen_note_ids: HashSet, + pub(super) new_order: NewCardOrder, + pub(super) review_order: ReviewCardOrder, + pub(super) day_learn_mix: ReviewMix, + pub(super) new_review_mix: ReviewMix, + pub(super) bury_new: bool, + pub(super) bury_reviews: bool, +} + +impl QueueBuilder { + pub(super) fn build( + mut self, + top_deck_limits: RemainingLimits, + learn_ahead_secs: u32, + selected_deck: DeckID, + current_day: u32, + ) -> CardQueues { + self.sort_new(); + self.sort_reviews(); + + // split and sort learning + let learn_ahead_secs = learn_ahead_secs as i64; + let (due_learning, later_learning) = split_learning(self.learning, learn_ahead_secs); + let learn_count = due_learning.len(); + + // merge day learning in, and cap to parent review count + let main_iter = merge_day_learning(self.review, self.day_learning, self.day_learn_mix); + let main_iter = main_iter.take(top_deck_limits.review as usize); + let review_count = main_iter.len(); + + // cap to parent new count, note down the new count, then merge new in + self.new.truncate(top_deck_limits.new as usize); + let new_count = self.new.len(); + let main_iter = merge_new(main_iter, self.new, self.new_review_mix); + + CardQueues { + new_count, + review_count, + learn_count, + main: main_iter.collect(), + due_learning, + later_learning, + learn_ahead_secs, + selected_deck, + current_day, + } + } +} + +fn merge_day_learning( + reviews: Vec, + day_learning: Vec, + mode: ReviewMix, +) -> Box> { + let day_learning_iter = day_learning.into_iter().map(Into::into); + let reviews_iter = reviews.into_iter().map(Into::into); + + match mode { + ReviewMix::AfterReviews => Box::new(SizedChain::new(reviews_iter, day_learning_iter)), + ReviewMix::BeforeReviews => Box::new(SizedChain::new(day_learning_iter, reviews_iter)), + ReviewMix::MixWithReviews => Box::new(Intersperser::new(reviews_iter, day_learning_iter)), + } +} + +fn merge_new( + review_iter: impl ExactSizeIterator + 'static, + new: Vec, + mode: ReviewMix, +) -> Box> { + let new_iter = new.into_iter().map(Into::into); + + match mode { + ReviewMix::BeforeReviews => Box::new(SizedChain::new(new_iter, review_iter)), + ReviewMix::AfterReviews => Box::new(SizedChain::new(review_iter, new_iter)), + ReviewMix::MixWithReviews => Box::new(Intersperser::new(review_iter, new_iter)), + } +} + +/// Split the learning queue into cards due within limit, and cards due later +/// today. Learning does not need to be sorted in advance, as the sorting is +/// done as the heaps/dequeues are built. +fn split_learning( + learning: Vec, + learn_ahead_secs: i64, +) -> ( + VecDeque, + BinaryHeap>, +) { + let cutoff = TimestampSecs(TimestampSecs::now().0 + learn_ahead_secs); + + // split learning into now and later + let (mut now, later): (Vec<_>, Vec<_>) = learning + .into_iter() + .map(LearningQueueEntry::from) + .partition(|c| c.due <= cutoff); + + // sort due items in ascending order, as we pop the deque from the front + now.sort_unstable_by(|a, b| a.due.cmp(&b.due)); + // partition() requires both outputs to be the same, so we need to create the deque + // separately + let now = VecDeque::from(now); + + // build the binary min heap + let later: BinaryHeap<_> = later.into_iter().map(Reverse).collect(); + + (now, later) +} + +impl Collection { + pub(crate) fn build_queues(&mut self, deck_id: DeckID) -> Result { + let now = TimestampSecs::now(); + let timing = self.timing_for_timestamp(now)?; + let (decks, parent_count) = self.storage.deck_with_parents_and_children(deck_id)?; + let config = self.storage.get_deck_config_map()?; + let limits = remaining_limits_capped_to_parents(&decks, &config); + let selected_deck_limits = limits[parent_count]; + + let mut queues = QueueBuilder::default(); + + for (deck, mut limit) in decks.iter().zip(limits).skip(parent_count) { + if limit.review > 0 { + self.storage.for_each_due_card_in_deck( + timing.days_elapsed, + timing.next_day_at, + deck.id, + |queue, card| queues.add_due_card(&mut limit, queue, card), + )?; + } + if limit.new > 0 { + self.storage.for_each_new_card_in_deck(deck.id, |card| { + queues.add_new_card(&mut limit, card) + })?; + } + } + + let queues = queues.build( + selected_deck_limits, + self.learn_ahead_secs(), + deck_id, + timing.days_elapsed, + ); + + Ok(queues) + } +} diff --git a/rslib/src/scheduler/queue/builder/sized_chain.rs b/rslib/src/scheduler/queue/builder/sized_chain.rs new file mode 100644 index 000000000..9cb34da9e --- /dev/null +++ b/rslib/src/scheduler/queue/builder/sized_chain.rs @@ -0,0 +1,80 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +/// The standard Rust chain does not implement ExactSizeIterator, and we need +/// to keep track of size so we can intersperse. +pub(crate) struct SizedChain { + one: I, + two: I2, + one_idx: usize, + two_idx: usize, + one_len: usize, + two_len: usize, +} + +impl SizedChain +where + I: ExactSizeIterator, + I2: ExactSizeIterator, +{ + pub fn new(one: I, two: I2) -> Self { + let one_len = one.len(); + let two_len = two.len(); + SizedChain { + one, + two, + one_idx: 0, + two_idx: 0, + one_len, + two_len, + } + } +} + +impl Iterator for SizedChain +where + I: ExactSizeIterator, + I2: ExactSizeIterator, +{ + type Item = I::Item; + + fn next(&mut self) -> Option { + if self.one_idx < self.one_len { + self.one_idx += 1; + self.one.next() + } else if self.two_idx < self.two_len { + self.two_idx += 1; + self.two.next() + } else { + None + } + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = (self.one_len + self.two_len) - (self.one_idx + self.two_idx); + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for SizedChain +where + I: ExactSizeIterator, + I2: ExactSizeIterator, +{ +} + +#[cfg(test)] +mod test { + use super::SizedChain; + + fn chain(a: &[u32], b: &[u32]) -> Vec { + SizedChain::new(a.iter().cloned(), b.iter().cloned()).collect() + } + + #[test] + fn sized_chain() { + let a = &[1, 2, 3]; + let b = &[11, 22, 33]; + assert_eq!(&chain(a, b), &[1, 2, 3, 11, 22, 33]); + } +} diff --git a/rslib/src/scheduler/queue/builder/sorting.rs b/rslib/src/scheduler/queue/builder/sorting.rs new file mode 100644 index 000000000..a95f65bbb --- /dev/null +++ b/rslib/src/scheduler/queue/builder/sorting.rs @@ -0,0 +1,71 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::{DueCard, NewCard, NewCardOrder, QueueBuilder, ReviewCardOrder}; +use fnv::FnvHasher; +use std::{cmp::Ordering, hash::Hasher}; + +impl QueueBuilder { + pub(super) fn sort_new(&mut self) { + match self.new_order { + NewCardOrder::Random => { + self.new.iter_mut().for_each(NewCard::hash_id_and_mtime); + self.new.sort_unstable_by(|a, b| a.extra.cmp(&b.extra)); + } + NewCardOrder::Due => { + self.new.sort_unstable_by(|a, b| a.due.cmp(&b.due)); + } + } + } + + pub(super) fn sort_reviews(&mut self) { + self.review.iter_mut().for_each(DueCard::hash_id_and_mtime); + self.day_learning + .iter_mut() + .for_each(DueCard::hash_id_and_mtime); + + match self.review_order { + ReviewCardOrder::ShuffledByDay => { + self.review.sort_unstable_by(shuffle_by_day); + self.day_learning.sort_unstable_by(shuffle_by_day); + } + ReviewCardOrder::Shuffled => { + self.review.sort_unstable_by(|a, b| a.extra.cmp(&b.extra)); + self.day_learning + .sort_unstable_by(|a, b| a.extra.cmp(&b.extra)); + } + ReviewCardOrder::IntervalsAscending => { + // fixme: implement; may require separate field if we want + // to shuffle cards that share an interval + } + ReviewCardOrder::IntervalsDescending => { + // fixme: implement; may require separate field if we want + // to shuffle cards that share an interval + } + } + } +} + +// We sort based on a hash so that if the queue is rebuilt, remaining +// cards come back in the same order. +impl DueCard { + fn hash_id_and_mtime(&mut self) { + let mut hasher = FnvHasher::default(); + hasher.write_i64(self.id.0); + hasher.write_i64(self.mtime.0); + self.extra = hasher.finish(); + } +} + +impl NewCard { + fn hash_id_and_mtime(&mut self) { + let mut hasher = FnvHasher::default(); + hasher.write_i64(self.id.0); + hasher.write_i64(self.mtime.0); + self.extra = hasher.finish(); + } +} + +fn shuffle_by_day(a: &DueCard, b: &DueCard) -> Ordering { + (a.due, a.extra).cmp(&(b.due, b.extra)) +} diff --git a/rslib/src/scheduler/queue/learning.rs b/rslib/src/scheduler/queue/learning.rs new file mode 100644 index 000000000..c2f9cd83d --- /dev/null +++ b/rslib/src/scheduler/queue/learning.rs @@ -0,0 +1,133 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::{ + cmp::{Ordering, Reverse}, + collections::VecDeque, +}; + +use super::{CardQueues, LearningQueueEntry}; +use crate::{prelude::*, scheduler::timing::SchedTimingToday}; + +impl CardQueues { + /// Check for any newly due cards, and then return the first, if any, + /// that is due before now. + pub(super) fn next_learning_entry_due_before_now( + &mut self, + now: TimestampSecs, + ) -> Option { + let learn_ahead_cutoff = now.adding_secs(self.learn_ahead_secs); + self.check_for_newly_due_learning_cards(learn_ahead_cutoff); + self.next_learning_entry_learning_ahead() + .filter(|c| c.due <= now) + } + + /// Check for due learning cards up to the learn ahead limit. + /// Does not check for newly due cards, as that is already done by + /// next_learning_entry_due_before_now() + pub(super) fn next_learning_entry_learning_ahead(&self) -> Option { + self.due_learning.front().copied() + } + + pub(super) fn pop_learning_entry(&mut self, id: CardID) -> Option { + if let Some(top) = self.due_learning.front() { + if top.id == id { + self.learn_count -= 1; + return self.due_learning.pop_front(); + } + } + + // fixme: remove this in the future + // the current python unit tests answer learning cards before they're due, + // so for now we also check the head of the later_due queue + if let Some(top) = self.later_learning.peek() { + if top.0.id == id { + // self.learn_count -= 1; + return self.later_learning.pop().map(|c| c.0); + } + } + + None + } + + /// Given the just-answered `card`, place it back in the learning queues if it's still + /// due today. Avoid placing it in a position where it would be shown again immediately. + pub(super) fn maybe_requeue_learning_card(&mut self, card: &Card, timing: SchedTimingToday) { + if !card.is_intraday_learning() { + return; + } + + let learn_ahead_limit = timing.now.adding_secs(self.learn_ahead_secs); + + if card.due < learn_ahead_limit.0 as i32 { + let mut entry = LearningQueueEntry { + due: TimestampSecs(card.due as i64), + id: card.id, + mtime: card.mtime, + }; + + if self.learning_collapsed() { + if let Some(next) = self.due_learning.front() { + if next.due >= entry.due { + // the earliest due card is due later than this one; make this one + // due after that one + entry.due = next.due.adding_secs(1); + } + self.push_due_learning_card(entry); + } else { + // nothing else waiting to review; make this due in a minute + entry.due = learn_ahead_limit.adding_secs(60); + self.later_learning.push(Reverse(entry)); + } + } else { + // not collapsed; can add normally + self.push_due_learning_card(entry); + } + } else if card.due < timing.next_day_at as i32 { + self.later_learning.push(Reverse(LearningQueueEntry { + due: TimestampSecs(card.due as i64), + id: card.id, + mtime: card.mtime, + })); + }; + } + + fn learning_collapsed(&self) -> bool { + self.main.is_empty() + } + + /// Adds card, maintaining correct sort order, and increments learning count. + pub(super) fn push_due_learning_card(&mut self, entry: LearningQueueEntry) { + self.learn_count += 1; + let target_idx = + binary_search_by(&self.due_learning, |e| e.due.cmp(&entry.due)).unwrap_or_else(|e| e); + self.due_learning.insert(target_idx, entry); + } + + fn check_for_newly_due_learning_cards(&mut self, cutoff: TimestampSecs) { + while let Some(earliest) = self.later_learning.peek() { + if earliest.0.due > cutoff { + break; + } + let entry = self.later_learning.pop().unwrap().0; + self.push_due_learning_card(entry); + } + } +} + +/// Adapted from the Rust stdlib VecDeque implementation; we can drop this when the following +/// lands: https://github.com/rust-lang/rust/issues/78021 +fn binary_search_by<'a, F, T>(deque: &'a VecDeque, mut f: F) -> Result +where + F: FnMut(&'a T) -> Ordering, +{ + let (front, back) = deque.as_slices(); + + match back.first().map(|elem| f(elem)) { + Some(Ordering::Less) | Some(Ordering::Equal) => back + .binary_search_by(f) + .map(|idx| idx + front.len()) + .map_err(|idx| idx + front.len()), + _ => front.binary_search_by(f), + } +} diff --git a/rslib/src/scheduler/queue/limits.rs b/rslib/src/scheduler/queue/limits.rs new file mode 100644 index 000000000..8559f44a0 --- /dev/null +++ b/rslib/src/scheduler/queue/limits.rs @@ -0,0 +1,216 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::{Deck, DeckKind}; +use crate::deckconf::{DeckConf, DeckConfID}; +use std::collections::HashMap; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct RemainingLimits { + pub review: u32, + pub new: u32, +} + +impl RemainingLimits { + pub(crate) fn new(deck: &Deck, config: Option<&DeckConf>) -> Self { + if let Some(config) = config { + RemainingLimits { + review: ((config.inner.reviews_per_day as i32) - deck.common.review_studied).max(0) + as u32, + new: ((config.inner.new_per_day as i32) - deck.common.new_studied).max(0) as u32, + } + } else { + RemainingLimits { + review: std::u32::MAX, + new: std::u32::MAX, + } + } + } + + fn limit_to_parent(&mut self, parent: RemainingLimits) { + self.review = self.review.min(parent.review); + self.new = self.new.min(parent.new); + } +} + +pub(super) fn remaining_limits_capped_to_parents( + decks: &[Deck], + config: &HashMap, +) -> Vec { + let mut limits = get_remaining_limits(decks, config); + cap_limits_to_parents(decks.iter().map(|d| d.name.as_str()), &mut limits); + limits +} + +/// Return the remaining limits for each of the provided decks, in +/// the provided deck order. +fn get_remaining_limits( + decks: &[Deck], + config: &HashMap, +) -> Vec { + decks + .iter() + .map(move |deck| { + // get deck config if not filtered + let config = if let DeckKind::Normal(normal) = &deck.kind { + config.get(&DeckConfID(normal.config_id)) + } else { + None + }; + RemainingLimits::new(deck, config) + }) + .collect() +} + +/// Given a sorted list of deck names and their current limits, +/// cap child limits to their parents. +fn cap_limits_to_parents<'a>( + names: impl IntoIterator, + limits: &'a mut Vec, +) { + let mut parent_limits = vec![]; + let mut last_limit = None; + let mut last_level = 0; + + names + .into_iter() + .zip(limits.iter_mut()) + .for_each(|(name, limits)| { + let level = name.matches('\x1f').count() + 1; + if last_limit.is_none() { + // top-level deck + last_limit = Some(*limits); + last_level = level; + } else { + // add/remove parent limits if descending/ascending + let mut target = level; + while target != last_level { + if target < last_level { + // current deck is at higher level than previous + parent_limits.pop(); + target += 1; + } else { + // current deck is at a lower level than previous. this + // will push the same remaining counts multiple times if + // the deck tree is missing a parent + parent_limits.push(last_limit.unwrap()); + target -= 1; + } + } + + // apply current parent limit + limits.limit_to_parent(*parent_limits.last().unwrap()); + last_level = level; + last_limit = Some(*limits); + } + }) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn limits() { + let limits_map = vec![ + ( + "A", + RemainingLimits { + review: 100, + new: 20, + }, + ), + ( + "A\x1fB", + RemainingLimits { + review: 50, + new: 30, + }, + ), + ( + "A\x1fC", + RemainingLimits { + review: 10, + new: 10, + }, + ), + ("A\x1fC\x1fD", RemainingLimits { review: 5, new: 30 }), + ( + "A\x1fE", + RemainingLimits { + review: 200, + new: 100, + }, + ), + ]; + let (names, mut limits): (Vec<_>, Vec<_>) = limits_map.into_iter().unzip(); + cap_limits_to_parents(names.into_iter(), &mut limits); + assert_eq!( + &limits, + &[ + RemainingLimits { + review: 100, + new: 20 + }, + RemainingLimits { + review: 50, + new: 20 + }, + RemainingLimits { + review: 10, + new: 10 + }, + RemainingLimits { review: 5, new: 10 }, + RemainingLimits { + review: 100, + new: 20 + } + ] + ); + + // missing parents should not break it + let limits_map = vec![ + ( + "A", + RemainingLimits { + review: 100, + new: 20, + }, + ), + ( + "A\x1fB\x1fC\x1fD", + RemainingLimits { + review: 50, + new: 30, + }, + ), + ( + "A\x1fC", + RemainingLimits { + review: 100, + new: 100, + }, + ), + ]; + + let (names, mut limits): (Vec<_>, Vec<_>) = limits_map.into_iter().unzip(); + cap_limits_to_parents(names.into_iter(), &mut limits); + assert_eq!( + &limits, + &[ + RemainingLimits { + review: 100, + new: 20 + }, + RemainingLimits { + review: 50, + new: 20 + }, + RemainingLimits { + review: 100, + new: 20 + }, + ] + ); + } +} diff --git a/rslib/src/scheduler/queue/main.rs b/rslib/src/scheduler/queue/main.rs new file mode 100644 index 000000000..60626d29f --- /dev/null +++ b/rslib/src/scheduler/queue/main.rs @@ -0,0 +1,37 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::{CardQueues, QueueEntry, QueueEntryKind}; +use crate::prelude::*; + +impl CardQueues { + pub(super) fn next_main_entry(&self) -> Option { + self.main.front().copied() + } + + pub(super) fn pop_main_entry(&mut self, id: CardID) -> Option { + if let Some(last) = self.main.front() { + if last.id == id { + match last.kind { + QueueEntryKind::New => self.new_count -= 1, + QueueEntryKind::Review => self.review_count -= 1, + QueueEntryKind::Learning => unreachable!(), + } + return self.main.pop_front(); + } + } + + None + } + + /// Add an undone card back to the 'front' of the list, and update + /// the counts. + pub(super) fn push_main_entry(&mut self, entry: QueueEntry) { + match entry.kind { + QueueEntryKind::New => self.new_count += 1, + QueueEntryKind::Review => self.review_count += 1, + QueueEntryKind::Learning => unreachable!(), + } + self.main.push_front(entry); + } +} diff --git a/rslib/src/scheduler/queue/mod.rs b/rslib/src/scheduler/queue/mod.rs new file mode 100644 index 000000000..ab8fd2d22 --- /dev/null +++ b/rslib/src/scheduler/queue/mod.rs @@ -0,0 +1,252 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +mod builder; +mod learning; +mod limits; +mod main; + +use std::{ + cmp::Reverse, + collections::{BinaryHeap, VecDeque}, +}; + +use crate::{backend_proto as pb, card::CardQueue, prelude::*, timestamp::TimestampSecs}; +pub(crate) use builder::{DueCard, NewCard}; + +use super::timing::SchedTimingToday; + +#[derive(Debug)] +pub(crate) struct CardQueues { + new_count: usize, + review_count: usize, + learn_count: usize, + + main: VecDeque, + due_learning: VecDeque, + later_learning: BinaryHeap>, + + selected_deck: DeckID, + current_day: u32, + learn_ahead_secs: i64, +} + +#[derive(Debug)] +pub(crate) struct Counts { + new: usize, + learning: usize, + review: usize, +} + +impl CardQueues { + /// Get the next due card, if there is one. + fn next_entry(&mut self, now: TimestampSecs) -> Option { + self.next_learning_entry_due_before_now(now) + .map(Into::into) + .or_else(|| self.next_main_entry()) + .or_else(|| self.next_learning_entry_learning_ahead().map(Into::into)) + } + + /// Remove the provided card from the top of the learning or main queues. + /// If it was not at the top, return an error. + fn pop_answered(&mut self, id: CardID) -> Result<()> { + if self.pop_main_entry(id).is_none() && self.pop_learning_entry(id).is_none() { + Err(AnkiError::invalid_input("not at top of queue")) + } else { + Ok(()) + } + } + + fn counts(&self) -> Counts { + Counts { + new: self.new_count, + learning: self.learn_count, + review: self.review_count, + } + } + + fn is_stale(&self, deck: DeckID, current_day: u32) -> bool { + self.selected_deck != deck || self.current_day != current_day + } + + fn update_after_answering_card(&mut self, card: &Card, timing: SchedTimingToday) -> Result<()> { + self.pop_answered(card.id)?; + self.maybe_requeue_learning_card(card, timing); + Ok(()) + } + + /// Add a just-undone card back to the appropriate queue, updating counts. + pub(crate) fn push_undone_card(&mut self, card: &Card) { + if card.is_intraday_learning() { + self.push_due_learning_card(LearningQueueEntry { + due: TimestampSecs(card.due as i64), + id: card.id, + mtime: card.mtime, + }) + } else { + self.push_main_entry(card.into()) + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct QueueEntry { + id: CardID, + mtime: TimestampSecs, + kind: QueueEntryKind, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum QueueEntryKind { + New, + /// Includes day-learning cards + Review, + Learning, +} + +impl PartialOrd for QueueEntry { + fn partial_cmp(&self, other: &Self) -> Option { + self.id.partial_cmp(&other.id) + } +} + +impl From<&Card> for QueueEntry { + fn from(card: &Card) -> Self { + let kind = match card.queue { + CardQueue::Learn | CardQueue::PreviewRepeat => QueueEntryKind::Learning, + CardQueue::New => QueueEntryKind::New, + CardQueue::Review | CardQueue::DayLearn => QueueEntryKind::Review, + CardQueue::Suspended | CardQueue::SchedBuried | CardQueue::UserBuried => { + unreachable!() + } + }; + QueueEntry { + id: card.id, + mtime: card.mtime, + kind, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, Eq, Ord)] +struct LearningQueueEntry { + // due comes first, so the derived ordering sorts by due + due: TimestampSecs, + id: CardID, + mtime: TimestampSecs, +} + +impl From for QueueEntry { + fn from(e: LearningQueueEntry) -> Self { + Self { + id: e.id, + mtime: e.mtime, + kind: QueueEntryKind::Learning, + } + } +} + +impl Collection { + pub(crate) fn get_queued_cards( + &mut self, + fetch_limit: u32, + intraday_learning_only: bool, + ) -> Result { + if let Some(next_cards) = self.next_cards(fetch_limit, intraday_learning_only)? { + Ok(pb::GetQueuedCardsOut { + value: Some(pb::get_queued_cards_out::Value::QueuedCards(next_cards)), + }) + } else { + Ok(pb::GetQueuedCardsOut { + value: Some(pb::get_queued_cards_out::Value::CongratsInfo( + self.congrats_info()?, + )), + }) + } + } + + pub(crate) fn clear_queues(&mut self) { + self.state.card_queues = None; + } + + /// FIXME: remove this once undoing is moved into backend + pub(crate) fn requeue_undone_card(&mut self, card_id: CardID) -> Result<()> { + let card = self.storage.get_card(card_id)?.ok_or(AnkiError::NotFound)?; + self.get_queues()?.push_undone_card(&card); + Ok(()) + } + + pub(crate) fn update_queues_after_answering_card( + &mut self, + card: &Card, + timing: SchedTimingToday, + ) -> Result<()> { + if let Some(queues) = &mut self.state.card_queues { + queues.update_after_answering_card(card, timing) + } else { + // we currenly allow the queues to be empty for unit tests + Ok(()) + } + } + + fn get_queues(&mut self) -> Result<&mut CardQueues> { + let timing = self.timing_today()?; + let deck = self.get_current_deck_id(); + let need_rebuild = self + .state + .card_queues + .as_ref() + .map(|q| q.is_stale(deck, timing.days_elapsed)) + .unwrap_or(true); + if need_rebuild { + self.state.card_queues = Some(self.build_queues(deck)?); + } + + Ok(self.state.card_queues.as_mut().unwrap()) + } + + fn next_cards( + &mut self, + _fetch_limit: u32, + _intraday_learning_only: bool, + ) -> Result> { + let queues = self.get_queues()?; + let mut cards = vec![]; + if let Some(entry) = queues.next_entry(TimestampSecs::now()) { + let card = self + .storage + .get_card(entry.id)? + .ok_or(AnkiError::NotFound)?; + if card.mtime != entry.mtime { + return Err(AnkiError::invalid_input( + "bug: card modified without updating queue", + )); + } + + // fixme: pass in card instead of id + let next_states = self.get_next_card_states(card.id)?; + + cards.push(pb::get_queued_cards_out::QueuedCard { + card: Some(card.into()), + next_states: Some(next_states.into()), + queue: match entry.kind { + QueueEntryKind::New => 0, + QueueEntryKind::Learning => 1, + QueueEntryKind::Review => 2, + }, + }); + } + + if cards.is_empty() { + Ok(None) + } else { + let counts = self.get_queues()?.counts(); + Ok(Some(pb::get_queued_cards_out::QueuedCards { + cards, + new_count: counts.new as u32, + learning_count: counts.learning as u32, + review_count: counts.review as u32, + })) + } + } +} diff --git a/rslib/src/scheduler/cutoff.rs b/rslib/src/scheduler/timing.rs similarity index 98% rename from rslib/src/scheduler/cutoff.rs rename to rslib/src/scheduler/timing.rs index d4972e143..e5de473c5 100644 --- a/rslib/src/scheduler/cutoff.rs +++ b/rslib/src/scheduler/timing.rs @@ -6,6 +6,7 @@ use chrono::{Date, Duration, FixedOffset, Local, TimeZone, Timelike}; #[derive(Debug, PartialEq, Clone, Copy)] pub struct SchedTimingToday { + pub now: TimestampSecs, /// The number of days that have passed since the collection was created. pub days_elapsed: u32, /// Timestamp of the next day rollover. @@ -43,6 +44,7 @@ pub fn sched_timing_today_v2_new( let days_elapsed = days_elapsed(created_date, today, rollover_passed); SchedTimingToday { + now: current_secs, days_elapsed, next_day_at, } @@ -119,6 +121,7 @@ fn sched_timing_today_v1(crt: TimestampSecs, now: TimestampSecs) -> SchedTimingT let days_elapsed = (now.0 - crt.0) / 86_400; let next_day_at = crt.0 + (days_elapsed + 1) * 86_400; SchedTimingToday { + now, days_elapsed: days_elapsed as u32, next_day_at, } @@ -147,6 +150,7 @@ fn sched_timing_today_v2_legacy( } SchedTimingToday { + now, days_elapsed: days_elapsed as u32, next_day_at, } @@ -351,6 +355,7 @@ mod test { assert_eq!( sched_timing_today_v1(TimestampSecs(1575226800), now), SchedTimingToday { + now, days_elapsed: 107, next_day_at: 1584558000 } @@ -359,6 +364,7 @@ mod test { assert_eq!( sched_timing_today_v2_legacy(TimestampSecs(1533564000), 0, now, aest_offset()), SchedTimingToday { + now, days_elapsed: 589, next_day_at: 1584540000 } @@ -367,6 +373,7 @@ mod test { assert_eq!( sched_timing_today_v2_legacy(TimestampSecs(1524038400), 4, now, aest_offset()), SchedTimingToday { + now, days_elapsed: 700, next_day_at: 1584554400 } diff --git a/rslib/src/scheduler/upgrade.rs b/rslib/src/scheduler/upgrade.rs index 0f1c9bc03..f981943dc 100644 --- a/rslib/src/scheduler/upgrade.rs +++ b/rslib/src/scheduler/upgrade.rs @@ -10,7 +10,7 @@ use crate::{ search::SortMode, }; -use super::cutoff::local_minutes_west_for_stamp; +use super::timing::local_minutes_west_for_stamp; struct V1FilteredDeckInfo { /// True if the filtered deck had rescheduling enabled. diff --git a/rslib/src/storage/card/due_cards.sql b/rslib/src/storage/card/due_cards.sql new file mode 100644 index 000000000..96241c48e --- /dev/null +++ b/rslib/src/storage/card/due_cards.sql @@ -0,0 +1,18 @@ +SELECT queue, + id, + nid, + due, + cast(ivl AS integer), + cast(mod AS integer) +FROM cards +WHERE did = ?1 + AND ( + ( + queue IN (2, 3) + AND due <= ?2 + ) + OR ( + queue IN (1, 4) + AND due <= ?3 + ) + ) \ No newline at end of file diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 4b9fa7168..0cd3b0ad2 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -7,7 +7,10 @@ use crate::{ decks::{Deck, DeckID, DeckKind}, err::Result, notes::NoteID, - scheduler::congrats::CongratsInfo, + scheduler::{ + congrats::CongratsInfo, + queue::{DueCard, NewCard}, + }, timestamp::{TimestampMillis, TimestampSecs}, types::Usn, }; @@ -159,6 +162,67 @@ impl super::SqliteStorage { Ok(()) } + /// Call func() for each due card, stopping when it returns false + /// or no more cards found. + pub(crate) fn for_each_due_card_in_deck( + &self, + day_cutoff: u32, + learn_cutoff: i64, + deck: DeckID, + mut func: F, + ) -> Result<()> + where + F: FnMut(CardQueue, DueCard) -> bool, + { + let mut stmt = self.db.prepare_cached(include_str!("due_cards.sql"))?; + let mut rows = stmt.query(params![ + // with many subdecks, avoiding named params shaves off a few milliseconds + deck, + day_cutoff, + learn_cutoff + ])?; + while let Some(row) = rows.next()? { + let queue: CardQueue = row.get(0)?; + if !func( + queue, + DueCard { + id: row.get(1)?, + note_id: row.get(2)?, + due: row.get(3).ok().unwrap_or_default(), + extra: row.get::<_, u32>(4)? as u64, + mtime: row.get(5)?, + }, + ) { + break; + } + } + + Ok(()) + } + + /// Call func() for each new card, stopping when it returns false + /// or no more cards found. Cards will arrive in (deck_id, due) order. + pub(crate) fn for_each_new_card_in_deck(&self, deck: DeckID, mut func: F) -> Result<()> + where + F: FnMut(NewCard) -> bool, + { + let mut stmt = self.db.prepare_cached(include_str!("new_cards.sql"))?; + let mut rows = stmt.query(params![deck])?; + while let Some(row) = rows.next()? { + if !func(NewCard { + id: row.get(0)?, + note_id: row.get(1)?, + due: row.get(2)?, + extra: row.get::<_, u32>(3)? as u64, + mtime: row.get(4)?, + }) { + break; + } + } + + Ok(()) + } + /// Fix some invalid card properties, and return number of changed cards. pub(crate) fn fix_card_properties( &self, diff --git a/rslib/src/storage/card/new_cards.sql b/rslib/src/storage/card/new_cards.sql new file mode 100644 index 000000000..aa8ec5987 --- /dev/null +++ b/rslib/src/storage/card/new_cards.sql @@ -0,0 +1,8 @@ +SELECT id, + nid, + due, + ord, + cast(mod AS integer) +FROM cards +WHERE did = ? + AND queue = 0 \ No newline at end of file diff --git a/rslib/src/storage/deck/mod.rs b/rslib/src/storage/deck/mod.rs index b69224c0c..f0e0c5045 100644 --- a/rslib/src/storage/deck/mod.rs +++ b/rslib/src/storage/deck/mod.rs @@ -162,6 +162,38 @@ impl SqliteStorage { .collect() } + /// Return the provided deck with its parents and children in an ordered list, and + /// the number of parent decks that need to be skipped to get to the chosen deck. + pub(crate) fn deck_with_parents_and_children( + &self, + deck_id: DeckID, + ) -> Result<(Vec, usize)> { + let deck = self.get_deck(deck_id)?.ok_or(AnkiError::NotFound)?; + let mut parents = self.parent_decks(&deck)?; + parents.reverse(); + let parent_count = parents.len(); + + let prefix_start = format!("{}\x1f", deck.name); + let prefix_end = format!("{}\x20", deck.name); + parents.push(deck); + + let decks = parents + .into_iter() + .map(Result::Ok) + .chain( + self.db + .prepare_cached(concat!( + include_str!("get_deck.sql"), + " where name > ? and name < ?" + ))? + .query_and_then(&[prefix_start, prefix_end], row_to_deck)?, + ) + .collect::>()?; + + Ok((decks, parent_count)) + } + + /// Return the parents of `child`, with the most immediate parent coming first. pub(crate) fn parent_decks(&self, child: &Deck) -> Result> { let mut decks: Vec = vec![]; while let Some(parent_name) = diff --git a/rslib/src/storage/sqlite.rs b/rslib/src/storage/sqlite.rs index 4030de0aa..99c41ef64 100644 --- a/rslib/src/storage/sqlite.rs +++ b/rslib/src/storage/sqlite.rs @@ -5,7 +5,7 @@ use crate::config::schema11_config_as_string; use crate::err::Result; use crate::err::{AnkiError, DBErrorKind}; use crate::timestamp::{TimestampMillis, TimestampSecs}; -use crate::{i18n::I18n, scheduler::cutoff::v1_creation_date, text::without_combining}; +use crate::{i18n::I18n, scheduler::timing::v1_creation_date, text::without_combining}; use regex::Regex; use rusqlite::{functions::FunctionFlags, params, Connection, NO_PARAMS}; use std::cmp::Ordering; diff --git a/rslib/src/timestamp.rs b/rslib/src/timestamp.rs index ebadf757e..a037abae8 100644 --- a/rslib/src/timestamp.rs +++ b/rslib/src/timestamp.rs @@ -35,6 +35,10 @@ impl TimestampSecs { pub fn datetime(self, utc_offset: FixedOffset) -> DateTime { utc_offset.timestamp(self.0, 0) } + + pub fn adding_secs(self, secs: i64) -> Self { + TimestampSecs(self.0 + secs) + } } impl TimestampMillis {