1# -*- coding: utf-8 -*-
2# Copyright: Ankitects Pty Ltd and contributors
3# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
4
5import sre_constants
6import html
7import time
8import re
9import unicodedata
10from operator import  itemgetter
11from anki.lang import ngettext
12import json
13
14from aqt.qt import *
15import anki
16import aqt.forms
17from anki.utils import fmtTimeSpan, ids2str, htmlToTextLine, \
18    isWin, intTime, \
19    isMac, bodyClass
20from aqt.utils import saveGeom, restoreGeom, saveSplitter, restoreSplitter, \
21    saveHeader, restoreHeader, saveState, restoreState, getTag, \
22    showInfo, askUser, tooltip, openHelp, showWarning, shortcut, mungeQA, \
23    getOnlyText, MenuList, SubMenu, qtMenuShortcutWorkaround
24from anki.lang import _
25from anki.hooks import runHook, addHook, remHook, runFilter
26from aqt.webview import AnkiWebView
27from anki.consts import *
28from anki.sound import clearAudioQueue, allSounds, play
29
30
31# Data model
32##########################################################################
33
34class DataModel(QAbstractTableModel):
35
36    def __init__(self, browser):
37        QAbstractTableModel.__init__(self)
38        self.browser = browser
39        self.col = browser.col
40        self.sortKey = None
41        self.activeCols = self.col.conf.get(
42            "activeCols", ["noteFld", "template", "cardDue", "deck"])
43        self.cards = []
44        self.cardObjs = {}
45
46    def getCard(self, index):
47        id = self.cards[index.row()]
48        if not id in self.cardObjs:
49            self.cardObjs[id] = self.col.getCard(id)
50        return self.cardObjs[id]
51
52    def refreshNote(self, note):
53        refresh = False
54        for c in note.cards():
55            if c.id in self.cardObjs:
56                del self.cardObjs[c.id]
57                refresh = True
58        if refresh:
59            self.layoutChanged.emit()
60
61    # Model interface
62    ######################################################################
63
64    def rowCount(self, parent):
65        if parent and parent.isValid():
66            return 0
67        return len(self.cards)
68
69    def columnCount(self, parent):
70        if parent and parent.isValid():
71            return 0
72        return len(self.activeCols)
73
74    def data(self, index, role):
75        if not index.isValid():
76            return
77        if role == Qt.FontRole:
78            if self.activeCols[index.column()] not in (
79                "question", "answer", "noteFld"):
80                return
81            row = index.row()
82            c = self.getCard(index)
83            t = c.template()
84            if not t.get("bfont"):
85                return
86            f = QFont()
87            f.setFamily(t.get("bfont", "arial"))
88            f.setPixelSize(t.get("bsize", 12))
89            return f
90
91        elif role == Qt.TextAlignmentRole:
92            align = Qt.AlignVCenter
93            if self.activeCols[index.column()] not in ("question", "answer",
94               "template", "deck", "noteFld", "note"):
95                align |= Qt.AlignHCenter
96            return align
97        elif role == Qt.DisplayRole or role == Qt.EditRole:
98            return self.columnData(index)
99        else:
100            return
101
102    def headerData(self, section, orientation, role):
103        if orientation == Qt.Vertical:
104            return
105        elif role == Qt.DisplayRole and section < len(self.activeCols):
106            type = self.columnType(section)
107            txt = None
108            for stype, name in self.browser.columns:
109                if type == stype:
110                    txt = name
111                    break
112            # give the user a hint an invalid column was added by an add-on
113            if not txt:
114                txt = _("Add-on")
115            return txt
116        else:
117            return
118
119    def flags(self, index):
120        return Qt.ItemFlag(Qt.ItemIsEnabled |
121                           Qt.ItemIsSelectable)
122
123    # Filtering
124    ######################################################################
125
126    def search(self, txt):
127        self.beginReset()
128        t = time.time()
129        # the db progress handler may cause a refresh, so we need to zero out
130        # old data first
131        self.cards = []
132        invalid = False
133        try:
134            self.cards = self.col.findCards(txt, order=True)
135        except Exception as e:
136            if str(e) == "invalidSearch":
137                self.cards = []
138                invalid = True
139            else:
140                raise
141        #print "fetch cards in %dms" % ((time.time() - t)*1000)
142        self.endReset()
143
144        if invalid:
145            showWarning(_("Invalid search - please check for typing mistakes."))
146
147
148    def reset(self):
149        self.beginReset()
150        self.endReset()
151
152    # caller must have called editor.saveNow() before calling this or .reset()
153    def beginReset(self):
154        self.browser.editor.setNote(None, hide=False)
155        self.browser.mw.progress.start()
156        self.saveSelection()
157        self.beginResetModel()
158        self.cardObjs = {}
159
160    def endReset(self):
161        t = time.time()
162        self.endResetModel()
163        self.restoreSelection()
164        self.browser.mw.progress.finish()
165
166    def reverse(self):
167        self.browser.editor.saveNow(self._reverse)
168
169    def _reverse(self):
170        self.beginReset()
171        self.cards.reverse()
172        self.endReset()
173
174    def saveSelection(self):
175        cards = self.browser.selectedCards()
176        self.selectedCards = dict([(id, True) for id in cards])
177        if getattr(self.browser, 'card', None):
178            self.focusedCard = self.browser.card.id
179        else:
180            self.focusedCard = None
181
182    def restoreSelection(self):
183        if not self.cards:
184            return
185        sm = self.browser.form.tableView.selectionModel()
186        sm.clear()
187        # restore selection
188        items = QItemSelection()
189        count = 0
190        firstIdx = None
191        focusedIdx = None
192        for row, id in enumerate(self.cards):
193            # if the id matches the focused card, note the index
194            if self.focusedCard == id:
195                focusedIdx = self.index(row, 0)
196                items.select(focusedIdx, focusedIdx)
197                self.focusedCard = None
198            # if the card was previously selected, select again
199            if id in self.selectedCards:
200                count += 1
201                idx = self.index(row, 0)
202                items.select(idx, idx)
203                # note down the first card of the selection, in case we don't
204                # have a focused card
205                if not firstIdx:
206                    firstIdx = idx
207        # focus previously focused or first in selection
208        idx = focusedIdx or firstIdx
209        tv = self.browser.form.tableView
210        if idx:
211            tv.selectRow(idx.row())
212            # scroll if the selection count has changed
213            if count != len(self.selectedCards):
214                # we save and then restore the horizontal scroll position because
215                # scrollTo() also scrolls horizontally which is confusing
216                h = tv.horizontalScrollBar().value()
217                tv.scrollTo(idx, tv.PositionAtCenter)
218                tv.horizontalScrollBar().setValue(h)
219            if count < 500:
220                # discard large selections; they're too slow
221                sm.select(items, QItemSelectionModel.SelectCurrent |
222                          QItemSelectionModel.Rows)
223        else:
224            tv.selectRow(0)
225
226    # Column data
227    ######################################################################
228
229    def columnType(self, column):
230        return self.activeCols[column]
231
232    def columnData(self, index):
233        row = index.row()
234        col = index.column()
235        type = self.columnType(col)
236        c = self.getCard(index)
237        if type == "question":
238            return self.question(c)
239        elif type == "answer":
240            return self.answer(c)
241        elif type == "noteFld":
242            f = c.note()
243            return htmlToTextLine(f.fields[self.col.models.sortIdx(f.model())])
244        elif type == "template":
245            t = c.template()['name']
246            if c.model()['type'] == MODEL_CLOZE:
247                t += " %d" % (c.ord+1)
248            return t
249        elif type == "cardDue":
250            # catch invalid dates
251            try:
252                t = self.nextDue(c, index)
253            except:
254                t = ""
255            if c.queue < 0:
256                t = "(" + t + ")"
257            return t
258        elif type == "noteCrt":
259            return time.strftime("%Y-%m-%d", time.localtime(c.note().id/1000))
260        elif type == "noteMod":
261            return time.strftime("%Y-%m-%d", time.localtime(c.note().mod))
262        elif type == "cardMod":
263            return time.strftime("%Y-%m-%d", time.localtime(c.mod))
264        elif type == "cardReps":
265            return str(c.reps)
266        elif type == "cardLapses":
267            return str(c.lapses)
268        elif type == "noteTags":
269            return " ".join(c.note().tags)
270        elif type == "note":
271            return c.model()['name']
272        elif type == "cardIvl":
273            if c.type == 0:
274                return _("(new)")
275            elif c.type == 1:
276                return _("(learning)")
277            return fmtTimeSpan(c.ivl*86400)
278        elif type == "cardEase":
279            if c.type == 0:
280                return _("(new)")
281            return "%d%%" % (c.factor/10)
282        elif type == "deck":
283            if c.odid:
284                # in a cram deck
285                return "%s (%s)" % (
286                    self.browser.mw.col.decks.name(c.did),
287                    self.browser.mw.col.decks.name(c.odid))
288            # normal deck
289            return self.browser.mw.col.decks.name(c.did)
290
291    def question(self, c):
292        return htmlToTextLine(c.q(browser=True))
293
294    def answer(self, c):
295        if c.template().get('bafmt'):
296            # they have provided a template, use it verbatim
297            c.q(browser=True)
298            return htmlToTextLine(c.a())
299        # need to strip question from answer
300        q = self.question(c)
301        a = htmlToTextLine(c.a())
302        if a.startswith(q):
303            return a[len(q):].strip()
304        return a
305
306    def nextDue(self, c, index):
307        if c.odid:
308            return _("(filtered)")
309        elif c.queue == 1:
310            date = c.due
311        elif c.queue == 0 or c.type == 0:
312            return str(c.due)
313        elif c.queue in (2,3) or (c.type == 2 and c.queue < 0):
314            date = time.time() + ((c.due - self.col.sched.today)*86400)
315        else:
316            return ""
317        return time.strftime("%Y-%m-%d", time.localtime(date))
318
319    def isRTL(self, index):
320        col = index.column()
321        type = self.columnType(col)
322        if type != "noteFld":
323            return False
324
325        row = index.row()
326        c = self.getCard(index)
327        nt = c.note().model()
328        return nt['flds'][self.col.models.sortIdx(nt)]['rtl']
329
330# Line painter
331######################################################################
332
333COLOUR_SUSPENDED = "#FFFFB2"
334COLOUR_MARKED = "#ccc"
335
336flagColours = {
337    1: "#ffaaaa",
338    2: "#ffb347",
339    3: "#82E0AA",
340    4: "#85C1E9",
341}
342
343class StatusDelegate(QItemDelegate):
344
345    def __init__(self, browser, model):
346        QItemDelegate.__init__(self, browser)
347        self.browser = browser
348        self.model = model
349
350    def paint(self, painter, option, index):
351        self.browser.mw.progress.blockUpdates = True
352        try:
353            c = self.model.getCard(index)
354        except:
355            # in the the middle of a reset; return nothing so this row is not
356            # rendered until we have a chance to reset the model
357            return
358        finally:
359            self.browser.mw.progress.blockUpdates = True
360
361        if self.model.isRTL(index):
362            option.direction = Qt.RightToLeft
363
364        col = None
365        if c.userFlag() > 0:
366            col = flagColours[c.userFlag()]
367        elif c.note().hasTag("Marked"):
368            col = COLOUR_MARKED
369        elif c.queue == -1:
370            col = COLOUR_SUSPENDED
371        if col:
372            brush = QBrush(QColor(col))
373            painter.save()
374            painter.fillRect(option.rect, brush)
375            painter.restore()
376
377        return QItemDelegate.paint(self, painter, option, index)
378
379# Browser window
380######################################################################
381
382# fixme: respond to reset+edit hooks
383
384class Browser(QMainWindow):
385
386    def __init__(self, mw):
387        QMainWindow.__init__(self, None, Qt.Window)
388        self.mw = mw
389        self.col = self.mw.col
390        self.lastFilter = ""
391        self.focusTo = None
392        self._previewWindow = None
393        self._closeEventHasCleanedUp = False
394        self.form = aqt.forms.browser.Ui_Dialog()
395        self.form.setupUi(self)
396        self.setupSidebar()
397        restoreGeom(self, "editor", 0)
398        restoreState(self, "editor")
399        restoreSplitter(self.form.splitter, "editor3")
400        self.form.splitter.setChildrenCollapsible(False)
401        self.card = None
402        self.setupColumns()
403        self.setupTable()
404        self.setupMenus()
405        self.setupHeaders()
406        self.setupHooks()
407        self.setupEditor()
408        self.updateFont()
409        self.onUndoState(self.mw.form.actionUndo.isEnabled())
410        self.setupSearch()
411        self.show()
412
413    def setupMenus(self):
414        # pylint: disable=unnecessary-lambda
415        # actions
416        f = self.form
417        f.previewButton.clicked.connect(self.onTogglePreview)
418        f.previewButton.setToolTip(_("Preview Selected Card (%s)") %
419                                   shortcut(_("Ctrl+Shift+P")))
420
421        f.filter.clicked.connect(self.onFilterButton)
422        # edit
423        f.actionUndo.triggered.connect(self.mw.onUndo)
424        f.actionInvertSelection.triggered.connect(self.invertSelection)
425        f.actionSelectNotes.triggered.connect(self.selectNotes)
426        if not isMac:
427            f.actionClose.setVisible(False)
428        # notes
429        f.actionAdd.triggered.connect(self.mw.onAddCard)
430        f.actionAdd_Tags.triggered.connect(lambda: self.addTags())
431        f.actionRemove_Tags.triggered.connect(lambda: self.deleteTags())
432        f.actionClear_Unused_Tags.triggered.connect(self.clearUnusedTags)
433        f.actionToggle_Mark.triggered.connect(lambda: self.onMark())
434        f.actionChangeModel.triggered.connect(self.onChangeModel)
435        f.actionFindDuplicates.triggered.connect(self.onFindDupes)
436        f.actionFindReplace.triggered.connect(self.onFindReplace)
437        f.actionManage_Note_Types.triggered.connect(self.mw.onNoteTypes)
438        f.actionDelete.triggered.connect(self.deleteNotes)
439        # cards
440        f.actionChange_Deck.triggered.connect(self.setDeck)
441        f.action_Info.triggered.connect(self.showCardInfo)
442        f.actionReposition.triggered.connect(self.reposition)
443        f.actionReschedule.triggered.connect(self.reschedule)
444        f.actionToggle_Suspend.triggered.connect(self.onSuspend)
445        f.actionRed_Flag.triggered.connect(lambda: self.onSetFlag(1))
446        f.actionOrange_Flag.triggered.connect(lambda: self.onSetFlag(2))
447        f.actionGreen_Flag.triggered.connect(lambda: self.onSetFlag(3))
448        f.actionBlue_Flag.triggered.connect(lambda: self.onSetFlag(4))
449        # jumps
450        f.actionPreviousCard.triggered.connect(self.onPreviousCard)
451        f.actionNextCard.triggered.connect(self.onNextCard)
452        f.actionFirstCard.triggered.connect(self.onFirstCard)
453        f.actionLastCard.triggered.connect(self.onLastCard)
454        f.actionFind.triggered.connect(self.onFind)
455        f.actionNote.triggered.connect(self.onNote)
456        f.actionTags.triggered.connect(self.onFilterButton)
457        f.actionSidebar.triggered.connect(self.focusSidebar)
458        f.actionCardList.triggered.connect(self.onCardList)
459        # help
460        f.actionGuide.triggered.connect(self.onHelp)
461        # keyboard shortcut for shift+home/end
462        self.pgUpCut = QShortcut(QKeySequence("Shift+Home"), self)
463        self.pgUpCut.activated.connect(self.onFirstCard)
464        self.pgDownCut = QShortcut(QKeySequence("Shift+End"), self)
465        self.pgDownCut.activated.connect(self.onLastCard)
466        # add-on hook
467        runHook('browser.setupMenus', self)
468        self.mw.maybeHideAccelerators(self)
469
470        # context menu
471        self.form.tableView.setContextMenuPolicy(Qt.CustomContextMenu)
472        self.form.tableView.customContextMenuRequested.connect(self.onContextMenu)
473
474    def onContextMenu(self, _point):
475        m = QMenu()
476        for act in self.form.menu_Cards.actions():
477            m.addAction(act)
478        m.addSeparator()
479        for act in self.form.menu_Notes.actions():
480            m.addAction(act)
481        runHook("browser.onContextMenu", self, m)
482
483        qtMenuShortcutWorkaround(m)
484        m.exec_(QCursor.pos())
485
486    def updateFont(self):
487        # we can't choose different line heights efficiently, so we need
488        # to pick a line height big enough for any card template
489        curmax = 16
490        for m in self.col.models.all():
491            for t in m['tmpls']:
492                bsize = t.get("bsize", 0)
493                if bsize > curmax:
494                    curmax = bsize
495        self.form.tableView.verticalHeader().setDefaultSectionSize(
496            curmax + 6)
497
498    def closeEvent(self, evt):
499        if self._closeEventHasCleanedUp:
500            evt.accept()
501            return
502        self.editor.saveNow(self._closeWindow)
503        evt.ignore()
504
505    def _closeWindow(self):
506        self._cancelPreviewTimer()
507        self.editor.cleanup()
508        saveSplitter(self.form.splitter, "editor3")
509        saveGeom(self, "editor")
510        saveState(self, "editor")
511        saveHeader(self.form.tableView.horizontalHeader(), "editor")
512        self.col.conf['activeCols'] = self.model.activeCols
513        self.col.setMod()
514        self.teardownHooks()
515        self.mw.maybeReset()
516        aqt.dialogs.markClosed("Browser")
517        self._closeEventHasCleanedUp = True
518        self.mw.gcWindow(self)
519        self.close()
520
521    def closeWithCallback(self, onsuccess):
522        def callback():
523            self._closeWindow()
524            onsuccess()
525        self.editor.saveNow(callback)
526
527    def keyPressEvent(self, evt):
528        if evt.key() == Qt.Key_Escape:
529            self.close()
530        else:
531            super().keyPressEvent(evt)
532
533    def setupColumns(self):
534        self.columns = [
535            ('question', _("Question")),
536            ('answer', _("Answer")),
537            ('template', _("Card")),
538            ('deck', _("Deck")),
539            ('noteFld', _("Sort Field")),
540            ('noteCrt', _("Created")),
541            ('noteMod', _("Edited")),
542            ('cardMod', _("Changed")),
543            ('cardDue', _("Due")),
544            ('cardIvl', _("Interval")),
545            ('cardEase', _("Ease")),
546            ('cardReps', _("Reviews")),
547            ('cardLapses', _("Lapses")),
548            ('noteTags', _("Tags")),
549            ('note', _("Note")),
550        ]
551        self.columns.sort(key=itemgetter(1))
552
553    # Searching
554    ######################################################################
555
556    def setupSearch(self):
557        self.form.searchButton.clicked.connect(self.onSearchActivated)
558        self.form.searchEdit.lineEdit().returnPressed.connect(self.onSearchActivated)
559        self.form.searchEdit.setCompleter(None)
560        self._searchPrompt = _("<type here to search; hit enter to show current deck>")
561        self.form.searchEdit.addItems([self._searchPrompt] + self.mw.pm.profile['searchHistory'])
562        self._lastSearchTxt = "is:current"
563        self.search()
564        # then replace text for easily showing the deck
565        self.form.searchEdit.lineEdit().setText(self._searchPrompt)
566        self.form.searchEdit.lineEdit().selectAll()
567        self.form.searchEdit.setFocus()
568
569    # search triggered by user
570    def onSearchActivated(self):
571        self.editor.saveNow(self._onSearchActivated)
572
573    def _onSearchActivated(self):
574        # convert guide text before we save history
575        if self.form.searchEdit.lineEdit().text() == self._searchPrompt:
576            self.form.searchEdit.lineEdit().setText("deck:current ")
577
578        # grab search text and normalize
579        txt = self.form.searchEdit.lineEdit().text()
580        txt = unicodedata.normalize("NFC", txt)
581
582        # update history
583        sh = self.mw.pm.profile['searchHistory']
584        if txt in sh:
585            sh.remove(txt)
586        sh.insert(0, txt)
587        sh = sh[:30]
588        self.form.searchEdit.clear()
589        self.form.searchEdit.addItems(sh)
590        self.mw.pm.profile['searchHistory'] = sh
591
592        # keep track of search string so that we reuse identical search when
593        # refreshing, rather than whatever is currently in the search field
594        self._lastSearchTxt = txt
595        self.search()
596
597    # search triggered programmatically. caller must have saved note first.
598    def search(self):
599        if "is:current" in self._lastSearchTxt:
600            # show current card if there is one
601            c = self.mw.reviewer.card
602            self.card = self.mw.reviewer.card
603            nid = c and c.nid or 0
604            self.model.search("nid:%d"%nid)
605        else:
606            self.model.search(self._lastSearchTxt)
607
608        if not self.model.cards:
609            # no row change will fire
610            self._onRowChanged(None, None)
611
612    def updateTitle(self):
613        selected = len(self.form.tableView.selectionModel().selectedRows())
614        cur = len(self.model.cards)
615        self.setWindowTitle(ngettext("Browse (%(cur)d card shown; %(sel)s)",
616                                     "Browse (%(cur)d cards shown; %(sel)s)",
617                                 cur) % {
618            "cur": cur,
619            "sel": ngettext("%d selected", "%d selected", selected) % selected
620            })
621        return selected
622
623    def onReset(self):
624        self.editor.setNote(None)
625        self.search()
626
627    # Table view & editor
628    ######################################################################
629
630    def setupTable(self):
631        self.model = DataModel(self)
632        self.form.tableView.setSortingEnabled(True)
633        self.form.tableView.setModel(self.model)
634        self.form.tableView.selectionModel()
635        self.form.tableView.setItemDelegate(StatusDelegate(self, self.model))
636        self.form.tableView.selectionModel().selectionChanged.connect(self.onRowChanged)
637        self.form.tableView.setStyleSheet("QTableView{ selection-background-color: rgba(127, 127, 127, 50);  }")
638        self.singleCard = False
639
640    def setupEditor(self):
641        self.editor = aqt.editor.Editor(
642            self.mw, self.form.fieldsArea, self)
643
644    def onRowChanged(self, current, previous):
645        "Update current note and hide/show editor."
646        self.editor.saveNow(lambda: self._onRowChanged(current, previous))
647
648    def _onRowChanged(self, current, previous):
649        update = self.updateTitle()
650        show = self.model.cards and update == 1
651        self.form.splitter.widget(1).setVisible(not not show)
652        idx = self.form.tableView.selectionModel().currentIndex()
653        if idx.isValid():
654            self.card = self.model.getCard(idx)
655
656        if not show:
657            self.editor.setNote(None)
658            self.singleCard = False
659        else:
660            self.editor.setNote(self.card.note(reload=True), focusTo=self.focusTo)
661            self.focusTo = None
662            self.editor.card = self.card
663            self.singleCard = True
664        self._updateFlagsMenu()
665        runHook("browser.rowChanged", self)
666        self._renderPreview(True)
667
668    def refreshCurrentCard(self, note):
669        self.model.refreshNote(note)
670        self._renderPreview(False)
671
672    def onLoadNote(self, editor):
673        self.refreshCurrentCard(editor.note)
674
675    def refreshCurrentCardFilter(self, flag, note, fidx):
676        self.refreshCurrentCard(note)
677        return flag
678
679    def currentRow(self):
680        idx = self.form.tableView.selectionModel().currentIndex()
681        return idx.row()
682
683    # Headers & sorting
684    ######################################################################
685
686    def setupHeaders(self):
687        vh = self.form.tableView.verticalHeader()
688        hh = self.form.tableView.horizontalHeader()
689        if not isWin:
690            vh.hide()
691            hh.show()
692        restoreHeader(hh, "editor")
693        hh.setHighlightSections(False)
694        hh.setMinimumSectionSize(50)
695        hh.setSectionsMovable(True)
696        self.setColumnSizes()
697        hh.setContextMenuPolicy(Qt.CustomContextMenu)
698        hh.customContextMenuRequested.connect(self.onHeaderContext)
699        self.setSortIndicator()
700        hh.sortIndicatorChanged.connect(self.onSortChanged)
701        hh.sectionMoved.connect(self.onColumnMoved)
702
703    def onSortChanged(self, idx, ord):
704        ord = bool(ord)
705        self.editor.saveNow(lambda: self._onSortChanged(idx, ord))
706
707    def _onSortChanged(self, idx, ord):
708        type = self.model.activeCols[idx]
709        noSort = ("question", "answer", "template", "deck", "note", "noteTags")
710        if type in noSort:
711            if type == "template":
712                showInfo(_("""\
713This column can't be sorted on, but you can search for individual card types, \
714such as 'card:1'."""))
715            elif type == "deck":
716                showInfo(_("""\
717This column can't be sorted on, but you can search for specific decks \
718by clicking on one on the left."""))
719            else:
720                showInfo(_("Sorting on this column is not supported. Please "
721                           "choose another."))
722            type = self.col.conf['sortType']
723        if self.col.conf['sortType'] != type:
724            self.col.conf['sortType'] = type
725            # default to descending for non-text fields
726            if type == "noteFld":
727                ord = not ord
728            self.col.conf['sortBackwards'] = ord
729            self.search()
730        else:
731            if self.col.conf['sortBackwards'] != ord:
732                self.col.conf['sortBackwards'] = ord
733                self.model.reverse()
734        self.setSortIndicator()
735
736    def setSortIndicator(self):
737        hh = self.form.tableView.horizontalHeader()
738        type = self.col.conf['sortType']
739        if type not in self.model.activeCols:
740            hh.setSortIndicatorShown(False)
741            return
742        idx = self.model.activeCols.index(type)
743        if self.col.conf['sortBackwards']:
744            ord = Qt.DescendingOrder
745        else:
746            ord = Qt.AscendingOrder
747        hh.blockSignals(True)
748        hh.setSortIndicator(idx, ord)
749        hh.blockSignals(False)
750        hh.setSortIndicatorShown(True)
751
752    def onHeaderContext(self, pos):
753        gpos = self.form.tableView.mapToGlobal(pos)
754        m = QMenu()
755        for type, name in self.columns:
756            a = m.addAction(name)
757            a.setCheckable(True)
758            a.setChecked(type in self.model.activeCols)
759            a.toggled.connect(lambda b, t=type: self.toggleField(t))
760        m.exec_(gpos)
761
762    def toggleField(self, type):
763        self.editor.saveNow(lambda: self._toggleField(type))
764
765    def _toggleField(self, type):
766        self.model.beginReset()
767        if type in self.model.activeCols:
768            if len(self.model.activeCols) < 2:
769                self.model.endReset()
770                return showInfo(_("You must have at least one column."))
771            self.model.activeCols.remove(type)
772            adding=False
773        else:
774            self.model.activeCols.append(type)
775            adding=True
776        # sorted field may have been hidden
777        self.setSortIndicator()
778        self.setColumnSizes()
779        self.model.endReset()
780        # if we added a column, scroll to it
781        if adding:
782            row = self.currentRow()
783            idx = self.model.index(row, len(self.model.activeCols) - 1)
784            self.form.tableView.scrollTo(idx)
785
786    def setColumnSizes(self):
787        hh = self.form.tableView.horizontalHeader()
788        hh.setSectionResizeMode(QHeaderView.Interactive)
789        hh.setSectionResizeMode(hh.logicalIndex(len(self.model.activeCols)-1),
790                         QHeaderView.Stretch)
791        # this must be set post-resize or it doesn't work
792        hh.setCascadingSectionResizes(False)
793
794    def onColumnMoved(self, a, b, c):
795        self.setColumnSizes()
796
797    # Sidebar
798    ######################################################################
799
800    class CallbackItem(QTreeWidgetItem):
801        def __init__(self, root, name, onclick, oncollapse=None, expanded=False):
802            QTreeWidgetItem.__init__(self, root, [name])
803            self.setExpanded(expanded)
804            self.onclick = onclick
805            self.oncollapse = oncollapse
806
807    class SidebarTreeWidget(QTreeWidget):
808        def __init__(self):
809            QTreeWidget.__init__(self)
810            self.itemClicked.connect(self.onTreeClick)
811            self.itemExpanded.connect(self.onTreeCollapse)
812            self.itemCollapsed.connect(self.onTreeCollapse)
813
814        def keyPressEvent(self, evt):
815            if evt.key() in (Qt.Key_Return, Qt.Key_Enter):
816                item = self.currentItem()
817                self.onTreeClick(item, 0)
818            else:
819                super().keyPressEvent(evt)
820
821        def onTreeClick(self, item, col):
822            if getattr(item, 'onclick', None):
823                item.onclick()
824
825        def onTreeCollapse(self, item):
826            if getattr(item, 'oncollapse', None):
827                item.oncollapse()
828
829    def setupSidebar(self):
830        dw = self.sidebarDockWidget = QDockWidget(_("Sidebar"), self)
831        dw.setFeatures(QDockWidget.DockWidgetClosable)
832        dw.setObjectName("Sidebar")
833        dw.setAllowedAreas(Qt.LeftDockWidgetArea)
834        self.sidebarTree = self.SidebarTreeWidget()
835        self.sidebarTree.mw = self.mw
836        self.sidebarTree.header().setVisible(False)
837        dw.setWidget(self.sidebarTree)
838        p = QPalette()
839        p.setColor(QPalette.Base, p.window().color())
840        self.sidebarTree.setPalette(p)
841        self.sidebarDockWidget.setFloating(False)
842        self.sidebarDockWidget.visibilityChanged.connect(self.onSidebarVisChanged)
843        self.sidebarDockWidget.setTitleBarWidget(QWidget())
844        self.addDockWidget(Qt.LeftDockWidgetArea, dw)
845
846    def onSidebarVisChanged(self, visible):
847        if visible:
848            self.buildTree()
849        else:
850            pass
851
852    def focusSidebar(self):
853        self.sidebarDockWidget.setVisible(True)
854        self.sidebarTree.setFocus()
855
856    def maybeRefreshSidebar(self):
857        if self.sidebarDockWidget.isVisible():
858            self.buildTree()
859
860    def buildTree(self):
861        self.sidebarTree.clear()
862        root = self.sidebarTree
863        self._stdTree(root)
864        self._favTree(root)
865        self._decksTree(root)
866        self._modelTree(root)
867        self._userTagTree(root)
868        self.sidebarTree.setIndentation(15)
869
870    def _stdTree(self, root):
871        for name, filt, icon in [[_("Whole Collection"), "", "collection"],
872                           [_("Current Deck"), "deck:current", "deck"]]:
873            item = self.CallbackItem(
874                root, name, self._filterFunc(filt))
875            item.setIcon(0, QIcon(":/icons/{}.svg".format(icon)))
876
877    def _favTree(self, root):
878        saved = self.col.conf.get('savedFilters', {})
879        for name, filt in sorted(saved.items()):
880            item = self.CallbackItem(root, name, lambda s=filt: self.setFilter(s))
881            item.setIcon(0, QIcon(":/icons/heart.svg"))
882
883    def _userTagTree(self, root):
884        for t in sorted(self.col.tags.all(), key=lambda t: t.lower()):
885            item = self.CallbackItem(
886                root, t, lambda t=t: self.setFilter("tag", t))
887            item.setIcon(0, QIcon(":/icons/tag.svg"))
888
889    def _decksTree(self, root):
890        grps = self.col.sched.deckDueTree()
891        def fillGroups(root, grps, head=""):
892            for g in grps:
893                item = self.CallbackItem(
894                    root, g[0],
895                    lambda g=g: self.setFilter("deck", head+g[0]),
896                    lambda g=g: self.mw.col.decks.collapseBrowser(g[1]),
897                    not self.mw.col.decks.get(g[1]).get('browserCollapsed', False))
898                item.setIcon(0, QIcon(":/icons/deck.svg"))
899                newhead = head + g[0]+"::"
900                fillGroups(item, g[5], newhead)
901        fillGroups(root, grps)
902
903    def _modelTree(self, root):
904        for m in sorted(self.col.models.all(), key=itemgetter("name")):
905            mitem = self.CallbackItem(
906                root, m['name'], lambda m=m: self.setFilter("note", m['name']))
907            mitem.setIcon(0, QIcon(":/icons/notetype.svg"))
908
909    # Filter tree
910    ######################################################################
911
912    def onFilterButton(self):
913        ml = MenuList()
914
915        ml.addChild(self._commonFilters())
916        ml.addSeparator()
917
918        ml.addChild(self._todayFilters())
919        ml.addChild(self._cardStateFilters())
920        ml.addChild(self._deckFilters())
921        ml.addChild(self._noteTypeFilters())
922        ml.addChild(self._tagFilters())
923        ml.addSeparator()
924
925        ml.addChild(self.sidebarDockWidget.toggleViewAction())
926        ml.addSeparator()
927
928        ml.addChild(self._savedSearches())
929
930        ml.popupOver(self.form.filter)
931
932    def setFilter(self, *args):
933        if len(args) == 1:
934            txt = args[0]
935        else:
936            txt = ""
937            items = []
938            for c, a in enumerate(args):
939                if c % 2 == 0:
940                    txt += a + ":"
941                else:
942                    txt += a
943                    for chr in "  ()":
944                        if chr in txt:
945                            txt = '"%s"' % txt
946                            break
947                    items.append(txt)
948                    txt = ""
949            txt = " ".join(items)
950        if self.mw.app.keyboardModifiers() & Qt.AltModifier:
951            txt = "-"+txt
952        if self.mw.app.keyboardModifiers() & Qt.ControlModifier:
953            cur = str(self.form.searchEdit.lineEdit().text())
954            if cur and cur != self._searchPrompt:
955                txt = cur + " " + txt
956        elif self.mw.app.keyboardModifiers() & Qt.ShiftModifier:
957            cur = str(self.form.searchEdit.lineEdit().text())
958            if cur:
959                txt = cur + " or " + txt
960        self.form.searchEdit.lineEdit().setText(txt)
961        self.onSearchActivated()
962
963    def _simpleFilters(self, items):
964        ml = MenuList()
965        for row in items:
966            if row is None:
967                ml.addSeparator()
968            else:
969                label, filter = row
970                ml.addItem(label, self._filterFunc(filter))
971        return ml
972
973    def _filterFunc(self, *args):
974        return lambda *, f=args: self.setFilter(*f)
975
976    def _commonFilters(self):
977        return self._simpleFilters((
978            (_("Whole Collection"), ""),
979            (_("Current Deck"), "deck:current")))
980
981    def _todayFilters(self):
982        subm = SubMenu(_("Today"))
983        subm.addChild(self._simpleFilters((
984            (_("Added Today"), "added:1"),
985            (_("Studied Today"), "rated:1"),
986            (_("Again Today"), "rated:1:1"))))
987        return subm
988
989    def _cardStateFilters(self):
990        subm = SubMenu(_("Card State"))
991        subm.addChild(self._simpleFilters((
992            (_("New"), "is:new"),
993            (_("Learning"), "is:learn"),
994            (_("Review"), "is:review"),
995            (_("Due"), "is:due"),
996            None,
997            (_("Suspended"), "is:suspended"),
998            (_("Buried"), "is:buried"),
999            None,
1000            (_("Red Flag"), "flag:1"),
1001            (_("Orange Flag"), "flag:2"),
1002            (_("Green Flag"), "flag:3"),
1003            (_("Blue Flag"), "flag:4"),
1004            (_("No Flag"), "flag:0"),
1005            (_("Any Flag"), "-flag:0"),
1006        )))
1007        return subm
1008
1009    def _tagFilters(self):
1010        m = SubMenu(_("Tags"))
1011
1012        m.addItem(_("Clear Unused"), self.clearUnusedTags)
1013        m.addSeparator()
1014
1015        tagList = MenuList()
1016        for t in sorted(self.col.tags.all(), key=lambda s: s.lower()):
1017            tagList.addItem(t, self._filterFunc("tag", t))
1018
1019        m.addChild(tagList.chunked())
1020        return m
1021
1022    def _deckFilters(self):
1023        def addDecks(parent, decks):
1024            for head, did, rev, lrn, new, children in decks:
1025                name = self.mw.col.decks.get(did)['name']
1026                shortname = name.split("::")[-1]
1027                if children:
1028                    subm = parent.addMenu(shortname)
1029                    subm.addItem(_("Filter"), self._filterFunc("deck", name))
1030                    subm.addSeparator()
1031                    addDecks(subm, children)
1032                else:
1033                    parent.addItem(shortname, self._filterFunc("deck", name))
1034
1035        # fixme: could rewrite to avoid calculating due # in the future
1036        alldecks = self.col.sched.deckDueTree()
1037        ml = MenuList()
1038        addDecks(ml, alldecks)
1039
1040        root = SubMenu(_("Decks"))
1041        root.addChild(ml.chunked())
1042
1043        return root
1044
1045    def _noteTypeFilters(self):
1046        m = SubMenu(_("Note Types"))
1047
1048        m.addItem(_("Manage..."), self.mw.onNoteTypes)
1049        m.addSeparator()
1050
1051        noteTypes = MenuList()
1052        for nt in sorted(self.col.models.all(), key=lambda nt: nt['name'].lower()):
1053            # no sub menu if it's a single template
1054            if len(nt['tmpls']) == 1:
1055                noteTypes.addItem(nt['name'], self._filterFunc("note", nt['name']))
1056            else:
1057                subm = noteTypes.addMenu(nt['name'])
1058
1059                subm.addItem(_("All Card Types"), self._filterFunc("note", nt['name']))
1060                subm.addSeparator()
1061
1062                # add templates
1063                for c, tmpl in enumerate(nt['tmpls']):
1064                    #T: name is a card type name. n it's order in the list of card type.
1065                    #T: this is shown in browser's filter, when seeing the list of card type of a note type.
1066                    name = _("%(n)d: %(name)s") % dict(n=c+1, name=tmpl['name'])
1067                    subm.addItem(name, self._filterFunc(
1068                        "note", nt['name'], "card", str(c+1)))
1069
1070        m.addChild(noteTypes.chunked())
1071        return m
1072
1073    # Favourites
1074    ######################################################################
1075
1076    def _savedSearches(self):
1077        ml = MenuList()
1078        # make sure exists
1079        if "savedFilters" not in self.col.conf:
1080            self.col.conf['savedFilters'] = {}
1081
1082        ml.addSeparator()
1083
1084        if self._currentFilterIsSaved():
1085            ml.addItem(_("Remove Current Filter..."), self._onRemoveFilter)
1086        else:
1087            ml.addItem(_("Save Current Filter..."), self._onSaveFilter)
1088
1089        saved = self.col.conf['savedFilters']
1090        if not saved:
1091            return ml
1092
1093        ml.addSeparator()
1094        for name, filt in sorted(saved.items()):
1095            ml.addItem(name, self._filterFunc(filt))
1096
1097        return ml
1098
1099    def _onSaveFilter(self):
1100        name = getOnlyText(_("Please give your filter a name:"))
1101        if not name:
1102            return
1103        filt = self.form.searchEdit.lineEdit().text()
1104        self.col.conf['savedFilters'][name] = filt
1105        self.col.setMod()
1106        self.maybeRefreshSidebar()
1107
1108    def _onRemoveFilter(self):
1109        name = self._currentFilterIsSaved()
1110        if not askUser(_("Remove %s from your saved searches?") % name):
1111            return
1112        del self.col.conf['savedFilters'][name]
1113        self.col.setMod()
1114        self.maybeRefreshSidebar()
1115
1116    # returns name if found
1117    def _currentFilterIsSaved(self):
1118        filt = self.form.searchEdit.lineEdit().text()
1119        for k,v in self.col.conf['savedFilters'].items():
1120            if filt == v:
1121                return k
1122        return None
1123
1124    # Info
1125    ######################################################################
1126
1127    def showCardInfo(self):
1128        if not self.card:
1129            return
1130        info, cs = self._cardInfoData()
1131        reps = self._revlogData(cs)
1132        class CardInfoDialog(QDialog):
1133            silentlyClose = True
1134
1135            def reject(self):
1136                saveGeom(self, "revlog")
1137                return QDialog.reject(self)
1138        d = CardInfoDialog(self)
1139        l = QVBoxLayout()
1140        l.setContentsMargins(0,0,0,0)
1141        w = AnkiWebView()
1142        l.addWidget(w)
1143        w.stdHtml(info + "<p>" + reps)
1144        bb = QDialogButtonBox(QDialogButtonBox.Close)
1145        l.addWidget(bb)
1146        bb.rejected.connect(d.reject)
1147        d.setLayout(l)
1148        d.setWindowModality(Qt.WindowModal)
1149        d.resize(500, 400)
1150        restoreGeom(d, "revlog")
1151        d.show()
1152
1153    def _cardInfoData(self):
1154        from anki.stats import CardStats
1155        cs = CardStats(self.col, self.card)
1156        rep = cs.report()
1157        m = self.card.model()
1158        rep = """
1159<div style='width: 400px; margin: 0 auto 0;
1160border: 1px solid #000; padding: 3px; '>%s</div>""" % rep
1161        return rep, cs
1162
1163    def _revlogData(self, cs):
1164        entries = self.mw.col.db.all(
1165            "select id/1000.0, ease, ivl, factor, time/1000.0, type "
1166            "from revlog where cid = ?", self.card.id)
1167        if not entries:
1168            return ""
1169        s = "<table width=100%%><tr><th align=left>%s</th>" % _("Date")
1170        s += ("<th align=right>%s</th>" * 5) % (
1171            _("Type"), _("Rating"), _("Interval"), _("Ease"), _("Time"))
1172        cnt = 0
1173        for (date, ease, ivl, factor, taken, type) in reversed(entries):
1174            cnt += 1
1175            s += "<tr><td>%s</td>" % time.strftime(_("<b>%Y-%m-%d</b> @ %H:%M"),
1176                                                   time.localtime(date))
1177            tstr = [_("Learn"), _("Review"), _("Relearn"), _("Filtered"),
1178                    _("Resched")][type]
1179            import anki.stats as st
1180            fmt = "<span style='color:%s'>%s</span>"
1181            if type == 0:
1182                tstr = fmt % (st.colLearn, tstr)
1183            elif type == 1:
1184                tstr = fmt % (st.colMature, tstr)
1185            elif type == 2:
1186                tstr = fmt % (st.colRelearn, tstr)
1187            elif type == 3:
1188                tstr = fmt % (st.colCram, tstr)
1189            else:
1190                tstr = fmt % ("#000", tstr)
1191            if ease == 1:
1192                ease = fmt % (st.colRelearn, ease)
1193            if ivl == 0:
1194                ivl = _("0d")
1195            elif ivl > 0:
1196                ivl = fmtTimeSpan(ivl*86400, short=True)
1197            else:
1198                ivl = cs.time(-ivl)
1199            s += ("<td align=right>%s</td>" * 5) % (
1200                tstr,
1201                ease, ivl,
1202                "%d%%" % (factor/10) if factor else "",
1203                cs.time(taken)) + "</tr>"
1204        s += "</table>"
1205        if cnt < self.card.reps:
1206            s += _("""\
1207Note: Some of the history is missing. For more information, \
1208please see the browser documentation.""")
1209        return s
1210
1211    # Menu helpers
1212    ######################################################################
1213
1214    def selectedCards(self):
1215        return [self.model.cards[idx.row()] for idx in
1216                self.form.tableView.selectionModel().selectedRows()]
1217
1218    def selectedNotes(self):
1219        return self.col.db.list("""
1220select distinct nid from cards
1221where id in %s""" % ids2str(
1222    [self.model.cards[idx.row()] for idx in
1223    self.form.tableView.selectionModel().selectedRows()]))
1224
1225    def selectedNotesAsCards(self):
1226        return self.col.db.list(
1227            "select id from cards where nid in (%s)" %
1228            ",".join([str(s) for s in self.selectedNotes()]))
1229
1230    def oneModelNotes(self):
1231        sf = self.selectedNotes()
1232        if not sf:
1233            return
1234        mods = self.col.db.scalar("""
1235select count(distinct mid) from notes
1236where id in %s""" % ids2str(sf))
1237        if mods > 1:
1238            showInfo(_("Please select cards from only one note type."))
1239            return
1240        return sf
1241
1242    def onHelp(self):
1243        openHelp("browser")
1244
1245    # Misc menu options
1246    ######################################################################
1247
1248    def onChangeModel(self):
1249        self.editor.saveNow(self._onChangeModel)
1250
1251    def _onChangeModel(self):
1252        nids = self.oneModelNotes()
1253        if nids:
1254            ChangeModel(self, nids)
1255
1256    # Preview
1257    ######################################################################
1258
1259    _previewTimer = None
1260    _lastPreviewRender = 0
1261    _lastPreviewState = None
1262    _previewCardChanged = False
1263
1264    def onTogglePreview(self):
1265        if self._previewWindow:
1266            self._closePreview()
1267        else:
1268            self._openPreview()
1269
1270    def _openPreview(self):
1271        self._previewState = "question"
1272        self._lastPreviewState = None
1273        self._previewWindow = QDialog(None, Qt.Window)
1274        self._previewWindow.setWindowTitle(_("Preview"))
1275
1276        self._previewWindow.finished.connect(self._onPreviewFinished)
1277        self._previewWindow.silentlyClose = True
1278        vbox = QVBoxLayout()
1279        vbox.setContentsMargins(0,0,0,0)
1280        self._previewWeb = AnkiWebView()
1281        vbox.addWidget(self._previewWeb)
1282        bbox = QDialogButtonBox()
1283
1284        self._previewReplay = bbox.addButton(_("Replay Audio"), QDialogButtonBox.ActionRole)
1285        self._previewReplay.setAutoDefault(False)
1286        self._previewReplay.setShortcut(QKeySequence("R"))
1287        self._previewReplay.setToolTip(_("Shortcut key: %s" % "R"))
1288
1289        self._previewPrev = bbox.addButton("<", QDialogButtonBox.ActionRole)
1290        self._previewPrev.setAutoDefault(False)
1291        self._previewPrev.setShortcut(QKeySequence("Left"))
1292        self._previewPrev.setToolTip(_("Shortcut key: Left arrow"))
1293
1294        self._previewNext = bbox.addButton(">", QDialogButtonBox.ActionRole)
1295        self._previewNext.setAutoDefault(True)
1296        self._previewNext.setShortcut(QKeySequence("Right"))
1297        self._previewNext.setToolTip(_("Shortcut key: Right arrow or Enter"))
1298
1299        self._previewPrev.clicked.connect(self._onPreviewPrev)
1300        self._previewNext.clicked.connect(self._onPreviewNext)
1301        self._previewReplay.clicked.connect(self._onReplayAudio)
1302
1303        self.previewShowBothSides = QCheckBox(_("Show Both Sides"))
1304        self.previewShowBothSides.setShortcut(QKeySequence("B"))
1305        self.previewShowBothSides.setToolTip(_("Shortcut key: %s" % "B"))
1306        bbox.addButton(self.previewShowBothSides, QDialogButtonBox.ActionRole)
1307        self._previewBothSides = self.col.conf.get("previewBothSides", False)
1308        self.previewShowBothSides.setChecked(self._previewBothSides)
1309        self.previewShowBothSides.toggled.connect(self._onPreviewShowBothSides)
1310
1311        self._setupPreviewWebview()
1312
1313        vbox.addWidget(bbox)
1314        self._previewWindow.setLayout(vbox)
1315        restoreGeom(self._previewWindow, "preview")
1316        self._previewWindow.show()
1317        self._renderPreview(True)
1318
1319    def _onPreviewFinished(self, ok):
1320        saveGeom(self._previewWindow, "preview")
1321        self.mw.progress.timer(100, self._onClosePreview, False)
1322        self.form.previewButton.setChecked(False)
1323
1324    def _onPreviewPrev(self):
1325        if self._previewState == "answer" and not self._previewBothSides:
1326            self._previewState = "question"
1327            self._renderPreview()
1328        else:
1329            self.editor.saveNow(lambda: self._moveCur(QAbstractItemView.MoveUp))
1330
1331    def _onPreviewNext(self):
1332        if self._previewState == "question":
1333            self._previewState = "answer"
1334            self._renderPreview()
1335        else:
1336            self.editor.saveNow(lambda: self._moveCur(QAbstractItemView.MoveDown))
1337
1338    def _onReplayAudio(self):
1339        self.mw.reviewer.replayAudio(self)
1340
1341    def _updatePreviewButtons(self):
1342        if not self._previewWindow:
1343            return
1344        current = self.currentRow()
1345        canBack = (current > 0 or (current == 0 and self._previewState == "answer"
1346                                   and not self._previewBothSides))
1347        self._previewPrev.setEnabled(not not (self.singleCard and canBack))
1348        canForward = self.currentRow() < self.model.rowCount(None) - 1 or \
1349                     self._previewState == "question"
1350        self._previewNext.setEnabled(not not (self.singleCard and canForward))
1351
1352    def _closePreview(self):
1353        if self._previewWindow:
1354            self._previewWindow.close()
1355            self._onClosePreview()
1356
1357    def _onClosePreview(self):
1358        self._previewWindow = self._previewPrev = self._previewNext = None
1359
1360    def _setupPreviewWebview(self):
1361        jsinc = ["jquery.js","browsersel.js",
1362                 "mathjax/conf.js", "mathjax/MathJax.js",
1363                 "reviewer.js"]
1364        self._previewWeb.stdHtml(self.mw.reviewer.revHtml(),
1365                                 css=["reviewer.css"],
1366                                 js=jsinc)
1367
1368
1369    def _renderPreview(self, cardChanged=False):
1370        self._cancelPreviewTimer()
1371        # Keep track of whether _renderPreview() has ever been called
1372        # with cardChanged=True since the last successful render
1373        self._previewCardChanged |= cardChanged
1374        # avoid rendering in quick succession
1375        elapMS = int((time.time() - self._lastPreviewRender)*1000)
1376        delay = 300
1377        if elapMS < delay:
1378            self._previewTimer = self.mw.progress.timer(
1379                delay-elapMS, self._renderScheduledPreview, False)
1380        else:
1381            self._renderScheduledPreview()
1382
1383    def _cancelPreviewTimer(self):
1384        if self._previewTimer:
1385            self._previewTimer.stop()
1386            self._previewTimer = None
1387
1388    def _renderScheduledPreview(self):
1389        self._cancelPreviewTimer()
1390        self._lastPreviewRender = time.time()
1391
1392        if not self._previewWindow:
1393            return
1394        c = self.card
1395        func = "_showQuestion"
1396        if not c or not self.singleCard:
1397            txt = _("(please select 1 card)")
1398            bodyclass = ""
1399            self._lastPreviewState = None
1400        else:
1401            if self._previewBothSides:
1402                self._previewState = "answer"
1403            elif self._previewCardChanged:
1404                self._previewState = "question"
1405
1406            currentState = self._previewStateAndMod()
1407            if currentState == self._lastPreviewState:
1408                # nothing has changed, avoid refreshing
1409                return
1410
1411            # need to force reload even if answer
1412            txt = c.q(reload=True)
1413
1414            questionAudio = []
1415            if self._previewBothSides:
1416                questionAudio = allSounds(txt)
1417            if self._previewState == "answer":
1418                func = "_showAnswer"
1419                txt = c.a()
1420            txt = re.sub(r"\[\[type:[^]]+\]\]", "", txt)
1421
1422            bodyclass = bodyClass(self.mw.col, c)
1423
1424            clearAudioQueue()
1425            if self.mw.reviewer.autoplay(c):
1426                # if we're showing both sides at once, play question audio first
1427                for audio in questionAudio:
1428                    play(audio)
1429                # then play any audio that hasn't already been played
1430                for audio in allSounds(txt):
1431                    if audio not in questionAudio:
1432                        play(audio)
1433
1434            txt = mungeQA(self.col, txt)
1435            txt = runFilter("prepareQA", txt, c,
1436                            "preview"+self._previewState.capitalize())
1437            self._lastPreviewState = self._previewStateAndMod()
1438        self._updatePreviewButtons()
1439        self._previewWeb.eval(
1440            "{}({},'{}');".format(func, json.dumps(txt), bodyclass))
1441        self._previewCardChanged = False
1442
1443    def _onPreviewShowBothSides(self, toggle):
1444        self._previewBothSides = toggle
1445        self.col.conf["previewBothSides"] = toggle
1446        self.col.setMod()
1447        if self._previewState == "answer" and not toggle:
1448            self._previewState = "question"
1449        self._renderPreview()
1450
1451    def _previewStateAndMod(self):
1452        c = self.card
1453        n = c.note()
1454        n.load()
1455        return (self._previewState, c.id, n.mod)
1456
1457    # Card deletion
1458    ######################################################################
1459
1460    def deleteNotes(self):
1461        focus = self.focusWidget()
1462        if focus != self.form.tableView:
1463            return
1464        self._deleteNotes()
1465
1466    def _deleteNotes(self):
1467        nids = self.selectedNotes()
1468        if not nids:
1469            return
1470        self.mw.checkpoint(_("Delete Notes"))
1471        self.model.beginReset()
1472        # figure out where to place the cursor after the deletion
1473        curRow = self.form.tableView.selectionModel().currentIndex().row()
1474        selectedRows = [i.row() for i in
1475                self.form.tableView.selectionModel().selectedRows()]
1476        if min(selectedRows) < curRow < max(selectedRows):
1477            # last selection in middle; place one below last selected item
1478            move = sum(1 for i in selectedRows if i > curRow)
1479            newRow = curRow - move
1480        elif max(selectedRows) <= curRow:
1481            # last selection at bottom; place one below bottommost selection
1482            newRow = max(selectedRows) - len(nids) + 1
1483        else:
1484            # last selection at top; place one above topmost selection
1485            newRow = min(selectedRows) - 1
1486        self.col.remNotes(nids)
1487        self.search()
1488        if len(self.model.cards):
1489            newRow = min(newRow, len(self.model.cards) - 1)
1490            newRow = max(newRow, 0)
1491            self.model.focusedCard = self.model.cards[newRow]
1492        self.model.endReset()
1493        self.mw.requireReset()
1494        tooltip(ngettext("%d note deleted.", "%d notes deleted.", len(nids)) % len(nids))
1495
1496    # Deck change
1497    ######################################################################
1498
1499    def setDeck(self):
1500        self.editor.saveNow(self._setDeck)
1501
1502    def _setDeck(self):
1503        from aqt.studydeck import StudyDeck
1504        cids = self.selectedCards()
1505        if not cids:
1506            return
1507        did = self.mw.col.db.scalar(
1508            "select did from cards where id = ?", cids[0])
1509        current=self.mw.col.decks.get(did)['name']
1510        ret = StudyDeck(
1511            self.mw, current=current, accept=_("Move Cards"),
1512            title=_("Change Deck"), help="browse", parent=self)
1513        if not ret.name:
1514            return
1515        did = self.col.decks.id(ret.name)
1516        deck = self.col.decks.get(did)
1517        if deck['dyn']:
1518            showWarning(_("Cards can't be manually moved into a filtered deck."))
1519            return
1520        self.model.beginReset()
1521        self.mw.checkpoint(_("Change Deck"))
1522        mod = intTime()
1523        usn = self.col.usn()
1524        # normal cards
1525        scids = ids2str(cids)
1526        # remove any cards from filtered deck first
1527        self.col.sched.remFromDyn(cids)
1528        # then move into new deck
1529        self.col.db.execute("""
1530update cards set usn=?, mod=?, did=? where id in """ + scids,
1531                            usn, mod, did)
1532        self.model.endReset()
1533        self.mw.requireReset()
1534
1535    # Tags
1536    ######################################################################
1537
1538    def addTags(self, tags=None, label=None, prompt=None, func=None):
1539        self.editor.saveNow(lambda: self._addTags(tags, label, prompt, func))
1540
1541    def _addTags(self, tags, label, prompt, func):
1542        if prompt is None:
1543            prompt = _("Enter tags to add:")
1544        if tags is None:
1545            (tags, r) = getTag(self, self.col, prompt)
1546        else:
1547            r = True
1548        if not r:
1549            return
1550        if func is None:
1551            func = self.col.tags.bulkAdd
1552        if label is None:
1553            label = _("Add Tags")
1554        if label:
1555            self.mw.checkpoint(label)
1556        self.model.beginReset()
1557        func(self.selectedNotes(), tags)
1558        self.model.endReset()
1559        self.mw.requireReset()
1560
1561    def deleteTags(self, tags=None, label=None):
1562        if label is None:
1563            label = _("Delete Tags")
1564        self.addTags(tags, label, _("Enter tags to delete:"),
1565                     func=self.col.tags.bulkRem)
1566
1567    def clearUnusedTags(self):
1568        self.editor.saveNow(self._clearUnusedTags)
1569
1570    def _clearUnusedTags(self):
1571        self.col.tags.registerNotes()
1572
1573    # Suspending
1574    ######################################################################
1575
1576    def isSuspended(self):
1577        return not not (self.card and self.card.queue == -1)
1578
1579    def onSuspend(self):
1580        self.editor.saveNow(self._onSuspend)
1581
1582    def _onSuspend(self):
1583        sus = not self.isSuspended()
1584        c = self.selectedCards()
1585        if sus:
1586            self.col.sched.suspendCards(c)
1587        else:
1588            self.col.sched.unsuspendCards(c)
1589        self.model.reset()
1590        self.mw.requireReset()
1591
1592    # Flags & Marking
1593    ######################################################################
1594
1595    def onSetFlag(self, n):
1596        if not self.card:
1597            return
1598        # flag needs toggling off?
1599        if n == self.card.userFlag():
1600            n = 0
1601        self.col.setUserFlag(n, self.selectedCards())
1602        self.model.reset()
1603
1604    def _updateFlagsMenu(self):
1605        flag = self.card and self.card.userFlag()
1606        flag = flag or 0
1607
1608        f = self.form
1609        flagActions = [f.actionRed_Flag,
1610                       f.actionOrange_Flag,
1611                       f.actionGreen_Flag,
1612                       f.actionBlue_Flag]
1613
1614        for c, act in enumerate(flagActions):
1615            act.setChecked(flag == c+1)
1616
1617        qtMenuShortcutWorkaround(self.form.menuFlag)
1618
1619    def onMark(self, mark=None):
1620        if mark is None:
1621            mark = not self.isMarked()
1622        if mark:
1623            self.addTags(tags="marked", label=False)
1624        else:
1625            self.deleteTags(tags="marked", label=False)
1626
1627    def isMarked(self):
1628        return not not (self.card and self.card.note().hasTag("Marked"))
1629
1630    # Repositioning
1631    ######################################################################
1632
1633    def reposition(self):
1634        self.editor.saveNow(self._reposition)
1635
1636    def _reposition(self):
1637        cids = self.selectedCards()
1638        cids2 = self.col.db.list(
1639            "select id from cards where type = 0 and id in " + ids2str(cids))
1640        if not cids2:
1641            return showInfo(_("Only new cards can be repositioned."))
1642        d = QDialog(self)
1643        d.setWindowModality(Qt.WindowModal)
1644        frm = aqt.forms.reposition.Ui_Dialog()
1645        frm.setupUi(d)
1646        (pmin, pmax) = self.col.db.first(
1647            "select min(due), max(due) from cards where type=0 and odid=0")
1648        pmin = pmin or 0
1649        pmax = pmax or 0
1650        txt = _("Queue top: %d") % pmin
1651        txt += "\n" + _("Queue bottom: %d") % pmax
1652        frm.label.setText(txt)
1653        if not d.exec_():
1654            return
1655        self.model.beginReset()
1656        self.mw.checkpoint(_("Reposition"))
1657        self.col.sched.sortCards(
1658            cids, start=frm.start.value(), step=frm.step.value(),
1659            shuffle=frm.randomize.isChecked(), shift=frm.shift.isChecked())
1660        self.search()
1661        self.mw.requireReset()
1662        self.model.endReset()
1663
1664    # Rescheduling
1665    ######################################################################
1666
1667    def reschedule(self):
1668        self.editor.saveNow(self._reschedule)
1669
1670    def _reschedule(self):
1671        d = QDialog(self)
1672        d.setWindowModality(Qt.WindowModal)
1673        frm = aqt.forms.reschedule.Ui_Dialog()
1674        frm.setupUi(d)
1675        if not d.exec_():
1676            return
1677        self.model.beginReset()
1678        self.mw.checkpoint(_("Reschedule"))
1679        if frm.asNew.isChecked():
1680            self.col.sched.forgetCards(self.selectedCards())
1681        else:
1682            fmin = frm.min.value()
1683            fmax = frm.max.value()
1684            fmax = max(fmin, fmax)
1685            self.col.sched.reschedCards(
1686                self.selectedCards(), fmin, fmax)
1687        self.search()
1688        self.mw.requireReset()
1689        self.model.endReset()
1690
1691    # Edit: selection
1692    ######################################################################
1693
1694    def selectNotes(self):
1695        self.editor.saveNow(self._selectNotes)
1696
1697    def _selectNotes(self):
1698        nids = self.selectedNotes()
1699        # bypass search history
1700        self._lastSearchTxt = "nid:"+",".join([str(x) for x in nids])
1701        self.form.searchEdit.lineEdit().setText(self._lastSearchTxt)
1702        # clear the selection so we don't waste energy preserving it
1703        tv = self.form.tableView
1704        tv.selectionModel().clear()
1705        self.search()
1706        tv.selectAll()
1707
1708    def invertSelection(self):
1709        sm = self.form.tableView.selectionModel()
1710        items = sm.selection()
1711        self.form.tableView.selectAll()
1712        sm.select(items, QItemSelectionModel.Deselect | QItemSelectionModel.Rows)
1713
1714    # Edit: undo
1715    ######################################################################
1716
1717    def setupHooks(self):
1718        addHook("undoState", self.onUndoState)
1719        addHook("reset", self.onReset)
1720        addHook("editTimer", self.refreshCurrentCard)
1721        addHook("loadNote", self.onLoadNote)
1722        addHook("editFocusLost", self.refreshCurrentCardFilter)
1723        for t in "newTag", "newModel", "newDeck":
1724            addHook(t, self.maybeRefreshSidebar)
1725
1726    def teardownHooks(self):
1727        remHook("reset", self.onReset)
1728        remHook("editTimer", self.refreshCurrentCard)
1729        remHook("loadNote", self.onLoadNote)
1730        remHook("editFocusLost", self.refreshCurrentCardFilter)
1731        remHook("undoState", self.onUndoState)
1732        for t in "newTag", "newModel", "newDeck":
1733            remHook(t, self.maybeRefreshSidebar)
1734
1735    def onUndoState(self, on):
1736        self.form.actionUndo.setEnabled(on)
1737        if on:
1738            self.form.actionUndo.setText(self.mw.form.actionUndo.text())
1739
1740    # Edit: replacing
1741    ######################################################################
1742
1743    def onFindReplace(self):
1744        self.editor.saveNow(self._onFindReplace)
1745
1746    def _onFindReplace(self):
1747        sf = self.selectedNotes()
1748        if not sf:
1749            return
1750        import anki.find
1751        fields = anki.find.fieldNamesForNotes(self.mw.col, sf)
1752        d = QDialog(self)
1753        frm = aqt.forms.findreplace.Ui_Dialog()
1754        frm.setupUi(d)
1755        d.setWindowModality(Qt.WindowModal)
1756        frm.field.addItems([_("All Fields")] + fields)
1757        frm.buttonBox.helpRequested.connect(self.onFindReplaceHelp)
1758        restoreGeom(d, "findreplace")
1759        r = d.exec_()
1760        saveGeom(d, "findreplace")
1761        if not r:
1762            return
1763        if frm.field.currentIndex() == 0:
1764            field = None
1765        else:
1766            field = fields[frm.field.currentIndex()-1]
1767        self.mw.checkpoint(_("Find and Replace"))
1768        self.mw.progress.start()
1769        self.model.beginReset()
1770        try:
1771            changed = self.col.findReplace(sf,
1772                                            str(frm.find.text()),
1773                                            str(frm.replace.text()),
1774                                            frm.re.isChecked(),
1775                                            field,
1776                                            frm.ignoreCase.isChecked())
1777        except sre_constants.error:
1778            showInfo(_("Invalid regular expression."), parent=self)
1779            return
1780        else:
1781            self.search()
1782            self.mw.requireReset()
1783        finally:
1784            self.model.endReset()
1785            self.mw.progress.finish()
1786        showInfo(ngettext(
1787            "%(a)d of %(b)d note updated",
1788            "%(a)d of %(b)d notes updated", len(sf)) % {
1789                'a': changed,
1790                'b': len(sf),
1791            }, parent=self)
1792
1793    def onFindReplaceHelp(self):
1794        openHelp("findreplace")
1795
1796    # Edit: finding dupes
1797    ######################################################################
1798
1799    def onFindDupes(self):
1800        self.editor.saveNow(self._onFindDupes)
1801
1802    def _onFindDupes(self):
1803        d = QDialog(self)
1804        self.mw.setupDialogGC(d)
1805        frm = aqt.forms.finddupes.Ui_Dialog()
1806        frm.setupUi(d)
1807        restoreGeom(d, "findDupes")
1808        fields = sorted(anki.find.fieldNames(self.col, downcase=False),
1809                        key=lambda x: x.lower())
1810        frm.fields.addItems(fields)
1811        self._dupesButton = None
1812        # links
1813        frm.webView.onBridgeCmd = self.dupeLinkClicked
1814        def onFin(code):
1815            saveGeom(d, "findDupes")
1816        d.finished.connect(onFin)
1817        def onClick():
1818            field = fields[frm.fields.currentIndex()]
1819            self.duplicatesReport(frm.webView, field, frm.search.text(), frm)
1820        search = frm.buttonBox.addButton(
1821            _("Search"), QDialogButtonBox.ActionRole)
1822        search.clicked.connect(onClick)
1823        d.show()
1824
1825    def duplicatesReport(self, web, fname, search, frm):
1826        self.mw.progress.start()
1827        res = self.mw.col.findDupes(fname, search)
1828        if not self._dupesButton:
1829            self._dupesButton = b = frm.buttonBox.addButton(
1830                _("Tag Duplicates"), QDialogButtonBox.ActionRole)
1831            b.clicked.connect(lambda: self._onTagDupes(res))
1832        t = "<html><body>"
1833        groups = len(res)
1834        notes = sum(len(r[1]) for r in res)
1835        part1 = ngettext("%d group", "%d groups", groups) % groups
1836        part2 = ngettext("%d note", "%d notes", notes) % notes
1837        t += _("Found %(a)s across %(b)s.") % dict(a=part1, b=part2)
1838        t += "<p><ol>"
1839        for val, nids in res:
1840            t += '''<li><a href=# onclick="pycmd('%s');return false;">%s</a>: %s</a>''' % (
1841                "nid:" + ",".join(str(id) for id in nids),
1842                ngettext("%d note", "%d notes", len(nids)) % len(nids),
1843                html.escape(val))
1844        t += "</ol>"
1845        t += "</body></html>"
1846        web.setHtml(t)
1847        self.mw.progress.finish()
1848
1849    def _onTagDupes(self, res):
1850        if not res:
1851            return
1852        self.model.beginReset()
1853        self.mw.checkpoint(_("Tag Duplicates"))
1854        nids = set()
1855        for s, nidlist in res:
1856            nids.update(nidlist)
1857        self.col.tags.bulkAdd(nids, _("duplicate"))
1858        self.mw.progress.finish()
1859        self.model.endReset()
1860        self.mw.requireReset()
1861        tooltip(_("Notes tagged."))
1862
1863    def dupeLinkClicked(self, link):
1864        self.form.searchEdit.lineEdit().setText(link)
1865        # manually, because we've already saved
1866        self._lastSearchTxt = link
1867        self.search()
1868        self.onNote()
1869
1870    # Jumping
1871    ######################################################################
1872
1873    def _moveCur(self, dir=None, idx=None):
1874        if not self.model.cards:
1875            return
1876        tv = self.form.tableView
1877        if idx is None:
1878            idx = tv.moveCursor(dir, self.mw.app.keyboardModifiers())
1879        tv.selectionModel().setCurrentIndex(
1880            idx,
1881            QItemSelectionModel.Clear|
1882            QItemSelectionModel.Select|
1883            QItemSelectionModel.Rows)
1884
1885    def onPreviousCard(self):
1886        self.focusTo = self.editor.currentField
1887        self.editor.saveNow(self._onPreviousCard)
1888
1889    def _onPreviousCard(self):
1890        self._moveCur(QAbstractItemView.MoveUp)
1891
1892    def onNextCard(self):
1893        self.focusTo = self.editor.currentField
1894        self.editor.saveNow(self._onNextCard)
1895
1896    def _onNextCard(self):
1897        self._moveCur(QAbstractItemView.MoveDown)
1898
1899    def onFirstCard(self):
1900        sm = self.form.tableView.selectionModel()
1901        idx = sm.currentIndex()
1902        self._moveCur(None, self.model.index(0, 0))
1903        if not self.mw.app.keyboardModifiers() & Qt.ShiftModifier:
1904            return
1905        idx2 = sm.currentIndex()
1906        item = QItemSelection(idx2, idx)
1907        sm.select(item, QItemSelectionModel.SelectCurrent|
1908                  QItemSelectionModel.Rows)
1909
1910    def onLastCard(self):
1911        sm = self.form.tableView.selectionModel()
1912        idx = sm.currentIndex()
1913        self._moveCur(
1914            None, self.model.index(len(self.model.cards) - 1, 0))
1915        if not self.mw.app.keyboardModifiers() & Qt.ShiftModifier:
1916            return
1917        idx2 = sm.currentIndex()
1918        item = QItemSelection(idx, idx2)
1919        sm.select(item, QItemSelectionModel.SelectCurrent|
1920                  QItemSelectionModel.Rows)
1921
1922    def onFind(self):
1923        self.form.searchEdit.setFocus()
1924        self.form.searchEdit.lineEdit().selectAll()
1925
1926    def onNote(self):
1927        self.editor.web.setFocus()
1928        self.editor.loadNote(focusTo=0)
1929
1930    def onCardList(self):
1931        self.form.tableView.setFocus()
1932
1933    def focusCid(self, cid):
1934        try:
1935            row = self.model.cards.index(cid)
1936        except:
1937            return
1938        self.form.tableView.selectRow(row)
1939
1940# Change model dialog
1941######################################################################
1942
1943class ChangeModel(QDialog):
1944
1945    def __init__(self, browser, nids):
1946        QDialog.__init__(self, browser)
1947        self.browser = browser
1948        self.nids = nids
1949        self.oldModel = browser.card.note().model()
1950        self.form = aqt.forms.changemodel.Ui_Dialog()
1951        self.form.setupUi(self)
1952        self.setWindowModality(Qt.WindowModal)
1953        self.setup()
1954        restoreGeom(self, "changeModel")
1955        addHook("reset", self.onReset)
1956        addHook("currentModelChanged", self.onReset)
1957        self.exec_()
1958
1959    def setup(self):
1960        # maps
1961        self.flayout = QHBoxLayout()
1962        self.flayout.setContentsMargins(0,0,0,0)
1963        self.fwidg = None
1964        self.form.fieldMap.setLayout(self.flayout)
1965        self.tlayout = QHBoxLayout()
1966        self.tlayout.setContentsMargins(0,0,0,0)
1967        self.twidg = None
1968        self.form.templateMap.setLayout(self.tlayout)
1969        if self.style().objectName() == "gtk+":
1970            # gtk+ requires margins in inner layout
1971            self.form.verticalLayout_2.setContentsMargins(0, 11, 0, 0)
1972            self.form.verticalLayout_3.setContentsMargins(0, 11, 0, 0)
1973        # model chooser
1974        import aqt.modelchooser
1975        self.oldModel = self.browser.col.models.get(
1976            self.browser.col.db.scalar(
1977                "select mid from notes where id = ?", self.nids[0]))
1978        self.form.oldModelLabel.setText(self.oldModel['name'])
1979        self.modelChooser = aqt.modelchooser.ModelChooser(
1980            self.browser.mw, self.form.modelChooserWidget, label=False)
1981        self.modelChooser.models.setFocus()
1982        self.form.buttonBox.helpRequested.connect(self.onHelp)
1983        self.modelChanged(self.browser.mw.col.models.current())
1984        self.pauseUpdate = False
1985
1986    def onReset(self):
1987        self.modelChanged(self.browser.col.models.current())
1988
1989    def modelChanged(self, model):
1990        self.targetModel = model
1991        self.rebuildTemplateMap()
1992        self.rebuildFieldMap()
1993
1994    def rebuildTemplateMap(self, key=None, attr=None):
1995        if not key:
1996            key = "t"
1997            attr = "tmpls"
1998        map = getattr(self, key + "widg")
1999        lay = getattr(self, key + "layout")
2000        src = self.oldModel[attr]
2001        dst = self.targetModel[attr]
2002        if map:
2003            lay.removeWidget(map)
2004            map.deleteLater()
2005            setattr(self, key + "MapWidget", None)
2006        map = QWidget()
2007        l = QGridLayout()
2008        combos = []
2009        targets = [x['name'] for x in dst] + [_("Nothing")]
2010        indices = {}
2011        for i, x in enumerate(src):
2012            l.addWidget(QLabel(_("Change %s to:") % x['name']), i, 0)
2013            cb = QComboBox()
2014            cb.addItems(targets)
2015            idx = min(i, len(targets)-1)
2016            cb.setCurrentIndex(idx)
2017            indices[cb] = idx
2018            cb.currentIndexChanged.connect(
2019                lambda i, cb=cb, key=key: self.onComboChanged(i, cb, key))
2020            combos.append(cb)
2021            l.addWidget(cb, i, 1)
2022        map.setLayout(l)
2023        lay.addWidget(map)
2024        setattr(self, key + "widg", map)
2025        setattr(self, key + "layout", lay)
2026        setattr(self, key + "combos", combos)
2027        setattr(self, key + "indices", indices)
2028
2029    def rebuildFieldMap(self):
2030        return self.rebuildTemplateMap(key="f", attr="flds")
2031
2032    def onComboChanged(self, i, cb, key):
2033        indices = getattr(self, key + "indices")
2034        if self.pauseUpdate:
2035            indices[cb] = i
2036            return
2037        combos = getattr(self, key + "combos")
2038        if i == cb.count() - 1:
2039            # set to 'nothing'
2040            return
2041        # find another combo with same index
2042        for c in combos:
2043            if c == cb:
2044                continue
2045            if c.currentIndex() == i:
2046                self.pauseUpdate = True
2047                c.setCurrentIndex(indices[cb])
2048                self.pauseUpdate = False
2049                break
2050        indices[cb] = i
2051
2052    def getTemplateMap(self, old=None, combos=None, new=None):
2053        if not old:
2054            old = self.oldModel['tmpls']
2055            combos = self.tcombos
2056            new = self.targetModel['tmpls']
2057        map = {}
2058        for i, f in enumerate(old):
2059            idx = combos[i].currentIndex()
2060            if idx == len(new):
2061                # ignore
2062                map[f['ord']] = None
2063            else:
2064                f2 = new[idx]
2065                map[f['ord']] = f2['ord']
2066        return map
2067
2068    def getFieldMap(self):
2069        return self.getTemplateMap(
2070            old=self.oldModel['flds'],
2071            combos=self.fcombos,
2072            new=self.targetModel['flds'])
2073
2074    def cleanup(self):
2075        remHook("reset", self.onReset)
2076        remHook("currentModelChanged", self.onReset)
2077        self.modelChooser.cleanup()
2078        saveGeom(self, "changeModel")
2079
2080    def reject(self):
2081        self.cleanup()
2082        return QDialog.reject(self)
2083
2084    def accept(self):
2085        # check maps
2086        fmap = self.getFieldMap()
2087        cmap = self.getTemplateMap()
2088        if any(True for c in list(cmap.values()) if c is None):
2089            if not askUser(_("""\
2090Any cards mapped to nothing will be deleted. \
2091If a note has no remaining cards, it will be lost. \
2092Are you sure you want to continue?""")):
2093                return
2094        self.browser.mw.checkpoint(_("Change Note Type"))
2095        b = self.browser
2096        b.mw.col.modSchema(check=True)
2097        b.mw.progress.start()
2098        b.model.beginReset()
2099        mm = b.mw.col.models
2100        mm.change(self.oldModel, self.nids, self.targetModel, fmap, cmap)
2101        b.search()
2102        b.model.endReset()
2103        b.mw.progress.finish()
2104        b.mw.reset()
2105        self.cleanup()
2106        QDialog.accept(self)
2107
2108    def onHelp(self):
2109        openHelp("browsermisc")
2110
2111