# -*- coding: utf-8 -*- # Copyright: Damien Elmes # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html from __future__ import division import difflib, re, cgi import unicodedata as ucd import HTMLParser from anki.lang import _, ngettext from aqt.qt import * from anki.utils import stripHTML, isMac, json from anki.hooks import addHook, runHook from anki.sound import playFromText, clearAudioQueue, play from aqt.utils import mungeQA, getBase, openLink, tooltip, askUserDialog from aqt.sound import getAudio import aqt class Reviewer(object): "Manage reviews. Maintains a separate state." def __init__(self, mw): self.mw = mw self.web = mw.web self.card = None self.cardQueue = [] self.hadCardQueue = False self._answeredIds = [] self._recordedAudio = None self.typeCorrect = None # web init happens before this is set self.state = None self.bottom = aqt.toolbar.BottomBar(mw, mw.bottomWeb) # qshortcut so we don't autorepeat self.delShortcut = QShortcut(QKeySequence("Delete"), self.mw) self.delShortcut.setAutoRepeat(False) self.mw.connect(self.delShortcut, SIGNAL("activated()"), self.onDelete) addHook("leech", self.onLeech) def show(self): self.mw.col.reset() self.mw.keyHandler = self._keyHandler self.web.setLinkHandler(self._linkHandler) self.web.setKeyHandler(self._catchEsc) if isMac: self.bottom.web.setFixedHeight(46) else: self.bottom.web.setFixedHeight(52+self.mw.fontHeightDelta*4) self.bottom.web.setLinkHandler(self._linkHandler) self._reps = None self.nextCard() def lastCard(self): if self._answeredIds: if not self.card or self._answeredIds[-1] != self.card.id: try: return self.mw.col.getCard(self._answeredIds[-1]) except TypeError: # id was deleted return def cleanup(self): runHook("reviewCleanup") # Fetching a card ########################################################################## def nextCard(self): elapsed = self.mw.col.timeboxReached() if elapsed: part1 = ngettext("%d card studied in", "%d cards studied in", elapsed[1]) % elapsed[1] mins = int(round(elapsed[0]/60)) part2 = ngettext("%s minute.", "%s minutes.", mins) % mins fin = _("Finish") diag = askUserDialog("%s %s" % (part1, part2), [_("Continue"), fin]) diag.setIcon(QMessageBox.Information) if diag.run() == fin: return self.mw.moveToState("deckBrowser") self.mw.col.startTimebox() if self.cardQueue: # undone/edited cards to show c = self.cardQueue.pop() c.startTimer() self.hadCardQueue = True else: if self.hadCardQueue: # the undone/edited cards may be sitting in the regular queue; # need to reset self.mw.col.reset() self.hadCardQueue = False c = self.mw.col.sched.getCard() self.card = c clearAudioQueue() if not c: self.mw.moveToState("overview") return if self._reps is None or self._reps % 100 == 0: # we recycle the webview periodically so webkit can free memory self._initWeb() else: self._showQuestion() # Audio ########################################################################## def replayAudio(self): clearAudioQueue() c = self.card if self.state == "question": playFromText(c.q()) elif self.state == "answer": txt = "" if self._replayq(c): txt = c.q() txt += c.a() playFromText(txt) # Initializing the webview ########################################################################## _revHtml = """
""" def _initWeb(self): self._reps = 0 self._bottomReady = False base = getBase(self.mw.col) # main window self.web.stdHtml(self._revHtml, self._styles(), loadCB=lambda x: self._showQuestion(), head=base) # show answer / ease buttons self.bottom.web.show() self.bottom.web.stdHtml( self._bottomHTML(), self.bottom._css + self._bottomCSS, loadCB=lambda x: self._showAnswerButton()) # Showing the question ########################################################################## def _mungeQA(self, buf): return self.typeAnsFilter(mungeQA(self.mw.col, buf)) def _showQuestion(self): self._reps += 1 self.state = "question" self.typedAnswer = None c = self.card # grab the question and play audio if c.isEmpty(): q = _("""\ The front of this card is empty. Please run Tools>Empty Cards.""") else: q = c.q() if self.autoplay(c): playFromText(q) # render & update bottom q = self._mungeQA(q) klass = "card card%d" % (c.ord+1) self.web.eval("_updateQA(%s, false, '%s');" % (json.dumps(q), klass)) self._toggleStar() if self._bottomReady: self._showAnswerButton() # if we have a type answer field, focus main web if self.typeCorrect: self.mw.web.setFocus() # user hook runHook('showQuestion') def autoplay(self, card): return self.mw.col.decks.confForDid( card.odid or card.did)['autoplay'] def _replayq(self, card): return self.mw.col.decks.confForDid( self.card.odid or self.card.did).get('replayq', True) def _toggleStar(self): self.web.eval("_toggleStar(%s);" % json.dumps( self.card.note().hasTag("marked"))) # Showing the answer ########################################################################## def _showAnswer(self): if self.mw.state != "review": # showing resetRequired screen; ignore space return self.state = "answer" c = self.card a = c.a() # play audio? if self.autoplay(c): playFromText(a) # render and update bottom a = self._mungeQA(a) self.web.eval("_updateQA(%s, true);" % json.dumps(a)) self._showEaseButtons() # user hook runHook('showAnswer') # Answering a card ############################################################ def _answerCard(self, ease): "Reschedule card and show next." if self.mw.state != "review": # showing resetRequired screen; ignore key return if self.state != "answer": return if self.mw.col.sched.answerButtons(self.card) < ease: return self.mw.col.sched.answerCard(self.card, ease) self._answeredIds.append(self.card.id) self.mw.autosave() self.nextCard() # Handlers ############################################################ def _catchEsc(self, evt): if evt.key() == Qt.Key_Escape: self.web.eval("$('#typeans').blur();") return True def _showAnswerHack(self): # on Empty Cards""") else: warn = _("Type answer: unknown field %s") % fld return re.sub(self.typeAnsPat, warn, buf) else: # empty field, remove type answer pattern return re.sub(self.typeAnsPat, "", buf) return re.sub(self.typeAnsPat, """
""" % (self.typeFont, self.typeSize), buf) def typeAnsAnswerFilter(self, buf): # tell webview to call us back with the input content self.web.eval("_getTypedText();") if not self.typeCorrect: return re.sub(self.typeAnsPat, "", buf) origSize = len(buf) buf = buf.replace("
", "") hadHR = len(buf) != origSize # munge correct value parser = HTMLParser.HTMLParser() cor = stripHTML(self.mw.col.media.strip(self.typeCorrect)) # ensure we don't chomp multiple whitespace cor = cor.replace(" ", " ") cor = parser.unescape(cor) cor = cor.replace(u"\xa0", " ") given = self.typedAnswer # compare with typed answer res = self.correct(given, cor, showBad=False) # and update the type answer area def repl(match): # can't pass a string in directly, and can't use re.escape as it # escapes too much s = """ %s""" % ( self.typeFont, self.typeSize, res) if hadHR: # a hack to ensure the q/a separator falls before the answer # comparison when user is using {{FrontSide}} s = "
" + s return s return re.sub(self.typeAnsPat, repl, buf) def _contentForCloze(self, txt, idx): matches = re.findall("\{\{c%s::(.+?)\}\}"%idx, txt) if not matches: return None def noHint(txt): if "::" in txt: return txt.split("::")[0] return txt matches = [noHint(txt) for txt in matches] if len(matches) > 1: txt = ", ".join(matches) else: txt = matches[0] return txt def tokenizeComparison(self, given, correct): # compare in NFC form so accents appear correct given = ucd.normalize("NFC", given) correct = ucd.normalize("NFC", correct) try: s = difflib.SequenceMatcher(None, given, correct, autojunk=False) except: # autojunk was added in python 2.7.1 s = difflib.SequenceMatcher(None, given, correct) givenElems = [] correctElems = [] givenPoint = 0 correctPoint = 0 offby = 0 def logBad(old, new, str, array): if old != new: array.append((False, str[old:new])) def logGood(start, cnt, str, array): if cnt: array.append((True, str[start:start+cnt])) for x, y, cnt in s.get_matching_blocks(): # if anything was missed in correct, pad given if cnt and y-offby > x: givenElems.append((False, "-"*(y-x-offby))) offby = y-x # log any proceeding bad elems logBad(givenPoint, x, given, givenElems) logBad(correctPoint, y, correct, correctElems) givenPoint = x+cnt correctPoint = y+cnt # log the match logGood(x, cnt, given, givenElems) logGood(y, cnt, correct, correctElems) return givenElems, correctElems def correct(self, given, correct, showBad=True): "Diff-corrects the typed-in answer." givenElems, correctElems = self.tokenizeComparison(given, correct) def good(s): return ""+cgi.escape(s)+"" def bad(s): return ""+cgi.escape(s)+"" def missed(s): return ""+cgi.escape(s)+"" if given == correct: res = good(given) else: res = "" for ok, txt in givenElems: if ok: res += good(txt) else: res += bad(txt) res += "

" for ok, txt in correctElems: if ok: res += good(txt) else: res += missed(txt) res = "
" + res + "
" return res # Bottom bar ########################################################################## _bottomCSS = """ body { background: -webkit-gradient(linear, left top, left bottom, from(#fff), to(#ddd)); border-bottom: 0; border-top: 1px solid #aaa; margin: 0; padding: 0px; padding-left: 5px; padding-right: 5px; } button { min-width: 60px; white-space: nowrap; } .hitem { margin-top: 2px; } .stat { padding-top: 5px; } .stat2 { padding-top: 3px; font-weight: normal; } .stattxt { padding-left: 5px; padding-right: 5px; white-space: nowrap; } .nobold { font-weight: normal; display: inline-block; padding-top: 4px; } .spacer { height: 18px; } .spacer2 { height: 16px; } """ def _bottomHTML(self): return """


""" % dict(rem=self._remaining(), edit=_("Edit"), editkey=_("Shortcut key: %s") % "E", more=_("More"), time=self.card.timeTaken() // 1000) def _showAnswerButton(self): self._bottomReady = True if not self.typeCorrect: self.bottom.web.setFocus() middle = ''' %s
''' % ( self._remaining(), _("Shortcut key: %s") % _("Space"), _("Show Answer")) # wrap it in a table so it has the same top margin as the ease buttons middle = "
%s
" % middle if self.card.shouldShowTimer(): maxTime = self.card.timeLimit() / 1000 else: maxTime = 0 self.bottom.web.eval("showQuestion(%s,%d);" % ( json.dumps(middle), maxTime)) def _showEaseButtons(self): self.bottom.web.setFocus() middle = self._answerButtons() self.bottom.web.eval("showAnswer(%s);" % json.dumps(middle)) def _remaining(self): if not self.mw.col.conf['dueCounts']: return "" if self.hadCardQueue: # if it's come from the undo queue, don't count it separately counts = list(self.mw.col.sched.counts()) else: counts = list(self.mw.col.sched.counts(self.card)) idx = self.mw.col.sched.countIdx(self.card) counts[idx] = "%s" % (counts[idx]) space = " + " ctxt = '%s' % counts[0] ctxt += space + '%s' % counts[1] ctxt += space + '%s' % counts[2] return ctxt def _defaultEase(self): if self.mw.col.sched.answerButtons(self.card) == 4: return 3 else: return 2 def _answerButtonList(self): l = ((1, _("Again")),) cnt = self.mw.col.sched.answerButtons(self.card) if cnt == 2: return l + ((2, _("Good")),) elif cnt == 3: return l + ((2, _("Good")), (3, _("Easy"))) else: return l + ((2, _("Hard")), (3, _("Good")), (4, _("Easy"))) def _answerButtons(self): times = [] default = self._defaultEase() def but(i, label): if i == default: extra = "id=defease" else: extra = "" due = self._buttonTime(i) return ''' %s''' % (due, extra, _("Shortcut key: %s") % i, i, label) buf = "
" for ease, label in self._answerButtonList(): buf += but(ease, label) buf += "
" script = """ """ return buf + script def _buttonTime(self, i): if not self.mw.col.conf['estTimes']: return "
" txt = self.mw.col.sched.nextIvlStr(self.card, i, True) or " " return '%s
' % txt # Leeches ########################################################################## def onLeech(self, card): # for now s = _("Card was a leech.") if card.queue < 0: s += " " + _("It has been suspended.") tooltip(s) # Context menu ########################################################################## # note the shortcuts listed here also need to be defined above def showContextMenu(self): opts = [ [_("Mark Note"), "*", self.onMark], [_("Bury Note"), "-", self.onBuryNote], [_("Suspend Card"), "@", self.onSuspendCard], [_("Suspend Note"), "!", self.onSuspend], [_("Delete Note"), "Delete", self.onDelete], [_("Options"), "O", self.onOptions], None, [_("Replay Audio"), "R", self.replayAudio], [_("Record Own Voice"), "Shift+V", self.onRecordVoice], [_("Replay Own Voice"), "V", self.onReplayRecorded], ] m = QMenu(self.mw) for row in opts: if not row: m.addSeparator() continue label, scut, func = row a = m.addAction(label) a.setShortcut(QKeySequence(scut)) a.connect(a, SIGNAL("triggered()"), func) runHook("Reviewer.contextMenuEvent",self,m) m.exec_(QCursor.pos()) def onOptions(self): self.mw.onDeckConf(self.mw.col.decks.get( self.card.odid or self.card.did)) def onMark(self): f = self.card.note() if f.hasTag("marked"): f.delTag("marked") else: f.addTag("marked") f.flush() self._toggleStar() def onSuspend(self): self.mw.checkpoint(_("Suspend")) self.mw.col.sched.suspendCards( [c.id for c in self.card.note().cards()]) tooltip(_("Note suspended.")) self.mw.reset() def onSuspendCard(self): self.mw.checkpoint(_("Suspend")) self.mw.col.sched.suspendCards([self.card.id]) tooltip(_("Card suspended.")) self.mw.reset() def onDelete(self): # need to check state because the shortcut is global to the main # window if self.mw.state != "review" or not self.card: return self.mw.checkpoint(_("Delete")) cnt = len(self.card.note().cards()) self.mw.col.remNotes([self.card.note().id]) self.mw.reset() tooltip(ngettext( "Note and its %d card deleted.", "Note and its %d cards deleted.", cnt) % cnt) def onBuryNote(self): self.mw.checkpoint(_("Bury")) self.mw.col.sched.buryNote(self.card.nid) self.mw.reset() tooltip(_("Note buried.")) def onRecordVoice(self): self._recordedAudio = getAudio(self.mw, encode=False) self.onReplayRecorded() def onReplayRecorded(self): if not self._recordedAudio: return tooltip(_("You haven't recorded your voice yet.")) clearAudioQueue() play(self._recordedAudio)