1# 2# browse_cards_dlg.py <Peter.Bienstman@UGent.be> 3# 4 5import sys 6import time 7import locale 8 9from PyQt5 import QtCore, QtGui, QtSql, QtWidgets 10 11from mnemosyne.libmnemosyne.tag import Tag 12from mnemosyne.libmnemosyne.fact import Fact 13from mnemosyne.libmnemosyne.card import Card 14from mnemosyne.libmnemosyne.gui_translator import _ 15from mnemosyne.pyqt_ui.qwebengineview2 import QWebEngineView2 16from mnemosyne.libmnemosyne.component import Component 17from mnemosyne.pyqt_ui.tag_tree_wdgt import TagsTreeWdgt 18from mnemosyne.pyqt_ui.ui_browse_cards_dlg import Ui_BrowseCardsDlg 19from mnemosyne.pyqt_ui.card_type_tree_wdgt import CardTypesTreeWdgt 20from mnemosyne.libmnemosyne.ui_components.dialogs import BrowseCardsDialog 21from mnemosyne.libmnemosyne.criteria.default_criterion import DefaultCriterion 22from mnemosyne.pyqt_ui.convert_card_type_keys_dlg import \ 23 ConvertCardTypeKeysDlg 24from mnemosyne.pyqt_ui.tip_after_starting_n_times import \ 25 TipAfterStartingNTimes 26 27_ID = 0 28ID = 1 29CARD_TYPE_ID = 2 30_FACT_ID = 3 31FACT_VIEW_ID = 4 32QUESTION = 5 33ANSWER = 6 34TAGS = 7 35GRADE = 8 36NEXT_REP = 9 37LAST_REP = 10 38EASINESS = 11 39ACQ_REPS = 12 40RET_REPS = 13 41LAPSES = 14 42ACQ_REPS_SINCE_LAPSE = 15 43RET_REPS_SINCE_LAPSE = 16 44CREATION_TIME = 17 45MODIFICATION_TIME = 18 46EXTRA_DATA = 19 47SCHEDULER_DATA = 20 48ACTIVE = 21 49 50 51class CardModel(QtSql.QSqlTableModel, Component): 52 53 def __init__(self, **kwds): 54 super().__init__(**kwds) 55 self.search_string = "" 56 self.adjusted_now = self.scheduler().adjusted_now() 57 try: 58 self.date_format = locale.nl_langinfo(locale.D_FMT) 59 except: 60 self.date_format = "%m/%d/%y" 61 self.background_colour_for_card_type_id = {} 62 for card_type_id, rgb in \ 63 self.config()["background_colour"].items(): 64 # If the card type has been deleted since, don't bother. 65 if not card_type_id in self.component_manager.card_type_with_id: 66 continue 67 self.background_colour_for_card_type_id[card_type_id] = \ 68 QtGui.QColor(rgb) 69 self.font_colour_for_card_type_id = {} 70 for card_type_id in self.config()["font_colour"]: 71 if not card_type_id in self.component_manager.card_type_with_id: 72 continue 73 if not self.card_type_with_id(card_type_id).fact_keys_and_names: 74 continue # M-sided card type. 75 first_key = \ 76 self.card_type_with_id(card_type_id).fact_keys_and_names[0][0] 77 self.font_colour_for_card_type_id[card_type_id] = QtGui.QColor(\ 78 self.config()["font_colour"][card_type_id][first_key]) 79 80 def data(self, index, role=QtCore.Qt.DisplayRole): 81 if role == QtCore.Qt.TextColorRole: 82 card_type_id_index = self.index(index.row(), CARD_TYPE_ID) 83 card_type_id = QtSql.QSqlTableModel.data(\ 84 self, card_type_id_index) 85 colour = QtGui.QColor(QtCore.Qt.black) 86 if card_type_id in self.font_colour_for_card_type_id: 87 colour = self.font_colour_for_card_type_id[card_type_id] 88 return QtCore.QVariant(colour) 89 if role == QtCore.Qt.BackgroundColorRole: 90 card_type_id_index = self.index(index.row(), CARD_TYPE_ID) 91 card_type_id = QtSql.QSqlTableModel.data(\ 92 self, card_type_id_index) 93 if card_type_id in self.background_colour_for_card_type_id: 94 return QtCore.QVariant(\ 95 self.background_colour_for_card_type_id[card_type_id]) 96 else: 97 return QtCore.QVariant(\ 98 QtWidgets.qApp.palette().color(QtGui.QPalette.Base)) 99 column = index.column() 100 if role == QtCore.Qt.TextAlignmentRole and column not in \ 101 (QUESTION, ANSWER, TAGS): 102 return QtCore.QVariant(QtCore.Qt.AlignCenter) 103 if role == QtCore.Qt.FontRole and column not in \ 104 (QUESTION, ANSWER, TAGS): 105 active_index = self.index(index.row(), ACTIVE) 106 active = super().data(active_index) 107 font = QtGui.QFont() 108 if not active: 109 font.setStrikeOut(True) 110 return QtCore.QVariant(font) 111 if role != QtCore.Qt.DisplayRole: 112 return super().data(index, role) 113 # Display roles to format some columns in a more pretty way. Note that 114 # sorting still uses the orginal database keys, which is good 115 # for speed. 116 if column == GRADE: 117 grade = super().data(index) 118 if grade == -1: 119 return QtCore.QVariant(_("Yet to learn")) 120 else: 121 return QtCore.QVariant(grade) 122 if column == NEXT_REP: 123 grade_index = self.index(index.row(), GRADE) 124 grade = super().data(grade_index) 125 if grade < 2: 126 return QtCore.QVariant("") 127 next_rep = super().data(index, role) 128 if next_rep <= 0: 129 return QtCore.QVariant("") 130 return QtCore.QVariant(\ 131 self.scheduler().next_rep_to_interval_string(next_rep)) 132 if column == LAST_REP: 133 last_rep = super().data(index, role) 134 if last_rep <= 0: 135 return QtCore.QVariant("") 136 return QtCore.QVariant(\ 137 self.scheduler().last_rep_to_interval_string(last_rep)) 138 if column == EASINESS: 139 old_data = super().data(index, role) 140 return QtCore.QVariant("%.2f" % float(old_data)) 141 if column in (CREATION_TIME, MODIFICATION_TIME): 142 old_data = super().data(index, role) 143 return QtCore.QVariant(time.strftime(self.date_format, 144 time.localtime(old_data))) 145 return super().data(index, role) 146 147 148 149class QA_Delegate(QtWidgets.QStyledItemDelegate, Component): 150 151 """Uses webview to render the questions and answers.""" 152 153 # Unfortunately, due to the port from Webkit in Qt4 to Webengine in Qt5 154 # this is not supported at the moment... 155 # See: https://bugreports.qt.io/browse/QTBUG-50523 156 157 def __init__(self, Q_or_A, **kwds): 158 super().__init__(**kwds) 159 160 self.doc = QtGui.QTextDocument(self) 161 162 #self.doc = QWebEngineView2() 163 #self.doc.show() 164 #self.doc.loadFinished.connect(self.loaded_html) 165 #self.load_finished = False 166 167 self.Q_or_A = Q_or_A 168 169 # We need to reimplement the database access functions here using Qt's 170 # database driver. Otherwise, both Qt and libmnemosyne try to claim 171 # ownership at the same time. We don't reconstruct everything in order 172 # to save time. This could in theory give problems if the browser render 173 # chain makes use of this extra information, but that seems unlikely. 174 175 def tag(self, _id): 176 query = QtSql.QSqlQuery(\ 177 "select name from tags where _id=%d" % (_id, )) 178 query.first() 179 tag = Tag(query.value(0), "dummy_id") 180 tag._id = _id 181 return tag 182 183 def fact(self, _id): 184 # Create dictionary with fact.data. 185 fact_data = {} 186 query = QtSql.QSqlQuery(\ 187 "select key, value from data_for_fact where _fact_id=%d" % (_id, )) 188 query.next() 189 while query.isValid(): 190 fact_data[query.value(0)] = query.value(1) 191 query.next() 192 # Create fact. 193 fact = Fact(fact_data, "dummy_id") 194 fact._id = _id 195 return fact 196 197 def card(self, _id): 198 query = QtSql.QSqlQuery("""select _fact_id, card_type_id, 199 fact_view_id, extra_data from cards where _id=%d""" % (_id, )) 200 query.first() 201 fact = self.fact(query.value(0)) 202 # Note that for the card type, we turn to the component manager as 203 # opposed to this database, as we would otherwise miss the built-in 204 # system card types 205 card_type = self.card_type_with_id(query.value(1)) 206 fact_view_id = query.value(2) 207 for fact_view in card_type.fact_views: 208 if fact_view.id == fact_view_id: 209 card = Card(card_type, fact, fact_view) 210 # We need extra_data to display the cloze cards. 211 extra_data = query.value(3) 212 if extra_data == "": 213 card.extra_data = {} 214 else: 215 card.extra_data = eval(extra_data) 216 break 217 218 # Let's not add tags to speed things up, they don't affect the card 219 # browser renderer 220 221 #query = QtSql.QSqlQuery("""select _tag_id from tags_for_card 222 # where _card_id=%d""" % (_id, )) 223 #query.next() 224 #while query.isValid(): 225 # card.tags.add(self.tag(query.value(0))) 226 # query.next() 227 228 return card 229 230 def loaded_html(self, result): 231 self.load_finished = True 232 233 def paint(self, painter, option, index): 234 option = QtWidgets.QStyleOptionViewItem(option) 235 self.initStyleOption(option, index) 236 if option.widget: 237 style = option.widget.style() 238 else: 239 style = QtGui.QApplication.style() 240 # Get the data. 241 _id_index = index.model().index(index.row(), _ID) 242 _id = index.model().data(_id_index) 243 ignore_text_colour = bool(option.state & QtWidgets.QStyle.State_Selected) 244 search_string = index.model().search_string 245 card = self.card(_id) 246 if self.Q_or_A == QUESTION: 247 self.doc.setHtml(card.question(render_chain="card_browser", 248 ignore_text_colour=ignore_text_colour, 249 search_string=search_string)) 250 else: 251 self.doc.setHtml(card.answer(render_chain="card_browser", 252 ignore_text_colour=ignore_text_colour, 253 search_string=search_string)) 254 # Paint the item without the text. 255 option.text = "" 256 style.drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter) 257 context = QtGui.QAbstractTextDocumentLayout.PaintContext() 258 # Highlight text if item is selected. 259 if option.state & QtWidgets.QStyle.State_Selected: 260 painter.fillRect(option.rect, option.palette.highlight()) 261 context.palette.setColor(QtGui.QPalette.Text, 262 option.palette.color(QtGui.QPalette.Active, 263 QtGui.QPalette.HighlightedText)) 264 rect = style.subElementRect(QtWidgets.QStyle.SE_ItemViewItemText, 265 option, None) 266 # Render. 267 painter.save() 268 painter.translate(rect.topLeft()) 269 painter.translate(0, 2) # There seems to be a small offset needed... 270 painter.setClipRect(rect.translated(-rect.topLeft())) 271 self.doc.documentLayout().draw(painter, context) 272 painter.restore() 273 274 def paint_webengine(self, painter, option, index): 275 painter.save() 276 option = QtWidgets.QStyleOptionViewItem(option) 277 self.initStyleOption(option, index) 278 if option.widget: 279 style = option.widget.style() 280 else: 281 style = QtWidgets.QApplication.style() 282 # Get the data. 283 _id_index = index.model().index(index.row(), _ID) 284 _id = index.model().data(_id_index) 285 if option.state & QtWidgets.QStyle.State_Selected: 286 force_text_colour = option.palette.color(\ 287 QtWidgets.QPalette.Active, QtWidgets.QPalette.HighlightedText).rgb() 288 else: 289 force_text_colour = None 290 search_string = index.model().search_string 291 card = self.card(_id) 292 # Set the html. 293 self.load_finished = False 294 if self.Q_or_A == QUESTION: 295 self.doc.setHtml(card.question(render_chain="card_browser", 296 force_text_colour=force_text_colour, 297 search_string=search_string)) 298 else: 299 self.doc.setHtml(card.answer(render_chain="card_browser", 300 force_text_colour=force_text_colour, 301 search_string=search_string)) 302 self.doc.setStyleSheet("background:transparent") 303 self.doc.setAttribute(QtCore.Qt.WA_TranslucentBackground) 304 self.doc.show() 305 while not self.load_finished: 306 QtWidgets.QApplication.instance().processEvents(\ 307 QtCore.QEventLoop.ExcludeUserInputEvents | \ 308 QtCore.QEventLoop.ExcludeSocketNotifiers | \ 309 QtCore.QEventLoop.WaitForMoreEvents) 310 # Background colour. 311 rect = \ 312 style.subElementRect(QtWidgets.QStyle.SE_ItemViewItemText, option) 313 if option.state & QtWidgets.QStyle.State_Selected: 314 background_colour = option.palette.color(QtWidgets.QPalette.Active, 315 QtWidgets.QPalette.Highlight) 316 else: 317 background_colour = index.model().background_colour_for_card_type_id.\ 318 get(card.card_type.id, None) 319 if background_colour: 320 painter.fillRect(rect, background_colour) 321 # Render from browser. 322 painter.translate(rect.topLeft()) 323 painter.setClipRect(rect.translated(-rect.topLeft())) 324 self.doc.setStyleSheet("background:transparent") 325 self.doc.setAttribute(QtCore.Qt.WA_TranslucentBackground) 326 self.doc.render(painter) 327 painter.restore() 328 329 330class BrowseCardsDlg(QtWidgets.QDialog, BrowseCardsDialog, 331 TipAfterStartingNTimes, Ui_BrowseCardsDlg): 332 333 started_n_times_counter = "started_browse_cards_n_times" 334 tip_after_n_times = \ 335 {3 : _("Right-click on a tag name in the card browser to edit or delete it."), 336 6 : _("Double-click on a card or tag name in the card browser to edit them."), 337 9 : _("You can reorder columns in the card browser by dragging the header label."), 338 12 : _("You can resize columns in the card browser by dragging between the header labels."), 339 15 : _("When editing or previewing cards from the card browser, PageUp/PageDown can be used to move to the previous/next card."), 340 18 : _("You change the relative size of the card list, card type tree and tag tree by dragging the dividers between them."), 341 21 : _("In the search box, you can use SQL wildcards like _ (matching a single character) and % (matching one or more characters)."), 342 24 : _("Cards with strike-through text are inactive in the current set.")} 343 344 def __init__(self, **kwds): 345 super().__init__(**kwds) 346 self.show_tip_after_starting_n_times() 347 self.setupUi(self) 348 self.setWindowFlags(self.windowFlags() \ 349 | QtCore.Qt.WindowMinMaxButtonsHint) 350 self.setWindowFlags(self.windowFlags() \ 351 & ~ QtCore.Qt.WindowContextHelpButtonHint) 352 self.saved_row = None 353 self.selected_rows = [] 354 self.card_model = None 355 # Set up card type tree. 356 self.container_1 = QtWidgets.QWidget(self.splitter_1) 357 self.layout_1 = QtWidgets.QVBoxLayout(self.container_1) 358 self.label_1 = QtWidgets.QLabel(_("Show cards from these card types:"), 359 self.container_1) 360 self.layout_1.addWidget(self.label_1) 361 self.card_type_tree_wdgt = \ 362 CardTypesTreeWdgt(acquire_database=self.unload_qt_database, 363 component_manager=kwds["component_manager"], 364 parent=self.container_1) 365 self.card_type_tree_wdgt.card_types_changed_signal.\ 366 connect(self.reload_database_and_redraw) 367 self.layout_1.addWidget(self.card_type_tree_wdgt) 368 self.splitter_1.insertWidget(0, self.container_1) 369 # Set up tag tree plus search box. 370 self.container_2 = QtWidgets.QWidget(self.splitter_1) 371 self.layout_2 = QtWidgets.QVBoxLayout(self.container_2) 372 self.any_all_tags = QtWidgets.QComboBox(self.container_2) 373 self.any_all_tags.addItem(_("having any of these tags:")) 374 self.any_all_tags.addItem(_("having all of these tags:")) 375 self.layout_2.addWidget(self.any_all_tags) 376 self.tag_tree_wdgt = \ 377 TagsTreeWdgt(acquire_database=self.unload_qt_database, 378 component_manager=kwds["component_manager"], parent=self.container_2) 379 self.tag_tree_wdgt.tags_changed_signal.\ 380 connect(self.reload_database_and_redraw) 381 self.layout_2.addWidget(self.tag_tree_wdgt) 382 self.label_3 = QtWidgets.QLabel(_("containing this text in the cards:"), 383 self.container_2) 384 self.layout_2.addWidget(self.label_3) 385 self.search_box = QtWidgets.QLineEdit(self.container_2) 386 self.search_box.textChanged.connect(self.search_text_changed) 387 self.timer = QtCore.QTimer(self) 388 self.timer.setSingleShot(True) 389 self.timer.timeout.connect(self.update_filter) 390 self.search_box.setFocus() 391 self.layout_2.addWidget(self.search_box) 392 self.splitter_1.insertWidget(1, self.container_2) 393 # Fill tree widgets. 394 criterion = self.database().current_criterion() 395 self.card_type_tree_wdgt.display(criterion) 396 self.tag_tree_wdgt.display(criterion) 397 # When starting the widget, we default with the current criterion 398 # as filter. In this case, we can make a shortcut simply by selecting 399 # on 'active=1' 400 self.load_qt_database() 401 self.display_card_table(run_filter=False) 402 self.card_model.setFilter("cards.active=1") 403 self.card_model.select() 404 self.update_card_counters() 405 self.card_type_tree_wdgt.tree_wdgt.\ 406 itemClicked.connect(self.update_filter) 407 self.tag_tree_wdgt.tree_wdgt.\ 408 itemClicked.connect(self.update_filter) 409 self.any_all_tags.\ 410 currentIndexChanged.connect(self.update_filter) 411 # Context menu. 412 self.table.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) 413 self.table.customContextMenuRequested.connect(self.context_menu) 414 # Restore state. 415 state = self.config()["browse_cards_dlg_state"] 416 if state: 417 self.restoreGeometry(state) 418 splitter_1_state = self.config()["browse_cards_dlg_splitter_1_state"] 419 if not splitter_1_state: 420 self.splitter_1.setSizes([230, 320]) 421 else: 422 self.splitter_1.restoreState(splitter_1_state) 423 splitter_2_state = self.config()["browse_cards_dlg_splitter_2_state"] 424 if not splitter_2_state: 425 self.splitter_2.setSizes([333, 630]) 426 else: 427 self.splitter_2.restoreState(splitter_2_state) 428 for column in (_ID, ID, CARD_TYPE_ID, _FACT_ID, FACT_VIEW_ID, 429 ACQ_REPS_SINCE_LAPSE, RET_REPS_SINCE_LAPSE, 430 EXTRA_DATA, ACTIVE, SCHEDULER_DATA): 431 self.table.setColumnHidden(column, True) 432 #self.table.setColumnHidden(_ID, False) 433 434 def context_menu(self, point): 435 menu = QtWidgets.QMenu(self) 436 edit_action = QtWidgets.QAction(_("&Edit"), menu) 437 edit_action.setShortcut(QtCore.Qt.CTRL + QtCore.Qt.Key_E) 438 edit_action.triggered.connect(self.menu_edit) 439 menu.addAction(edit_action) 440 preview_action = QtWidgets.QAction(_("&Preview"), menu) 441 preview_action.setShortcut(QtCore.Qt.CTRL + QtCore.Qt.Key_P) 442 preview_action.triggered.connect(self.menu_preview) 443 menu.addAction(preview_action) 444 delete_action = QtWidgets.QAction(_("&Delete"), menu) 445 delete_action.setShortcut(QtGui.QKeySequence.Delete) 446 delete_action.triggered.connect(self.menu_delete) 447 menu.addAction(delete_action) 448 menu.addSeparator() 449 change_card_type_action = QtWidgets.QAction(_("Change card &type"), menu) 450 change_card_type_action.triggered.connect(self.menu_change_card_type) 451 menu.addAction(change_card_type_action) 452 menu.addSeparator() 453 add_tags_action = QtWidgets.QAction(_("&Add tags"), menu) 454 add_tags_action.triggered.connect(self.menu_add_tags) 455 menu.addAction(add_tags_action) 456 remove_tags_action = QtWidgets.QAction(_("&Remove tags"), menu) 457 remove_tags_action.triggered.connect(self.menu_remove_tags) 458 menu.addAction(remove_tags_action) 459 indexes = self.table.selectionModel().selectedRows() 460 if len(indexes) > 1: 461 edit_action.setEnabled(False) 462 preview_action.setEnabled(False) 463 if len(indexes) >= 1: 464 menu.exec_(self.table.mapToGlobal(point)) 465 466 def keyPressEvent(self, event): 467 if len(self.table.selectionModel().selectedRows()) == 0: 468 QtWidgets.QDialog.keyPressEvent(self, event) 469 if event.key() in [QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return]: 470 self.menu_edit() 471 elif event.key() == QtCore.Qt.Key_E and \ 472 event.modifiers() == QtCore.Qt.ControlModifier: 473 self.menu_edit() 474 elif event.key() == QtCore.Qt.Key_P and \ 475 event.modifiers() == QtCore.Qt.ControlModifier: 476 self.menu_preview() 477 elif event.key() == QtCore.Qt.Key_F and \ 478 event.modifiers() == QtCore.Qt.ControlModifier: 479 self.search_box.setFocus() 480 elif event.key() in [QtCore.Qt.Key_Delete, QtCore.Qt.Key_Backspace]: 481 self.menu_delete() 482 else: 483 QtWidgets.QDialog.keyPressEvent(self, event) 484 485 def sister_cards_from_single_selection(self): 486 selected_rows = self.table.selectionModel().selectedRows() 487 if len(selected_rows) == 0: 488 return [] 489 index = selected_rows[0] 490 _fact_id_index = index.model().index(\ 491 index.row(), _FACT_ID, index.parent()) 492 _fact_id = index.model().data(_fact_id_index) 493 fact = self.database().fact(_fact_id, is_id_internal=True) 494 return self.database().cards_from_fact(fact) 495 496 def facts_from_selection(self): 497 _fact_ids = set() 498 for index in self.table.selectionModel().selectedRows(): 499 _fact_id_index = index.model().index(\ 500 index.row(), _FACT_ID, index.parent()) 501 _fact_id = index.model().data(_fact_id_index) 502 _fact_ids.add(_fact_id) 503 facts = [] 504 for _fact_id in _fact_ids: 505 facts.append(self.database().fact(_fact_id, is_id_internal=True)) 506 return facts 507 508 def _card_ids_from_selection(self): 509 _card_ids = set() 510 for index in self.table.selectionModel().selectedRows(): 511 _card_id_index = index.model().index(\ 512 index.row(), _ID, index.parent()) 513 _card_id = index.model().data(_card_id_index) 514 _card_ids.add(_card_id) 515 return _card_ids 516 517 def menu_edit(self, index=None): 518 # 'index' gets passed if this function gets called through the 519 # table.doubleClicked event. 520 _card_ids = self._card_ids_from_selection() 521 if len(_card_ids) == 0: 522 return 523 card = self.database().card(_card_ids.pop(), is_id_internal=True) 524 self.edit_dlg = self.component_manager.current("edit_card_dialog")\ 525 (card, allow_cancel=True, started_from_card_browser=True, 526 parent=self, component_manager=self.component_manager) 527 # Here, we don't unload the database already by ourselves, but leave 528 # it to the edit dialog to only do so if needed. 529 self.edit_dlg.before_apply_hook = self.unload_qt_database 530 self.edit_dlg.after_apply_hook = None 531 self.edit_dlg.page_up_down_signal.connect(self.page_up_down_edit) 532 if self.edit_dlg.exec_() == QtWidgets.QDialog.Accepted: 533 self.card_type_tree_wdgt.rebuild() 534 self.tag_tree_wdgt.rebuild() 535 self.load_qt_database() 536 self.display_card_table() 537 # Avoid multiple connections. 538 self.edit_dlg.page_up_down_signal.disconnect(self.page_up_down_edit) 539 540 def page_up_down_edit(self, up_down): 541 current_index = self.table.selectionModel().selectedRows()[0] 542 current_row = self.table.selectionModel().selectedRows()[0].row() 543 model = current_index.model() 544 if up_down == self.edit_dlg.UP: 545 shift = -1 546 elif up_down == self.edit_dlg.DOWN: 547 shift = 1 548 if current_row + shift < 0 or current_row + shift >= model.rowCount(): 549 return 550 next__card_id_index = model.index(\ 551 current_row + shift, _ID, current_index.parent()) 552 next__card_id = model.data(next__card_id_index) 553 self.table.selectRow(current_row + shift) 554 del model; del current_index # Otherwise we cannot release the database. 555 self.edit_dlg.before_apply_hook = self.unload_qt_database 556 def after_apply(): 557 self.load_qt_database() 558 self.display_card_table() 559 self.edit_dlg.after_apply_hook = after_apply 560 self.edit_dlg.apply_changes() 561 # Reload card to make sure the changes are picked up. 562 card = self.database().card(next__card_id, is_id_internal=True) 563 self.edit_dlg.set_new_card(card) 564 565 def menu_preview(self): 566 from mnemosyne.pyqt_ui.preview_cards_dlg import PreviewCardsDlg 567 cards = self.sister_cards_from_single_selection() 568 tag_text = cards[0].tag_string() 569 self.preview_dlg = \ 570 PreviewCardsDlg(cards, tag_text, 571 component_manager=self.component_manager, parent=self) 572 self.preview_dlg.page_up_down_signal.connect(\ 573 self.page_up_down_preview) 574 self.preview_dlg.exec_() 575 # Avoid multiple connections. 576 self.preview_dlg.page_up_down_signal.disconnect(\ 577 self.page_up_down_preview) 578 579 def page_up_down_preview(self, up_down): 580 from mnemosyne.pyqt_ui.preview_cards_dlg import PreviewCardsDlg 581 current_index = self.table.selectionModel().selectedRows()[0] 582 current_row = self.table.selectionModel().selectedRows()[0].row() 583 model = current_index.model() 584 if up_down == PreviewCardsDlg.UP: 585 shift = -1 586 elif up_down == PreviewCardsDlg.DOWN: 587 shift = 1 588 if current_row + shift < 0 or current_row + shift >= model.rowCount(): 589 return 590 self.table.selectRow(current_row + shift) 591 self.preview_dlg.index = 0 592 self.preview_dlg.cards = self.sister_cards_from_single_selection() 593 self.preview_dlg.tag_text = self.preview_dlg.cards[0].tag_string() 594 self.preview_dlg.update_dialog() 595 596 def menu_delete(self): 597 answer = self.main_widget().show_question\ 598 (_("Go ahead with delete? Sister cards will be deleted as well."), 599 _("&OK"), _("&Cancel"), "") 600 if answer == 1: # Cancel. 601 return 602 _fact_ids = set() 603 for index in self.table.selectionModel().selectedRows(): 604 _fact_id_index = index.model().index(\ 605 index.row(), _FACT_ID, index.parent()) 606 _fact_id = index.model().data(_fact_id_index) 607 _fact_ids.add(_fact_id) 608 facts = [] 609 for _fact_id in _fact_ids: 610 facts.append(self.database().fact(_fact_id, is_id_internal=True)) 611 self.unload_qt_database() 612 self.selected_rows = [] 613 self.controller().delete_facts_and_their_cards(facts) 614 self.card_type_tree_wdgt.rebuild() 615 self.tag_tree_wdgt.rebuild() 616 self.load_qt_database() 617 self.display_card_table() 618 619 def menu_change_card_type(self): 620 # Test if all selected cards have the same card type. 621 current_card_type_ids = set() 622 for index in self.table.selectionModel().selectedRows(): 623 card_type_id_index = index.model().index(\ 624 index.row(), CARD_TYPE_ID, index.parent()) 625 card_type_id = index.model().data(card_type_id_index) 626 current_card_type_ids.add(card_type_id) 627 if len(current_card_type_ids) > 1: 628 self.main_widget().show_error\ 629 (_("The selected cards should have the same card type.")) 630 return 631 current_card_type = self.card_type_with_id(current_card_type_ids.pop()) 632 # Get new card type. Use a dict as backdoor to return values 633 # from the dialog. 634 return_values = {} 635 from mnemosyne.pyqt_ui.change_card_type_dlg import ChangeCardTypeDlg 636 dlg = ChangeCardTypeDlg(current_card_type, return_values, 637 component_manager=self.component_manager, parent=self) 638 if dlg.exec_() != QtWidgets.QDialog.Accepted: 639 return 640 new_card_type = return_values["new_card_type"] 641 # Get correspondence. 642 self.correspondence = {} 643 if not current_card_type.fact_keys().issubset(new_card_type.fact_keys()): 644 dlg = ConvertCardTypeKeysDlg(current_card_type, new_card_type, 645 self.correspondence, check_required_fact_keys=True, parent=self) 646 if dlg.exec_() != QtWidgets.QDialog.Accepted: 647 return 648 # Start the actual conversion. 649 facts = self.facts_from_selection() 650 self.unload_qt_database() 651 self.controller().change_card_type(facts, current_card_type, 652 new_card_type, self.correspondence) 653 self.card_type_tree_wdgt.rebuild() 654 self.tag_tree_wdgt.rebuild() 655 self.load_qt_database() 656 self.display_card_table() 657 658 def menu_add_tags(self): 659 if not self.config()["showed_help_on_adding_tags"]: 660 self.main_widget().show_information(\ 661"With this option, can you edit the tags of individual cards, without affecting sister cards.") 662 self.config()["showed_help_on_adding_tags"] = True 663 # Get new tag names. Use a dict as backdoor to return values 664 # from the dialog. 665 return_values = {} 666 from mnemosyne.pyqt_ui.add_tags_dlg import AddTagsDlg 667 dlg = AddTagsDlg(return_values, component_manager=self.component_manager, 668 parent=self) 669 if dlg.exec_() != QtWidgets.QDialog.Accepted: 670 return 671 # Add the tags. 672 _card_ids = self._card_ids_from_selection() 673 self.unload_qt_database() 674 for tag_name in return_values["tag_names"]: 675 if not tag_name: 676 continue 677 tag = self.database().get_or_create_tag_with_name(tag_name) 678 self.database().add_tag_to_cards_with_internal_ids(tag, _card_ids) 679 self.tag_tree_wdgt.rebuild() 680 self.load_qt_database() 681 self.display_card_table() 682 683 def menu_remove_tags(self): 684 if not self.config()["showed_help_on_adding_tags"]: 685 self.main_widget().show_information(\ 686"With this option, can you edit the tags of individual cards, without affecting sister cards.") 687 self.config()["showed_help_on_adding_tags"] = True 688 # Figure out the tags used by the selected cards. 689 _card_ids = self._card_ids_from_selection() 690 tags = self.database().tags_from_cards_with_internal_ids(_card_ids) 691 # Get new tag names. Use a dict as backdoor to return values 692 # from the dialog. 693 return_values = {} 694 from mnemosyne.pyqt_ui.remove_tags_dlg import RemoveTagsDlg 695 dlg = RemoveTagsDlg(tags, return_values, parent=self) 696 if dlg.exec_() != QtWidgets.QDialog.Accepted: 697 return 698 # Remove the tags. 699 self.unload_qt_database() 700 for tag_name in return_values["tag_names"]: 701 if not tag_name: 702 continue 703 tag = self.database().get_or_create_tag_with_name(tag_name) 704 self.database().remove_tag_from_cards_with_internal_ids(\ 705 tag, _card_ids) 706 self.tag_tree_wdgt.rebuild() 707 self.load_qt_database() 708 self.display_card_table() 709 710 def load_qt_database(self): 711 self.database().release_connection() 712 qt_db = QtSql.QSqlDatabase.addDatabase("QSQLITE") 713 qt_db.setDatabaseName(self.database().path()) 714 if not qt_db.open(): 715 QtWidgets.QMessageBox.warning(None, _("Mnemosyne"), 716 _("Database error: ") + qt_db.lastError().text()) 717 sys.exit(1) 718 719 def unload_qt_database(self): 720 # Don't save state twice when closing dialog. 721 if self.card_model is None: 722 return 723 self.saved_row = self.table.indexAt(QtCore.QPoint(0,0)).row() 724 self.selected_rows = [index.row() for index in \ 725 self.table.selectionModel().selectedRows()] 726 self.config()["browse_cards_dlg_table_settings"] \ 727 = self.table.horizontalHeader().saveState() 728 self.table.setModel(QtGui.QStandardItemModel()) 729 del self.card_model 730 self.card_model = None 731 import gc; gc.collect() 732 QtSql.QSqlDatabase.removeDatabase(\ 733 QtSql.QSqlDatabase.database().connectionName()) 734 735 def display_card_table(self, run_filter=True): 736 self.card_model = CardModel(component_manager=self.component_manager) 737 self.card_model.setTable("cards") 738 headers = {QUESTION: _("Question"), ANSWER: _("Answer"), 739 TAGS: _("Tags"), GRADE: _("Grade"), NEXT_REP: _("Next rep"), 740 LAST_REP: _("Last rep"), EASINESS: _("Easiness"), 741 ACQ_REPS: _("Learning\nreps"), 742 RET_REPS: _("Review\nreps"), LAPSES: _("Lapses"), 743 CREATION_TIME: _("Created"), MODIFICATION_TIME: _("Modified")} 744 for key, value in headers.items(): 745 self.card_model.setHeaderData(key, QtCore.Qt.Horizontal, 746 QtCore.QVariant(value)) 747 self.table.setModel(self.card_model) 748 # Slow, and doesn't work very well. 749 #self.table.verticalHeader().setSectionResizeMode(\ 750 # QtWidgets.QHeaderView.ResizeToContents) 751 self.table.horizontalHeader().sectionClicked.connect(\ 752 self.horizontal_header_section_clicked) 753 table_settings = self.config()["browse_cards_dlg_table_settings"] 754 if table_settings: 755 self.table.horizontalHeader().restoreState(table_settings) 756 self.table.horizontalHeader().setSectionsMovable(True) 757 self.table.setItemDelegateForColumn(\ 758 QUESTION, QA_Delegate(QUESTION, 759 component_manager=self.component_manager, parent=self)) 760 self.table.setItemDelegateForColumn(\ 761 ANSWER, QA_Delegate(ANSWER, 762 component_manager=self.component_manager, parent=self)) 763 self.table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) 764 # Since this function can get called multiple times, we need to make 765 # sure there is only a single connection for the double-click event. 766 try: 767 self.table.doubleClicked.disconnect(self.menu_edit) 768 except TypeError: 769 pass 770 self.table.doubleClicked.connect(self.menu_edit) 771 self.table.verticalHeader().hide() 772 query = QtSql.QSqlQuery("select count() from tags") 773 query.first() 774 self.tag_count = query.value(0) 775 if run_filter: 776 self.update_filter() # Needed after tag rename. 777 if self.saved_row: 778 # All of the statements below are needed. 779 saved_index = self.card_model.index(self.saved_row, QUESTION) 780 self.table.scrollTo(saved_index) 781 self.table.scrollTo(saved_index, 782 QtWidgets.QAbstractItemView.PositionAtTop) 783 if self.selected_rows: 784 # Restore selection. 785 old_selection_mode = self.table.selectionMode() 786 self.table.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) 787 # Note that there seem to be serious Qt preformance problems with 788 # selectRow, so we only do this for a small number of rows. 789 if len(self.selected_rows) < 10: 790 for row in self.selected_rows: 791 self.table.selectRow(row) 792 self.table.setSelectionMode(\ 793 QtWidgets.QAbstractItemView.ExtendedSelection) 794 795 def reload_database_and_redraw(self): 796 self.load_qt_database() 797 self.display_card_table() 798 799 def horizontal_header_section_clicked(self, index): 800 if not self.config()["browse_cards_dlg_sorting_warning_shown"]: 801 self.main_widget().show_information(\ 802_("You chose to sort this table. Operations in the card browser could now be slower. Next time you start the card browser, the table will be unsorted again.")) 803 self.config()["browse_cards_dlg_sorting_warning_shown"] = True 804 805 def activate(self): 806 BrowseCardsDialog.activate(self) 807 self.exec_() 808 809 def search_text_changed(self): 810 # Don't immediately start updating the filter, but wait until the last 811 # keypress was 300 ms ago. 812 self.timer.start(300) 813 814 def update_filter(self, dummy=None): 815 # Card types and fact views. 816 criterion = DefaultCriterion(self.component_manager) 817 self.card_type_tree_wdgt.checked_to_criterion(criterion) 818 filter = "" 819 for card_type_id, fact_view_id in \ 820 criterion.deactivated_card_type_fact_view_ids: 821 filter += """not (cards.fact_view_id='%s' and 822 cards.card_type_id='%s') and """ \ 823 % (fact_view_id, card_type_id) 824 filter = filter.rsplit("and ", 1)[0] 825 # Tags. 826 self.tag_tree_wdgt.checked_to_active_tags_in_criterion(criterion) 827 if len(criterion._tag_ids_active) == 0: 828 filter = "_id='not_there'" 829 elif len(criterion._tag_ids_active) != self.tag_count: 830 if filter: 831 filter += "and " 832 # Determine all _card_ids. 833 query = QtSql.QSqlQuery("select _id from cards") 834 all__card_ids = set() 835 while query.next(): 836 all__card_ids.add(str(query.value(0))) 837 # Determine _card_ids of card with an active tag. 838 if self.any_all_tags.currentIndex() == 0: 839 query = "select _card_id from tags_for_card where _tag_id in (" 840 for _tag_id in criterion._tag_ids_active: 841 query += "'%s', " % (_tag_id, ) 842 query = query[:-2] + ")" 843 # Determine _card_ids of cards which have all active tags. 844 else: 845 query = "" 846 for _tag_id in criterion._tag_ids_active: 847 query += "select _card_id from tags_for_card where " + \ 848 "_tag_id='%s' intersect " % (_tag_id, ) 849 query = query[:-(len(" intersect "))] 850 query = QtSql.QSqlQuery(query) 851 active__card_ids = set() 852 while query.next(): 853 active__card_ids.add(str(query.value(0))) 854 # Construct most optimal query. 855 if len(active__card_ids) > len(all__card_ids)/2: 856 filter += "_id not in (" + \ 857 ",".join(all__card_ids - active__card_ids) + ")" 858 else: 859 filter += "_id in (" + ",".join(active__card_ids) + ")" 860 # Search string. 861 search_string = self.search_box.text().replace("'", "''") 862 self.card_model.search_string = search_string 863 if search_string: 864 if filter: 865 filter += " and " 866 filter += "(question like '%%%s%%' or answer like '%%%s%%')" \ 867 % (search_string, search_string) 868 self.card_model.setFilter(filter) 869 self.card_model.select() 870 self.update_card_counters() 871 872 def update_card_counters(self): 873 filter = self.card_model.filter() 874 # Selected count. 875 query_string = "select count() from cards" 876 if filter: 877 query_string += " where " + filter 878 query = QtSql.QSqlQuery(query_string) 879 query.first() 880 selected = query.value(0) 881 # Active selected count. 882 if not filter: 883 query_string += " where active=1" 884 else: 885 query_string += " and active=1" 886 query = QtSql.QSqlQuery(query_string) 887 query.first() 888 active = query.value(0) 889 self.counter_label.setText(\ 890 _("%d cards shown, of which %d active.") % (selected, active)) 891 892 def _store_state(self): 893 self.config()["browse_cards_dlg_state"] = self.saveGeometry() 894 self.config()["browse_cards_dlg_splitter_1_state"] = \ 895 self.splitter_1.saveState() 896 self.config()["browse_cards_dlg_splitter_2_state"] = \ 897 self.splitter_2.saveState() 898 # Make sure we start unsorted again next time. 899 if not self.config()["start_card_browser_sorted"]: 900 self.table.horizontalHeader().setSortIndicator\ 901 (-1, QtCore.Qt.AscendingOrder) 902 903 def closeEvent(self, event): 904 # Generated when clicking the window's close button. 905 self._store_state() 906 self.unload_qt_database() 907 # This allows the state of the tag tree to be saved. 908 self.tag_tree_wdgt.close() 909 910 def reject(self): 911 self._store_state() 912 # Generated when pressing escape. 913 self.unload_qt_database() 914 return QtWidgets.QDialog.reject(self) 915 916 def accept(self): 917 # 'accept' does not generate a close event. 918 self._store_state() 919 self.unload_qt_database() 920 return QtWidgets.QDialog.accept(self) 921