Monkeytype qt/aqt/reviewer.py

This commit is contained in:
Alan Du 2020-02-26 22:11:21 -05:00
parent 96ca469d12
commit 6c2dda6c9c
2 changed files with 78 additions and 68 deletions

View File

@ -657,6 +657,7 @@ where c.nid = n.id and c.id in %s group by nid"""
self._startTime = time.time() self._startTime = time.time()
self._startReps = self.sched.reps self._startReps = self.sched.reps
# FIXME: Use Literal[False] when on Python 3.8
def timeboxReached(self) -> Union[bool, Tuple[Any, int]]: def timeboxReached(self) -> Union[bool, Tuple[Any, int]]:
"Return (elapsedTime, reps) if timebox reached, or False." "Return (elapsedTime, reps) if timebox reached, or False."
if not self.conf["timeLim"]: if not self.conf["timeLim"]:

View File

@ -6,11 +6,12 @@ from __future__ import annotations
import difflib import difflib
import html import html
import html.parser
import json import json
import re import re
import unicodedata as ucd import unicodedata as ucd
from typing import List, Optional from typing import Callable, List, Optional, Sequence, Tuple, Union
from PyQt5.QtCore import Qt
from anki import hooks from anki import hooks
from anki.cards import Card from anki.cards import Card
@ -25,7 +26,7 @@ from aqt.utils import askUserDialog, downArrow, qtMenuShortcutWorkaround, toolti
class ReviewerBottomBar: class ReviewerBottomBar:
def __init__(self, reviewer: Reviewer): def __init__(self, reviewer: Reviewer) -> None:
self.reviewer = reviewer self.reviewer = reviewer
@ -39,28 +40,29 @@ class Reviewer:
self.cardQueue: List[Card] = [] self.cardQueue: List[Card] = []
self.hadCardQueue = False self.hadCardQueue = False
self._answeredIds: List[int] = [] self._answeredIds: List[int] = []
self._recordedAudio = None self._recordedAudio: Optional[str] = None
self.typeCorrect = None # web init happens before this is set self.typeCorrect: str = None # web init happens before this is set
self.state: Optional[str] = None self.state: Optional[str] = None
self.bottom = BottomBar(mw, mw.bottomWeb) self.bottom = BottomBar(mw, mw.bottomWeb)
hooks.card_did_leech.append(self.onLeech) hooks.card_did_leech.append(self.onLeech)
def show(self): def show(self) -> None:
self.mw.col.reset() self.mw.col.reset()
self.mw.setStateShortcuts(self._shortcutKeys()) self.mw.setStateShortcuts(self._shortcutKeys()) # type: ignore
self.web.set_bridge_command(self._linkHandler, self) self.web.set_bridge_command(self._linkHandler, self)
self.bottom.web.set_bridge_command(self._linkHandler, ReviewerBottomBar(self)) self.bottom.web.set_bridge_command(self._linkHandler, ReviewerBottomBar(self))
self._reps = None self._reps: int = None
self.nextCard() self.nextCard()
def lastCard(self): def lastCard(self) -> Optional[Card]:
if self._answeredIds: if self._answeredIds:
if not self.card or self._answeredIds[-1] != self.card.id: if not self.card or self._answeredIds[-1] != self.card.id:
try: try:
return self.mw.col.getCard(self._answeredIds[-1]) return self.mw.col.getCard(self._answeredIds[-1])
except TypeError: except TypeError:
# id was deleted # id was deleted
return return None
return None
def cleanup(self) -> None: def cleanup(self) -> None:
gui_hooks.reviewer_will_end() gui_hooks.reviewer_will_end()
@ -68,9 +70,10 @@ class Reviewer:
# Fetching a card # Fetching a card
########################################################################## ##########################################################################
def nextCard(self): def nextCard(self) -> None:
elapsed = self.mw.col.timeboxReached() elapsed = self.mw.col.timeboxReached()
if elapsed: if elapsed:
assert not isinstance(elapsed, bool)
part1 = ( part1 = (
ngettext("%d card studied in", "%d cards studied in", elapsed[1]) ngettext("%d card studied in", "%d cards studied in", elapsed[1])
% elapsed[1] % elapsed[1]
@ -125,7 +128,7 @@ class Reviewer:
# Initializing the webview # Initializing the webview
########################################################################## ##########################################################################
def revHtml(self): def revHtml(self) -> str:
extra = self.mw.col.conf.get("reviewExtra", "") extra = self.mw.col.conf.get("reviewExtra", "")
fade = "" fade = ""
if self.mw.pm.glMode() == "software": if self.mw.pm.glMode() == "software":
@ -140,7 +143,7 @@ class Reviewer:
fade, extra fade, extra
) )
def _initWeb(self): def _initWeb(self) -> None:
self._reps = 0 self._reps = 0
# main window # main window
self.web.stdHtml( self.web.stdHtml(
@ -167,13 +170,13 @@ class Reviewer:
# Showing the question # Showing the question
########################################################################## ##########################################################################
def _mungeQA(self, buf): def _mungeQA(self, buf: str) -> str:
return self.typeAnsFilter(self.mw.prepare_card_text_for_display(buf)) return self.typeAnsFilter(self.mw.prepare_card_text_for_display(buf))
def _showQuestion(self) -> None: def _showQuestion(self) -> None:
self._reps += 1 self._reps += 1
self.state = "question" self.state = "question"
self.typedAnswer = None self.typedAnswer: str = None
c = self.card c = self.card
# grab the question and play audio # grab the question and play audio
if c.isEmpty(): if c.isEmpty():
@ -206,17 +209,17 @@ The front of this card is empty. Please run Tools>Empty Cards."""
# user hook # user hook
gui_hooks.reviewer_did_show_question(c) gui_hooks.reviewer_did_show_question(c)
def autoplay(self, card): def autoplay(self, card: Card) -> bool:
return self.mw.col.decks.confForDid(card.odid or card.did)["autoplay"] return self.mw.col.decks.confForDid(card.odid or card.did)["autoplay"]
def _replayq(self, card, previewer=None): def _replayq(self, card, previewer=None):
s = previewer if previewer else self s = previewer if previewer else self
return s.mw.col.decks.confForDid(s.card.odid or s.card.did).get("replayq", True) return s.mw.col.decks.confForDid(s.card.odid or s.card.did).get("replayq", True)
def _drawFlag(self): def _drawFlag(self) -> None:
self.web.eval("_drawFlag(%s);" % self.card.userFlag()) self.web.eval("_drawFlag(%s);" % self.card.userFlag())
def _drawMark(self): def _drawMark(self) -> None:
self.web.eval("_drawMark(%s);" % json.dumps(self.card.note().hasTag("marked"))) self.web.eval("_drawMark(%s);" % json.dumps(self.card.note().hasTag("marked")))
# Showing the answer # Showing the answer
@ -246,7 +249,7 @@ The front of this card is empty. Please run Tools>Empty Cards."""
# Answering a card # Answering a card
############################################################ ############################################################
def _answerCard(self, ease): def _answerCard(self, ease: int) -> None:
"Reschedule card and show next." "Reschedule card and show next."
if self.mw.state != "review": if self.mw.state != "review":
# showing resetRequired screen; ignore key # showing resetRequired screen; ignore key
@ -269,7 +272,9 @@ The front of this card is empty. Please run Tools>Empty Cards."""
# Handlers # Handlers
############################################################ ############################################################
def _shortcutKeys(self): def _shortcutKeys(
self,
) -> List[Union[Tuple[str, Callable], Tuple[Qt.Key, Callable]]]:
return [ return [
("e", self.mw.onEditCurrent), ("e", self.mw.onEditCurrent),
(" ", self.onEnterKey), (" ", self.onEnterKey),
@ -299,18 +304,18 @@ The front of this card is empty. Please run Tools>Empty Cards."""
("7", self.on_seek_forward), ("7", self.on_seek_forward),
] ]
def on_pause_audio(self): def on_pause_audio(self) -> None:
av_player.toggle_pause() av_player.toggle_pause()
seek_secs = 5 seek_secs = 5
def on_seek_backward(self): def on_seek_backward(self) -> None:
av_player.seek_relative(-self.seek_secs) av_player.seek_relative(-self.seek_secs)
def on_seek_forward(self): def on_seek_forward(self) -> None:
av_player.seek_relative(self.seek_secs) av_player.seek_relative(self.seek_secs)
def onEnterKey(self): def onEnterKey(self) -> None:
if self.state == "question": if self.state == "question":
self._getTypedAnswer() self._getTypedAnswer()
elif self.state == "answer": elif self.state == "answer":
@ -318,14 +323,14 @@ The front of this card is empty. Please run Tools>Empty Cards."""
"selectedAnswerButton()", self._onAnswerButton "selectedAnswerButton()", self._onAnswerButton
) )
def _onAnswerButton(self, val): def _onAnswerButton(self, val: str) -> None:
# button selected? # button selected?
if val and val in "1234": if val and val in "1234":
self._answerCard(int(val)) self._answerCard(int(val))
else: else:
self._answerCard(self._defaultEase()) self._answerCard(self._defaultEase())
def _linkHandler(self, url): def _linkHandler(self, url: str) -> None:
if url == "ans": if url == "ans":
self._getTypedAnswer() self._getTypedAnswer()
elif url.startswith("ease"): elif url.startswith("ease"):
@ -344,13 +349,13 @@ The front of this card is empty. Please run Tools>Empty Cards."""
typeAnsPat = r"\[\[type:(.+?)\]\]" typeAnsPat = r"\[\[type:(.+?)\]\]"
def typeAnsFilter(self, buf): def typeAnsFilter(self, buf: str) -> str:
if self.state == "question": if self.state == "question":
return self.typeAnsQuestionFilter(buf) return self.typeAnsQuestionFilter(buf)
else: else:
return self.typeAnsAnswerFilter(buf) return self.typeAnsAnswerFilter(buf)
def typeAnsQuestionFilter(self, buf): def typeAnsQuestionFilter(self, buf: str) -> str:
self.typeCorrect = None self.typeCorrect = None
clozeIdx = None clozeIdx = None
m = re.search(self.typeAnsPat, buf) m = re.search(self.typeAnsPat, buf)
@ -397,20 +402,19 @@ Please run Tools>Empty Cards"""
buf, buf,
) )
def typeAnsAnswerFilter(self, buf): def typeAnsAnswerFilter(self, buf: str) -> str:
if not self.typeCorrect: if not self.typeCorrect:
return re.sub(self.typeAnsPat, "", buf) return re.sub(self.typeAnsPat, "", buf)
origSize = len(buf) origSize = len(buf)
buf = buf.replace("<hr id=answer>", "") buf = buf.replace("<hr id=answer>", "")
hadHR = len(buf) != origSize hadHR = len(buf) != origSize
# munge correct value # munge correct value
parser = html.parser.HTMLParser()
cor = self.mw.col.media.strip(self.typeCorrect) cor = self.mw.col.media.strip(self.typeCorrect)
cor = re.sub("(\n|<br ?/?>|</?div>)+", " ", cor) cor = re.sub("(\n|<br ?/?>|</?div>)+", " ", cor)
cor = stripHTML(cor) cor = stripHTML(cor)
# ensure we don't chomp multiple whitespace # ensure we don't chomp multiple whitespace
cor = cor.replace(" ", "&nbsp;") cor = cor.replace(" ", "&nbsp;")
cor = parser.unescape(cor) cor = html.unescape(cor)
cor = cor.replace("\xa0", " ") cor = cor.replace("\xa0", " ")
cor = cor.strip() cor = cor.strip()
given = self.typedAnswer given = self.typedAnswer
@ -434,7 +438,7 @@ Please run Tools>Empty Cards"""
return re.sub(self.typeAnsPat, repl, buf) return re.sub(self.typeAnsPat, repl, buf)
def _contentForCloze(self, txt, idx): def _contentForCloze(self, txt: str, idx) -> str:
matches = re.findall(r"\{\{c%s::(.+?)\}\}" % idx, txt, re.DOTALL) matches = re.findall(r"\{\{c%s::(.+?)\}\}" % idx, txt, re.DOTALL)
if not matches: if not matches:
return None return None
@ -452,24 +456,28 @@ Please run Tools>Empty Cards"""
txt = ", ".join(matches) txt = ", ".join(matches)
return txt return txt
def tokenizeComparison(self, given, correct): def tokenizeComparison(
self, given: str, correct: str
) -> Tuple[List[Tuple[bool, str]], List[Tuple[bool, str]]]:
# compare in NFC form so accents appear correct # compare in NFC form so accents appear correct
given = ucd.normalize("NFC", given) given = ucd.normalize("NFC", given)
correct = ucd.normalize("NFC", correct) correct = ucd.normalize("NFC", correct)
s = difflib.SequenceMatcher(None, given, correct, autojunk=False) s = difflib.SequenceMatcher(None, given, correct, autojunk=False)
givenElems = [] givenElems: List[Tuple[bool, str]] = []
correctElems = [] correctElems: List[Tuple[bool, str]] = []
givenPoint = 0 givenPoint = 0
correctPoint = 0 correctPoint = 0
offby = 0 offby = 0
def logBad(old, new, str, array): def logBad(old: int, new: int, s: str, array: List[Tuple[bool, str]]) -> None:
if old != new: if old != new:
array.append((False, str[old:new])) array.append((False, s[old:new]))
def logGood(start, cnt, str, array): def logGood(
start: int, cnt: int, s: str, array: List[Tuple[bool, str]]
) -> None:
if cnt: if cnt:
array.append((True, str[start : start + cnt])) array.append((True, s[start : start + cnt]))
for x, y, cnt in s.get_matching_blocks(): for x, y, cnt in s.get_matching_blocks():
# if anything was missed in correct, pad given # if anything was missed in correct, pad given
@ -486,17 +494,17 @@ Please run Tools>Empty Cards"""
logGood(y, cnt, correct, correctElems) logGood(y, cnt, correct, correctElems)
return givenElems, correctElems return givenElems, correctElems
def correct(self, given, correct, showBad=True): def correct(self, given: str, correct: str, showBad: bool = True) -> str:
"Diff-corrects the typed-in answer." "Diff-corrects the typed-in answer."
givenElems, correctElems = self.tokenizeComparison(given, correct) givenElems, correctElems = self.tokenizeComparison(given, correct)
def good(s): def good(s: str) -> str:
return "<span class=typeGood>" + html.escape(s) + "</span>" return "<span class=typeGood>" + html.escape(s) + "</span>"
def bad(s): def bad(s: str) -> str:
return "<span class=typeBad>" + html.escape(s) + "</span>" return "<span class=typeBad>" + html.escape(s) + "</span>"
def missed(s): def missed(s: str) -> str:
return "<span class=typeMissed>" + html.escape(s) + "</span>" return "<span class=typeMissed>" + html.escape(s) + "</span>"
if given == correct: if given == correct:
@ -519,24 +527,24 @@ Please run Tools>Empty Cards"""
res = "<div><code id=typeans>" + res + "</code></div>" res = "<div><code id=typeans>" + res + "</code></div>"
return res return res
def _noLoneMarks(self, s): def _noLoneMarks(self, s: str) -> str:
# ensure a combining character at the start does not join to # ensure a combining character at the start does not join to
# previous text # previous text
if s and ucd.category(s[0]).startswith("M"): if s and ucd.category(s[0]).startswith("M"):
return "\xa0" + s return "\xa0" + s
return s return s
def _getTypedAnswer(self): def _getTypedAnswer(self) -> None:
self.web.evalWithCallback("typeans ? typeans.value : null", self._onTypedAnswer) self.web.evalWithCallback("typeans ? typeans.value : null", self._onTypedAnswer)
def _onTypedAnswer(self, val): def _onTypedAnswer(self, val: None) -> None:
self.typedAnswer = val or "" self.typedAnswer = val or ""
self._showAnswer() self._showAnswer()
# Bottom bar # Bottom bar
########################################################################## ##########################################################################
def _bottomHTML(self): def _bottomHTML(self) -> str:
return """ return """
<center id=outer> <center id=outer>
<table id=innertable width=100%% cellspacing=0 cellpadding=0> <table id=innertable width=100%% cellspacing=0 cellpadding=0>
@ -565,7 +573,7 @@ time = %(time)d;
time=self.card.timeTaken() // 1000, time=self.card.timeTaken() // 1000,
) )
def _showAnswerButton(self): def _showAnswerButton(self) -> None:
if not self.typeCorrect: if not self.typeCorrect:
self.bottom.web.setFocus() self.bottom.web.setFocus()
middle = """ middle = """
@ -587,17 +595,17 @@ time = %(time)d;
self.bottom.web.eval("showQuestion(%s,%d);" % (json.dumps(middle), maxTime)) self.bottom.web.eval("showQuestion(%s,%d);" % (json.dumps(middle), maxTime))
self.bottom.web.adjustHeightToFit() self.bottom.web.adjustHeightToFit()
def _showEaseButtons(self): def _showEaseButtons(self) -> None:
self.bottom.web.setFocus() self.bottom.web.setFocus()
middle = self._answerButtons() middle = self._answerButtons()
self.bottom.web.eval("showAnswer(%s);" % json.dumps(middle)) self.bottom.web.eval("showAnswer(%s);" % json.dumps(middle))
def _remaining(self): def _remaining(self) -> str:
if not self.mw.col.conf["dueCounts"]: if not self.mw.col.conf["dueCounts"]:
return "" return ""
if self.hadCardQueue: if self.hadCardQueue:
# if it's come from the undo queue, don't count it separately # if it's come from the undo queue, don't count it separately
counts = list(self.mw.col.sched.counts()) counts: List[Union[int, str]] = list(self.mw.col.sched.counts())
else: else:
counts = list(self.mw.col.sched.counts(self.card)) counts = list(self.mw.col.sched.counts(self.card))
idx = self.mw.col.sched.countIdx(self.card) idx = self.mw.col.sched.countIdx(self.card)
@ -608,13 +616,13 @@ time = %(time)d;
ctxt += space + "<span class=review-count>%s</span>" % counts[2] ctxt += space + "<span class=review-count>%s</span>" % counts[2]
return ctxt return ctxt
def _defaultEase(self): def _defaultEase(self) -> int:
if self.mw.col.sched.answerButtons(self.card) == 4: if self.mw.col.sched.answerButtons(self.card) == 4:
return 3 return 3
else: else:
return 2 return 2
def _answerButtonList(self): def _answerButtonList(self) -> Sequence[Tuple[int, str]]:
l = ((1, _("Again")),) l = ((1, _("Again")),)
cnt = self.mw.col.sched.answerButtons(self.card) cnt = self.mw.col.sched.answerButtons(self.card)
if cnt == 2: if cnt == 2:
@ -624,7 +632,7 @@ time = %(time)d;
else: else:
return l + ((2, _("Hard")), (3, _("Good")), (4, _("Easy"))) return l + ((2, _("Hard")), (3, _("Good")), (4, _("Easy")))
def _answerButtons(self): def _answerButtons(self) -> str:
default = self._defaultEase() default = self._defaultEase()
def but(i, label): def but(i, label):
@ -652,7 +660,7 @@ time = %(time)d;
<script>$(function () { $("#defease").focus(); });</script>""" <script>$(function () { $("#defease").focus(); });</script>"""
return buf + script return buf + script
def _buttonTime(self, i): def _buttonTime(self, i: int) -> str:
if not self.mw.col.conf["estTimes"]: if not self.mw.col.conf["estTimes"]:
return "<div class=spacer></div>" return "<div class=spacer></div>"
txt = self.mw.col.sched.nextIvlStr(self.card, i, True) or "&nbsp;" txt = self.mw.col.sched.nextIvlStr(self.card, i, True) or "&nbsp;"
@ -661,7 +669,7 @@ time = %(time)d;
# Leeches # Leeches
########################################################################## ##########################################################################
def onLeech(self, card): def onLeech(self, card: Card) -> None:
# for now # for now
s = _("Card was a leech.") s = _("Card was a leech.")
if card.queue < 0: if card.queue < 0:
@ -730,7 +738,7 @@ time = %(time)d;
qtMenuShortcutWorkaround(m) qtMenuShortcutWorkaround(m)
m.exec_(QCursor.pos()) m.exec_(QCursor.pos())
def _addMenuItems(self, m, rows): def _addMenuItems(self, m, rows) -> None:
for row in rows: for row in rows:
if not row: if not row:
m.addSeparator() m.addSeparator()
@ -753,10 +761,10 @@ time = %(time)d;
a.setChecked(True) a.setChecked(True)
a.triggered.connect(func) a.triggered.connect(func)
def onOptions(self): def onOptions(self) -> None:
self.mw.onDeckConf(self.mw.col.decks.get(self.card.odid or self.card.did)) self.mw.onDeckConf(self.mw.col.decks.get(self.card.odid or self.card.did))
def setFlag(self, flag): def setFlag(self, flag: int) -> None:
# need to toggle off? # need to toggle off?
if self.card.userFlag() == flag: if self.card.userFlag() == flag:
flag = 0 flag = 0
@ -764,7 +772,7 @@ time = %(time)d;
self.card.flush() self.card.flush()
self._drawFlag() self._drawFlag()
def onMark(self): def onMark(self) -> None:
f = self.card.note() f = self.card.note()
if f.hasTag("marked"): if f.hasTag("marked"):
f.delTag("marked") f.delTag("marked")
@ -773,19 +781,19 @@ time = %(time)d;
f.flush() f.flush()
self._drawMark() self._drawMark()
def onSuspend(self): def onSuspend(self) -> None:
self.mw.checkpoint(_("Suspend")) self.mw.checkpoint(_("Suspend"))
self.mw.col.sched.suspendCards([c.id for c in self.card.note().cards()]) self.mw.col.sched.suspendCards([c.id for c in self.card.note().cards()])
tooltip(_("Note suspended.")) tooltip(_("Note suspended."))
self.mw.reset() self.mw.reset()
def onSuspendCard(self): def onSuspendCard(self) -> None:
self.mw.checkpoint(_("Suspend")) self.mw.checkpoint(_("Suspend"))
self.mw.col.sched.suspendCards([self.card.id]) self.mw.col.sched.suspendCards([self.card.id])
tooltip(_("Card suspended.")) tooltip(_("Card suspended."))
self.mw.reset() self.mw.reset()
def onDelete(self): def onDelete(self) -> None:
# need to check state because the shortcut is global to the main # need to check state because the shortcut is global to the main
# window # window
if self.mw.state != "review" or not self.card: if self.mw.state != "review" or not self.card:
@ -801,23 +809,24 @@ time = %(time)d;
% cnt % cnt
) )
def onBuryCard(self): def onBuryCard(self) -> None:
self.mw.checkpoint(_("Bury")) self.mw.checkpoint(_("Bury"))
self.mw.col.sched.buryCards([self.card.id]) self.mw.col.sched.buryCards([self.card.id])
self.mw.reset() self.mw.reset()
tooltip(_("Card buried.")) tooltip(_("Card buried."))
def onBuryNote(self): def onBuryNote(self) -> None:
self.mw.checkpoint(_("Bury")) self.mw.checkpoint(_("Bury"))
self.mw.col.sched.buryNote(self.card.nid) self.mw.col.sched.buryNote(self.card.nid)
self.mw.reset() self.mw.reset()
tooltip(_("Note buried.")) tooltip(_("Note buried."))
def onRecordVoice(self): def onRecordVoice(self) -> None:
self._recordedAudio = getAudio(self.mw, encode=False) self._recordedAudio = getAudio(self.mw, encode=False)
self.onReplayRecorded() self.onReplayRecorded()
def onReplayRecorded(self): def onReplayRecorded(self) -> None:
if not self._recordedAudio: if not self._recordedAudio:
return tooltip(_("You haven't recorded your voice yet.")) tooltip(_("You haven't recorded your voice yet."))
return
av_player.play_file(self._recordedAudio) av_player.play_file(self._recordedAudio)