# -*- coding: utf-8 -*- # Copyright: Damien Elmes # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import difflib, re, cgi import unicodedata as ucd import HTMLParser 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 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] part2 = ngettext("%s minute.", "%s minutes.", elapsed[0]/60) % (elapsed[0]/60) tooltip("%s %s" % (part1, part2), period=5000) 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.mw.col.media.escapeImages( self.typeAnsFilter(mungeQA(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>Maintenance>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( self.card.odid or self.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 Maintenance>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): if not self.typeCorrect: return re.sub(self.typeAnsPat, "", buf) # tell webview to call us back with the input content self.web.eval("_getTypedText();") # munge correct value parser = HTMLParser.HTMLParser() cor = stripHTML(self.mw.col.media.strip(self.typeCorrect)) cor = parser.unescape(cor) given = self.typedAnswer # compare with typed answer res = self.correct(cor, given) if cor != given: # Wrap the extra text in an id-ed span. res += u"
{0}
{1}
".format( _(u"Correct answer was:"), cor) # 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 return """ %s""" % ( self.typeFont, self.typeSize, res) 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 # following type answer functions thanks to Bernhard def calculateOkBadStyle(self): "Precalculates styles for correct and incorrect part of answer" st = "background: %s; color: #000;" self.styleOk = st % self.passedCharColour self.styleBad = st % self.failedCharColour def ok(self, a): "returns given sring in style correct (green)" if len(a) == 0: return "" return "%s" % (self.styleOk, cgi.escape(a)) def bad(self, a): "returns given sring in style incorrect (red)" if len(a) == 0: return "" return "%s" % (self.styleBad, cgi.escape(a)) def applyStyle(self, testChar, correct, wrong): "Calculates answer fragment depending on testChar's unicode category" ZERO_SIZE = 'Mn' def head(a): return a[:len(a) - 1] def tail(a): return a[len(a) - 1:] if ucd.category(testChar) == ZERO_SIZE: return self.ok(head(correct)) + self.bad(tail(correct) + wrong) return self.ok(correct) + self.bad(wrong) def correct(self, a, b): "Diff-corrects the typed-in answer." if b == "": return ""; self.calculateOkBadStyle() ret = "" lastEqual = "" s = difflib.SequenceMatcher(None, b, a) for tag, i1, i2, j1, j2 in s.get_opcodes(): if tag == "equal": lastEqual = b[i1:i2] elif tag == "replace": ret += self.applyStyle(b[i1], lastEqual, b[i1:i2] + ("-" * ((j2 - j1) - (i2 - i1)))) lastEqual = "" elif tag == "delete": ret += self.applyStyle(b[i1], lastEqual, b[i1:i2]) lastEqual = "" elif tag == "insert": if ucd.category(a[j1]) != 'Mn': dashNum = (j2 - j1) else: dashNum = ((j2 - j1) - 1) ret += self.applyStyle(a[j1], lastEqual, "-" * dashNum) lastEqual = "" return ret + self.ok(lastEqual) # 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 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) 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 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)