split out common scheduler code into base.py, use scheduler/ dir

Also move the legacy aliases into a separate file
This commit is contained in:
Damien Elmes 2021-03-12 14:07:52 +10:00
parent a0c47243b6
commit ad973bb701
10 changed files with 592 additions and 818 deletions

View file

@ -31,7 +31,7 @@ from anki.media import MediaManager, media_paths_from_col_path
from anki.models import ModelManager, NoteType from anki.models import ModelManager, NoteType
from anki.notes import Note from anki.notes import Note
from anki.sched import Scheduler as V1Scheduler from anki.sched import Scheduler as V1Scheduler
from anki.scheduler import Scheduler as V2TestScheduler from anki.scheduler.v3 import Scheduler as V3TestScheduler
from anki.schedv2 import Scheduler as V2Scheduler from anki.schedv2 import Scheduler as V2Scheduler
from anki.sync import SyncAuth, SyncOutput, SyncStatus from anki.sync import SyncAuth, SyncOutput, SyncStatus
from anki.tags import TagManager from anki.tags import TagManager
@ -151,7 +151,7 @@ class Collection:
self.sched = V1Scheduler(self) self.sched = V1Scheduler(self)
elif ver == 2: elif ver == 2:
if self.is_2021_test_scheduler_enabled(): if self.is_2021_test_scheduler_enabled():
self.sched = V2TestScheduler(self) # type: ignore self.sched = V3TestScheduler(self) # type: ignore
else: else:
self.sched = V2Scheduler(self) self.sched = V2Scheduler(self)

View file

@ -22,6 +22,7 @@ from anki.utils import ids2str, intTime
class Scheduler(V2): class Scheduler(V2):
version = 1
name = "std" name = "std"
haveCustomStudy = True haveCustomStudy = True
_spreadRev = True _spreadRev = True
@ -38,7 +39,6 @@ class Scheduler(V2):
self.lrnCount = 0 self.lrnCount = 0
self.revCount = 0 self.revCount = 0
self.newCount = 0 self.newCount = 0
self.today: Optional[int] = None
self._haveQueues = False self._haveQueues = False
self._updateCutoff() self._updateCutoff()

View file

@ -1,531 +0,0 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
"""
This file contains experimental scheduler changes, and is not currently
used by Anki.
"""
from __future__ import annotations
from heapq import *
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 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
SchedTimingToday = _pb.SchedTimingTodayOut
UnburyCurrentDeck = _pb.UnburyCardsInCurrentDeckIn
BuryOrSuspend = _pb.BuryOrSuspendCardsIn
# fixme: reviewer.cardQueue/editCurrent/undo handling/retaining current card
class Scheduler:
is_2021 = True
def __init__(self, col: anki.collection.Collection) -> None:
self.col = col.weakref()
# don't rely on this, it will likely be removed out in the future
self.reps = 0
# Timing
##########################################################################
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:
# backend automatically resets queues as operations are performed
pass
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:
assert_exhaustive(kind)
assert False
def getCard(self) -> Optional[Card]:
"""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
else:
return None
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 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]
return tuple(counts) # type: ignore
@property
def newCount(self) -> int:
return self.counts()[0]
@property
def lrnCount(self) -> int:
return self.counts()[1]
@property
def reviewCount(self) -> int:
return self.counts()[2]
# Answering a card
##########################################################################
def answerCard(self, card: Card, ease: int) -> None:
assert 1 <= ease <= 4
assert 0 <= card.queue <= 4
new_state = self._answerCard(card, ease)
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)
if ease == BUTTON_ONE:
new_state = states.again
rating = _pb.AnswerCardIn.AGAIN
elif ease == BUTTON_TWO:
new_state = states.hard
rating = _pb.AnswerCardIn.HARD
elif ease == BUTTON_THREE:
new_state = states.good
rating = _pb.AnswerCardIn.GOOD
elif ease == BUTTON_FOUR:
new_state = states.easy
rating = _pb.AnswerCardIn.EASY
else:
assert False, "invalid ease"
self.col._backend.answer_card(
card_id=card.id,
current_state=states.current,
new_state=new_state,
rating=rating,
answered_at_millis=intTime(1000),
milliseconds_taken=card.timeTaken(),
)
# fixme: tests assume card will be mutated, so we need to reload it
card.load()
return new_state
def _handle_leech(self, card: Card, new_state: _pb.SchedulingState) -> bool:
"True if was leech."
if self.col._backend.state_is_leech(new_state):
if hooks.card_did_leech.count() > 0:
hooks.card_did_leech(card)
# leech hooks assumed that card mutations would be saved for them
card.mod = intTime()
card.usn = self.col.usn()
card.flush()
return True
else:
return False
# Next times
##########################################################################
# fixme: move these into tests_schedv2 in the future
def _interval_for_state(self, state: _pb.SchedulingState) -> int:
kind = state.WhichOneof("value")
if kind == "normal":
return self._interval_for_normal_state(state.normal)
elif kind == "filtered":
return self._interval_for_filtered_state(state.filtered)
else:
assert_exhaustive(kind)
return 0 # unreachable
def _interval_for_normal_state(self, normal: _pb.SchedulingState.Normal) -> int:
kind = normal.WhichOneof("value")
if kind == "new":
return 0
elif kind == "review":
return normal.review.scheduled_days * 86400
elif kind == "learning":
return normal.learning.scheduled_secs
elif kind == "relearning":
return normal.relearning.learning.scheduled_secs
else:
assert_exhaustive(kind)
return 0 # unreachable
def _interval_for_filtered_state(
self, filtered: _pb.SchedulingState.Filtered
) -> int:
kind = filtered.WhichOneof("value")
if kind == "preview":
return filtered.preview.scheduled_secs
elif kind == "rescheduling":
return self._interval_for_normal_state(filtered.rescheduling.original_state)
else:
assert_exhaustive(kind)
return 0 # unreachable
def nextIvl(self, card: Card, ease: int) -> Any:
"Don't use this - it is only required by tests, and will be moved in the future."
states = self.col._backend.get_next_card_states(card.id)
if ease == BUTTON_ONE:
new_state = states.again
elif ease == BUTTON_TWO:
new_state = states.hard
elif ease == BUTTON_THREE:
new_state = states.good
elif ease == BUTTON_FOUR:
new_state = states.easy
else:
assert False, "invalid ease"
return self._interval_for_state(new_state)
# Review-related UI helpers
##########################################################################
def countIdx(self, card: Card) -> int:
if card.queue in (QUEUE_TYPE_DAY_LEARN_RELEARN, QUEUE_TYPE_PREVIEW):
return QUEUE_TYPE_LRN
return card.queue
def answerButtons(self, card: Card) -> int:
return 4
def nextIvlStr(self, card: Card, ease: int, short: bool = False) -> str:
"Return the next interval for CARD as a string."
states = self.col._backend.get_next_card_states(card.id)
return self.col._backend.describe_next_states(states)[ease - 1]
# Deck list
##########################################################################
def deck_due_tree(self, top_deck_id: int = 0) -> DeckTreeNode:
"""Returns a tree of decks with counts.
If top_deck_id provided, counts are limited to that node."""
return self.col._backend.deck_tree(top_deck_id=top_deck_id, now=intTime())
# Deck finished state & custom study
##########################################################################
def congratulations_info(self) -> CongratsInfo:
return self.col._backend.congrats_info()
def haveBuriedSiblings(self) -> bool:
return self.congratulations_info().have_sched_buried
def haveManuallyBuried(self) -> bool:
return self.congratulations_info().have_user_buried
def haveBuried(self) -> bool:
info = self.congratulations_info()
return info.have_sched_buried or info.have_user_buried
def extendLimits(self, new: int, rev: int) -> None:
did = self.col.decks.current()["id"]
self.col._backend.extend_limits(deck_id=did, new_delta=new, review_delta=rev)
# 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 9999)"""
% self._deckLimit(),
self.today,
)
# Filtered deck handling
##########################################################################
def rebuild_filtered_deck(self, deck_id: int) -> int:
return self.col._backend.rebuild_filtered_deck(deck_id)
def empty_filtered_deck(self, deck_id: int) -> None:
self.col._backend.empty_filtered_deck(deck_id)
# Suspending & burying
##########################################################################
def unsuspend_cards(self, ids: List[int]) -> None:
self.col._backend.restore_buried_and_suspended_cards(ids)
def unbury_cards(self, ids: List[int]) -> None:
self.col._backend.restore_buried_and_suspended_cards(ids)
def unbury_cards_in_current_deck(
self,
mode: UnburyCurrentDeck.Mode.V = UnburyCurrentDeck.ALL,
) -> None:
self.col._backend.unbury_cards_in_current_deck(mode)
def suspend_cards(self, ids: Sequence[int]) -> None:
self.col._backend.bury_or_suspend_cards(
card_ids=ids, mode=BuryOrSuspend.SUSPEND
)
def bury_cards(self, ids: Sequence[int], manual: bool = True) -> None:
if manual:
mode = BuryOrSuspend.BURY_USER
else:
mode = BuryOrSuspend.BURY_SCHED
self.col._backend.bury_or_suspend_cards(card_ids=ids, mode=mode)
def bury_note(self, note: Note) -> None:
self.bury_cards(note.card_ids())
# Resetting/rescheduling
##########################################################################
def schedule_cards_as_new(self, card_ids: List[int]) -> None:
"Put cards at the end of the new queue."
self.col._backend.schedule_cards_as_new(card_ids=card_ids, log=True)
def set_due_date(self, card_ids: List[int], days: str) -> None:
"""Set cards to be due in `days`, turning them into review cards if necessary.
`days` can be of the form '5' or '5..7'"""
self.col._backend.set_due_date(card_ids=card_ids, days=days)
def resetCards(self, ids: List[int]) -> None:
"Completely reset cards for export."
sids = ids2str(ids)
# we want to avoid resetting due number of existing new cards on export
nonNew = self.col.db.list(
f"select id from cards where id in %s and (queue != {QUEUE_TYPE_NEW} or type != {CARD_TYPE_NEW})"
% sids
)
# reset all cards
self.col.db.execute(
f"update cards set reps=0,lapses=0,odid=0,odue=0,queue={QUEUE_TYPE_NEW}"
" where id in %s" % sids
)
# and forget any non-new cards, changing their due numbers
self.col._backend.schedule_cards_as_new(card_ids=nonNew, log=False)
# Repositioning new cards
##########################################################################
def sortCards(
self,
cids: List[int],
start: int = 1,
step: int = 1,
shuffle: bool = False,
shift: bool = False,
) -> None:
self.col._backend.sort_cards(
card_ids=cids,
starting_from=start,
step_size=step,
randomize=shuffle,
shift_existing=shift,
)
def randomizeCards(self, did: int) -> None:
self.col._backend.sort_deck(deck_id=did, randomize=True)
def orderCards(self, did: int) -> None:
self.col._backend.sort_deck(deck_id=did, randomize=False)
def resortConf(self, conf: DeckConfig) -> None:
for did in self.col.decks.didsForConf(conf):
if conf["new"]["order"] == 0:
self.randomizeCards(did)
else:
self.orderCards(did)
# for post-import
def maybeRandomizeDeck(self, did: Optional[int] = None) -> None:
if not did:
did = self.col.decks.selected()
conf = self.col.decks.confForDid(did)
# in order due?
if conf["new"]["order"] == NEW_CARDS_RANDOM:
self.randomizeCards(did)
##########################################################################
# unit tests
def _fuzzIvlRange(self, ivl: int) -> Tuple[int, int]:
return (ivl, ivl)
# 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:
self.set_due_date(card_ids, f"{min_interval}-{max_interval}!")
def buryNote(self, nid: int) -> None:
note = self.col.get_note(nid)
self.bury_cards(note.card_ids())
def unburyCards(self) -> None:
print(
"please use unbury_cards() or unbury_cards_in_current_deck instead of unburyCards()"
)
self.unbury_cards_in_current_deck()
def unburyCardsForDeck(self, type: str = "all") -> None:
print(
"please use unbury_cards_in_current_deck() instead of unburyCardsForDeck()"
)
if type == "all":
mode = UnburyCurrentDeck.ALL
elif type == "manual":
mode = UnburyCurrentDeck.USER_ONLY
else: # elif type == "siblings":
mode = UnburyCurrentDeck.SCHED_ONLY
self.unbury_cards_in_current_deck(mode)
def finishedMsg(self) -> str:
print("finishedMsg() is obsolete")
return ""
def _nextDueMsg(self) -> str:
print("_nextDueMsg() is obsolete")
return ""
def rebuildDyn(self, did: Optional[int] = None) -> Optional[int]:
did = did or self.col.decks.selected()
count = self.rebuild_filtered_deck(did) or None
if not count:
return None
# and change to our new deck
self.col.decks.select(did)
return count
def emptyDyn(self, did: Optional[int], lim: Optional[str] = None) -> None:
if lim is None:
self.empty_filtered_deck(did)
return
queue = f"""
queue = (case when queue < 0 then queue
when type in (1,{CARD_TYPE_RELEARNING}) then
(case when (case when odue then odue else due end) > 1000000000 then 1 else
{QUEUE_TYPE_DAY_LEARN_RELEARN} end)
else
type
end)
"""
self.col.db.execute(
"""
update cards set did = odid, %s,
due = (case when odue>0 then odue else due end), odue = 0, odid = 0, usn = ? where %s"""
% (queue, lim),
self.col.usn(),
)
def remFromDyn(self, cids: List[int]) -> None:
self.emptyDyn(None, f"id in {ids2str(cids)} and odid")
def update_stats(
self,
deck_id: int,
new_delta: int = 0,
review_delta: int = 0,
milliseconds_delta: int = 0,
) -> None:
self.col._backend.update_stats(
deck_id=deck_id,
new_delta=new_delta,
review_delta=review_delta,
millisecond_delta=milliseconds_delta,
)
def _updateStats(self, card: Card, type: str, cnt: int = 1) -> None:
did = card.did
if type == "new":
self.update_stats(did, new_delta=cnt)
elif type == "rev":
self.update_stats(did, review_delta=cnt)
elif type == "time":
self.update_stats(did, milliseconds_delta=cnt)
def deckDueTree(self) -> List:
"List of (base name, did, rev, lrn, new, children)"
print(
"deckDueTree() is deprecated; use decks.deck_tree() for a tree without counts, or sched.deck_due_tree()"
)
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"]
def _lapseConf(self, card: Card) -> QueueConfig:
return self._home_config(card)["lapse"]
def _revConf(self, card: Card) -> QueueConfig:
return self._home_config(card)["rev"]
def _lrnConf(self, card: Card) -> QueueConfig:
if card.type in (CARD_TYPE_REV, CARD_TYPE_RELEARNING):
return self._lapseConf(card)
else:
return self._newConf(card)
unsuspendCards = unsuspend_cards
buryCards = bury_cards
suspendCards = suspend_cards
forgetCards = schedule_cards_as_new

View file

@ -0,0 +1,10 @@
# 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 anki.scheduler.base as _base
UnburyCurrentDeck = _base.UnburyCurrentDeck
CongratsInfo = _base.CongratsInfo
BuryOrSuspend = _base.BuryOrSuspend

View file

@ -0,0 +1,192 @@
# 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 anki
import anki._backend.backend_pb2 as _pb
SchedTimingToday = _pb.SchedTimingTodayOut
from typing import List, Optional, Sequence
from anki.consts import CARD_TYPE_NEW, NEW_CARDS_RANDOM, QUEUE_TYPE_NEW, QUEUE_TYPE_REV
from anki.decks import DeckConfig, DeckTreeNode
from anki.notes import Note
from anki.utils import ids2str, intTime
CongratsInfo = _pb.CongratsInfoOut
UnburyCurrentDeck = _pb.UnburyCardsInCurrentDeckIn
BuryOrSuspend = _pb.BuryOrSuspendCardsIn
class SchedulerBase:
"Actions shared between schedulers."
version = 0
def __init__(self, col: anki.collection.Collection) -> None:
self.col = col.weakref()
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
# Deck list
##########################################################################
def deck_due_tree(self, top_deck_id: int = 0) -> DeckTreeNode:
"""Returns a tree of decks with counts.
If top_deck_id provided, counts are limited to that node."""
return self.col._backend.deck_tree(top_deck_id=top_deck_id, now=intTime())
# Deck finished state & custom study
##########################################################################
def congratulations_info(self) -> CongratsInfo:
return self.col._backend.congrats_info()
def haveBuriedSiblings(self) -> bool:
return self.congratulations_info().have_sched_buried
def haveManuallyBuried(self) -> bool:
return self.congratulations_info().have_user_buried
def haveBuried(self) -> bool:
info = self.congratulations_info()
return info.have_sched_buried or info.have_user_buried
def extendLimits(self, new: int, rev: int) -> None:
did = self.col.decks.current()["id"]
self.col._backend.extend_limits(deck_id=did, new_delta=new, review_delta=rev)
# 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 9999)"""
% self._deckLimit(),
self.today,
)
# fixme: only used by totalRevForCurrentDeck and old deck stats;
# schedv2 defines separate version
def _deckLimit(self) -> str:
self.col.decks.update_active()
return ids2str(self.col.decks.active())
# Filtered deck handling
##########################################################################
def rebuild_filtered_deck(self, deck_id: int) -> int:
return self.col._backend.rebuild_filtered_deck(deck_id)
def empty_filtered_deck(self, deck_id: int) -> None:
self.col._backend.empty_filtered_deck(deck_id)
# Suspending & burying
##########################################################################
def unsuspend_cards(self, ids: List[int]) -> None:
self.col._backend.restore_buried_and_suspended_cards(ids)
def unbury_cards(self, ids: List[int]) -> None:
self.col._backend.restore_buried_and_suspended_cards(ids)
def unbury_cards_in_current_deck(
self,
mode: UnburyCurrentDeck.Mode.V = UnburyCurrentDeck.ALL,
) -> None:
self.col._backend.unbury_cards_in_current_deck(mode)
def suspend_cards(self, ids: Sequence[int]) -> None:
self.col._backend.bury_or_suspend_cards(
card_ids=ids, mode=BuryOrSuspend.SUSPEND
)
def bury_cards(self, ids: Sequence[int], manual: bool = True) -> None:
if manual:
mode = BuryOrSuspend.BURY_USER
else:
mode = BuryOrSuspend.BURY_SCHED
self.col._backend.bury_or_suspend_cards(card_ids=ids, mode=mode)
def bury_note(self, note: Note) -> None:
self.bury_cards(note.card_ids())
# Resetting/rescheduling
##########################################################################
def schedule_cards_as_new(self, card_ids: List[int]) -> None:
"Put cards at the end of the new queue."
self.col._backend.schedule_cards_as_new(card_ids=card_ids, log=True)
def set_due_date(self, card_ids: List[int], days: str) -> None:
"""Set cards to be due in `days`, turning them into review cards if necessary.
`days` can be of the form '5' or '5..7'"""
self.col._backend.set_due_date(card_ids=card_ids, days=days)
def resetCards(self, ids: List[int]) -> None:
"Completely reset cards for export."
sids = ids2str(ids)
# we want to avoid resetting due number of existing new cards on export
nonNew = self.col.db.list(
f"select id from cards where id in %s and (queue != {QUEUE_TYPE_NEW} or type != {CARD_TYPE_NEW})"
% sids
)
# reset all cards
self.col.db.execute(
f"update cards set reps=0,lapses=0,odid=0,odue=0,queue={QUEUE_TYPE_NEW}"
" where id in %s" % sids
)
# and forget any non-new cards, changing their due numbers
self.col._backend.schedule_cards_as_new(card_ids=nonNew, log=False)
# Repositioning new cards
##########################################################################
def sortCards(
self,
cids: List[int],
start: int = 1,
step: int = 1,
shuffle: bool = False,
shift: bool = False,
) -> None:
self.col._backend.sort_cards(
card_ids=cids,
starting_from=start,
step_size=step,
randomize=shuffle,
shift_existing=shift,
)
def randomizeCards(self, did: int) -> None:
self.col._backend.sort_deck(deck_id=did, randomize=True)
def orderCards(self, did: int) -> None:
self.col._backend.sort_deck(deck_id=did, randomize=False)
def resortConf(self, conf: DeckConfig) -> None:
for did in self.col.decks.didsForConf(conf):
if conf["new"]["order"] == 0:
self.randomizeCards(did)
else:
self.orderCards(did)
# for post-import
def maybeRandomizeDeck(self, did: Optional[int] = None) -> None:
if not did:
did = self.col.decks.selected()
conf = self.col.decks.confForDid(did)
# in order due?
if conf["new"]["order"] == NEW_CARDS_RANDOM:
self.randomizeCards(did)

View file

@ -0,0 +1,148 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from typing import List, Optional, Tuple
from anki.cards import Card
from anki.consts import (
CARD_TYPE_RELEARNING,
CARD_TYPE_REV,
QUEUE_TYPE_DAY_LEARN_RELEARN,
)
from anki.decks import DeckConfig, QueueConfig
from anki.scheduler.base import SchedulerBase, UnburyCurrentDeck
from anki.utils import from_json_bytes, ids2str
class SchedulerBaseWithLegacy(SchedulerBase):
"Legacy aliases and helpers. These will go away in the future."
def reschedCards(
self, card_ids: List[int], min_interval: int, max_interval: int
) -> None:
self.set_due_date(card_ids, f"{min_interval}-{max_interval}!")
def buryNote(self, nid: int) -> None:
note = self.col.get_note(nid)
self.bury_cards(note.card_ids())
def unburyCards(self) -> None:
print(
"please use unbury_cards() or unbury_cards_in_current_deck instead of unburyCards()"
)
self.unbury_cards_in_current_deck()
def unburyCardsForDeck(self, type: str = "all") -> None:
print(
"please use unbury_cards_in_current_deck() instead of unburyCardsForDeck()"
)
if type == "all":
mode = UnburyCurrentDeck.ALL
elif type == "manual":
mode = UnburyCurrentDeck.USER_ONLY
else: # elif type == "siblings":
mode = UnburyCurrentDeck.SCHED_ONLY
self.unbury_cards_in_current_deck(mode)
def finishedMsg(self) -> str:
print("finishedMsg() is obsolete")
return ""
def _nextDueMsg(self) -> str:
print("_nextDueMsg() is obsolete")
return ""
def rebuildDyn(self, did: Optional[int] = None) -> Optional[int]:
did = did or self.col.decks.selected()
count = self.rebuild_filtered_deck(did) or None
if not count:
return None
# and change to our new deck
self.col.decks.select(did)
return count
def emptyDyn(self, did: Optional[int], lim: Optional[str] = None) -> None:
if lim is None:
self.empty_filtered_deck(did)
return
queue = f"""
queue = (case when queue < 0 then queue
when type in (1,{CARD_TYPE_RELEARNING}) then
(case when (case when odue then odue else due end) > 1000000000 then 1 else
{QUEUE_TYPE_DAY_LEARN_RELEARN} end)
else
type
end)
"""
self.col.db.execute(
"""
update cards set did = odid, %s,
due = (case when odue>0 then odue else due end), odue = 0, odid = 0, usn = ? where %s"""
% (queue, lim),
self.col.usn(),
)
def remFromDyn(self, cids: List[int]) -> None:
self.emptyDyn(None, f"id in {ids2str(cids)} and odid")
# used by v2 scheduler and some add-ons
def update_stats(
self,
deck_id: int,
new_delta: int = 0,
review_delta: int = 0,
milliseconds_delta: int = 0,
) -> None:
self.col._backend.update_stats(
deck_id=deck_id,
new_delta=new_delta,
review_delta=review_delta,
millisecond_delta=milliseconds_delta,
)
def _updateStats(self, card: Card, type: str, cnt: int = 1) -> None:
did = card.did
if type == "new":
self.update_stats(did, new_delta=cnt)
elif type == "rev":
self.update_stats(did, review_delta=cnt)
elif type == "time":
self.update_stats(did, milliseconds_delta=cnt)
def deckDueTree(self) -> List:
"List of (base name, did, rev, lrn, new, children)"
print(
"deckDueTree() is deprecated; use decks.deck_tree() for a tree without counts, or sched.deck_due_tree()"
)
return from_json_bytes(self.col._backend.deck_tree_legacy())[5]
# unit tests
def _fuzzIvlRange(self, ivl: int) -> Tuple[int, int]:
return (ivl, ivl)
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"]
def _lapseConf(self, card: Card) -> QueueConfig:
return self._home_config(card)["lapse"]
def _revConf(self, card: Card) -> QueueConfig:
return self._home_config(card)["rev"]
def _lrnConf(self, card: Card) -> QueueConfig:
if card.type in (CARD_TYPE_REV, CARD_TYPE_RELEARNING):
return self._lapseConf(card)
else:
return self._newConf(card)
unsuspendCards = SchedulerBase.unsuspend_cards
buryCards = SchedulerBase.bury_cards
suspendCards = SchedulerBase.suspend_cards
forgetCards = SchedulerBase.schedule_cards_as_new

225
pylib/anki/scheduler/v3.py Normal file
View file

@ -0,0 +1,225 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
"""
This file contains experimental scheduler changes, and is not currently
used by Anki.
It uses the same DB schema as the V2 scheduler, and 'schedVer' remains
as '2' internally.
"""
from __future__ import annotations
from typing import Tuple, Union
import anki._backend.backend_pb2 as _pb
from anki import hooks
from anki.cards import Card
from anki.consts import *
from anki.scheduler.base import CongratsInfo
from anki.scheduler.legacy import SchedulerBaseWithLegacy
from anki.types import assert_exhaustive
from anki.utils import intTime
QueuedCards = _pb.GetQueuedCardsOut.QueuedCards
class Scheduler(SchedulerBaseWithLegacy):
version = 3
# don't rely on this, it will likely be removed in the future
reps = 0
# Fetching the next card
##########################################################################
def reset(self) -> None:
# backend automatically resets queues as operations are performed
pass
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:
assert_exhaustive(kind)
assert False
def getCard(self) -> Optional[Card]:
"""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
else:
return None
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 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]
return tuple(counts) # type: ignore
@property
def newCount(self) -> int:
return self.counts()[0]
@property
def lrnCount(self) -> int:
return self.counts()[1]
@property
def reviewCount(self) -> int:
return self.counts()[2]
# Answering a card
##########################################################################
def answerCard(self, card: Card, ease: int) -> None:
assert 1 <= ease <= 4
assert 0 <= card.queue <= 4
new_state = self._answerCard(card, ease)
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)
if ease == BUTTON_ONE:
new_state = states.again
rating = _pb.AnswerCardIn.AGAIN
elif ease == BUTTON_TWO:
new_state = states.hard
rating = _pb.AnswerCardIn.HARD
elif ease == BUTTON_THREE:
new_state = states.good
rating = _pb.AnswerCardIn.GOOD
elif ease == BUTTON_FOUR:
new_state = states.easy
rating = _pb.AnswerCardIn.EASY
else:
assert False, "invalid ease"
self.col._backend.answer_card(
card_id=card.id,
current_state=states.current,
new_state=new_state,
rating=rating,
answered_at_millis=intTime(1000),
milliseconds_taken=card.timeTaken(),
)
# fixme: tests assume card will be mutated, so we need to reload it
card.load()
return new_state
def _handle_leech(self, card: Card, new_state: _pb.SchedulingState) -> bool:
"True if was leech."
if self.col._backend.state_is_leech(new_state):
if hooks.card_did_leech.count() > 0:
hooks.card_did_leech(card)
# leech hooks assumed that card mutations would be saved for them
card.mod = intTime()
card.usn = self.col.usn()
card.flush()
return True
else:
return False
# Next times
##########################################################################
# fixme: move these into tests_schedv2 in the future
def _interval_for_state(self, state: _pb.SchedulingState) -> int:
kind = state.WhichOneof("value")
if kind == "normal":
return self._interval_for_normal_state(state.normal)
elif kind == "filtered":
return self._interval_for_filtered_state(state.filtered)
else:
assert_exhaustive(kind)
return 0 # unreachable
def _interval_for_normal_state(self, normal: _pb.SchedulingState.Normal) -> int:
kind = normal.WhichOneof("value")
if kind == "new":
return 0
elif kind == "review":
return normal.review.scheduled_days * 86400
elif kind == "learning":
return normal.learning.scheduled_secs
elif kind == "relearning":
return normal.relearning.learning.scheduled_secs
else:
assert_exhaustive(kind)
return 0 # unreachable
def _interval_for_filtered_state(
self, filtered: _pb.SchedulingState.Filtered
) -> int:
kind = filtered.WhichOneof("value")
if kind == "preview":
return filtered.preview.scheduled_secs
elif kind == "rescheduling":
return self._interval_for_normal_state(filtered.rescheduling.original_state)
else:
assert_exhaustive(kind)
return 0 # unreachable
def nextIvl(self, card: Card, ease: int) -> Any:
"Don't use this - it is only required by tests, and will be moved in the future."
states = self.col._backend.get_next_card_states(card.id)
if ease == BUTTON_ONE:
new_state = states.again
elif ease == BUTTON_TWO:
new_state = states.hard
elif ease == BUTTON_THREE:
new_state = states.good
elif ease == BUTTON_FOUR:
new_state = states.easy
else:
assert False, "invalid ease"
return self._interval_for_state(new_state)
# Review-related UI helpers
##########################################################################
def countIdx(self, card: Card) -> int:
if card.queue in (QUEUE_TYPE_DAY_LEARN_RELEARN, QUEUE_TYPE_PREVIEW):
return QUEUE_TYPE_LRN
return card.queue
def answerButtons(self, card: Card) -> int:
return 4
def nextIvlStr(self, card: Card, ease: int, short: bool = False) -> str:
"Return the next interval for CARD as a string."
states = self.col._backend.get_next_card_states(card.id)
return self.col._backend.describe_next_states(states)[ease - 1]

View file

@ -3,27 +3,23 @@
from __future__ import annotations from __future__ import annotations
import pprint
import random import random
import time import time
from heapq import * from heapq import *
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union from typing import Any, Callable, Dict, List, Optional, Tuple, Union
import anki # pylint: disable=unused-import import anki # pylint: disable=unused-import
import anki._backend.backend_pb2 as _pb import anki._backend.backend_pb2 as _pb
from anki import hooks from anki import hooks
from anki.cards import Card from anki.cards import Card
from anki.consts import * from anki.consts import *
from anki.decks import Deck, DeckConfig, DeckTreeNode, QueueConfig from anki.decks import Deck, DeckConfig, QueueConfig
from anki.lang import FormatTimeSpan from anki.lang import FormatTimeSpan
from anki.notes import Note from anki.scheduler.legacy import SchedulerBaseWithLegacy
from anki.utils import from_json_bytes, ids2str, intTime from anki.utils import ids2str, intTime
CongratsInfo = _pb.CongratsInfoOut
CountsForDeckToday = _pb.CountsForDeckTodayOut CountsForDeckToday = _pb.CountsForDeckTodayOut
SchedTimingToday = _pb.SchedTimingTodayOut SchedTimingToday = _pb.SchedTimingTodayOut
UnburyCurrentDeck = _pb.UnburyCardsInCurrentDeckIn
BuryOrSuspend = _pb.BuryOrSuspendCardsIn
# card types: 0=new, 1=lrn, 2=rev, 3=relrn # card types: 0=new, 1=lrn, 2=rev, 3=relrn
# queue types: 0=new, 1=(re)lrn, 2=rev, 3=day (re)lrn, # queue types: 0=new, 1=(re)lrn, 2=rev, 3=day (re)lrn,
@ -34,20 +30,19 @@ BuryOrSuspend = _pb.BuryOrSuspendCardsIn
# odue/odid store original due/did when cards moved to filtered deck # odue/odid store original due/did when cards moved to filtered deck
class Scheduler: class Scheduler(SchedulerBaseWithLegacy):
version = 2
name = "std2" name = "std2"
haveCustomStudy = True haveCustomStudy = True
_burySiblingsOnAnswer = True _burySiblingsOnAnswer = True
revCount: int revCount: int
is_2021 = False
def __init__(self, col: anki.collection.Collection) -> None: def __init__(self, col: anki.collection.Collection) -> None:
self.col = col.weakref() super().__init__(col)
self.queueLimit = 50 self.queueLimit = 50
self.reportLimit = 1000 self.reportLimit = 1000
self.dynReportLimit = 99999 self.dynReportLimit = 99999
self.reps = 0 self.reps = 0
self.today: Optional[int] = None
self._haveQueues = False self._haveQueues = False
self._lrnCutoff = 0 self._lrnCutoff = 0
self._updateCutoff() self._updateCutoff()
@ -56,18 +51,13 @@ class Scheduler:
########################################################################## ##########################################################################
def _updateCutoff(self) -> None: def _updateCutoff(self) -> None:
timing = self._timing_today() pass
self.today = timing.days_elapsed
self.dayCutoff = timing.next_day_at
def _checkDay(self) -> None: def _checkDay(self) -> None:
# check if the day has rolled over # check if the day has rolled over
if time.time() > self.dayCutoff: if time.time() > self.dayCutoff:
self.reset() self.reset()
def _timing_today(self) -> SchedTimingToday:
return self.col._backend.sched_timing_today()
# Fetching the next card # Fetching the next card
########################################################################## ##########################################################################
@ -926,11 +916,11 @@ limit ?"""
min, max = self._fuzzIvlRange(ivl) min, max = self._fuzzIvlRange(ivl)
return random.randint(min, max) return random.randint(min, max)
def _fuzzIvlRange(self, ivl: int) -> List[int]: def _fuzzIvlRange(self, ivl: int) -> Tuple[int, int]:
if ivl < 2: if ivl < 2:
return [1, 1] return (1, 1)
elif ivl == 2: elif ivl == 2:
return [2, 3] return (2, 3)
elif ivl < 7: elif ivl < 7:
fuzz = int(ivl * 0.25) fuzz = int(ivl * 0.25)
elif ivl < 30: elif ivl < 30:
@ -939,7 +929,7 @@ limit ?"""
fuzz = max(4, int(ivl * 0.05)) fuzz = max(4, int(ivl * 0.05))
# fuzz at least a day # fuzz at least a day
fuzz = max(fuzz, 1) fuzz = max(fuzz, 1)
return [ivl - fuzz, ivl + fuzz] return (ivl - fuzz, ivl + fuzz)
def _constrainedIvl( def _constrainedIvl(
self, ivl: float, conf: QueueConfig, prev: int, fuzz: bool self, ivl: float, conf: QueueConfig, prev: int, fuzz: bool
@ -1001,20 +991,6 @@ limit ?"""
# Daily limits # Daily limits
########################################################################## ##########################################################################
def update_stats(
self,
deck_id: int,
new_delta: int = 0,
review_delta: int = 0,
milliseconds_delta: int = 0,
) -> None:
self.col._backend.update_stats(
deck_id=deck_id,
new_delta=new_delta,
review_delta=review_delta,
millisecond_delta=milliseconds_delta,
)
def counts_for_deck_today(self, deck_id: int) -> CountsForDeckToday: def counts_for_deck_today(self, deck_id: int) -> CountsForDeckToday:
return self.col._backend.counts_for_deck_today(deck_id) return self.col._backend.counts_for_deck_today(deck_id)
@ -1157,252 +1133,6 @@ and (queue={QUEUE_TYPE_NEW} or (queue={QUEUE_TYPE_REV} and due<=?))""",
s = "<" + s s = "<" + s
return s return s
# Deck list
##########################################################################
def deck_due_tree(self, top_deck_id: int = 0) -> DeckTreeNode:
"""Returns a tree of decks with counts.
If top_deck_id provided, counts are limited to that node."""
return self.col._backend.deck_tree(top_deck_id=top_deck_id, now=intTime())
# Deck finished state & custom study
##########################################################################
def congratulations_info(self) -> CongratsInfo:
return self.col._backend.congrats_info()
def haveBuriedSiblings(self) -> bool:
return self.congratulations_info().have_sched_buried
def haveManuallyBuried(self) -> bool:
return self.congratulations_info().have_user_buried
def haveBuried(self) -> bool:
info = self.congratulations_info()
return info.have_sched_buried or info.have_user_buried
def extendLimits(self, new: int, rev: int) -> None:
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: def _is_finished(self) -> bool:
"Don't use this, it is a stop-gap until this code is refactored." "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)) return not any((self.newCount, self.revCount, self._immediate_learn_count))
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 ?)"""
% self._deckLimit(),
self.today,
self.reportLimit,
)
# Filtered deck handling
##########################################################################
def rebuild_filtered_deck(self, deck_id: int) -> int:
return self.col._backend.rebuild_filtered_deck(deck_id)
def empty_filtered_deck(self, deck_id: int) -> None:
self.col._backend.empty_filtered_deck(deck_id)
# Suspending & burying
##########################################################################
def unsuspend_cards(self, ids: List[int]) -> None:
self.col._backend.restore_buried_and_suspended_cards(ids)
def unbury_cards(self, ids: List[int]) -> None:
self.col._backend.restore_buried_and_suspended_cards(ids)
def unbury_cards_in_current_deck(
self,
mode: UnburyCurrentDeck.Mode.V = UnburyCurrentDeck.ALL,
) -> None:
self.col._backend.unbury_cards_in_current_deck(mode)
def suspend_cards(self, ids: Sequence[int]) -> None:
self.col._backend.bury_or_suspend_cards(
card_ids=ids, mode=BuryOrSuspend.SUSPEND
)
def bury_cards(self, ids: Sequence[int], manual: bool = True) -> None:
if manual:
mode = BuryOrSuspend.BURY_USER
else:
mode = BuryOrSuspend.BURY_SCHED
self.col._backend.bury_or_suspend_cards(card_ids=ids, mode=mode)
def bury_note(self, note: Note) -> None:
self.bury_cards(note.card_ids())
# Resetting/rescheduling
##########################################################################
def schedule_cards_as_new(self, card_ids: List[int]) -> None:
"Put cards at the end of the new queue."
self.col._backend.schedule_cards_as_new(card_ids=card_ids, log=True)
def set_due_date(self, card_ids: List[int], days: str) -> None:
"""Set cards to be due in `days`, turning them into review cards if necessary.
`days` can be of the form '5' or '5..7'"""
self.col._backend.set_due_date(card_ids=card_ids, days=days)
def resetCards(self, ids: List[int]) -> None:
"Completely reset cards for export."
sids = ids2str(ids)
# we want to avoid resetting due number of existing new cards on export
nonNew = self.col.db.list(
f"select id from cards where id in %s and (queue != {QUEUE_TYPE_NEW} or type != {CARD_TYPE_NEW})"
% sids
)
# reset all cards
self.col.db.execute(
f"update cards set reps=0,lapses=0,odid=0,odue=0,queue={QUEUE_TYPE_NEW}"
" where id in %s" % sids
)
# and forget any non-new cards, changing their due numbers
self.col._backend.schedule_cards_as_new(card_ids=nonNew, log=False)
# Repositioning new cards
##########################################################################
def sortCards(
self,
cids: List[int],
start: int = 1,
step: int = 1,
shuffle: bool = False,
shift: bool = False,
) -> None:
self.col._backend.sort_cards(
card_ids=cids,
starting_from=start,
step_size=step,
randomize=shuffle,
shift_existing=shift,
)
def randomizeCards(self, did: int) -> None:
self.col._backend.sort_deck(deck_id=did, randomize=True)
def orderCards(self, did: int) -> None:
self.col._backend.sort_deck(deck_id=did, randomize=False)
def resortConf(self, conf: DeckConfig) -> None:
for did in self.col.decks.didsForConf(conf):
if conf["new"]["order"] == 0:
self.randomizeCards(did)
else:
self.orderCards(did)
# for post-import
def maybeRandomizeDeck(self, did: Optional[int] = None) -> None:
if not did:
did = self.col.decks.selected()
conf = self.col.decks.confForDid(did)
# in order due?
if conf["new"]["order"] == NEW_CARDS_RANDOM:
self.randomizeCards(did)
##########################################################################
def __repr__(self) -> str:
d = dict(self.__dict__)
del d["col"]
return f"{super().__repr__()} {pprint.pformat(d, width=300)}"
# Legacy aliases and helpers
##########################################################################
def reschedCards(
self, card_ids: List[int], min_interval: int, max_interval: int
) -> None:
self.set_due_date(card_ids, f"{min_interval}-{max_interval}!")
def buryNote(self, nid: int) -> None:
note = self.col.get_note(nid)
self.bury_cards(note.card_ids())
def unburyCards(self) -> None:
print(
"please use unbury_cards() or unbury_cards_in_current_deck instead of unburyCards()"
)
self.unbury_cards_in_current_deck()
def unburyCardsForDeck(self, type: str = "all") -> None:
print(
"please use unbury_cards_in_current_deck() instead of unburyCardsForDeck()"
)
if type == "all":
mode = UnburyCurrentDeck.ALL
elif type == "manual":
mode = UnburyCurrentDeck.USER_ONLY
else: # elif type == "siblings":
mode = UnburyCurrentDeck.SCHED_ONLY
self.unbury_cards_in_current_deck(mode)
def finishedMsg(self) -> str:
print("finishedMsg() is obsolete")
return ""
def _nextDueMsg(self) -> str:
print("_nextDueMsg() is obsolete")
return ""
def rebuildDyn(self, did: Optional[int] = None) -> Optional[int]:
did = did or self.col.decks.selected()
count = self.rebuild_filtered_deck(did) or None
if not count:
return None
# and change to our new deck
self.col.decks.select(did)
return count
def emptyDyn(self, did: Optional[int], lim: Optional[str] = None) -> None:
if lim is None:
self.empty_filtered_deck(did)
return
queue = f"""
queue = (case when queue < 0 then queue
when type in (1,{CARD_TYPE_RELEARNING}) then
(case when (case when odue then odue else due end) > 1000000000 then 1 else
{QUEUE_TYPE_DAY_LEARN_RELEARN} end)
else
type
end)
"""
self.col.db.execute(
"""
update cards set did = odid, %s,
due = (case when odue>0 then odue else due end), odue = 0, odid = 0, usn = ? where %s"""
% (queue, lim),
self.col.usn(),
)
def remFromDyn(self, cids: List[int]) -> None:
self.emptyDyn(None, f"id in {ids2str(cids)} and odid")
def _updateStats(self, card: Card, type: str, cnt: int = 1) -> None:
did = card.did
if type == "new":
self.update_stats(did, new_delta=cnt)
elif type == "rev":
self.update_stats(did, review_delta=cnt)
elif type == "time":
self.update_stats(did, milliseconds_delta=cnt)
def deckDueTree(self) -> List:
"List of (base name, did, rev, lrn, new, children)"
print(
"deckDueTree() is deprecated; use decks.deck_tree() for a tree without counts, or sched.deck_due_tree()"
)
return from_json_bytes(self.col._backend.deck_tree_legacy())[5]
unsuspendCards = unsuspend_cards
buryCards = bury_cards
suspendCards = suspend_cards
forgetCards = schedule_cards_as_new

View file

@ -10,7 +10,7 @@ import pytest
from anki import hooks from anki import hooks
from anki.consts import * from anki.consts import *
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.schedv2 import UnburyCurrentDeck from anki.scheduler import UnburyCurrentDeck
from anki.utils import intTime from anki.utils import intTime
from tests.shared import getEmptyCol as getEmptyColOrig from tests.shared import getEmptyCol as getEmptyColOrig

View file

@ -1056,7 +1056,7 @@ title="%s" %s>%s</button>""" % (
elif isinstance(result, BackendUndo): elif isinstance(result, BackendUndo):
name = result.name name = result.name
if reviewing and self.col.sched.is_2021: if reviewing and self.col.sched.version == 3:
# new scheduler will have taken care of updating queue # new scheduler will have taken care of updating queue
just_refresh_reviewer = True just_refresh_reviewer = True