From 0d8b1c9d0b00501cbe91250dd27c6eed1b285e9b Mon Sep 17 00:00:00 2001 From: RumovZ Date: Mon, 29 Mar 2021 16:12:26 +1000 Subject: [PATCH] squash merge browser refactor Closes #1100 --- ftl/core/browsing.ftl | 2 + pylib/anki/collection.py | 100 +-- qt/aqt/addcards.py | 13 +- qt/aqt/browser.py | 776 ++--------------- qt/aqt/forms/browser.ui | 34 +- qt/aqt/previewer.py | 16 +- qt/aqt/table.py | 1136 +++++++++++++++++++++++++ qt/tools/genhooks_gui.py | 6 +- rslib/backend.proto | 82 +- rslib/src/backend/config.rs | 2 + rslib/src/backend/search/mod.rs | 23 +- rslib/src/browser_rows.rs | 261 ++++-- rslib/src/config/bool.rs | 5 +- rslib/src/config/mod.rs | 22 + rslib/src/search/cards.rs | 198 ----- rslib/src/search/mod.rs | 270 +++++- rslib/src/search/note_cards_order.sql | 10 + rslib/src/search/note_ease_order.sql | 11 + rslib/src/search/notes.rs | 23 - rslib/src/search/sqlwriter.rs | 42 +- 20 files changed, 1897 insertions(+), 1135 deletions(-) create mode 100644 qt/aqt/table.py delete mode 100644 rslib/src/search/cards.rs create mode 100644 rslib/src/search/note_cards_order.sql create mode 100644 rslib/src/search/note_ease_order.sql delete mode 100644 rslib/src/search/notes.rs diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl index d9bfedcb1..e2f05b275 100644 --- a/ftl/core/browsing.ftl +++ b/ftl/core/browsing.ftl @@ -7,6 +7,7 @@ browsing-all-fields = All Fields browsing-answer = Answer browsing-any-cards-mapped-to-nothing-will = Any cards mapped to nothing will be deleted. If a note has no remaining cards, it will be lost. Are you sure you want to continue? browsing-any-flag = Any Flag +browsing-average-ease = Average Ease browsing-browser-appearance = Browser Appearance browsing-browser-options = Browser Options browsing-buried = Buried @@ -100,6 +101,7 @@ browsing-toggle-suspend = Toggle Suspend browsing-treat-input-as-regular-expression = Treat input as regular expression browsing-update-saved-search = Update with Current Search browsing-whole-collection = Whole Collection +browsing-window-title-notes = Browse ({ $selected } of { $total } notes selected) browsing-you-must-have-at-least-one = You must have at least one column. browsing-group = { $count -> diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 27faa8a07..20b819a25 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -495,55 +495,26 @@ class Collection: query: str, order: Union[bool, str, BuiltinSort.Kind.V] = False, reverse: bool = False, - ) -> Sequence[CardId]: + ) -> List[CardId]: """Return card ids matching the provided search. - To programmatically construct a search string, see .build_search_string(). - - If order=True, use the sort order stored in the collection config - If order=False, do no ordering - - If order is a string, that text is added after 'order by' in the sql statement. - You must add ' asc' or ' desc' to the order, as Anki will replace asc with - desc and vice versa when reverse is set in the collection config, eg - order="c.ivl asc, c.due desc". - - If order is a BuiltinSort.Kind value, sort using that builtin sort, eg - col.find_cards("", order=BuiltinSort.Kind.CARD_DUE) - - The reverse argument only applies when a BuiltinSort.Kind is provided; - otherwise the collection config defines whether reverse is set or not. + To define a sort order, see _build_sort_mode(). """ - if isinstance(order, str): - mode = _pb.SortOrder(custom=order) - elif isinstance(order, bool): - if order is True: - mode = _pb.SortOrder(from_config=_pb.Empty()) - else: - mode = _pb.SortOrder(none=_pb.Empty()) - else: - mode = _pb.SortOrder( - builtin=_pb.SortOrder.Builtin(kind=order, reverse=reverse) - ) - return [ - CardId(id) for id in self._backend.search_cards(search=query, order=mode) - ] + mode = _build_sort_mode(order, reverse) + return list(map(CardId, self._backend.search_cards(search=query, order=mode))) - def find_notes(self, *terms: Union[str, SearchNode]) -> Sequence[NoteId]: - """Return note ids matching the provided search or searches. - - If more than one search is provided, they will be ANDed together. - - Eg: col.find_notes("test", "another") will search for "test AND another" - and return matching note ids. - - Eg: col.find_notes(SearchNode(deck="test"), "foo") will return notes - that have a card in deck called "test", and have the text "foo". + def find_notes( + self, + query: str, + order: Union[bool, str, BuiltinSort.Kind.V] = False, + reverse: bool = False, + ) -> List[NoteId]: + """Return note ids matching the provided search. + To programmatically construct a search string, see .build_search_string(). + To define a sort order, see _build_sort_mode(). """ - return [ - NoteId(did) - for did in self._backend.search_notes(self.build_search_string(*terms)) - ] + mode = _build_sort_mode(order, reverse) + return list(map(NoteId, self._backend.search_notes(search=query, order=mode))) def find_and_replace( self, @@ -570,7 +541,9 @@ class Collection: # returns array of ("dupestr", [nids]) def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]: - nids = self.findNotes(search, SearchNode(field_name=fieldName)) + nids = self.find_notes( + self.build_search_string(search, SearchNode(field_name=fieldName)) + ) # go through notes vals: Dict[str, List[int]] = {} dupes = [] @@ -692,10 +665,10 @@ class Collection: # Browser rows ########################################################################## - def browser_row_for_card( - self, cid: int + def browser_row_for_id( + self, id_: int ) -> Tuple[Generator[Tuple[str, bool], None, None], BrowserRow.Color.V, str, int]: - row = self._backend.browser_row_for_card(cid) + row = self._backend.browser_row_for_id(id_) return ( ((cell.text, cell.is_rtl) for cell in row.cells), row.color, @@ -1089,3 +1062,34 @@ class _ReviewsUndo: _UndoInfo = Union[_ReviewsUndo, Checkpoint, None] + + +def _build_sort_mode( + order: Union[bool, str, BuiltinSort.Kind.V], + reverse: bool, +) -> _pb.SortOrder: + """Return a SortOrder object for use in find_cards() or find_notes(). + + If order=True, use the sort order stored in the collection config + If order=False, do no ordering + + If order is a string, that text is added after 'order by' in the sql statement. + You must add ' asc' or ' desc' to the order, as Anki will replace asc with + desc and vice versa when reverse is set in the collection config, eg + order="c.ivl asc, c.due desc". + + If order is a BuiltinSort.Kind value, sort using that builtin sort, eg + col.find_cards("", order=BuiltinSort.Kind.CARD_DUE) + + The reverse argument only applies when a BuiltinSort.Kind is provided; + otherwise the collection config defines whether reverse is set or not. + """ + if isinstance(order, str): + return _pb.SortOrder(custom=order) + elif isinstance(order, bool): + if order is True: + return _pb.SortOrder(from_config=_pb.Empty()) + else: + return _pb.SortOrder(none=_pb.Empty()) + else: + return _pb.SortOrder(builtin=_pb.SortOrder.Builtin(kind=order, reverse=reverse)) diff --git a/qt/aqt/addcards.py b/qt/aqt/addcards.py index 8f532ac52..3b45b9e25 100644 --- a/qt/aqt/addcards.py +++ b/qt/aqt/addcards.py @@ -38,6 +38,7 @@ class AddCards(QDialog): QDialog.__init__(self, None, Qt.Window) mw.garbage_collect_on_dialog_finish(self) self.mw = mw + self.col = mw.col self.form = aqt.forms.addcards.Ui_Dialog() self.form.setupUi(self) self.setWindowTitle(tr.actions_add()) @@ -59,7 +60,7 @@ class AddCards(QDialog): self.editor = aqt.editor.Editor(self.mw, self.form.fieldsArea, self, True) def setup_choosers(self) -> None: - defaults = self.mw.col.defaults_for_adding( + defaults = self.col.defaults_for_adding( current_review_card=self.mw.reviewer.card ) self.notetype_chooser = NotetypeChooser( @@ -112,7 +113,7 @@ class AddCards(QDialog): def on_notetype_change(self, notetype_id: NotetypeId) -> None: # need to adjust current deck? - if deck_id := self.mw.col.default_deck_for_notetype(notetype_id): + if deck_id := self.col.default_deck_for_notetype(notetype_id): self.deck_chooser.selected_deck_id = deck_id # only used for detecting changed sticky fields on close @@ -151,8 +152,8 @@ class AddCards(QDialog): self.setAndFocusNote(note) def _new_note(self) -> Note: - return self.mw.col.new_note( - self.mw.col.models.get(self.notetype_chooser.selected_notetype_id) + return self.col.new_note( + self.col.models.get(self.notetype_chooser.selected_notetype_id) ) def addHistory(self, note: Note) -> None: @@ -163,8 +164,8 @@ class AddCards(QDialog): def onHistory(self) -> None: m = QMenu(self) for nid in self.history: - if self.mw.col.findNotes(SearchNode(nid=nid)): - note = self.mw.col.get_note(nid) + if self.col.find_notes(self.col.build_search_string(SearchNode(nid=nid))): + note = self.col.get_note(nid) fields = note.fields txt = htmlToTextLine(", ".join(fields)) if len(txt) > 30: diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 0042ff895..b69060248 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -4,26 +4,13 @@ from __future__ import annotations import html -import time from dataclasses import dataclass -from operator import itemgetter -from typing import ( - Any, - Callable, - Dict, - Generator, - List, - Optional, - Sequence, - Tuple, - Union, - cast, -) +from typing import Any, Callable, Dict, List, Optional, Tuple, Union import aqt import aqt.forms from anki.cards import Card, CardId -from anki.collection import BrowserRow, Collection, Config, OpChanges, SearchNode +from anki.collection import Collection, Config, OpChanges, SearchNode from anki.consts import * from anki.errors import NotFoundError from anki.lang import without_unicode_isolation @@ -31,8 +18,8 @@ from anki.models import NotetypeDict from anki.notes import NoteId from anki.stats import CardStats from anki.tags import MARKED_TAG -from anki.utils import ids2str, isMac, isWin -from aqt import AnkiQt, colors, gui_hooks +from anki.utils import ids2str, isMac +from aqt import AnkiQt, gui_hooks from aqt.card_ops import set_card_deck, set_card_flag from aqt.editor import Editor from aqt.exporting import ExportDialog @@ -50,8 +37,8 @@ from aqt.scheduling_ops import ( unsuspend_cards, ) from aqt.sidebar import SidebarTreeView +from aqt.table import Table from aqt.tag_ops import add_tags, clear_unused_tags, remove_tags_for_notes -from aqt.theme import theme_manager from aqt.utils import ( HelpPage, KeyboardModifiersPressed, @@ -66,7 +53,6 @@ from aqt.utils import ( restore_combo_history, restore_combo_index_for_session, restoreGeom, - restoreHeader, restoreSplitter, restoreState, save_combo_history, @@ -90,353 +76,15 @@ class FindDupesDialog: browser: Browser -@dataclass -class SearchContext: - search: str - browser: Browser - order: Union[bool, str] = True - # if set, provided card ids will be used instead of the regular search - card_ids: Optional[Sequence[CardId]] = None - - -# Data model -########################################################################## - - -@dataclass -class Cell: - text: str - is_rtl: bool - - -class CellRow: - def __init__( - self, - cells: Generator[Tuple[str, bool], None, None], - color: BrowserRow.Color.V, - font_name: str, - font_size: int, - ) -> None: - self.refreshed_at: float = time.time() - self.cells: Tuple[Cell, ...] = tuple(Cell(*cell) for cell in cells) - self.color: Optional[Tuple[str, str]] = backend_color_to_aqt_color(color) - self.font_name: str = font_name or "arial" - self.font_size: int = font_size if font_size > 0 else 12 - - def is_stale(self, threshold: float) -> bool: - return self.refreshed_at < threshold - - @staticmethod - def generic(length: int, cell_text: str) -> CellRow: - return CellRow( - ((cell_text, False) for cell in range(length)), - BrowserRow.COLOR_DEFAULT, - "arial", - 12, - ) - - @staticmethod - def placeholder(length: int) -> CellRow: - return CellRow.generic(length, "...") - - @staticmethod - def deleted(length: int) -> CellRow: - return CellRow.generic(length, tr.browsing_row_deleted()) - - -def backend_color_to_aqt_color(color: BrowserRow.Color.V) -> Optional[Tuple[str, str]]: - if color == BrowserRow.COLOR_MARKED: - return colors.MARKED_BG - if color == BrowserRow.COLOR_SUSPENDED: - return colors.SUSPENDED_BG - if color == BrowserRow.COLOR_FLAG_RED: - return colors.FLAG1_BG - if color == BrowserRow.COLOR_FLAG_ORANGE: - return colors.FLAG2_BG - if color == BrowserRow.COLOR_FLAG_GREEN: - return colors.FLAG3_BG - if color == BrowserRow.COLOR_FLAG_BLUE: - return colors.FLAG4_BG - return None - - -class DataModel(QAbstractTableModel): - def __init__(self, browser: Browser) -> None: - QAbstractTableModel.__init__(self) - self.browser = browser - self.col = browser.col - self.sortKey = None - self.activeCols: List[str] = self.col.get_config( - "activeCols", ["noteFld", "template", "cardDue", "deck"] - ) - self.cards: Sequence[CardId] = [] - self._rows: Dict[int, CellRow] = {} - self._last_refresh = 0.0 - # serve stale content to avoid hitting the DB? - self.block_updates = False - - def get_id(self, index: QModelIndex) -> CardId: - return self.cards[index.row()] - - def get_cell(self, index: QModelIndex) -> Cell: - return self.get_row(index).cells[index.column()] - - def get_row(self, index: QModelIndex) -> CellRow: - cid = self.get_id(index) - if row := self._rows.get(cid): - if not self.block_updates and row.is_stale(self._last_refresh): - # need to refresh - self._rows[cid] = self._fetch_row_from_backend(cid) - return self._rows[cid] - # return row, even if it's stale - return row - if self.block_updates: - # blank row until we unblock - return CellRow.placeholder(len(self.activeCols)) - # missing row, need to build - self._rows[cid] = self._fetch_row_from_backend(cid) - return self._rows[cid] - - def _fetch_row_from_backend(self, cid: CardId) -> CellRow: - try: - row = CellRow(*self.col.browser_row_for_card(cid)) - except NotFoundError: - return CellRow.deleted(len(self.activeCols)) - except Exception as e: - return CellRow.generic(len(self.activeCols), str(e)) - - gui_hooks.browser_did_fetch_row(cid, row, self.activeCols) - return row - - def getCard(self, index: QModelIndex) -> Optional[Card]: - """Try to return the indicated, possibly deleted card.""" - try: - return self.col.getCard(self.get_id(index)) - except: - return None - - # Model interface - ###################################################################### - - def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: - if parent and parent.isValid(): - return 0 - return len(self.cards) - - def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: - if parent and parent.isValid(): - return 0 - return len(self.activeCols) - - def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any: - if not index.isValid(): - return QVariant() - if role == Qt.FontRole: - if self.activeCols[index.column()] not in ("question", "answer", "noteFld"): - return QVariant() - qfont = QFont() - row = self.get_row(index) - qfont.setFamily(row.font_name) - qfont.setPixelSize(row.font_size) - return qfont - if role == Qt.TextAlignmentRole: - align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter - if self.activeCols[index.column()] not in ( - "question", - "answer", - "template", - "deck", - "noteFld", - "note", - "noteTags", - ): - align |= Qt.AlignHCenter - return align - if role in (Qt.DisplayRole, Qt.ToolTipRole): - return self.get_cell(index).text - return QVariant() - - def headerData( - self, section: int, orientation: Qt.Orientation, role: int = 0 - ) -> Optional[str]: - if orientation == Qt.Vertical: - return None - elif role == Qt.DisplayRole and section < len(self.activeCols): - type = self.activeCols[section] - txt = None - for stype, name in self.browser.columns: - if type == stype: - txt = name - break - # give the user a hint an invalid column was added by an add-on - if not txt: - txt = tr.browsing_addon() - return txt - else: - return None - - def flags(self, index: QModelIndex) -> Qt.ItemFlags: - return cast(Qt.ItemFlags, Qt.ItemIsEnabled | Qt.ItemIsSelectable) - - # Filtering - ###################################################################### - - def search(self, txt: str) -> None: - self.beginReset() - self.cards = [] - try: - ctx = SearchContext(search=txt, browser=self.browser) - gui_hooks.browser_will_search(ctx) - if ctx.card_ids is None: - ctx.card_ids = self.col.find_cards(ctx.search, order=ctx.order) - gui_hooks.browser_did_search(ctx) - self.cards = ctx.card_ids - except Exception as err: - raise err - finally: - self.endReset() - - def redraw_cells(self) -> None: - "Update cell contents, without changing search count/columns/sorting." - if not self.cards: - return - top_left = self.index(0, 0) - bottom_right = self.index(len(self.cards) - 1, len(self.activeCols) - 1) - self._last_refresh = time.time() - self.dataChanged.emit(top_left, bottom_right) # type: ignore - - def reset(self) -> None: - self.beginReset() - self.endReset() - - # caller must have called editor.saveNow() before calling this or .reset() - def beginReset(self) -> None: - self.browser.editor.set_note(None, hide=False) - self.browser.mw.progress.start() - self.saveSelection() - self.beginResetModel() - self._rows = {} - - def endReset(self) -> None: - self.endResetModel() - self.restoreSelection() - self.browser.mw.progress.finish() - - def reverse(self) -> None: - self.browser.editor.call_after_note_saved(self._reverse) - - def _reverse(self) -> None: - self.beginReset() - self.cards = list(reversed(self.cards)) - self.endReset() - - def saveSelection(self) -> None: - cards = self.browser.selected_cards() - self.selectedCards = {id: True for id in cards} - if getattr(self.browser, "card", None): - self.focusedCard = self.browser.card.id - else: - self.focusedCard = None - - def restoreSelection(self) -> None: - if not self.cards: - return - sm = self.browser.form.tableView.selectionModel() - sm.clear() - # restore selection - items = QItemSelection() - count = 0 - firstIdx = None - focusedIdx = None - for row, id in enumerate(self.cards): - # if the id matches the focused card, note the index - if self.focusedCard == id: - focusedIdx = self.index(row, 0) - items.select(focusedIdx, focusedIdx) - self.focusedCard = None - # if the card was previously selected, select again - if id in self.selectedCards: - count += 1 - idx = self.index(row, 0) - items.select(idx, idx) - # note down the first card of the selection, in case we don't - # have a focused card - if not firstIdx: - firstIdx = idx - # focus previously focused or first in selection - idx = focusedIdx or firstIdx - tv = self.browser.form.tableView - if idx: - row = idx.row() - pos = tv.rowViewportPosition(row) - visible = pos >= 0 and pos < tv.viewport().height() - tv.selectRow(row) - - # we save and then restore the horizontal scroll position because - # scrollTo() also scrolls horizontally which is confusing - if not visible: - h = tv.horizontalScrollBar().value() - tv.scrollTo(idx, tv.PositionAtCenter) - tv.horizontalScrollBar().setValue(h) - if count < 500: - # discard large selections; they're too slow - sm.select( - items, - cast( - QItemSelectionModel.SelectionFlags, - QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows, - ), - ) - else: - tv.selectRow(0) - - def op_executed(self, op: OpChanges, focused: bool) -> None: - print("op executed") - if op.card or op.note or op.deck or op.notetype: - self._rows = {} - if focused: - self.redraw_cells() - - def begin_blocking(self) -> None: - self.block_updates = True - - def end_blocking(self) -> None: - self.block_updates = False - self.redraw_cells() - - -# Line painter -###################################################################### - - -class StatusDelegate(QItemDelegate): - def __init__(self, browser: Browser, model: DataModel) -> None: - QItemDelegate.__init__(self, browser) - self.browser = browser - self.model = model - - def paint( - self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex - ) -> None: - if self.model.get_cell(index).is_rtl: - option.direction = Qt.RightToLeft - if row_color := self.model.get_row(index).color: - brush = QBrush(theme_manager.qcolor(row_color)) - painter.save() - painter.fillRect(option.rect, brush) - painter.restore() - return QItemDelegate.paint(self, painter, option, index) - - # Browser window ###################################################################### class Browser(QMainWindow): - model: DataModel mw: AnkiQt col: Collection editor: Optional[Editor] + table: Table def __init__( self, @@ -464,29 +112,18 @@ class Browser(QMainWindow): restoreSplitter(self.form.splitter, "editor3") self.form.splitter.setChildrenCollapsible(False) self.card: Optional[Card] = None - self.setupColumns() - self.setupTable() + self.setup_table() self.setupMenus() - self.setupHeaders() self.setupHooks() self.setupEditor() - self.updateFont() self.onUndoState(self.mw.form.actionUndo.isEnabled()) self.setupSearch(card, search) gui_hooks.browser_will_show(self) self.show() - def on_backend_will_block(self) -> None: - # make sure the card list doesn't try to refresh itself during the operation, - # as that will block the UI - self.model.begin_blocking() - - def on_backend_did_block(self) -> None: - self.model.end_blocking() - def on_operation_did_execute(self, changes: OpChanges) -> None: focused = current_top_level_widget() == self - self.model.op_executed(changes, focused) + self.table.op_executed(changes, focused) self.sidebar.op_executed(changes, focused) if changes.note or changes.notetype: if not self.editor.is_updating_note(): @@ -506,7 +143,7 @@ class Browser(QMainWindow): def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None: if current_top_level_widget() == self: self.setUpdatesEnabled(True) - self.model.redraw_cells() + self.table.redraw_cells() self.sidebar.refresh_if_needed() def setupMenus(self) -> None: @@ -515,7 +152,7 @@ class Browser(QMainWindow): f = self.form # edit qconnect(f.actionUndo.triggered, self.undo) - qconnect(f.actionInvertSelection.triggered, self.invertSelection) + qconnect(f.actionInvertSelection.triggered, self.table.invert_selection) qconnect(f.actionSelectNotes.triggered, self.selectNotes) if not isMac: f.actionClose.setVisible(False) @@ -575,32 +212,6 @@ class Browser(QMainWindow): gui_hooks.browser_menus_did_init(self) self.mw.maybeHideAccelerators(self) - # context menu - self.form.tableView.setContextMenuPolicy(Qt.CustomContextMenu) - qconnect(self.form.tableView.customContextMenuRequested, self.onContextMenu) - - def onContextMenu(self, _point: QPoint) -> None: - m = QMenu() - for act in self.form.menu_Cards.actions(): - m.addAction(act) - m.addSeparator() - for act in self.form.menu_Notes.actions(): - m.addAction(act) - gui_hooks.browser_will_show_context_menu(self, m) - qtMenuShortcutWorkaround(m) - m.exec_(QCursor.pos()) - - def updateFont(self) -> None: - # we can't choose different line heights efficiently, so we need - # to pick a line height big enough for any card template - curmax = 16 - for m in self.col.models.all(): - for t in m["tmpls"]: - bsize = t.get("bsize", 0) - if bsize > curmax: - curmax = bsize - self.form.tableView.verticalHeader().setDefaultSectionSize(curmax + 6) - def closeEvent(self, evt: QCloseEvent) -> None: if self._closeEventHasCleanedUp: evt.accept() @@ -633,26 +244,6 @@ class Browser(QMainWindow): else: super().keyPressEvent(evt) - def setupColumns(self) -> None: - self.columns = [ - ("question", tr.browsing_question()), - ("answer", tr.browsing_answer()), - ("template", tr.browsing_card()), - ("deck", tr.decks_deck()), - ("noteFld", tr.browsing_sort_field()), - ("noteCrt", tr.browsing_created()), - ("noteMod", tr.search_note_modified()), - ("cardMod", tr.search_card_modified()), - ("cardDue", tr.statistics_due_date()), - ("cardIvl", tr.browsing_interval()), - ("cardEase", tr.browsing_ease()), - ("cardReps", tr.scheduling_reviews()), - ("cardLapses", tr.scheduling_lapses()), - ("noteTags", tr.editing_tags()), - ("note", tr.browsing_note()), - ] - self.columns.sort(key=itemgetter(1)) - def reopen( self, _mw: AnkiQt, @@ -720,12 +311,9 @@ class Browser(QMainWindow): """Search triggered programmatically. Caller must have saved note first.""" try: - self.model.search(self._lastSearchTxt) + self.table.search(self._lastSearchTxt) except Exception as err: showWarning(str(err)) - if not self.model.cards: - # no row change will fire - self.onRowChanged(None, None) def update_history(self) -> None: sh = self.mw.pm.profile["searchHistory"] @@ -737,15 +325,17 @@ class Browser(QMainWindow): self.form.searchEdit.addItems(sh) self.mw.pm.profile["searchHistory"] = sh - def updateTitle(self) -> int: - selected = len(self.form.tableView.selectionModel().selectedRows()) - cur = len(self.model.cards) - self.setWindowTitle( - without_unicode_isolation( - tr.browsing_window_title(total=cur, selected=selected) - ) + def updateTitle(self) -> None: + selected = self.table.len_selection() + cur = self.table.len() + tr_title = ( + tr.browsing_window_title + if self.table.is_card_state() + else tr.browsing_window_title_notes + ) + self.setWindowTitle( + without_unicode_isolation(tr_title(total=cur, selected=selected)) ) - return selected def search_for_terms(self, *search_terms: Union[str, SearchNode]) -> None: search = self.col.build_search_string(*search_terms) @@ -760,40 +350,34 @@ class Browser(QMainWindow): search = self.col.build_search_string(SearchNode(nid=card.nid)) search = gui_hooks.default_search(search, card) self.search_for(search, "") - self.focusCid(card.id) + self.table.select_single_card(card.id) self.editor.call_after_note_saved(on_show_single_card) def onReset(self) -> None: self.sidebar.refresh() - self.model.reset() + self.begin_reset() + self.end_reset() - # Table view & editor + # caller must have called editor.saveNow() before calling this or .reset() + def begin_reset(self) -> None: + self.editor.set_note(None, hide=False) + self.mw.progress.start() + self.table.begin_reset() + + def end_reset(self) -> None: + self.table.end_reset() + self.mw.progress.finish() + + # Table & Editor ###################################################################### - def setupTable(self) -> None: - self.model = DataModel(self) - self.form.tableView.setSortingEnabled(True) - self.form.tableView.setModel(self.model) - self.form.tableView.selectionModel() - self.form.tableView.setItemDelegate(StatusDelegate(self, self.model)) - qconnect( - self.form.tableView.selectionModel().selectionChanged, self.onRowChanged - ) - self.form.tableView.setWordWrap(False) - if not theme_manager.night_mode: - self.form.tableView.setStyleSheet( - "QTableView{ selection-background-color: rgba(150, 150, 150, 50); " - "selection-color: black; }" - ) - elif theme_manager.macos_dark_mode(): - grid = colors.FRAME_BG - self.form.tableView.setStyleSheet( - f""" -QTableView {{ gridline-color: {grid} }} - """ - ) - self.singleCard = False + def setup_table(self) -> None: + self.table = Table(self) + self.form.radio_cards.setChecked(self.table.is_card_state()) + self.form.radio_notes.setChecked(not self.table.is_card_state()) + self.table.set_view(self.form.tableView) + qconnect(self.form.radio_cards.toggled, self.on_table_state_changed) def setupEditor(self) -> None: def add_preview_button(leftbuttons: List[str], editor: Editor) -> None: @@ -824,138 +408,36 @@ QTableView {{ gridline-color: {grid} }} def onRowChanged( self, current: Optional[QItemSelection], previous: Optional[QItemSelection] ) -> None: - """Update current note and hide/show editor.""" + """Update current note and hide/show editor. """ if self._closeEventHasCleanedUp: return - update = self.updateTitle() - show = self.model.cards and update == 1 - idx = self.form.tableView.selectionModel().currentIndex() - if idx.isValid(): - self.card = self.model.getCard(idx) - show = show and self.card is not None - self.form.splitter.widget(1).setVisible(bool(show)) - if not show: - self.editor.set_note(None) - self.singleCard = False - self._renderPreview() - else: - self.editor.set_note(self.card.note(reload=True), focusTo=self.focusTo) + self.updateTitle() + # the current card is used for context actions + self.card = self.table.get_current_card() + # if there is only one selected card, use it in the editor + # it might differ from the current card + card = self.table.get_single_selected_card() + self.singleCard = bool(card) + self.form.splitter.widget(1).setVisible(self.singleCard) + if self.singleCard: + self.editor.set_note(card.note(), focusTo=self.focusTo) self.focusTo = None - self.editor.card = self.card - self.singleCard = True + self.editor.card = card + else: + self.editor.set_note(None) + self._renderPreview() self._update_flags_menu() gui_hooks.browser_did_change_row(self) - def currentRow(self) -> int: - idx = self.form.tableView.selectionModel().currentIndex() - return idx.row() - - # Headers & sorting - ###################################################################### - - def setupHeaders(self) -> None: - vh = self.form.tableView.verticalHeader() - hh = self.form.tableView.horizontalHeader() - if not isWin: - vh.hide() - hh.show() - restoreHeader(hh, "editor") - hh.setHighlightSections(False) - hh.setMinimumSectionSize(50) - hh.setSectionsMovable(True) - self.setColumnSizes() - hh.setContextMenuPolicy(Qt.CustomContextMenu) - qconnect(hh.customContextMenuRequested, self.onHeaderContext) - self.setSortIndicator() - qconnect(hh.sortIndicatorChanged, self.onSortChanged) - qconnect(hh.sectionMoved, self.onColumnMoved) - - @ensure_editor_saved - def onSortChanged(self, idx: int, ord: int) -> None: - ord = bool(ord) - type = self.model.activeCols[idx] - noSort = ("question", "answer") - if type in noSort: - showInfo(tr.browsing_sorting_on_this_column_is_not()) - type = self.col.conf["sortType"] - if self.col.conf["sortType"] != type: - self.col.conf["sortType"] = type - # default to descending for non-text fields - if type == "noteFld": - ord = not ord - self.col.set_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS, ord) - self.col.save() - self.search() - else: - if self.col.get_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS) != ord: - self.col.set_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS, ord) - self.col.save() - self.model.reverse() - self.setSortIndicator() - - def setSortIndicator(self) -> None: - hh = self.form.tableView.horizontalHeader() - type = self.col.conf["sortType"] - if type not in self.model.activeCols: - hh.setSortIndicatorShown(False) - return - idx = self.model.activeCols.index(type) - if self.col.get_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS): - ord = Qt.DescendingOrder - else: - ord = Qt.AscendingOrder - hh.blockSignals(True) - hh.setSortIndicator(idx, ord) - hh.blockSignals(False) - hh.setSortIndicatorShown(True) - - def onHeaderContext(self, pos: QPoint) -> None: - gpos = self.form.tableView.mapToGlobal(pos) - m = QMenu() - for type, name in self.columns: - a = m.addAction(name) - a.setCheckable(True) - a.setChecked(type in self.model.activeCols) - qconnect(a.toggled, lambda b, t=type: self.toggleField(t)) - gui_hooks.browser_header_will_show_context_menu(self, m) - m.exec_(gpos) - @ensure_editor_saved_on_trigger - def toggleField(self, type: str) -> None: - self.model.beginReset() - if type in self.model.activeCols: - if len(self.model.activeCols) < 2: - self.model.endReset() - showInfo(tr.browsing_you_must_have_at_least_one()) - return - self.model.activeCols.remove(type) - adding = False - else: - self.model.activeCols.append(type) - adding = True - self.col.conf["activeCols"] = self.model.activeCols - # sorted field may have been hidden - self.setSortIndicator() - self.setColumnSizes() - self.model.endReset() - # if we added a column, scroll to it - if adding: - row = self.currentRow() - idx = self.model.index(row, len(self.model.activeCols) - 1) - self.form.tableView.scrollTo(idx) + def on_table_state_changed(self) -> None: + self.mw.progress.start() + self.table.toggle_state(self.form.radio_cards.isChecked(), self._lastSearchTxt) + self.mw.progress.finish() - def setColumnSizes(self) -> None: - hh = self.form.tableView.horizontalHeader() - hh.setSectionResizeMode(QHeaderView.Interactive) - hh.setSectionResizeMode( - hh.logicalIndex(len(self.model.activeCols) - 1), QHeaderView.Stretch - ) - # this must be set post-resize or it doesn't work - hh.setCascadingSectionResizes(False) - - def onColumnMoved(self, *args: Any) -> None: - self.setColumnSizes() + # Sidebar + ###################################################################### def setupSidebar(self) -> None: dw = self.sidebarDockWidget = QDockWidget(tr.browsing_sidebar(), self) @@ -1052,29 +534,13 @@ QTableView {{ gridline-color: {grid} }} ###################################################################### def selected_cards(self) -> List[CardId]: - return [ - self.model.cards[idx.row()] - for idx in self.form.tableView.selectionModel().selectedRows() - ] + return self.table.get_selected_card_ids() def selected_notes(self) -> List[NoteId]: - return self.col.db.list( - """ -select distinct nid from cards -where id in %s""" - % ids2str( - [ - self.model.cards[idx.row()] - for idx in self.form.tableView.selectionModel().selectedRows() - ] - ) - ) + return self.table.get_selected_note_ids() def selectedNotesAsCards(self) -> List[CardId]: - return self.col.db.list( - "select id from cards where nid in (%s)" - % ",".join([str(s) for s in self.selected_notes()]) - ) + return self.table.get_card_ids_from_selected_note_ids() def oneModelNotes(self) -> List[NoteId]: sf = self.selected_notes() @@ -1154,12 +620,13 @@ where id in %s""" return # nothing selected? - nids = self.selected_notes() + nids = self.table.get_selected_note_ids() if not nids: return # select the next card if there is one - self._onNextCard() + self.focusTo = self.editor.currentField + self.table.to_next_row() remove_notes( mw=self.mw, @@ -1178,7 +645,7 @@ where id in %s""" def set_deck_of_selected_cards(self) -> None: from aqt.studydeck import StudyDeck - cids = self.selected_cards() + cids = self.table.get_selected_card_ids() if not cids: return @@ -1351,27 +818,12 @@ where id in %s""" def selectNotes(self) -> None: nids = self.selected_notes() # clear the selection so we don't waste energy preserving it - tv = self.form.tableView - tv.selectionModel().clear() - + self.table.clear_selection() search = self.col.build_search_string( SearchNode(nids=SearchNode.IdList(ids=nids)) ) self.search_for(search) - - tv.selectAll() - - def invertSelection(self) -> None: - sm = self.form.tableView.selectionModel() - items = sm.selection() - self.form.tableView.selectAll() - sm.select( - items, - cast( - QItemSelectionModel.SelectionFlags, - QItemSelectionModel.Deselect | QItemSelectionModel.Rows, - ), - ) + self.table.select_all() # Hooks ###################################################################### @@ -1380,16 +832,16 @@ where id in %s""" gui_hooks.undo_state_did_change.append(self.onUndoState) # fixme: remove this once all items are using `operation_did_execute` gui_hooks.sidebar_should_refresh_notetypes.append(self.on_item_added) - gui_hooks.backend_will_block.append(self.on_backend_will_block) - gui_hooks.backend_did_block.append(self.on_backend_did_block) + gui_hooks.backend_will_block.append(self.table.on_backend_will_block) + gui_hooks.backend_did_block.append(self.table.on_backend_did_block) gui_hooks.operation_did_execute.append(self.on_operation_did_execute) gui_hooks.focus_did_change.append(self.on_focus_change) def teardownHooks(self) -> None: gui_hooks.undo_state_did_change.remove(self.onUndoState) gui_hooks.sidebar_should_refresh_notetypes.remove(self.on_item_added) - gui_hooks.backend_will_block.remove(self.on_backend_will_block) - gui_hooks.backend_did_block.remove(self.on_backend_will_block) + gui_hooks.backend_will_block.remove(self.table.on_backend_will_block) + gui_hooks.backend_did_block.remove(self.table.on_backend_will_block) gui_hooks.operation_did_execute.remove(self.on_operation_did_execute) gui_hooks.focus_did_change.remove(self.on_focus_change) @@ -1514,14 +966,14 @@ where id in %s""" def _onTagDupes(self, res: List[Any]) -> None: if not res: return - self.model.beginReset() + self.begin_reset() self.mw.checkpoint(tr.browsing_tag_duplicates()) nids = set() for _, nidlist in res: nids.update(nidlist) self.col.tags.bulk_add(list(nids), tr.browsing_duplicate()) self.mw.progress.finish() - self.model.endReset() + self.end_reset() self.mw.requireReset(reason=ResetReason.BrowserTagDupes, context=self) tooltip(tr.browsing_notes_tagged()) @@ -1532,69 +984,25 @@ where id in %s""" # Jumping ###################################################################### - def _moveCur( - self, dir: Optional[QTableView.CursorAction], idx: QModelIndex = None - ) -> None: - if not self.model.cards: - return - tv = self.form.tableView - if dir is not None: - idx = tv.moveCursor(dir, self.mw.app.keyboardModifiers()) - tv.selectionModel().setCurrentIndex( - idx, - cast( - QItemSelectionModel.SelectionFlags, - QItemSelectionModel.Clear - | QItemSelectionModel.Select - | QItemSelectionModel.Rows, - ), - ) + def has_previous_card(self) -> bool: + return self.table.has_previous() + + def has_next_card(self) -> bool: + return self.table.has_next() def onPreviousCard(self) -> None: self.focusTo = self.editor.currentField - self.editor.call_after_note_saved(self._onPreviousCard) - - def _onPreviousCard(self) -> None: - self._moveCur(QAbstractItemView.MoveUp) + self.editor.call_after_note_saved(self.table.to_previous_row) def onNextCard(self) -> None: self.focusTo = self.editor.currentField - self.editor.call_after_note_saved(self._onNextCard) - - def _onNextCard(self) -> None: - self._moveCur(QAbstractItemView.MoveDown) + self.editor.call_after_note_saved(self.table.to_next_row) def onFirstCard(self) -> None: - sm = self.form.tableView.selectionModel() - idx = sm.currentIndex() - self._moveCur(None, self.model.index(0, 0)) - if not KeyboardModifiersPressed().shift: - return - idx2 = sm.currentIndex() - item = QItemSelection(idx2, idx) - sm.select( - item, - cast( - QItemSelectionModel.SelectionFlags, - QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows, - ), - ) + self.table.to_first_row() def onLastCard(self) -> None: - sm = self.form.tableView.selectionModel() - idx = sm.currentIndex() - self._moveCur(None, self.model.index(len(self.model.cards) - 1, 0)) - if not KeyboardModifiersPressed().shift: - return - idx2 = sm.currentIndex() - item = QItemSelection(idx, idx2) - sm.select( - item, - cast( - QItemSelectionModel.SelectionFlags, - QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows, - ), - ) + self.table.to_last_row() def onFind(self) -> None: # workaround for PyQt focus bug @@ -1613,14 +1021,6 @@ where id in %s""" def onCardList(self) -> None: self.form.tableView.setFocus() - def focusCid(self, cid: CardId) -> None: - try: - row = list(self.model.cards).index(cid) - except ValueError: - return - self.form.tableView.clearSelection() - self.form.tableView.selectRow(row) - # Change model dialog ###################################################################### @@ -1796,11 +1196,11 @@ class ChangeModel(QDialog): b = self.browser b.mw.col.modSchema(check=True) b.mw.progress.start() - b.model.beginReset() + b.begin_reset() mm = b.mw.col.models mm.change(self.oldModel, self.nids, self.targetModel, fmap, cmap) b.search() - b.model.endReset() + b.end_reset() b.mw.progress.finish() b.mw.reset() self.cleanup() diff --git a/qt/aqt/forms/browser.ui b/qt/aqt/forms/browser.ui index 8d7a066d6..5ed6506fd 100644 --- a/qt/aqt/forms/browser.ui +++ b/qt/aqt/forms/browser.ui @@ -83,7 +83,7 @@ 0 - 12 + 6 12 @@ -109,6 +109,30 @@ + + + + 5 + + + + + qt_accel_cards + + + true + + + + + + + qt_accel_notes + + + + + @@ -144,12 +168,12 @@ false - - 20 - false + + 20 + true @@ -209,7 +233,7 @@ 0 0 750 - 24 + 21 diff --git a/qt/aqt/previewer.py b/qt/aqt/previewer.py index 0a5ae6838..73afb8462 100644 --- a/qt/aqt/previewer.py +++ b/qt/aqt/previewer.py @@ -13,7 +13,6 @@ from anki.cards import Card from anki.collection import Config from aqt import AnkiQt, gui_hooks from aqt.qt import ( - QAbstractItemView, QCheckBox, QDialog, QDialogButtonBox, @@ -326,23 +325,16 @@ class BrowserPreviewer(MultiCardPreviewer): return changed def _on_prev_card(self) -> None: - self._parent.editor.call_after_note_saved( - lambda: self._parent._moveCur(QAbstractItemView.MoveUp) - ) + self._parent.onPreviousCard() def _on_next_card(self) -> None: - self._parent.editor.call_after_note_saved( - lambda: self._parent._moveCur(QAbstractItemView.MoveDown) - ) + self._parent.onNextCard() def _should_enable_prev(self) -> bool: - return super()._should_enable_prev() or self._parent.currentRow() > 0 + return super()._should_enable_prev() or self._parent.has_previous_card() def _should_enable_next(self) -> bool: - return ( - super()._should_enable_next() - or self._parent.currentRow() < self._parent.model.rowCount(None) - 1 - ) + return super()._should_enable_next() or self._parent.has_next_card() def _render_scheduled(self) -> None: super()._render_scheduled() diff --git a/qt/aqt/table.py b/qt/aqt/table.py new file mode 100644 index 000000000..cbe655c06 --- /dev/null +++ b/qt/aqt/table.py @@ -0,0 +1,1136 @@ +# -*- coding: utf-8 -*- +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +from __future__ import annotations + +import time +from abc import ABC, abstractmethod, abstractproperty +from dataclasses import dataclass +from operator import itemgetter +from typing import ( + Any, + Callable, + Dict, + Generator, + List, + Optional, + Sequence, + Tuple, + Union, + cast, +) + +import aqt +import aqt.forms +from anki.cards import Card, CardId +from anki.collection import BrowserRow, Collection, Config, OpChanges +from anki.consts import * +from anki.errors import NotFoundError +from anki.notes import Note, NoteId +from anki.utils import ids2str, isWin +from aqt import colors, gui_hooks +from aqt.qt import * +from aqt.theme import theme_manager +from aqt.utils import ( + KeyboardModifiersPressed, + qtMenuShortcutWorkaround, + restoreHeader, + showInfo, + tr, +) + +Item = Union[CardId, NoteId] +ItemList = Union[List[CardId], List[NoteId]] + + +@dataclass +class SearchContext: + search: str + browser: aqt.browser.Browser + order: Union[bool, str] = True + # if set, provided card ids will be used instead of the regular search + # fixme: legacy support for card_ids? + ids: Optional[Sequence[Item]] = None + + +class Table: + SELECTION_LIMIT: int = 500 + + def __init__(self, browser: aqt.browser.Browser) -> None: + self.browser = browser + self.col: Collection = browser.col + self._state: ItemState = ( + CardState(self.col) + if self.col.get_config_bool(Config.Bool.BROWSER_CARD_STATE) + else NoteState(self.col) + ) + self._model = DataModel(self.col, self._state) + self._view: Optional[QTableView] = None + self._current_item: Optional[Item] = None + self._selected_items: Sequence[Item] = [] + + def set_view(self, view: QTableView) -> None: + self._view = view + self._setup_view() + self._setup_headers() + + # Public Methods + ###################################################################### + + # Get metadata + + def len(self) -> int: + return self._model.len_rows() + + def len_selection(self) -> int: + return len(self._view.selectionModel().selectedRows()) + + def has_current(self) -> bool: + return self._view.selectionModel().currentIndex().isValid() + + def has_previous(self) -> bool: + return self.has_current() and self._current().row() > 0 + + def has_next(self) -> bool: + return self.has_current() and self._current().row() < self.len() - 1 + + def is_card_state(self) -> bool: + return self._state.is_card_state() + + # Get objects + + def get_current_card(self) -> Optional[Card]: + if not self.has_current(): + return None + return self._model.get_card(self._current()) + + def get_current_note(self) -> Optional[Note]: + if not self.has_current(): + return None + return self._model.get_note(self._current()) + + def get_single_selected_card(self) -> Optional[Card]: + """If there is only one row selected return its card, else None. + This may be a different one than the current card.""" + if self.len_selection() != 1: + return None + return self._model.get_card(self._selected()[0]) + + # Get ids + + def get_selected_card_ids(self) -> List[CardId]: + return self._model.get_card_ids(self._selected()) + + def get_selected_note_ids(self) -> List[NoteId]: + return self._model.get_note_ids(self._selected()) + + def get_card_ids_from_selected_note_ids(self) -> List[CardId]: + return self._state.card_ids_from_note_ids(self.get_selected_note_ids()) + + # Selecting + + def select_all(self) -> None: + self._view.selectAll() + + def clear_selection(self) -> None: + self._view.selectionModel().clear() + + def invert_selection(self) -> None: + selection = self._view.selectionModel().selection() + self.select_all() + self._view.selectionModel().select( + selection, + cast( + QItemSelectionModel.SelectionFlags, + QItemSelectionModel.Deselect | QItemSelectionModel.Rows, + ), + ) + + def select_single_card(self, card_id: CardId) -> None: + """Try to set the selection to the item corresponding to the given card.""" + self.clear_selection() + try: + self._view.selectRow(self._model.get_card_row(card_id)) + except ValueError: + pass + + # Reset + + def reset(self) -> None: + """Reload table data from collection and redraw.""" + self.begin_reset() + self.end_reset() + + def begin_reset(self) -> None: + self._save_selection() + self._model.begin_reset() + + def end_reset(self) -> None: + self._model.end_reset() + self._restore_selection(self._intersected_selection) + + def on_backend_will_block(self) -> None: + # make sure the card list doesn't try to refresh itself during the operation, + # as that will block the UI + self._model.begin_blocking() + + def on_backend_did_block(self) -> None: + self._model.end_blocking() + + def redraw_cells(self) -> None: + self._model.redraw_cells() + + def op_executed(self, op: OpChanges, focused: bool) -> None: + print("op executed") + if op.card or op.note or op.deck or op.notetype: + self._model.empty_cache() + if focused: + self.redraw_cells() + + # Modify table + + def search(self, txt: str) -> None: + self._save_selection() + self._model.search(SearchContext(search=txt, browser=self.browser)) + self._restore_selection(self._intersected_selection) + + def toggle_state(self, is_card_state: bool, last_search: str) -> None: + if is_card_state == self.is_card_state(): + return + self._save_selection() + self._state = self._model.toggle_state( + SearchContext(search=last_search, browser=self.browser) + ) + self.col.set_config_bool(Config.Bool.BROWSER_CARD_STATE, self.is_card_state()) + self._set_sort_indicator() + self._set_column_sizes() + self._restore_selection(self._toggled_selection) + + # Move cursor + + def to_previous_row(self) -> None: + self._move_current(QAbstractItemView.MoveUp) + + def to_next_row(self) -> None: + self._move_current(QAbstractItemView.MoveDown) + + def to_first_row(self) -> None: + self._move_current_to_row(0) + + def to_last_row(self) -> None: + self._move_current_to_row(self._model.len_rows() - 1) + + # Private methods + ###################################################################### + + # Helpers + + def _current(self) -> QModelIndex: + return self._view.selectionModel().currentIndex() + + def _selected(self) -> List[QModelIndex]: + return self._view.selectionModel().selectedRows() + + def _set_current(self, row: int, column: int = 0) -> None: + index = self._model.index( + row, self._view.horizontalHeader().logicalIndex(column) + ) + self._view.selectionModel().setCurrentIndex(index, QItemSelectionModel.NoUpdate) + + def _select_rows(self, rows: List[int]) -> None: + selection = QItemSelection() + for row in rows: + selection.select( + self._model.index(row, 0), + self._model.index(row, self._model.len_columns() - 1), + ) + self._view.selectionModel().select(selection, QItemSelectionModel.SelectCurrent) + + def _set_sort_indicator(self) -> None: + hh = self._view.horizontalHeader() + index = self._model.active_column_index(self._state.sort_column) + if index is None: + hh.setSortIndicatorShown(False) + return + if self._state.sort_backwards: + order = Qt.DescendingOrder + else: + order = Qt.AscendingOrder + hh.blockSignals(True) + hh.setSortIndicator(index, order) + hh.blockSignals(False) + hh.setSortIndicatorShown(True) + + def _set_column_sizes(self) -> None: + hh = self._view.horizontalHeader() + hh.setSectionResizeMode(QHeaderView.Interactive) + hh.setSectionResizeMode( + hh.logicalIndex(self._model.len_columns() - 1), QHeaderView.Stretch + ) + # this must be set post-resize or it doesn't work + hh.setCascadingSectionResizes(False) + + # Setup + + def _setup_view(self) -> None: + self._view.setSortingEnabled(True) + self._view.setModel(self._model) + self._view.selectionModel() + self._view.setItemDelegate(StatusDelegate(self.browser, self._model)) + qconnect( + self._view.selectionModel().selectionChanged, self.browser.onRowChanged + ) + self._view.setWordWrap(False) + self._update_font() + if not theme_manager.night_mode: + self._view.setStyleSheet( + "QTableView{ selection-background-color: rgba(150, 150, 150, 50); " + "selection-color: black; }" + ) + elif theme_manager.macos_dark_mode(): + self._view.setStyleSheet( + f"QTableView {{ gridline-color: {colors.FRAME_BG} }}" + ) + self._view.setContextMenuPolicy(Qt.CustomContextMenu) + qconnect(self._view.customContextMenuRequested, self._on_context_menu) + + def _update_font(self) -> None: + # we can't choose different line heights efficiently, so we need + # to pick a line height big enough for any card template + curmax = 16 + for m in self.col.models.all(): + for t in m["tmpls"]: + bsize = t.get("bsize", 0) + if bsize > curmax: + curmax = bsize + self._view.verticalHeader().setDefaultSectionSize(curmax + 6) + + def _setup_headers(self) -> None: + vh = self._view.verticalHeader() + hh = self._view.horizontalHeader() + if not isWin: + vh.hide() + hh.show() + restoreHeader(hh, "editor") + hh.setHighlightSections(False) + hh.setMinimumSectionSize(50) + hh.setSectionsMovable(True) + self._set_column_sizes() + hh.setContextMenuPolicy(Qt.CustomContextMenu) + qconnect(hh.customContextMenuRequested, self._on_header_context) + self._set_sort_indicator() + qconnect(hh.sortIndicatorChanged, self._on_sort_column_changed) + qconnect(hh.sectionMoved, self._on_column_moved) + + # Slots + + def _on_context_menu(self, _point: QPoint) -> None: + menu = QMenu() + if self.is_card_state(): + main = self.browser.form.menu_Cards + other = self.browser.form.menu_Notes + other_name = tr.qt_accel_notes() + else: + main = self.browser.form.menu_Notes + other = self.browser.form.menu_Cards + other_name = tr.qt_accel_cards() + for action in main.actions(): + menu.addAction(action) + menu.addSeparator() + sub_menu = menu.addMenu(other_name) + for action in other.actions(): + sub_menu.addAction(action) + gui_hooks.browser_will_show_context_menu(self.browser, menu) + qtMenuShortcutWorkaround(menu) + menu.exec_(QCursor.pos()) + + def _on_header_context(self, pos: QPoint) -> None: + gpos = self._view.mapToGlobal(pos) + m = QMenu() + for column, name in self._state.columns: + a = m.addAction(name) + a.setCheckable(True) + a.setChecked(self._model.active_column_index(column) is not None) + qconnect( + a.toggled, + lambda checked, column=column: self._on_column_toggled(checked, column), + ) + gui_hooks.browser_header_will_show_context_menu(self.browser, m) + m.exec_(gpos) + + def _on_column_moved(self, *_args: Any) -> None: + self._set_column_sizes() + + def _on_column_toggled(self, checked: bool, column: str) -> None: + if not checked and self._model.len_columns() < 2: + showInfo(tr.browsing_you_must_have_at_least_one()) + return + self._model.toggle_column(column) + self._set_column_sizes() + # sorted field may have been hidden or revealed + self._set_sort_indicator() + if checked: + self._scroll_to_column(self._model.len_columns() - 1) + + def _on_sort_column_changed(self, index: int, order: int) -> None: + order = bool(order) + sort_column = self._model.active_column(index) + if sort_column in ("question", "answer"): + showInfo(tr.browsing_sorting_on_this_column_is_not()) + sort_column = self._state.sort_column + if self._state.sort_column != sort_column: + self._state.sort_column = sort_column + # default to descending for non-text fields + if sort_column == "noteFld": + order = not order + self._state.sort_backwards = order + self.browser.search() + else: + if self._state.sort_backwards != order: + self._state.sort_backwards = order + self._reverse() + self._set_sort_indicator() + + def _reverse(self) -> None: + self._save_selection() + self._model.reverse() + self._restore_selection(self._intersected_selection) + + # Restore selection + + def _save_selection(self) -> None: + """Save the current item and selected items.""" + if self.has_current(): + self._current_item = self._model.get_item(self._current()) + self._selected_items = self._model.get_items(self._selected()) + + def _restore_selection(self, new_selected_and_current: Callable) -> None: + """Restore the saved selection and current element as far as possible and scroll to the + new current element. Clear the saved selection. + """ + self.clear_selection() + if not self._model.is_empty(): + rows, current = new_selected_and_current() + rows = self._qualify_selected_rows(rows, current) + current = current or rows[0] + self._select_rows(rows) + self._set_current(current) + self._scroll_to_row(current) + if self.len_selection() == 0: + # no row change will fire + self.browser.onRowChanged(QItemSelection(), QItemSelection()) + self._selected_items = [] + self._current_item = None + + def _qualify_selected_rows( + self, rows: List[int], current: Optional[int] + ) -> List[int]: + """Return between 1 and SELECTION_LIMIT rows, as far as possible from rows or current.""" + if rows: + if len(rows) < self.SELECTION_LIMIT: + return rows + if current in rows: + return [current] + return rows[0:1] + return [current if current else 0] + + def _intersected_selection(self) -> Tuple[List[int], Optional[int]]: + """Return all rows of items that were in the saved selection and the row of the saved + current element if present. + """ + selected_rows = self._model.get_item_rows(self._selected_items) + current_row = self._current_item and self._model.get_item_row( + self._current_item + ) + return selected_rows, current_row + + def _toggled_selection(self) -> Tuple[List[int], Optional[int]]: + """Convert the items of the saved selection and current element to the new state and + return their rows. + """ + selected_rows = self._model.get_item_rows( + self._state.get_new_items(self._selected_items) + ) + current_row = self._current_item and self._model.get_item_row( + self._state.get_new_item(self._current_item) + ) + return selected_rows, current_row + + # Move + + def _scroll_to_row(self, row: int) -> None: + """Scroll vertically to row.""" + position = self._view.rowViewportPosition(row) + visible = 0 <= position < self._view.viewport().height() + if not visible: + horizontal = self._view.horizontalScrollBar().value() + self._view.scrollTo(self._model.index(row, 0), self._view.PositionAtCenter) + self._view.horizontalScrollBar().setValue(horizontal) + + def _scroll_to_column(self, column: int) -> None: + """Scroll horizontally to column.""" + position = self._view.columnViewportPosition(column) + visible = 0 <= position < self._view.viewport().width() + if not visible: + vertical = self._view.verticalScrollBar().value() + self._view.scrollTo( + self._model.index(0, column), self._view.PositionAtCenter + ) + self._view.verticalScrollBar().setValue(vertical) + + def _move_current(self, direction: int, index: QModelIndex = None) -> None: + if not self.has_current(): + return + if index is None: + index = self._view.moveCursor( + cast(QAbstractItemView.CursorAction, direction), + self.browser.mw.app.keyboardModifiers(), + ) + self._view.selectionModel().setCurrentIndex( + index, + cast( + QItemSelectionModel.SelectionFlag, + QItemSelectionModel.Clear + | QItemSelectionModel.Select + | QItemSelectionModel.Rows, + ), + ) + + def _move_current_to_row(self, row: int) -> None: + old = self._view.selectionModel().currentIndex() + self._move_current(None, self._model.index(row, 0)) + if not KeyboardModifiersPressed().shift: + return + new = self._view.selectionModel().currentIndex() + selection = QItemSelection(new, old) + self._view.selectionModel().select( + selection, + cast( + QItemSelectionModel.SelectionFlag, + QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows, + ), + ) + + +# ItemStates +###################################################################### + + +class ItemState(ABC): + _columns: List[Tuple[str, str]] + _active_columns: List[str] + _sort_column: str + _sort_backwards: bool + + def __init__(self, col: Collection) -> None: + self.col = col + + def is_card_state(self) -> bool: + """Return True if the state is a CardState.""" + return isinstance(self, CardState) + + # Stateless Helpers + + def note_ids_from_card_ids(self, items: Sequence[Item]) -> List[NoteId]: + return self.col.db.list( + f"select distinct nid from cards where id in {ids2str(items)}" + ) + + def card_ids_from_note_ids(self, items: Sequence[Item]) -> List[CardId]: + return self.col.db.list(f"select id from cards where nid in {ids2str(items)}") + + # Columns and sorting + + @abstractproperty + def columns(self) -> List[Tuple[str, str]]: + """Return all for the state available columns.""" + + @abstractproperty + def active_columns(self) -> List[str]: + """Return the saved or default columns for the state.""" + + @abstractmethod + def toggle_active_column(self, column: str) -> None: + """Add or remove an active column.""" + + @abstractproperty + def sort_column(self) -> str: + """Return the sort column from the config.""" + + @sort_column.setter + def sort_column(self, column: str) -> None: + """Save the sort column in the config.""" + + @abstractproperty + def sort_backwards(self) -> bool: + """Return the sort order from the config.""" + + @sort_backwards.setter + def sort_backwards(self, order: bool) -> None: + """Save the sort order in the config.""" + + # Get objects + + @abstractmethod + def get_card(self, item: Item) -> Card: + """Return the item if it's a card or its first card if it's a note.""" + + @abstractmethod + def get_note(self, item: Item) -> Note: + """Return the item if it's a note or its note if it's a card.""" + + # Get ids + + @abstractmethod + def find_items(self, search: str, order: Union[bool, str]) -> Sequence[Item]: + """Return the item ids fitting the given search and order.""" + + @abstractmethod + def get_item_from_card_id(self, card: CardId) -> Item: + """Return the appropriate item id for a card id.""" + + @abstractmethod + def get_card_ids(self, items: List[Item]) -> List[CardId]: + """Return the card ids for the given item ids.""" + + @abstractmethod + def get_note_ids(self, items: List[Item]) -> List[NoteId]: + """Return the note ids for the given item ids.""" + + # Toggle + + @abstractmethod + def toggle_state(self) -> ItemState: + """Return an instance of the other state.""" + + @abstractmethod + def get_new_item(self, old_item: Item) -> Item: + """Given an id from the other state, return the corresponding id for + this state.""" + + @abstractmethod + def get_new_items(self, old_items: Sequence[Item]) -> ItemList: + """Given a list of ids from the other state, return the corresponding + ids for this state.""" + + +class CardState(ItemState): + def __init__(self, col: Collection) -> None: + super().__init__(col) + self._load_columns() + self._load_active_columns() + self._sort_column = self.col.get_config("sortType") + self._sort_backwards = self.col.get_config_bool( + Config.Bool.BROWSER_SORT_BACKWARDS + ) + + def _load_columns(self) -> None: + self._columns = [ + ("question", tr.browsing_question()), + ("answer", tr.browsing_answer()), + ("template", tr.browsing_card()), + ("deck", tr.decks_deck()), + ("noteFld", tr.browsing_sort_field()), + ("noteCrt", tr.browsing_created()), + ("noteMod", tr.search_note_modified()), + ("cardMod", tr.search_card_modified()), + ("cardDue", tr.statistics_due_date()), + ("cardIvl", tr.browsing_interval()), + ("cardEase", tr.browsing_ease()), + ("cardReps", tr.scheduling_reviews()), + ("cardLapses", tr.scheduling_lapses()), + ("noteTags", tr.editing_tags()), + ("note", tr.browsing_note()), + ] + self._columns.sort(key=itemgetter(1)) + + def _load_active_columns(self) -> None: + self._active_columns = self.col.get_config( + "activeCols", ["noteFld", "template", "cardDue", "deck"] + ) + + @property + def columns(self) -> List[Tuple[str, str]]: + return self._columns + + @property + def active_columns(self) -> List[str]: + return self._active_columns + + def toggle_active_column(self, column: str) -> None: + if column in self._active_columns: + self._active_columns.remove(column) + else: + self._active_columns.append(column) + self.col.set_config("activeCols", self._active_columns) + + @property + def sort_column(self) -> str: + return self._sort_column + + @sort_column.setter + def sort_column(self, column: str) -> None: + self.col.set_config("sortType", column) + self._sort_column = column + + @property + def sort_backwards(self) -> bool: + return self._sort_backwards + + @sort_backwards.setter + def sort_backwards(self, order: bool) -> None: + self.col.set_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS, order) + self._sort_backwards = order + + def get_card(self, item: Item) -> Card: + return self.col.get_card(CardId(item)) + + def get_note(self, item: Item) -> Note: + return self.get_card(item).note() + + def find_items(self, search: str, order: Union[bool, str]) -> Sequence[Item]: + return self.col.find_cards(search, order) + + def get_item_from_card_id(self, card: CardId) -> Item: + return card + + def get_card_ids(self, items: List[Item]) -> List[CardId]: + return list(map(CardId, items)) + + def get_note_ids(self, items: List[Item]) -> List[NoteId]: + return super().note_ids_from_card_ids(items) + + def toggle_state(self) -> NoteState: + return NoteState(self.col) + + def get_new_item(self, old_item: Item) -> CardId: + return super().card_ids_from_note_ids([old_item])[0] + + def get_new_items(self, old_items: Sequence[Item]) -> List[CardId]: + return super().card_ids_from_note_ids(old_items) + + +class NoteState(ItemState): + def __init__(self, col: Collection) -> None: + super().__init__(col) + self._load_columns() + self._load_active_columns() + self._sort_column = self.col.get_config("noteSortType") + self._sort_backwards = self.col.get_config_bool( + Config.Bool.BROWSER_NOTE_SORT_BACKWARDS + ) + + def _load_columns(self) -> None: + self._columns = [ + ("note", tr.browsing_note()), + ("noteCards", tr.qt_accel_cards().replace("&", "")), + ("noteCrt", tr.browsing_created()), + ("noteEase", tr.browsing_average_ease()), + ("noteFld", tr.browsing_sort_field()), + ("noteMod", tr.search_note_modified()), + ("noteTags", tr.editing_tags()), + ] + self._columns.sort(key=itemgetter(1)) + + def _load_active_columns(self) -> None: + self._active_columns = self.col.get_config( + "activeNoteCols", ["noteFld", "note", "noteCards", "noteTags"] + ) + + @property + def columns(self) -> List[Tuple[str, str]]: + return self._columns + + @property + def active_columns(self) -> List[str]: + return self._active_columns + + def toggle_active_column(self, column: str) -> None: + if column in self._active_columns: + self._active_columns.remove(column) + else: + self._active_columns.append(column) + self.col.set_config("activeNoteCols", self._active_columns) + + @property + def sort_column(self) -> str: + return self._sort_column + + @sort_column.setter + def sort_column(self, column: str) -> None: + self.col.set_config("noteSortType", column) + self._sort_column = column + + @property + def sort_backwards(self) -> bool: + return self._sort_backwards + + @sort_backwards.setter + def sort_backwards(self, order: bool) -> None: + self.col.set_config_bool(Config.Bool.BROWSER_NOTE_SORT_BACKWARDS, order) + self._sort_backwards = order + + def get_card(self, item: Item) -> Card: + return self.get_note(item).cards()[0] + + def get_note(self, item: Item) -> Note: + return self.col.get_note(NoteId(item)) + + def find_items(self, search: str, order: Union[bool, str]) -> Sequence[Item]: + return self.col.find_notes(search, order) + + def get_item_from_card_id(self, card: CardId) -> Item: + return self.get_card(card).note().id + + def get_card_ids(self, items: List[Item]) -> List[CardId]: + return super().card_ids_from_note_ids(items) + + def get_note_ids(self, items: List[Item]) -> List[NoteId]: + return list(map(NoteId, items)) + + def toggle_state(self) -> CardState: + return CardState(self.col) + + def get_new_item(self, old_item: Item) -> NoteId: + return super().note_ids_from_card_ids([old_item])[0] + + def get_new_items(self, old_items: Sequence[Item]) -> List[NoteId]: + return super().note_ids_from_card_ids(old_items) + + +# Data model +########################################################################## + + +@dataclass +class Cell: + text: str + is_rtl: bool + + +class CellRow: + def __init__( + self, + cells: Generator[Tuple[str, bool], None, None], + color: BrowserRow.Color.V, + font_name: str, + font_size: int, + ) -> None: + self.refreshed_at: float = time.time() + self.cells: Tuple[Cell, ...] = tuple(Cell(*cell) for cell in cells) + self.color: Optional[Tuple[str, str]] = backend_color_to_aqt_color(color) + self.font_name: str = font_name or "arial" + self.font_size: int = font_size if font_size > 0 else 12 + + def is_stale(self, threshold: float) -> bool: + return self.refreshed_at < threshold + + @staticmethod + def generic(length: int, cell_text: str) -> CellRow: + return CellRow( + ((cell_text, False) for cell in range(length)), + BrowserRow.COLOR_DEFAULT, + "arial", + 12, + ) + + @staticmethod + def placeholder(length: int) -> CellRow: + return CellRow.generic(length, "...") + + @staticmethod + def deleted(length: int) -> CellRow: + return CellRow.generic(length, tr.browsing_row_deleted()) + + +def backend_color_to_aqt_color(color: BrowserRow.Color.V) -> Optional[Tuple[str, str]]: + if color == BrowserRow.COLOR_MARKED: + return colors.MARKED_BG + if color == BrowserRow.COLOR_SUSPENDED: + return colors.SUSPENDED_BG + if color == BrowserRow.COLOR_FLAG_RED: + return colors.FLAG1_BG + if color == BrowserRow.COLOR_FLAG_ORANGE: + return colors.FLAG2_BG + if color == BrowserRow.COLOR_FLAG_GREEN: + return colors.FLAG3_BG + if color == BrowserRow.COLOR_FLAG_BLUE: + return colors.FLAG4_BG + return None + + +class DataModel(QAbstractTableModel): + def __init__(self, col: Collection, state: ItemState) -> None: + QAbstractTableModel.__init__(self) + self.col: Collection = col + self._state: ItemState = state + self._items: Sequence[Item] = [] + self._rows: Dict[int, CellRow] = {} + self._last_refresh = 0.0 + # serve stale content to avoid hitting the DB? + self._block_updates = False + + # Row Object Interface + ###################################################################### + + # Get Rows + + def get_cell(self, index: QModelIndex) -> Cell: + return self.get_row(index).cells[index.column()] + + def get_row(self, index: QModelIndex) -> CellRow: + item = self.get_item(index) + if row := self._rows.get(item): + if not self._block_updates and row.is_stale(self._last_refresh): + # need to refresh + self._rows[item] = self._fetch_row_from_backend(item) + return self._rows[item] + # return row, even if it's stale + return row + if self._block_updates: + # blank row until we unblock + return CellRow.placeholder(self.len_columns()) + # missing row, need to build + self._rows[item] = self._fetch_row_from_backend(item) + return self._rows[item] + + def _fetch_row_from_backend(self, item: int) -> CellRow: + try: + row = CellRow(*self.col.browser_row_for_id(item)) + except NotFoundError: + return CellRow.deleted(self.len_columns()) + except Exception as e: + return CellRow.generic(self.len_columns(), str(e)) + + # fixme: hook needs state + gui_hooks.browser_did_fetch_row(item, row, self._state.active_columns) + return row + + # Reset + + def empty_cache(self) -> None: + self._rows = {} + + def reset(self) -> None: + self.begin_reset() + self.end_reset() + + def begin_reset(self) -> None: + self.beginResetModel() + self.empty_cache() + + def end_reset(self) -> None: + self.endResetModel() + + # Block/Unblock + + def begin_blocking(self) -> None: + self._block_updates = True + + def end_blocking(self) -> None: + self._block_updates = False + self.redraw_cells() + + def redraw_cells(self) -> None: + "Update cell contents, without changing search count/columns/sorting." + if self.is_empty(): + return + top_left = self.index(0, 0) + bottom_right = self.index(self.len_rows() - 1, self.len_columns() - 1) + self._last_refresh = time.time() + self.dataChanged.emit(top_left, bottom_right) # type: ignore + + # Item Interface + ###################################################################### + + # Get metadata + + def is_empty(self) -> bool: + return not self._items + + def len_rows(self) -> int: + return len(self._items) + + def len_columns(self) -> int: + return len(self._state.active_columns) + + # Get items (card or note ids depending on state) + + def get_item(self, index: QModelIndex) -> Item: + return self._items[index.row()] + + def get_items(self, indices: List[QModelIndex]) -> List[Item]: + return [self.get_item(index) for index in indices] + + def get_card_ids(self, indices: List[QModelIndex]) -> List[CardId]: + return self._state.get_card_ids(self.get_items(indices)) + + def get_note_ids(self, indices: List[QModelIndex]) -> List[NoteId]: + return self._state.get_note_ids(self.get_items(indices)) + + # Get row numbers from items + + def get_item_row(self, item: Item) -> Optional[int]: + for row, i in enumerate(self._items): + if i == item: + return row + return None + + def get_item_rows(self, items: Sequence[Item]) -> List[int]: + rows = [] + for row, i in enumerate(self._items): + if i in items: + rows.append(row) + return rows + + def get_card_row(self, card_id: CardId) -> Optional[int]: + return self.get_item_row(self._state.get_item_from_card_id(card_id)) + + # Get objects (cards or notes) + + def get_card(self, index: QModelIndex) -> Optional[Card]: + """Try to return the indicated, possibly deleted card.""" + try: + return self._state.get_card(self.get_item(index)) + except NotFoundError: + return None + + def get_note(self, index: QModelIndex) -> Optional[Note]: + """Try to return the indicated, possibly deleted note.""" + try: + return self._state.get_note(self.get_item(index)) + except NotFoundError: + return None + + # Table Interface + ###################################################################### + + def toggle_state(self, context: SearchContext) -> ItemState: + self.beginResetModel() + self._state = self._state.toggle_state() + self.search(context) + return self._state + + # Rows + + def search(self, context: SearchContext) -> None: + self.begin_reset() + try: + gui_hooks.browser_will_search(context) + if context.ids is None: + context.ids = self._state.find_items(context.search, context.order) + gui_hooks.browser_did_search(context) + self._items = context.ids + finally: + self.end_reset() + + def reverse(self) -> None: + self.beginResetModel() + self._items = list(reversed(self._items)) + self.endResetModel() + + # Columns + + def active_column(self, index: int) -> str: + return self._state.active_columns[index] + + def active_column_index(self, column: str) -> Optional[int]: + return ( + self._state.active_columns.index(column) + if column in self._state.active_columns + else None + ) + + def toggle_column(self, column: str) -> None: + self.begin_reset() + self._state.toggle_active_column(column) + self.end_reset() + + # Model interface + ###################################################################### + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + if parent and parent.isValid(): + return 0 + return self.len_rows() + + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + if parent and parent.isValid(): + return 0 + return self.len_columns() + + def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any: + if not index.isValid(): + return QVariant() + if role == Qt.FontRole: + if self.active_column(index.column()) not in ( + "question", + "answer", + "noteFld", + ): + return QVariant() + qfont = QFont() + row = self.get_row(index) + qfont.setFamily(row.font_name) + qfont.setPixelSize(row.font_size) + return qfont + if role == Qt.TextAlignmentRole: + align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter + if self.active_column(index.column()) not in ( + "question", + "answer", + "template", + "deck", + "noteFld", + "note", + "noteTags", + ): + align |= Qt.AlignHCenter + return align + if role in (Qt.DisplayRole, Qt.ToolTipRole): + return self.get_cell(index).text + return QVariant() + + def headerData( + self, section: int, orientation: Qt.Orientation, role: int = 0 + ) -> Optional[str]: + if orientation == Qt.Vertical: + return None + elif role == Qt.DisplayRole and section < self.len_columns(): + column = self.active_column(section) + txt = None + for stype, name in self._state.columns: + if column == stype: + txt = name + break + # give the user a hint an invalid column was added by an add-on + if not txt: + txt = tr.browsing_addon() + return txt + else: + return None + + def flags(self, index: QModelIndex) -> Qt.ItemFlags: + return cast(Qt.ItemFlags, Qt.ItemIsEnabled | Qt.ItemIsSelectable) + + +# Line painter +###################################################################### + + +class StatusDelegate(QItemDelegate): + def __init__(self, browser: aqt.browser.Browser, model: DataModel) -> None: + QItemDelegate.__init__(self, browser) + self._model = model + + def paint( + self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex + ) -> None: + if self._model.get_cell(index).is_rtl: + option.direction = Qt.RightToLeft + if row_color := self._model.get_row(index).color: + brush = QBrush(theme_manager.qcolor(row_color)) + painter.save() + painter.fillRect(option.rect, brush) + painter.restore() + return QItemDelegate.paint(self, painter, option, index) diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index 08b31a90c..d10af0831 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -386,7 +386,7 @@ hooks = [ ), Hook( name="browser_will_search", - args=["context: aqt.browser.SearchContext"], + args=["context: aqt.table.SearchContext"], doc="""Allows you to modify the search text, or perform your own search. You can modify context.search to change the text that is sent to the @@ -401,12 +401,12 @@ hooks = [ ), Hook( name="browser_did_search", - args=["context: aqt.browser.SearchContext"], + args=["context: aqt.table.SearchContext"], doc="""Allows you to modify the list of returned card ids from a search.""", ), Hook( name="browser_did_fetch_row", - args=["card_id: int", "row: aqt.browser.CellRow", "columns: Sequence[str]"], + args=["card_id: int", "row: aqt.table.CellRow", "columns: Sequence[str]"], doc="""Allows you to add or modify content to a row in the browser. You can mutate the row object to change what is displayed. Any columns the diff --git a/rslib/backend.proto b/rslib/backend.proto index f19b5a77d..b85c5475d 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -237,12 +237,12 @@ service TagsService { service SearchService { rpc BuildSearchString(SearchNode) returns (String); - rpc SearchCards(SearchCardsIn) returns (SearchCardsOut); - rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut); + rpc SearchCards(SearchIn) returns (SearchOut); + rpc SearchNotes(SearchIn) returns (SearchOut); rpc JoinSearchNodes(JoinSearchNodesIn) returns (String); rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String); rpc FindAndReplace(FindAndReplaceIn) returns (OpChangesWithCount); - rpc BrowserRowForCard(CardId) returns (BrowserRow); + rpc BrowserRowForId(Int64) returns (BrowserRow); } service StatsService { @@ -795,31 +795,33 @@ message OpenCollectionIn { string log_path = 4; } -message SearchCardsIn { +message SearchIn { string search = 1; SortOrder order = 2; } -message SearchCardsOut { - repeated int64 card_ids = 1; +message SearchOut { + repeated int64 ids = 1; } message SortOrder { message Builtin { enum Kind { - NOTE_CREATION = 0; - NOTE_MOD = 1; - NOTE_FIELD = 2; - NOTE_TAGS = 3; - NOTETYPE = 4; - CARD_MOD = 5; - CARD_REPS = 6; - CARD_DUE = 7; - CARD_EASE = 8; - CARD_LAPSES = 9; - CARD_INTERVAL = 10; - CARD_DECK = 11; - CARD_TEMPLATE = 12; + NOTE_CARDS = 0; + NOTE_CREATION = 1; + NOTE_EASE = 2; + NOTE_MOD = 3; + NOTE_FIELD = 4; + NOTE_TAGS = 5; + NOTETYPE = 6; + CARD_MOD = 7; + CARD_REPS = 8; + CARD_DUE = 9; + CARD_EASE = 10; + CARD_LAPSES = 11; + CARD_INTERVAL = 12; + CARD_DECK = 13; + CARD_TEMPLATE = 14; } Kind kind = 1; bool reverse = 2; @@ -832,14 +834,6 @@ message SortOrder { } } -message SearchNotesIn { - string search = 1; -} - -message SearchNotesOut { - repeated int64 note_ids = 2; -} - message SearchNode { message Dupe { int64 notetype_id = 1; @@ -1351,22 +1345,24 @@ message SetDeckIn { message Config { message Bool { enum Key { - BROWSER_SORT_BACKWARDS = 0; - PREVIEW_BOTH_SIDES = 1; - COLLAPSE_TAGS = 2; - COLLAPSE_NOTETYPES = 3; - COLLAPSE_DECKS = 4; - COLLAPSE_SAVED_SEARCHES = 5; - COLLAPSE_TODAY = 6; - COLLAPSE_CARD_STATE = 7; - COLLAPSE_FLAGS = 8; - SCHED_2021 = 9; - ADDING_DEFAULTS_TO_CURRENT_DECK = 10; - HIDE_AUDIO_PLAY_BUTTONS = 11; - INTERRUPT_AUDIO_WHEN_ANSWERING = 12; - PASTE_IMAGES_AS_PNG = 13; - PASTE_STRIPS_FORMATTING = 14; - NORMALIZE_NOTE_TEXT = 15; + BROWSER_CARD_STATE = 0; + BROWSER_SORT_BACKWARDS = 1; + BROWSER_NOTE_SORT_BACKWARDS = 2; + PREVIEW_BOTH_SIDES = 3; + COLLAPSE_TAGS = 4; + COLLAPSE_NOTETYPES = 5; + COLLAPSE_DECKS = 6; + COLLAPSE_SAVED_SEARCHES = 7; + COLLAPSE_TODAY = 8; + COLLAPSE_CARD_STATE = 9; + COLLAPSE_FLAGS = 10; + SCHED_2021 = 11; + ADDING_DEFAULTS_TO_CURRENT_DECK = 12; + HIDE_AUDIO_PLAY_BUTTONS = 13; + INTERRUPT_AUDIO_WHEN_ANSWERING = 14; + PASTE_IMAGES_AS_PNG = 15; + PASTE_STRIPS_FORMATTING = 16; + NORMALIZE_NOTE_TEXT = 17; } Key key = 1; } diff --git a/rslib/src/backend/config.rs b/rslib/src/backend/config.rs index a141eb0e0..ac769ec49 100644 --- a/rslib/src/backend/config.rs +++ b/rslib/src/backend/config.rs @@ -15,7 +15,9 @@ use serde_json::Value; impl From for BoolKey { fn from(k: BoolKeyProto) -> Self { match k { + BoolKeyProto::BrowserCardState => BoolKey::BrowserCardState, BoolKeyProto::BrowserSortBackwards => BoolKey::BrowserSortBackwards, + BoolKeyProto::BrowserNoteSortBackwards => BoolKey::BrowserNoteSortBackwards, BoolKeyProto::PreviewBothSides => BoolKey::PreviewBothSides, BoolKeyProto::CollapseTags => BoolKey::CollapseTags, BoolKeyProto::CollapseNotetypes => BoolKey::CollapseNotetypes, diff --git a/rslib/src/backend/search/mod.rs b/rslib/src/backend/search/mod.rs index d4346d46f..d88d2f9ce 100644 --- a/rslib/src/backend/search/mod.rs +++ b/rslib/src/backend/search/mod.rs @@ -24,21 +24,22 @@ impl SearchService for Backend { Ok(write_nodes(&node.into_node_list()).into()) } - fn search_cards(&self, input: pb::SearchCardsIn) -> Result { + fn search_cards(&self, input: pb::SearchIn) -> Result { self.with_col(|col| { let order = input.order.unwrap_or_default().value.into(); - let cids = col.search_cards(&input.search, order)?; - Ok(pb::SearchCardsOut { - card_ids: cids.into_iter().map(|v| v.0).collect(), + let cids = col.search::(&input.search, order)?; + Ok(pb::SearchOut { + ids: cids.into_iter().map(|v| v.0).collect(), }) }) } - fn search_notes(&self, input: pb::SearchNotesIn) -> Result { + fn search_notes(&self, input: pb::SearchIn) -> Result { self.with_col(|col| { - let nids = col.search_notes(&input.search)?; - Ok(pb::SearchNotesOut { - note_ids: nids.into_iter().map(|v| v.0).collect(), + let order = input.order.unwrap_or_default().value.into(); + let nids = col.search::(&input.search, order)?; + Ok(pb::SearchOut { + ids: nids.into_iter().map(|v| v.0).collect(), }) }) } @@ -88,15 +89,17 @@ impl SearchService for Backend { }) } - fn browser_row_for_card(&self, input: pb::CardId) -> Result { - self.with_col(|col| col.browser_row_for_card(input.cid.into()).map(Into::into)) + fn browser_row_for_id(&self, input: pb::Int64) -> Result { + self.with_col(|col| col.browser_row_for_id(input.val).map(Into::into)) } } impl From for SortKind { fn from(kind: SortKindProto) -> Self { match kind { + SortKindProto::NoteCards => SortKind::NoteCards, SortKindProto::NoteCreation => SortKind::NoteCreation, + SortKindProto::NoteEase => SortKind::NoteEase, SortKindProto::NoteMod => SortKind::NoteMod, SortKindProto::NoteField => SortKind::NoteField, SortKindProto::NoteTags => SortKind::NoteTags, diff --git a/rslib/src/browser_rows.rs b/rslib/src/browser_rows.rs index ab74317aa..d33e9e4dd 100644 --- a/rslib/src/browser_rows.rs +++ b/rslib/src/browser_rows.rs @@ -10,8 +10,9 @@ use crate::i18n::I18n; use crate::{ card::{Card, CardId, CardQueue, CardType}, collection::Collection, + config::BoolKey, decks::{Deck, DeckId}, - notes::Note, + notes::{Note, NoteId}, notetype::{CardTemplate, Notetype, NotetypeKind}, scheduler::{timespan::time_span, timing::SchedTimingToday}, template::RenderedNode, @@ -51,7 +52,54 @@ pub struct Font { pub size: u32, } -struct RowContext<'a> { +trait RowContext { + fn get_cell_text(&mut self, column: &str) -> Result; + fn get_row_color(&self) -> Color; + fn get_row_font(&self) -> Result; + fn note(&self) -> &Note; + fn notetype(&self) -> &Notetype; + + fn get_cell(&mut self, column: &str) -> Result { + Ok(Cell { + text: self.get_cell_text(column)?, + is_rtl: self.get_is_rtl(column), + }) + } + + fn note_creation_str(&self) -> String { + TimestampMillis(self.note().id.into()) + .as_secs() + .date_string() + } + + fn note_field_str(&self) -> String { + let index = self.notetype().config.sort_field_idx as usize; + html_to_text_line(&self.note().fields()[index]).into() + } + + fn get_is_rtl(&self, column: &str) -> bool { + match column { + "noteFld" => { + let index = self.notetype().config.sort_field_idx as usize; + self.notetype().fields[index].config.rtl + } + _ => false, + } + } + + fn browser_row_for_id(&mut self, columns: &[String]) -> Result { + Ok(Row { + cells: columns + .iter() + .map(|column| self.get_cell(column)) + .collect::>()?, + color: self.get_row_color(), + font: self.get_row_font()?, + }) + } +} + +struct CardRowContext<'a> { col: &'a Collection, card: Card, note: Note, @@ -70,6 +118,13 @@ struct RenderContext { answer_nodes: Vec, } +struct NoteRowContext<'a> { + note: Note, + notetype: Arc, + cards: Vec, + tr: &'a I18n, +} + fn card_render_required(columns: &[String]) -> bool { columns .iter() @@ -77,19 +132,28 @@ fn card_render_required(columns: &[String]) -> bool { } impl Collection { - pub fn browser_row_for_card(&mut self, id: CardId) -> Result { - // this is inefficient; we may want to use an enum in the future - let columns = self.get_desktop_browser_card_columns(); - let mut context = RowContext::new(self, id, card_render_required(&columns))?; + pub fn browser_row_for_id(&mut self, id: i64) -> Result { + if self.get_bool(BoolKey::BrowserCardState) { + // this is inefficient; we may want to use an enum in the future + let columns = self.get_desktop_browser_card_columns(); + CardRowContext::new(self, id, card_render_required(&columns))? + .browser_row_for_id(&columns) + } else { + let columns = self.get_desktop_browser_note_columns(); + NoteRowContext::new(self, id)?.browser_row_for_id(&columns) + } + } - Ok(Row { - cells: columns - .iter() - .map(|column| context.get_cell(column)) - .collect::>()?, - color: context.get_row_color(), - font: context.get_row_font()?, - }) + fn get_note_maybe_with_fields(&self, id: NoteId, _with_fields: bool) -> Result { + // todo: After note.sort_field has been modified so it can be displayed in the browser, + // we can update note_field_str() and only load the note with fields if a card render is + // necessary (see #1082). + if true { + self.storage.get_note(id)? + } else { + self.storage.get_note_without_fields(id)? + } + .ok_or(AnkiError::NotFound) } } @@ -123,18 +187,13 @@ impl RenderContext { } } -impl<'a> RowContext<'a> { - fn new(col: &'a mut Collection, id: CardId, with_card_render: bool) -> Result { - let card = col.storage.get_card(id)?.ok_or(AnkiError::NotFound)?; - // todo: After note.sort_field has been modified so it can be displayed in the browser, - // we can update note_field_str() and only load the note with fields if a card render is - // necessary (see #1082). - let note = if true { - col.storage.get_note(card.note_id)? - } else { - col.storage.get_note_without_fields(card.note_id)? - } - .ok_or(AnkiError::NotFound)?; +impl<'a> CardRowContext<'a> { + fn new(col: &'a mut Collection, id: i64, with_card_render: bool) -> Result { + let card = col + .storage + .get_card(CardId(id))? + .ok_or(AnkiError::NotFound)?; + let note = col.get_note_maybe_with_fields(card.note_id, with_card_render)?; let notetype = col .get_notetype(note.notetype_id)? .ok_or(AnkiError::NotFound)?; @@ -145,7 +204,7 @@ impl<'a> RowContext<'a> { None }; - Ok(RowContext { + Ok(CardRowContext { col, card, note, @@ -181,34 +240,6 @@ impl<'a> RowContext<'a> { Ok(self.original_deck.as_ref().unwrap()) } - fn get_cell(&mut self, column: &str) -> Result { - Ok(Cell { - text: self.get_cell_text(column)?, - is_rtl: self.get_is_rtl(column), - }) - } - - fn get_cell_text(&mut self, column: &str) -> Result { - Ok(match column { - "answer" => self.answer_str(), - "cardDue" => self.card_due_str(), - "cardEase" => self.card_ease_str(), - "cardIvl" => self.card_interval_str(), - "cardLapses" => self.card.lapses.to_string(), - "cardMod" => self.card.mtime.date_string(), - "cardReps" => self.card.reps.to_string(), - "deck" => self.deck_str()?, - "note" => self.notetype.name.to_owned(), - "noteCrt" => self.note_creation_str(), - "noteFld" => self.note_field_str(), - "noteMod" => self.note.mtime.date_string(), - "noteTags" => self.note.tags.join(" "), - "question" => self.question_str(), - "template" => self.template_str()?, - _ => "".to_string(), - }) - } - fn answer_str(&self) -> String { let render_context = self.render_context.as_ref().unwrap(); let answer = render_context @@ -284,15 +315,6 @@ impl<'a> RowContext<'a> { }) } - fn note_creation_str(&self) -> String { - TimestampMillis(self.note.id.into()).as_secs().date_string() - } - - fn note_field_str(&self) -> String { - let index = self.notetype.config.sort_field_idx as usize; - html_to_text_line(&self.note.fields()[index]).into() - } - fn template_str(&self) -> Result { let name = &self.template()?.name; Ok(match self.notetype.config.kind() { @@ -304,15 +326,28 @@ impl<'a> RowContext<'a> { fn question_str(&self) -> String { html_to_text_line(&self.render_context.as_ref().unwrap().question).to_string() } +} - fn get_is_rtl(&self, column: &str) -> bool { - match column { - "noteFld" => { - let index = self.notetype.config.sort_field_idx as usize; - self.notetype.fields[index].config.rtl - } - _ => false, - } +impl RowContext for CardRowContext<'_> { + fn get_cell_text(&mut self, column: &str) -> Result { + Ok(match column { + "answer" => self.answer_str(), + "cardDue" => self.card_due_str(), + "cardEase" => self.card_ease_str(), + "cardIvl" => self.card_interval_str(), + "cardLapses" => self.card.lapses.to_string(), + "cardMod" => self.card.mtime.date_string(), + "cardReps" => self.card.reps.to_string(), + "deck" => self.deck_str()?, + "note" => self.notetype.name.to_owned(), + "noteCrt" => self.note_creation_str(), + "noteFld" => self.note_field_str(), + "noteMod" => self.note.mtime.date_string(), + "noteTags" => self.note.tags.join(" "), + "question" => self.question_str(), + "template" => self.template_str()?, + _ => "".to_string(), + }) } fn get_row_color(&self) -> Color { @@ -344,4 +379,86 @@ impl<'a> RowContext<'a> { size: self.template()?.config.browser_font_size, }) } + + fn note(&self) -> &Note { + &self.note + } + + fn notetype(&self) -> &Notetype { + &self.notetype + } +} + +impl<'a> NoteRowContext<'a> { + fn new(col: &'a mut Collection, id: i64) -> Result { + let note = col.get_note_maybe_with_fields(NoteId(id), false)?; + let notetype = col + .get_notetype(note.notetype_id)? + .ok_or(AnkiError::NotFound)?; + let cards = col.storage.all_cards_of_note(note.id)?; + + Ok(NoteRowContext { + note, + notetype, + cards, + tr: &col.tr, + }) + } + + fn note_ease_str(&self) -> String { + let cards = self + .cards + .iter() + .filter(|c| c.ctype != CardType::New) + .collect::>(); + if cards.is_empty() { + self.tr.browsing_new().into() + } else { + let ease = cards.iter().map(|c| c.ease_factor).sum::() / cards.len() as u16; + format!("{}%", ease / 10) + } + } +} + +impl RowContext for NoteRowContext<'_> { + fn get_cell_text(&mut self, column: &str) -> Result { + Ok(match column { + "note" => self.notetype.name.to_owned(), + "noteCards" => self.cards.len().to_string(), + "noteCrt" => self.note_creation_str(), + "noteEase" => self.note_ease_str(), + "noteFld" => self.note_field_str(), + "noteMod" => self.note.mtime.date_string(), + "noteTags" => self.note.tags.join(" "), + _ => "".to_string(), + }) + } + + fn get_row_color(&self) -> Color { + if self + .note + .tags + .iter() + .any(|tag| tag.eq_ignore_ascii_case("marked")) + { + Color::Marked + } else { + Color::Default + } + } + + fn get_row_font(&self) -> Result { + Ok(Font { + name: "".to_owned(), + size: 0, + }) + } + + fn note(&self) -> &Note { + &self.note + } + + fn notetype(&self) -> &Notetype { + &self.notetype + } } diff --git a/rslib/src/config/bool.rs b/rslib/src/config/bool.rs index ebb247d8b..647fe0a94 100644 --- a/rslib/src/config/bool.rs +++ b/rslib/src/config/bool.rs @@ -9,6 +9,8 @@ use strum::IntoStaticStr; #[derive(Debug, Clone, Copy, IntoStaticStr)] #[strum(serialize_all = "camelCase")] pub enum BoolKey { + BrowserCardState, + BrowserNoteSortBackwards, CardCountsSeparateInactive, CollapseCardState, CollapseDecks, @@ -60,7 +62,8 @@ impl Collection { | BoolKey::FutureDueShowBacklog | BoolKey::ShowRemainingDueCountsInStudy | BoolKey::CardCountsSeparateInactive - | BoolKey::NormalizeNoteText => self.get_config_optional(key).unwrap_or(true), + | BoolKey::NormalizeNoteText + | BoolKey::BrowserCardState => self.get_config_optional(key).unwrap_or(true), // other options default to false other => self.get_config_default(other), diff --git a/rslib/src/config/mod.rs b/rslib/src/config/mod.rs index f7ca31358..722d0104f 100644 --- a/rslib/src/config/mod.rs +++ b/rslib/src/config/mod.rs @@ -48,6 +48,8 @@ pub(crate) enum ConfigKey { AnswerTimeLimitSecs, #[strum(to_string = "sortType")] BrowserSortKind, + #[strum(to_string = "noteSortType")] + BrowserNoteSortKind, #[strum(to_string = "curDeck")] CurrentDeckId, #[strum(to_string = "curModel")] @@ -65,6 +67,8 @@ pub(crate) enum ConfigKey { #[strum(to_string = "activeCols")] DesktopBrowserCardColumns, + #[strum(to_string = "activeNoteCols")] + DesktopBrowserNoteColumns, } #[derive(PartialEq, Serialize_repr, Deserialize_repr, Clone, Copy, Debug)] @@ -132,6 +136,10 @@ impl Collection { self.get_config_default(ConfigKey::BrowserSortKind) } + pub(crate) fn get_browser_note_sort_kind(&self) -> SortKind { + self.get_config_default(ConfigKey::BrowserNoteSortKind) + } + pub(crate) fn get_desktop_browser_card_columns(&self) -> Vec { self.get_config_optional(ConfigKey::DesktopBrowserCardColumns) .unwrap_or_else(|| { @@ -144,6 +152,18 @@ impl Collection { }) } + pub(crate) fn get_desktop_browser_note_columns(&self) -> Vec { + self.get_config_optional(ConfigKey::DesktopBrowserNoteColumns) + .unwrap_or_else(|| { + vec![ + "noteFld".to_string(), + "note".to_string(), + "noteTags".to_string(), + "noteMod".to_string(), + ] + }) + } + pub(crate) fn get_creation_utc_offset(&self) -> Option { self.get_config_optional(ConfigKey::CreationOffset) } @@ -251,8 +271,10 @@ impl Collection { #[derive(Deserialize, PartialEq, Debug, Clone, Copy)] #[serde(rename_all = "camelCase")] pub enum SortKind { + NoteCards, #[serde(rename = "noteCrt")] NoteCreation, + NoteEase, NoteMod, #[serde(rename = "noteFld")] NoteField, diff --git a/rslib/src/search/cards.rs b/rslib/src/search/cards.rs deleted file mode 100644 index 5aea9c4b6..000000000 --- a/rslib/src/search/cards.rs +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -use super::{ - parser::Node, - sqlwriter::{RequiredTable, SqlWriter}, -}; -use crate::{ - card::CardId, - card::CardType, - collection::Collection, - config::{BoolKey, SortKind}, - err::Result, - search::parser::parse, -}; - -#[derive(Debug, PartialEq, Clone)] -pub enum SortMode { - NoOrder, - FromConfig, - Builtin { kind: SortKind, reverse: bool }, - Custom(String), -} - -impl SortMode { - fn required_table(&self) -> RequiredTable { - match self { - SortMode::NoOrder => RequiredTable::Cards, - SortMode::FromConfig => unreachable!(), - SortMode::Builtin { kind, .. } => kind.required_table(), - SortMode::Custom(ref text) => { - if text.contains("n.") { - RequiredTable::CardsAndNotes - } else { - RequiredTable::Cards - } - } - } - } -} - -impl SortKind { - fn required_table(self) -> RequiredTable { - match self { - SortKind::NoteCreation - | SortKind::NoteMod - | SortKind::NoteField - | SortKind::Notetype - | SortKind::NoteTags - | SortKind::CardTemplate => RequiredTable::CardsAndNotes, - SortKind::CardMod - | SortKind::CardReps - | SortKind::CardDue - | SortKind::CardEase - | SortKind::CardLapses - | SortKind::CardInterval - | SortKind::CardDeck => RequiredTable::Cards, - } - } -} - -impl Collection { - pub fn search_cards(&mut self, search: &str, mut mode: SortMode) -> Result> { - let top_node = Node::Group(parse(search)?); - self.resolve_config_sort(&mut mode); - let writer = SqlWriter::new(self); - - let (mut sql, args) = writer.build_cards_query(&top_node, mode.required_table())?; - self.add_order(&mut sql, mode)?; - - let mut stmt = self.storage.db.prepare(&sql)?; - let ids: Vec<_> = stmt - .query_map(&args, |row| row.get(0))? - .collect::>()?; - - Ok(ids) - } - - fn add_order(&mut self, sql: &mut String, mode: SortMode) -> Result<()> { - match mode { - SortMode::NoOrder => (), - SortMode::FromConfig => unreachable!(), - SortMode::Builtin { kind, reverse } => { - prepare_sort(self, kind)?; - sql.push_str(" order by "); - write_order(sql, kind, reverse); - } - SortMode::Custom(order_clause) => { - sql.push_str(" order by "); - sql.push_str(&order_clause); - } - } - Ok(()) - } - - /// Place the matched card ids into a temporary 'search_cids' table - /// instead of returning them. Use clear_searched_cards() to remove it. - /// Returns number of added cards. - pub(crate) fn search_cards_into_table( - &mut self, - search: &str, - mode: SortMode, - ) -> Result { - let top_node = Node::Group(parse(search)?); - let writer = SqlWriter::new(self); - let want_order = mode != SortMode::NoOrder; - - let (mut sql, args) = writer.build_cards_query(&top_node, mode.required_table())?; - self.add_order(&mut sql, mode)?; - - if want_order { - self.storage - .setup_searched_cards_table_to_preserve_order()?; - } else { - self.storage.setup_searched_cards_table()?; - } - let sql = format!("insert into search_cids {}", sql); - - self.storage - .db - .prepare(&sql)? - .execute(&args) - .map_err(Into::into) - } - - /// If the sort mode is based on a config setting, look it up. - fn resolve_config_sort(&self, mode: &mut SortMode) { - if mode == &SortMode::FromConfig { - *mode = SortMode::Builtin { - kind: self.get_browser_sort_kind(), - reverse: self.get_bool(BoolKey::BrowserSortBackwards), - } - } - } -} - -/// Add the order clause to the sql. -fn write_order(sql: &mut String, kind: SortKind, reverse: bool) { - let tmp_str; - let order = match kind { - SortKind::NoteCreation => "n.id asc, c.ord asc", - SortKind::NoteMod => "n.mod asc, c.ord asc", - SortKind::NoteField => "n.sfld collate nocase asc, c.ord asc", - SortKind::CardMod => "c.mod asc", - SortKind::CardReps => "c.reps asc", - SortKind::CardDue => "c.type asc, c.due asc", - SortKind::CardEase => { - tmp_str = format!("c.type = {} asc, c.factor asc", CardType::New as i8); - &tmp_str - } - SortKind::CardLapses => "c.lapses asc", - SortKind::CardInterval => "c.ivl asc", - SortKind::NoteTags => "n.tags asc", - SortKind::CardDeck => "(select pos from sort_order where did = c.did) asc", - SortKind::Notetype => "(select pos from sort_order where ntid = n.mid) asc", - SortKind::CardTemplate => concat!( - "coalesce((select pos from sort_order where ntid = n.mid and ord = c.ord),", - // need to fall back on ord 0 for cloze cards - "(select pos from sort_order where ntid = n.mid and ord = 0)) asc" - ), - }; - if order.is_empty() { - return; - } - if reverse { - sql.push_str( - &order - .to_ascii_lowercase() - .replace(" desc", "") - .replace(" asc", " desc"), - ) - } else { - sql.push_str(order); - } -} - -fn needs_aux_sort_table(kind: SortKind) -> bool { - use SortKind::*; - matches!(kind, CardDeck | Notetype | CardTemplate) -} - -fn prepare_sort(col: &mut Collection, kind: SortKind) -> Result<()> { - if !needs_aux_sort_table(kind) { - return Ok(()); - } - - use SortKind::*; - let sql = match kind { - CardDeck => include_str!("deck_order.sql"), - Notetype => include_str!("notetype_order.sql"), - CardTemplate => include_str!("template_order.sql"), - _ => unreachable!(), - }; - - col.storage.db.execute_batch(sql)?; - - Ok(()) -} diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index 3cf6383e2..2b54e285c 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -1,14 +1,278 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -mod cards; -mod notes; mod parser; mod sqlwriter; pub(crate) mod writer; -pub use cards::SortMode; pub use parser::{ parse as parse_search, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind, }; pub use writer::{concatenate_searches, replace_search_node, write_nodes, BoolSeparator}; + +use rusqlite::types::FromSql; +use std::borrow::Cow; + +use crate::{ + card::CardId, + card::CardType, + collection::Collection, + config::{BoolKey, SortKind}, + err::Result, + notes::NoteId, + prelude::AnkiError, + search::parser::parse, +}; +use sqlwriter::{RequiredTable, SqlWriter}; + +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum SearchItems { + Cards, + Notes, +} + +#[derive(Debug, PartialEq, Clone)] +pub enum SortMode { + NoOrder, + FromConfig, + Builtin { kind: SortKind, reverse: bool }, + Custom(String), +} + +pub trait AsSearchItems { + fn as_search_items() -> SearchItems; +} + +impl AsSearchItems for CardId { + fn as_search_items() -> SearchItems { + SearchItems::Cards + } +} + +impl AsSearchItems for NoteId { + fn as_search_items() -> SearchItems { + SearchItems::Notes + } +} + +impl SearchItems { + fn required_table(&self) -> RequiredTable { + match self { + SearchItems::Cards => RequiredTable::Cards, + SearchItems::Notes => RequiredTable::Notes, + } + } +} + +impl SortMode { + fn required_table(&self) -> RequiredTable { + match self { + SortMode::NoOrder => RequiredTable::CardsOrNotes, + SortMode::FromConfig => unreachable!(), + SortMode::Builtin { kind, .. } => kind.required_table(), + SortMode::Custom(ref text) => { + if text.contains("n.") { + if text.contains("c.") { + RequiredTable::CardsAndNotes + } else { + RequiredTable::Notes + } + } else { + RequiredTable::Cards + } + } + } + } +} + +impl SortKind { + fn required_table(self) -> RequiredTable { + match self { + SortKind::NoteCards + | SortKind::NoteCreation + | SortKind::NoteEase + | SortKind::NoteMod + | SortKind::NoteField + | SortKind::Notetype + | SortKind::NoteTags => RequiredTable::Notes, + SortKind::CardTemplate => RequiredTable::CardsAndNotes, + SortKind::CardMod + | SortKind::CardReps + | SortKind::CardDue + | SortKind::CardEase + | SortKind::CardLapses + | SortKind::CardInterval + | SortKind::CardDeck => RequiredTable::Cards, + } + } +} + +impl Collection { + pub fn search(&mut self, search: &str, mut mode: SortMode) -> Result> + where + T: FromSql + AsSearchItems, + { + let items = T::as_search_items(); + let top_node = Node::Group(parse(search)?); + self.resolve_config_sort(items, &mut mode); + let writer = SqlWriter::new(self, items); + + let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?; + self.add_order(&mut sql, items, mode)?; + + let mut stmt = self.storage.db.prepare(&sql)?; + let ids: Vec<_> = stmt + .query_map(&args, |row| row.get(0))? + .collect::>()?; + + Ok(ids) + } + + pub fn search_cards(&mut self, search: &str, mode: SortMode) -> Result> { + self.search(search, mode) + } + + pub fn search_notes(&mut self, search: &str) -> Result> { + self.search(search, SortMode::NoOrder) + } + + fn add_order(&mut self, sql: &mut String, items: SearchItems, mode: SortMode) -> Result<()> { + match mode { + SortMode::NoOrder => (), + SortMode::FromConfig => unreachable!(), + SortMode::Builtin { kind, reverse } => { + prepare_sort(self, kind)?; + sql.push_str(" order by "); + write_order(sql, items, kind, reverse)?; + } + SortMode::Custom(order_clause) => { + sql.push_str(" order by "); + sql.push_str(&order_clause); + } + } + Ok(()) + } + + /// Place the matched card ids into a temporary 'search_cids' table + /// instead of returning them. Use clear_searched_cards() to remove it. + /// Returns number of added cards. + pub(crate) fn search_cards_into_table( + &mut self, + search: &str, + mode: SortMode, + ) -> Result { + let top_node = Node::Group(parse(search)?); + let writer = SqlWriter::new(self, SearchItems::Cards); + let want_order = mode != SortMode::NoOrder; + + let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?; + self.add_order(&mut sql, SearchItems::Cards, mode)?; + + if want_order { + self.storage + .setup_searched_cards_table_to_preserve_order()?; + } else { + self.storage.setup_searched_cards_table()?; + } + let sql = format!("insert into search_cids {}", sql); + + self.storage + .db + .prepare(&sql)? + .execute(&args) + .map_err(Into::into) + } + + /// If the sort mode is based on a config setting, look it up. + fn resolve_config_sort(&self, items: SearchItems, mode: &mut SortMode) { + if mode == &SortMode::FromConfig { + *mode = match items { + SearchItems::Cards => SortMode::Builtin { + kind: self.get_browser_sort_kind(), + reverse: self.get_bool(BoolKey::BrowserSortBackwards), + }, + SearchItems::Notes => SortMode::Builtin { + kind: self.get_browser_note_sort_kind(), + reverse: self.get_bool(BoolKey::BrowserNoteSortBackwards), + }, + } + } + } +} + +/// Add the order clause to the sql. +fn write_order(sql: &mut String, items: SearchItems, kind: SortKind, reverse: bool) -> Result<()> { + let order = match items { + SearchItems::Cards => card_order_from_sortkind(kind), + SearchItems::Notes => note_order_from_sortkind(kind), + }; + if order.is_empty() { + return Err(AnkiError::InvalidInput { + info: format!("Can't sort {:?} by {:?}.", items, kind), + }); + } + if reverse { + sql.push_str( + &order + .to_ascii_lowercase() + .replace(" desc", "") + .replace(" asc", " desc"), + ) + } else { + sql.push_str(&order); + } + Ok(()) +} + +fn card_order_from_sortkind(kind: SortKind) -> Cow<'static, str> { + match kind { + SortKind::NoteCreation => "n.id asc, c.ord asc".into(), + SortKind::NoteMod => "n.mod asc, c.ord asc".into(), + SortKind::NoteField => "n.sfld collate nocase asc, c.ord asc".into(), + SortKind::CardMod => "c.mod asc".into(), + SortKind::CardReps => "c.reps asc".into(), + SortKind::CardDue => "c.type asc, c.due asc".into(), + SortKind::CardEase => format!("c.type = {} asc, c.factor asc", CardType::New as i8).into(), + SortKind::CardLapses => "c.lapses asc".into(), + SortKind::CardInterval => "c.ivl asc".into(), + SortKind::NoteTags => "n.tags asc".into(), + SortKind::CardDeck => "(select pos from sort_order where did = c.did) asc".into(), + SortKind::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(), + SortKind::CardTemplate => concat!( + "coalesce((select pos from sort_order where ntid = n.mid and ord = c.ord),", + // need to fall back on ord 0 for cloze cards + "(select pos from sort_order where ntid = n.mid and ord = 0)) asc" + ) + .into(), + _ => "".into(), + } +} + +fn note_order_from_sortkind(kind: SortKind) -> Cow<'static, str> { + match kind { + SortKind::NoteCards => "(select pos from sort_order where nid = n.id) asc".into(), + SortKind::NoteCreation => "n.id asc".into(), + SortKind::NoteEase => "(select pos from sort_order where nid = n.id) asc".into(), + SortKind::NoteMod => "n.mod asc".into(), + SortKind::NoteField => "n.sfld collate nocase asc".into(), + SortKind::NoteTags => "n.tags asc".into(), + SortKind::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(), + _ => "".into(), + } +} + +fn prepare_sort(col: &mut Collection, kind: SortKind) -> Result<()> { + use SortKind::*; + let sql = match kind { + CardDeck => include_str!("deck_order.sql"), + Notetype => include_str!("notetype_order.sql"), + CardTemplate => include_str!("template_order.sql"), + NoteCards => include_str!("note_cards_order.sql"), + NoteEase => include_str!("note_ease_order.sql"), + _ => return Ok(()), + }; + + col.storage.db.execute_batch(sql)?; + + Ok(()) +} diff --git a/rslib/src/search/note_cards_order.sql b/rslib/src/search/note_cards_order.sql new file mode 100644 index 000000000..edbca1f76 --- /dev/null +++ b/rslib/src/search/note_cards_order.sql @@ -0,0 +1,10 @@ +DROP TABLE IF EXISTS sort_order; +CREATE TEMPORARY TABLE sort_order ( + pos integer PRIMARY KEY, + nid integer NOT NULL UNIQUE +); +INSERT INTO sort_order (nid) +SELECT nid +FROM cards +GROUP BY nid +ORDER BY COUNT(*); \ No newline at end of file diff --git a/rslib/src/search/note_ease_order.sql b/rslib/src/search/note_ease_order.sql new file mode 100644 index 000000000..5ad8eef5e --- /dev/null +++ b/rslib/src/search/note_ease_order.sql @@ -0,0 +1,11 @@ +DROP TABLE IF EXISTS sort_order; +CREATE TEMPORARY TABLE sort_order ( + pos integer PRIMARY KEY, + nid integer NOT NULL UNIQUE +); +INSERT INTO sort_order (nid) +SELECT nid +FROM cards +WHERE type != 0 +GROUP BY nid +ORDER BY AVG(factor); \ No newline at end of file diff --git a/rslib/src/search/notes.rs b/rslib/src/search/notes.rs deleted file mode 100644 index a646a8300..000000000 --- a/rslib/src/search/notes.rs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -use super::{parser::Node, sqlwriter::SqlWriter}; -use crate::collection::Collection; -use crate::err::Result; -use crate::notes::NoteId; -use crate::search::parser::parse; - -impl Collection { - pub fn search_notes(&mut self, search: &str) -> Result> { - let top_node = Node::Group(parse(search)?); - let writer = SqlWriter::new(self); - let (sql, args) = writer.build_notes_query(&top_node)?; - - let mut stmt = self.storage.db.prepare(&sql)?; - let ids: Vec<_> = stmt - .query_map(&args, |row| row.get(0))? - .collect::>()?; - - Ok(ids) - } -} diff --git a/rslib/src/search/sqlwriter.rs b/rslib/src/search/sqlwriter.rs index 0a3aaaba4..e5e150d8c 100644 --- a/rslib/src/search/sqlwriter.rs +++ b/rslib/src/search/sqlwriter.rs @@ -1,7 +1,10 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use super::parser::{Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind}; +use super::{ + parser::{Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind}, + SearchItems, +}; use crate::{ card::{CardQueue, CardType}, collection::Collection, @@ -22,55 +25,48 @@ use std::{borrow::Cow, fmt::Write}; pub(crate) struct SqlWriter<'a> { col: &'a mut Collection, sql: String, + items: SearchItems, args: Vec, normalize_note_text: bool, table: RequiredTable, } impl SqlWriter<'_> { - pub(crate) fn new(col: &mut Collection) -> SqlWriter<'_> { + pub(crate) fn new(col: &mut Collection, items: SearchItems) -> SqlWriter<'_> { let normalize_note_text = col.get_bool(BoolKey::NormalizeNoteText); let sql = String::new(); let args = vec![]; SqlWriter { col, sql, + items, args, normalize_note_text, - table: RequiredTable::CardsOrNotes, + table: items.required_table(), } } - pub(super) fn build_cards_query( + pub(super) fn build_query( mut self, node: &Node, table: RequiredTable, ) -> Result<(String, Vec)> { - self.table = table.combine(node.required_table()); - self.write_cards_table_sql(); + self.table = self.table.combine(table.combine(node.required_table())); + self.write_table_sql(); self.write_node_to_sql(&node)?; Ok((self.sql, self.args)) } - pub(super) fn build_notes_query(mut self, node: &Node) -> Result<(String, Vec)> { - self.table = RequiredTable::Notes.combine(node.required_table()); - self.write_notes_table_sql(); - self.write_node_to_sql(&node)?; - Ok((self.sql, self.args)) - } - - fn write_cards_table_sql(&mut self) { + fn write_table_sql(&mut self) { let sql = match self.table { RequiredTable::Cards => "select c.id from cards c where ", - _ => "select c.id from cards c, notes n where c.nid=n.id and ", - }; - self.sql.push_str(sql); - } - - fn write_notes_table_sql(&mut self) { - let sql = match self.table { RequiredTable::Notes => "select n.id from notes n where ", - _ => "select distinct n.id from cards c, notes n where c.nid=n.id and ", + _ => match self.items { + SearchItems::Cards => "select c.id from cards c, notes n where c.nid=n.id and ", + SearchItems::Notes => { + "select distinct n.id from cards c, notes n where c.nid=n.id and " + } + }, }; self.sql.push_str(sql); } @@ -592,7 +588,7 @@ mod test { // shortcut fn s(req: &mut Collection, search: &str) -> (String, Vec) { let node = Node::Group(parse(search).unwrap()); - let mut writer = SqlWriter::new(req); + let mut writer = SqlWriter::new(req, SearchItems::Cards); writer.table = RequiredTable::Notes.combine(node.required_table()); writer.write_node_to_sql(&node).unwrap(); (writer.sql, writer.args)