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