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