diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl index 89755266c..b99e40952 100644 --- a/ftl/core/browsing.ftl +++ b/ftl/core/browsing.ftl @@ -7,8 +7,8 @@ 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-average-interval = Average Interval +browsing-average-ease = Avg. Ease +browsing-average-interval = Avg. Interval browsing-browser-appearance = Browser Appearance browsing-browser-options = Browser Options browsing-buried = Buried @@ -101,7 +101,7 @@ browsing-suspended = Suspended browsing-tag-duplicates = Tag Duplicates browsing-tag-rename-warning-empty = You can't rename a tag that has no notes. browsing-target-field = Target field: -browsing-toggle-cards-notes-mode = Toggle Cards/Notes Mode +browsing-toggle-showing-cards-notes = Toggle Showing Cards/Notes browsing-toggle-mark = Toggle Mark browsing-toggle-suspend = Toggle Suspend browsing-treat-input-as-regular-expression = Treat input as regular expression diff --git a/pylib/.pylintrc b/pylib/.pylintrc index 915320c1e..b734d4c91 100644 --- a/pylib/.pylintrc +++ b/pylib/.pylintrc @@ -4,6 +4,7 @@ persistent = no [TYPECHECK] ignored-classes= + BrowserColumns, BrowserRow, FormatTimespanIn, AnswerCardIn, diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 14dbbafb2..84977650a 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -12,7 +12,6 @@ SearchNode = _pb.SearchNode Progress = _pb.Progress EmptyCardsReport = _pb.EmptyCardsReport GraphPreferences = _pb.GraphPreferences -BuiltinSort = _pb.SortOrder.Builtin Preferences = _pb.Preferences UndoStatus = _pb.UndoStatus OpChanges = _pb.OpChanges @@ -21,6 +20,7 @@ OpChangesWithId = _pb.OpChangesWithId OpChangesAfterUndo = _pb.OpChangesAfterUndo DefaultsForAdding = _pb.DeckAndNotetype BrowserRow = _pb.BrowserRow +BrowserColumns = _pb.BrowserColumns import copy import os @@ -40,7 +40,7 @@ from anki.config import Config, ConfigManager from anki.consts import * from anki.dbproxy import DBProxy from anki.decks import Deck, DeckConfig, DeckConfigId, DeckId, DeckManager -from anki.errors import AbortSchemaModification, DBError +from anki.errors import AbortSchemaModification, DBError, InvalidInput from anki.lang import FormatTimeSpan from anki.media import MediaManager, media_paths_from_col_path from anki.models import ModelManager, Notetype, NotetypeDict, NotetypeId @@ -505,7 +505,7 @@ class Collection: def find_cards( self, query: str, - order: Union[bool, str, BuiltinSort.Kind.V] = False, + order: Union[bool, str, BrowserColumns.Column] = False, reverse: bool = False, ) -> Sequence[CardId]: """Return card ids matching the provided search. @@ -520,13 +520,15 @@ class Collection: 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) + If order is a BrowserColumns.Column that supports sorting, sort using that + column. All available columns are available through col.all_browser_columns() + or browser.table._model.columns and support sorting unless column.sorting + is set to BrowserColumns.SORTING_NONE. - The reverse argument only applies when a BuiltinSort.Kind is provided; + The reverse argument only applies when a BrowserColumns.Column is provided; otherwise the collection config defines whether reverse is set or not. """ - mode = _build_sort_mode(order, reverse) + mode = self._build_sort_mode(order, reverse, False) return cast( Sequence[CardId], self._backend.search_cards(search=query, order=mode) ) @@ -534,7 +536,7 @@ class Collection: def find_notes( self, query: str, - order: Union[bool, str, BuiltinSort.Kind.V] = False, + order: Union[bool, str, BrowserColumns.Column] = False, reverse: bool = False, ) -> Sequence[NoteId]: """Return note ids matching the provided search. @@ -542,11 +544,38 @@ class Collection: To programmatically construct a search string, see .build_search_string(). The order parameter is documented in .find_cards(). """ - mode = _build_sort_mode(order, reverse) + mode = self._build_sort_mode(order, reverse, True) return cast( Sequence[NoteId], self._backend.search_notes(search=query, order=mode) ) + def _build_sort_mode( + self, + order: Union[bool, str, BrowserColumns.Column], + reverse: bool, + finding_notes: bool, + ) -> _pb.SortOrder: + if isinstance(order, str): + return _pb.SortOrder(custom=order) + if isinstance(order, bool): + if order is False: + return _pb.SortOrder(none=_pb.Empty()) + # order=True: set args to sort column and reverse from config + sort_key = "noteSortType" if finding_notes else "sortType" + order = self.get_browser_column(self.get_config(sort_key)) + reverse_key = ( + Config.Bool.BROWSER_NOTE_SORT_BACKWARDS + if finding_notes + else Config.Bool.BROWSER_SORT_BACKWARDS + ) + reverse = self.get_config_bool(reverse_key) + if isinstance(order, BrowserColumns.Column): + if order.sorting != BrowserColumns.SORTING_NONE: + return _pb.SortOrder( + builtin=_pb.SortOrder.Builtin(column=order.key, reverse=reverse) + ) + raise InvalidInput(f"{order} is not a valid sort order.") + def find_and_replace( self, *, @@ -696,6 +725,15 @@ class Collection: # Browser Table ########################################################################## + def all_browser_columns(self) -> Sequence[BrowserColumns.Column]: + return self._backend.all_browser_columns() + + def get_browser_column(self, key: str) -> Optional[BrowserColumns.Column]: + for column in self._backend.all_browser_columns(): + if column.key == key: + return column + return None + def browser_row_for_id( self, id_: int ) -> Tuple[Generator[Tuple[str, bool], None, None], BrowserRow.Color.V, str, int]: @@ -712,24 +750,24 @@ class Collection: columns = self.get_config( "activeCols", ["noteFld", "template", "cardDue", "deck"] ) - self._backend.set_desktop_browser_card_columns(columns) + self._backend.set_active_browser_columns(columns) return columns def set_browser_card_columns(self, columns: List[str]) -> None: self.set_config("activeCols", columns) - self._backend.set_desktop_browser_card_columns(columns) + self._backend.set_active_browser_columns(columns) def load_browser_note_columns(self) -> List[str]: """Return the stored note column names and ensure the backend columns are set and in sync.""" columns = self.get_config( "activeNoteCols", ["noteFld", "note", "noteCards", "noteTags"] ) - self._backend.set_desktop_browser_note_columns(columns) + self._backend.set_active_browser_columns(columns) return columns def set_browser_note_columns(self, columns: List[str]) -> None: self.set_config("activeNoteCols", columns) - self._backend.set_desktop_browser_note_columns(columns) + self._backend.set_active_browser_columns(columns) # Config ########################################################################## @@ -1111,18 +1149,3 @@ class _ReviewsUndo: _UndoInfo = Union[_ReviewsUndo, LegacyCheckpoint, None] - - -def _build_sort_mode( - order: Union[bool, str, BuiltinSort.Kind.V], - reverse: bool, -) -> _pb.SortOrder: - 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/pylib/tests/test_find.py b/pylib/tests/test_find.py index 91d7f1bb0..56ada7e5f 100644 --- a/pylib/tests/test_find.py +++ b/pylib/tests/test_find.py @@ -1,7 +1,7 @@ # coding: utf-8 import pytest -from anki.collection import BuiltinSort, Config +from anki.collection import Config from anki.consts import * from tests.shared import getEmptyCol, isNearCutoff @@ -125,10 +125,12 @@ def test_findCards(): col.flush() assert col.findCards("", order=True)[0] in latestCardIds assert ( - col.find_cards("", order=BuiltinSort.CARD_DUE, reverse=False)[0] == firstCardId + col.find_cards("", order=col.get_browser_column("cardDue"), reverse=False)[0] + == firstCardId ) assert ( - col.find_cards("", order=BuiltinSort.CARD_DUE, reverse=True)[0] != firstCardId + col.find_cards("", order=col.get_browser_column("cardDue"), reverse=True)[0] + != firstCardId ) # model assert len(col.findCards("note:basic")) == 3 diff --git a/qt/.pylintrc b/qt/.pylintrc index b4809e76d..fb0800e47 100644 --- a/qt/.pylintrc +++ b/qt/.pylintrc @@ -6,6 +6,7 @@ ignore = forms,hooks_gen.py [TYPECHECK] ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio ignored-classes= + BrowserColumns, BrowserRow, SearchNode, Config, diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 88c0a09a8..9a0c97c21 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -64,7 +64,6 @@ from aqt.utils import ( save_combo_history, save_combo_index_for_session, saveGeom, - saveHeader, saveSplitter, saveState, shortcut, @@ -228,10 +227,10 @@ class Browser(QMainWindow): def _closeWindow(self) -> None: self._cleanup_preview() self.editor.cleanup() + self.table.cleanup() saveSplitter(self.form.splitter, "editor3") saveGeom(self, "editor") saveState(self, "editor") - saveHeader(self.form.tableView.horizontalHeader(), "editor") self.teardownHooks() self.mw.maybeReset() aqt.dialogs.markClosed("Browser") @@ -383,7 +382,7 @@ class Browser(QMainWindow): self.table.set_view(self.form.tableView) switch = Switch(11, tr.browsing_card_initial(), tr.browsing_note_initial()) switch.setChecked(self.table.is_notes_mode()) - switch.setToolTip(tr.browsing_toggle_cards_notes_mode()) + switch.setToolTip(tr.browsing_toggle_showing_cards_notes()) qconnect(self.form.action_toggle_mode.triggered, switch.toggle) qconnect(switch.toggled, self.on_table_state_changed) self.form.gridLayout.addWidget(switch, 0, 0) diff --git a/qt/aqt/forms/browser.ui b/qt/aqt/forms/browser.ui index 810724b33..9f499fe60 100644 --- a/qt/aqt/forms/browser.ui +++ b/qt/aqt/forms/browser.ui @@ -607,10 +607,10 @@ - browsing_toggle_cards_notes_mode + browsing_toggle_showing_cards_notes - Ctrl+M + Alt+T diff --git a/qt/aqt/table.py b/qt/aqt/table.py index cc94b8f44..ea19fc379 100644 --- a/qt/aqt/table.py +++ b/qt/aqt/table.py @@ -6,7 +6,6 @@ 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, @@ -23,6 +22,7 @@ from typing import ( import aqt import aqt.forms from anki.cards import Card, CardId +from anki.collection import BrowserColumns as Columns from anki.collection import BrowserRow, Collection, Config, OpChanges from anki.consts import * from anki.errors import NotFoundError @@ -35,10 +35,12 @@ from aqt.utils import ( KeyboardModifiersPressed, qtMenuShortcutWorkaround, restoreHeader, + saveHeader, showInfo, tr, ) +Column = Columns.Column ItemId = Union[CardId, NoteId] ItemList = Union[Sequence[CardId], Sequence[NoteId]] @@ -47,7 +49,8 @@ ItemList = Union[Sequence[CardId], Sequence[NoteId]] class SearchContext: search: str browser: aqt.browser.Browser - order: Union[bool, str] = True + order: Union[bool, str, Column] = True + reverse: bool = False # if set, provided ids will be used instead of the regular search ids: Optional[Sequence[ItemId]] = None @@ -73,6 +76,9 @@ class Table: self._setup_view() self._setup_headers() + def cleanup(self) -> None: + self._save_header() + # Public Methods ###################################################################### @@ -148,11 +154,8 @@ class Table: 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() - if self.is_notes_mode(): - self._view.selectRow(0) - else: - if (row := self._model.get_card_row(card_id)) is not None: - self._view.selectRow(row) + if (row := self._model.get_card_row(card_id)) is not None: + self._view.selectRow(row) # Reset @@ -198,6 +201,7 @@ class Table: def toggle_state(self, is_notes_mode: bool, last_search: str) -> None: if is_notes_mode == self.is_notes_mode(): return + self._save_header() self._save_selection() self._state = self._model.toggle_state( SearchContext(search=last_search, browser=self.browser) @@ -205,8 +209,7 @@ class Table: self.col.set_config_bool( Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE, self.is_notes_mode() ) - self._set_sort_indicator() - self._set_column_sizes() + self._restore_header() self._restore_selection(self._toggled_selection) # Move cursor @@ -273,6 +276,12 @@ class Table: # this must be set post-resize or it doesn't work hh.setCascadingSectionResizes(False) + def _save_header(self) -> None: + saveHeader(self._view.horizontalHeader(), self._state.config_key_prefix) + + def _restore_header(self) -> None: + restoreHeader(self._view.horizontalHeader(), self._state.config_key_prefix) + # Setup def _setup_view(self) -> None: @@ -314,14 +323,14 @@ class Table: 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._restore_header() + self._set_column_sizes() self._set_sort_indicator() + qconnect(hh.customContextMenuRequested, self._on_header_context) qconnect(hh.sortIndicatorChanged, self._on_sort_column_changed) qconnect(hh.sectionMoved, self._on_column_moved) @@ -350,13 +359,13 @@ class Table: 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) + for key, column in self._model.columns.items(): + a = m.addAction(self._state.column_label(column)) a.setCheckable(True) - a.setChecked(self._model.active_column_index(column) is not None) + a.setChecked(self._model.active_column_index(key) is not None) qconnect( a.toggled, - lambda checked, column=column: self._on_column_toggled(checked, column), + lambda checked, key=key: self._on_column_toggled(checked, key), ) gui_hooks.browser_header_will_show_context_menu(self.browser, m) m.exec_(gpos) @@ -375,16 +384,18 @@ class Table: if checked: self._scroll_to_column(self._model.len_columns() - 1) - def _on_sort_column_changed(self, index: int, order: int) -> None: + def _on_sort_column_changed(self, section: int, order: int) -> None: order = bool(order) - sort_column = self._model.active_column(index) - if sort_column in ("question", "answer"): + column = self._model.column_at_section(section) + if column.sorting == Columns.SORTING_NONE: 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 + sort_key = self._state.sort_column + else: + sort_key = column.key + if self._state.sort_column != sort_key: + self._state.sort_column = sort_key # default to descending for non-text fields - if sort_column == "noteFld": + if column.sorting == Columns.SORTING_REVERSED: order = not order self._state.sort_backwards = order self.browser.search() @@ -523,7 +534,7 @@ class Table: class ItemState(ABC): - _columns: List[Tuple[str, str]] + config_key_prefix: str _active_columns: List[str] _sort_column: str _sort_backwards: bool @@ -545,14 +556,18 @@ class ItemState(ABC): def card_ids_from_note_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]: return self.col.db.list(f"select id from cards where nid in {ids2str(items)}") + def column_key_at(self, index: int) -> str: + return self._active_columns[index] + + def column_label(self, column: Column) -> str: + return ( + column.notes_mode_label if self.is_notes_mode() else column.cards_mode_label + ) + # Columns and sorting # abstractproperty is deprecated but used due to mypy limitations # (https://github.com/python/mypy/issues/1362) - @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.""" @@ -590,7 +605,9 @@ class ItemState(ABC): # Get ids @abstractmethod - def find_items(self, search: str, order: Union[bool, str]) -> Sequence[ItemId]: + def find_items( + self, search: str, order: Union[bool, str, Column], reverse: bool + ) -> Sequence[ItemId]: """Return the item ids fitting the given search and order.""" @abstractmethod @@ -619,37 +636,13 @@ class ItemState(ABC): class CardState(ItemState): def __init__(self, col: Collection) -> None: super().__init__(col) - self._load_columns() + self.config_key_prefix = "editor" self._active_columns = self.col.load_browser_card_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)) - - @property - def columns(self) -> List[Tuple[str, str]]: - return self._columns - @property def active_columns(self) -> List[str]: return self._active_columns @@ -685,8 +678,10 @@ class CardState(ItemState): def get_note(self, item: ItemId) -> Note: return self.get_card(item).note() - def find_items(self, search: str, order: Union[bool, str]) -> Sequence[ItemId]: - return self.col.find_cards(search, order) + def find_items( + self, search: str, order: Union[bool, str, Column], reverse: bool + ) -> Sequence[ItemId]: + return self.col.find_cards(search, order, reverse) def get_item_from_card_id(self, card: CardId) -> ItemId: return card @@ -707,33 +702,13 @@ class CardState(ItemState): class NoteState(ItemState): def __init__(self, col: Collection) -> None: super().__init__(col) - self._load_columns() + self.config_key_prefix = "editorNotesMode" self._active_columns = self.col.load_browser_note_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.editing_cards()), - ("noteCrt", tr.browsing_created()), - ("noteDue", tr.statistics_due_date()), - ("noteEase", tr.browsing_average_ease()), - ("noteFld", tr.browsing_sort_field()), - ("noteIvl", tr.browsing_average_interval()), - ("noteLapses", tr.scheduling_lapses()), - ("noteMod", tr.search_note_modified()), - ("noteReps", tr.scheduling_reviews()), - ("noteTags", tr.editing_tags()), - ] - self._columns.sort(key=itemgetter(1)) - - @property - def columns(self) -> List[Tuple[str, str]]: - return self._columns - @property def active_columns(self) -> List[str]: return self._active_columns @@ -769,11 +744,13 @@ class NoteState(ItemState): def get_note(self, item: ItemId) -> Note: return self.col.get_note(NoteId(item)) - def find_items(self, search: str, order: Union[bool, str]) -> Sequence[ItemId]: - return self.col.find_notes(search, order) + def find_items( + self, search: str, order: Union[bool, str, Column], reverse: bool + ) -> Sequence[ItemId]: + return self.col.find_notes(search, order, reverse) def get_item_from_card_id(self, card: CardId) -> ItemId: - return self.get_card(card).note().id + return self.col.get_card(card).note().id def get_card_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]: return super().card_ids_from_note_ids(items) @@ -854,13 +831,27 @@ def backend_color_to_aqt_color(color: BrowserRow.Color.V) -> Optional[Tuple[str, class DataModel(QAbstractTableModel): + """Data manager for the browser table. + + _items -- The card or note ids currently hold and corresponding to the + table's rows. + _rows -- The cached data objects to render items to rows. + columns -- The data objects of all available columns, used to define the display + of active columns and list all toggleable columns to the user. + _block_updates -- If True, serve stale content to avoid hitting the DB. + _stale_cutoff -- A threshold to decide whether a cached row has gone stale. + """ + def __init__(self, col: Collection, state: ItemState) -> None: QAbstractTableModel.__init__(self) self.col: Collection = col + self.columns: Dict[str, Column] = dict( + ((c.key, c) for c in self.col.all_browser_columns()) + ) + gui_hooks.browser_did_fetch_columns(self.columns) self._state: ItemState = state self._items: Sequence[ItemId] = [] self._rows: Dict[int, CellRow] = {} - # serve stale content to avoid hitting the DB? self._block_updates = False self._stale_cutoff = 0.0 @@ -1010,9 +1001,18 @@ class DataModel(QAbstractTableModel): def search(self, context: SearchContext) -> None: self.begin_reset() try: + if context.order is True: + try: + context.order = self.columns[self._state.sort_column] + except KeyError: + # invalid sort column in config + context.order = self.columns["noteCrt"] + context.reverse = self._state.sort_backwards gui_hooks.browser_will_search(context) if context.ids is None: - context.ids = self._state.find_items(context.search, context.order) + context.ids = self._state.find_items( + context.search, context.order, context.reverse + ) gui_hooks.browser_did_search(context) self._items = context.ids self._rows = {} @@ -1026,8 +1026,19 @@ class DataModel(QAbstractTableModel): # Columns - def active_column(self, index: int) -> str: - return self._state.active_columns[index] + def column_at(self, index: QModelIndex) -> Column: + return self.column_at_section(index.column()) + + def column_at_section(self, section: int) -> Column: + """Returns the column object corresponding to the active column at index or the default + column object if no data is associated with the active column. + """ + key = self._state.column_key_at(section) + try: + return self.columns[key] + except KeyError: + self.columns[key] = addon_column_fillin(key) + return self.columns[key] def active_column_index(self, column: str) -> Optional[int]: return ( @@ -1058,11 +1069,7 @@ class DataModel(QAbstractTableModel): if not index.isValid(): return QVariant() if role == Qt.FontRole: - if self.active_column(index.column()) not in ( - "question", - "answer", - "noteFld", - ): + if not self.column_at(index).uses_cell_font: return QVariant() qfont = QFont() row = self.get_row(index) @@ -1071,15 +1078,7 @@ class DataModel(QAbstractTableModel): 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", - ): + if self.column_at(index).alignment == Columns.ALIGNMENT_CENTER: align |= Qt.AlignHCenter return align if role in (Qt.DisplayRole, Qt.ToolTipRole): @@ -1089,21 +1088,9 @@ class DataModel(QAbstractTableModel): 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 + if orientation == Qt.Horizontal and role == Qt.DisplayRole: + return self._state.column_label(self.column_at_section(section)) + return None def flags(self, index: QModelIndex) -> Qt.ItemFlags: if self.get_row(index).is_deleted: @@ -1131,3 +1118,17 @@ class StatusDelegate(QItemDelegate): painter.fillRect(option.rect, brush) painter.restore() return QItemDelegate.paint(self, painter, option, index) + + +def addon_column_fillin(key: str) -> Column: + """Return a column with generic fields and a label indicating to the user that this column was + added by an add-on. + """ + return Column( + key=key, + cards_mode_label=tr.browsing_addon(), + notes_mode_label=tr.browsing_addon(), + sorting=Columns.SORTING_NONE, + uses_cell_font=False, + alignment=Columns.ALIGNMENT_CENTER, + ) diff --git a/qt/tools/genhooks_gui.py b/qt/tools/genhooks_gui.py index 5b4f2b4ab..f7fdd61c9 100644 --- a/qt/tools/genhooks_gui.py +++ b/qt/tools/genhooks_gui.py @@ -16,7 +16,7 @@ prefix = """\ from __future__ import annotations -from typing import Any, Callable, List, Sequence, Tuple, Optional, Union +from typing import Any, Callable, Dict, List, Sequence, Tuple, Optional, Union import anki import aqt @@ -422,6 +422,18 @@ hooks = [ represents. """, ), + Hook( + name="browser_did_fetch_columns", + args=["columns: Dict[str, aqt.table.Column]"], + doc="""Allows you to add custom columns to the browser. + + columns is a dictionary of data obejcts. You can add an entry with a custom + column to describe how it should be displayed in the browser or modify + existing entries. + + Every column in the dictionary will be toggleable by the user. + """, + ), # Main window states ################### # these refer to things like deckbrowser, overview and reviewer state, diff --git a/rslib/backend.proto b/rslib/backend.proto index f7ba5c40d..7a55cf2a0 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -250,9 +250,9 @@ service SearchService { rpc JoinSearchNodes(JoinSearchNodesIn) returns (String); rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String); rpc FindAndReplace(FindAndReplaceIn) returns (OpChangesWithCount); + rpc AllBrowserColumns(Empty) returns (BrowserColumns); rpc BrowserRowForId(Int64) returns (BrowserRow); - rpc SetDesktopBrowserCardColumns(StringList) returns (Empty); - rpc SetDesktopBrowserNoteColumns(StringList) returns (Empty); + rpc SetActiveBrowserColumns(StringList) returns (Empty); } service StatsService { @@ -797,35 +797,13 @@ message SearchOut { message SortOrder { message Builtin { - enum Kind { - NOTE_CARDS = 0; - NOTE_CREATION = 1; - NOTE_DUE = 2; - NOTE_EASE = 3; - NOTE_FIELD = 4; - NOTE_INTERVAL = 5; - NOTE_LAPSES = 6; - NOTE_MOD = 7; - NOTE_REPS = 8; - NOTE_TAGS = 9; - NOTETYPE = 10; - CARD_MOD = 11; - CARD_REPS = 12; - CARD_DUE = 13; - CARD_EASE = 14; - CARD_LAPSES = 15; - CARD_INTERVAL = 16; - CARD_DECK = 17; - CARD_TEMPLATE = 18; - } - Kind kind = 1; + string column = 1; bool reverse = 2; } oneof value { - Empty from_config = 1; - Empty none = 2; - string custom = 3; - Builtin builtin = 4; + Empty none = 1; + string custom = 2; + Builtin builtin = 3; } } @@ -1054,6 +1032,27 @@ message FindAndReplaceIn { string field_name = 6; } +message BrowserColumns { + enum Sorting { + SORTING_NONE = 0; + SORTING_NORMAL = 1; + SORTING_REVERSED = 2; + } + enum Alignment { + ALIGNMENT_START = 0; + ALIGNMENT_CENTER = 1; + } + message Column { + string key = 1; + string cards_mode_label = 2; + string notes_mode_label = 3; + Sorting sorting = 4; + bool uses_cell_font = 5; + Alignment alignment = 6; + } + repeated Column columns = 1; +} + message BrowserRow { message Cell { string text = 1; diff --git a/rslib/src/backend/search/browser_table.rs b/rslib/src/backend/search/browser_table.rs index cb405769a..7bcd74690 100644 --- a/rslib/src/backend/search/browser_table.rs +++ b/rslib/src/backend/search/browser_table.rs @@ -1,73 +1,29 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::{backend_proto as pb, browser_table}; +use std::str::FromStr; + +use crate::{backend_proto as pb, browser_table, i18n::I18n}; + +impl browser_table::Column { + pub fn to_pb_column(self, i18n: &I18n) -> pb::browser_columns::Column { + pb::browser_columns::Column { + key: self.to_string(), + cards_mode_label: self.cards_mode_label(i18n), + notes_mode_label: self.notes_mode_label(i18n), + sorting: self.sorting() as i32, + uses_cell_font: self.uses_cell_font(), + alignment: self.alignment() as i32, + } + } +} impl From for Vec { fn from(input: pb::StringList) -> Self { - input.vals.into_iter().map(Into::into).collect() - } -} - -impl From for browser_table::Column { - fn from(text: String) -> Self { - match text.as_str() { - "question" => browser_table::Column::Question, - "answer" => browser_table::Column::Answer, - "deck" => browser_table::Column::CardDeck, - "cardDue" => browser_table::Column::CardDue, - "cardEase" => browser_table::Column::CardEase, - "cardLapses" => browser_table::Column::CardLapses, - "cardIvl" => browser_table::Column::CardInterval, - "cardMod" => browser_table::Column::CardMod, - "cardReps" => browser_table::Column::CardReps, - "template" => browser_table::Column::CardTemplate, - "noteCards" => browser_table::Column::NoteCards, - "noteCrt" => browser_table::Column::NoteCreation, - "noteDue" => browser_table::Column::NoteDue, - "noteEase" => browser_table::Column::NoteEase, - "noteFld" => browser_table::Column::NoteField, - "noteIvl" => browser_table::Column::NoteInterval, - "noteLapses" => browser_table::Column::NoteLapses, - "noteMod" => browser_table::Column::NoteMod, - "noteReps" => browser_table::Column::NoteReps, - "noteTags" => browser_table::Column::NoteTags, - "note" => browser_table::Column::Notetype, - _ => browser_table::Column::Custom, - } - } -} - -impl From for pb::BrowserRow { - fn from(row: browser_table::Row) -> Self { - pb::BrowserRow { - cells: row.cells.into_iter().map(Into::into).collect(), - color: row.color.into(), - font_name: row.font.name, - font_size: row.font.size, - } - } -} - -impl From for pb::browser_row::Cell { - fn from(cell: browser_table::Cell) -> Self { - pb::browser_row::Cell { - text: cell.text, - is_rtl: cell.is_rtl, - } - } -} - -impl From for i32 { - fn from(color: browser_table::Color) -> Self { - match color { - browser_table::Color::Default => pb::browser_row::Color::Default as i32, - browser_table::Color::Marked => pb::browser_row::Color::Marked as i32, - browser_table::Color::Suspended => pb::browser_row::Color::Suspended as i32, - browser_table::Color::FlagRed => pb::browser_row::Color::FlagRed as i32, - browser_table::Color::FlagOrange => pb::browser_row::Color::FlagOrange as i32, - browser_table::Color::FlagGreen => pb::browser_row::Color::FlagGreen as i32, - browser_table::Color::FlagBlue => pb::browser_row::Color::FlagBlue as i32, - } + input + .vals + .iter() + .map(|c| browser_table::Column::from_str(c).unwrap_or_default()) + .collect() } } diff --git a/rslib/src/backend/search/mod.rs b/rslib/src/backend/search/mod.rs index 5aaf25780..49a49d7f5 100644 --- a/rslib/src/backend/search/mod.rs +++ b/rslib/src/backend/search/mod.rs @@ -4,15 +4,13 @@ mod browser_table; mod search_node; -use std::convert::TryInto; +use std::{convert::TryInto, str::FromStr, sync::Arc}; use super::Backend; use crate::{ backend_proto as pb, - backend_proto::{ - sort_order::builtin::Kind as SortKindProto, sort_order::Value as SortOrderProto, - }, - config::SortKind, + backend_proto::sort_order::Value as SortOrderProto, + browser_table::Column, prelude::*, search::{concatenate_searches, replace_search_node, write_nodes, Node, SortMode}, }; @@ -89,56 +87,31 @@ impl SearchService for Backend { }) } + fn all_browser_columns(&self, _input: pb::Empty) -> Result { + self.with_col(|col| Ok(col.all_browser_columns())) + } + + fn set_active_browser_columns(&self, input: pb::StringList) -> Result { + self.with_col(|col| { + col.state.active_browser_columns = Some(Arc::new(input.into())); + Ok(()) + }) + .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)) } - - fn set_desktop_browser_card_columns(&self, input: pb::StringList) -> Result { - self.with_col(|col| col.set_desktop_browser_card_columns(input.into()))?; - Ok(().into()) - } - - fn set_desktop_browser_note_columns(&self, input: pb::StringList) -> Result { - self.with_col(|col| col.set_desktop_browser_note_columns(input.into()))?; - Ok(().into()) - } -} - -impl From for SortKind { - fn from(kind: SortKindProto) -> Self { - match kind { - SortKindProto::NoteCards => SortKind::NoteCards, - SortKindProto::NoteCreation => SortKind::NoteCreation, - SortKindProto::NoteDue => SortKind::NoteDue, - SortKindProto::NoteEase => SortKind::NoteEase, - SortKindProto::NoteInterval => SortKind::NoteInterval, - SortKindProto::NoteLapses => SortKind::NoteLapses, - SortKindProto::NoteMod => SortKind::NoteMod, - SortKindProto::NoteField => SortKind::NoteField, - SortKindProto::NoteReps => SortKind::NoteReps, - SortKindProto::NoteTags => SortKind::NoteTags, - SortKindProto::Notetype => SortKind::Notetype, - SortKindProto::CardMod => SortKind::CardMod, - SortKindProto::CardReps => SortKind::CardReps, - SortKindProto::CardDue => SortKind::CardDue, - SortKindProto::CardEase => SortKind::CardEase, - SortKindProto::CardLapses => SortKind::CardLapses, - SortKindProto::CardInterval => SortKind::CardInterval, - SortKindProto::CardDeck => SortKind::CardDeck, - SortKindProto::CardTemplate => SortKind::CardTemplate, - } - } } impl From> for SortMode { fn from(order: Option) -> Self { use pb::sort_order::Value as V; - match order.unwrap_or(V::FromConfig(pb::Empty {})) { + match order.unwrap_or(V::None(pb::Empty {})) { V::None(_) => SortMode::NoOrder, V::Custom(s) => SortMode::Custom(s), - V::FromConfig(_) => SortMode::FromConfig, V::Builtin(b) => SortMode::Builtin { - kind: b.kind().into(), + column: Column::from_str(&b.column).unwrap_or_default(), reverse: b.reverse, }, } diff --git a/rslib/src/browser_table.rs b/rslib/src/browser_table.rs index d24465158..d0f2dded7 100644 --- a/rslib/src/browser_table.rs +++ b/rslib/src/browser_table.rs @@ -4,11 +4,12 @@ use std::sync::Arc; use itertools::Itertools; -use serde_repr::{Deserialize_repr, Serialize_repr}; +use strum::{Display, EnumIter, EnumString, IntoEnumIterator}; use crate::error::{AnkiError, Result}; use crate::i18n::I18n; use crate::{ + backend_proto as pb, card::{Card, CardId, CardQueue, CardType}, collection::Collection, config::BoolKey, @@ -21,112 +22,47 @@ use crate::{ timestamp::{TimestampMillis, TimestampSecs}, }; -#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Clone, Copy)] -#[repr(u8)] +#[derive(Debug, PartialEq, Clone, Copy, Display, EnumIter, EnumString)] +#[strum(serialize_all = "camelCase")] pub enum Column { + #[strum(serialize = "")] Custom, - Question, Answer, - CardDeck, - CardDue, - CardEase, - CardLapses, - CardInterval, CardMod, - CardReps, - CardTemplate, - NoteCards, + #[strum(serialize = "template")] + Cards, + Deck, + #[strum(serialize = "cardDue")] + Due, + #[strum(serialize = "cardEase")] + Ease, + #[strum(serialize = "cardLapses")] + Lapses, + #[strum(serialize = "cardIvl")] + Interval, + #[strum(serialize = "noteCrt")] NoteCreation, - NoteDue, - NoteEase, - NoteField, - NoteInterval, - NoteLapses, NoteMod, - NoteReps, - NoteTags, + #[strum(serialize = "note")] Notetype, + Question, + #[strum(serialize = "cardReps")] + Reps, + #[strum(serialize = "noteFld")] + SortField, + #[strum(serialize = "noteTags")] + Tags, } -#[derive(Debug, PartialEq)] -pub struct Row { - pub cells: Vec, - pub color: Color, - pub font: Font, -} - -#[derive(Debug, PartialEq)] -pub struct Cell { - pub text: String, - pub is_rtl: bool, -} - -#[derive(Debug, PartialEq)] -pub enum Color { - Default, - Marked, - Suspended, - FlagRed, - FlagOrange, - FlagGreen, - FlagBlue, -} - -#[derive(Debug, PartialEq)] -pub struct Font { - pub name: String, - pub size: u32, -} - -trait RowContext { - fn get_cell_text(&mut self, column: Column) -> 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: Column) -> 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: Column) -> bool { - match column { - Column::NoteField => { - 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: &[Column]) -> Result { - Ok(Row { - cells: columns - .iter() - .map(|&column| self.get_cell(column)) - .collect::>()?, - color: self.get_row_color(), - font: self.get_row_font()?, - }) +impl Default for Column { + fn default() -> Self { + Column::Custom } } -struct CardRowContext { - card: Card, +struct RowContext { + notes_mode: bool, + cards: Vec, note: Note, notetype: Arc, deck: Arc, @@ -143,14 +79,6 @@ struct RenderContext { answer_nodes: Vec, } -struct NoteRowContext { - note: Note, - notetype: Arc, - cards: Vec, - tr: I18n, - timing: SchedTimingToday, -} - fn card_render_required(columns: &[Column]) -> bool { columns .iter() @@ -200,20 +128,88 @@ impl Note { } } -impl Collection { - pub fn browser_row_for_id(&mut self, id: i64) -> Result { - if self.get_bool(BoolKey::BrowserTableShowNotesMode) { - let columns = self - .get_desktop_browser_note_columns() - .ok_or_else(|| AnkiError::invalid_input("Note columns not set."))?; - NoteRowContext::new(self, id)?.browser_row_for_id(&columns) - } else { - let columns = self - .get_desktop_browser_card_columns() - .ok_or_else(|| AnkiError::invalid_input("Card columns not set."))?; - CardRowContext::new(self, id, card_render_required(&columns))? - .browser_row_for_id(&columns) +impl Column { + pub fn cards_mode_label(self, i18n: &I18n) -> String { + match self { + Self::Answer => i18n.browsing_answer(), + Self::CardMod => i18n.search_card_modified(), + Self::Cards => i18n.browsing_card(), + Self::Deck => i18n.decks_deck(), + Self::Due => i18n.statistics_due_date(), + Self::Custom => i18n.browsing_addon(), + Self::Ease => i18n.browsing_ease(), + Self::Interval => i18n.browsing_interval(), + Self::Lapses => i18n.scheduling_lapses(), + Self::NoteCreation => i18n.browsing_created(), + Self::NoteMod => i18n.search_note_modified(), + Self::Notetype => i18n.browsing_note(), + Self::Question => i18n.browsing_question(), + Self::Reps => i18n.scheduling_reviews(), + Self::SortField => i18n.browsing_sort_field(), + Self::Tags => i18n.editing_tags(), } + .into() + } + + pub fn notes_mode_label(self, i18n: &I18n) -> String { + match self { + Self::CardMod => i18n.search_card_modified(), + Self::Cards => i18n.editing_cards(), + Self::Ease => i18n.browsing_average_ease(), + Self::Interval => i18n.browsing_average_interval(), + Self::Reps => i18n.scheduling_reviews(), + _ => return self.cards_mode_label(i18n), + } + .into() + } + + pub fn sorting(self) -> pb::browser_columns::Sorting { + use pb::browser_columns::Sorting; + match self { + Self::Question | Self::Answer | Self::Custom => Sorting::None, + Self::SortField => Sorting::Reversed, + _ => Sorting::Normal, + } + } + + pub fn uses_cell_font(self) -> bool { + matches!(self, Self::Question | Self::Answer | Self::SortField) + } + + pub fn alignment(self) -> pb::browser_columns::Alignment { + use pb::browser_columns::Alignment; + match self { + Self::Question + | Self::Answer + | Self::Cards + | Self::Deck + | Self::SortField + | Self::Notetype + | Self::Tags => Alignment::Start, + _ => Alignment::Center, + } + } +} + +impl Collection { + pub fn all_browser_columns(&self) -> pb::BrowserColumns { + let mut columns: Vec = Column::iter() + .filter(|&c| c != Column::Custom) + .map(|c| c.to_pb_column(&self.tr)) + .collect(); + columns.sort_by(|c1, c2| c1.cards_mode_label.cmp(&c2.cards_mode_label)); + pb::BrowserColumns { columns } + } + + pub fn browser_row_for_id(&mut self, id: i64) -> Result { + let notes_mode = self.get_bool(BoolKey::BrowserTableShowNotesMode); + let columns = Arc::clone( + self.state + .active_browser_columns + .as_ref() + .ok_or_else(|| AnkiError::invalid_input("Active browser columns not set."))?, + ); + RowContext::new(self, id, notes_mode, card_render_required(&columns))?.browser_row(&columns) } fn get_note_maybe_with_fields(&self, id: NoteId, _with_fields: bool) -> Result { @@ -259,20 +255,32 @@ impl RenderContext { } } -impl CardRowContext { - fn new(col: &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)?; +impl RowContext { + fn new( + col: &mut Collection, + id: i64, + notes_mode: bool, + with_card_render: bool, + ) -> Result { + let cards; + let note; + if notes_mode { + note = col.get_note_maybe_with_fields(NoteId(id), with_card_render)?; + cards = col.storage.all_cards_of_note(note.id)?; + } else { + cards = vec![col + .storage + .get_card(CardId(id))? + .ok_or(AnkiError::NotFound)?]; + note = col.get_note_maybe_with_fields(cards[0].note_id, with_card_render)?; + } let notetype = col .get_notetype(note.notetype_id)? .ok_or(AnkiError::NotFound)?; - let deck = col.get_deck(card.deck_id)?.ok_or(AnkiError::NotFound)?; - let original_deck = if card.original_deck_id.0 != 0 { + let deck = col.get_deck(cards[0].deck_id)?.ok_or(AnkiError::NotFound)?; + let original_deck = if cards[0].original_deck_id.0 != 0 { Some( - col.get_deck(card.original_deck_id)? + col.get_deck(cards[0].original_deck_id)? .ok_or(AnkiError::NotFound)?, ) } else { @@ -280,13 +288,14 @@ impl CardRowContext { }; let timing = col.timing_today()?; let render_context = if with_card_render { - Some(RenderContext::new(col, &card, ¬e, ¬etype)?) + Some(RenderContext::new(col, &cards[0], ¬e, ¬etype)?) } else { None }; - Ok(CardRowContext { - card, + Ok(RowContext { + notes_mode, + cards, note, notetype, deck, @@ -297,8 +306,67 @@ impl CardRowContext { }) } + fn browser_row(&self, columns: &[Column]) -> Result { + Ok(pb::BrowserRow { + cells: columns + .iter() + .map(|&column| self.get_cell(column)) + .collect::>()?, + color: self.get_row_color() as i32, + font_name: self.get_row_font_name()?, + font_size: self.get_row_font_size()?, + }) + } + + fn get_cell(&self, column: Column) -> Result { + Ok(pb::browser_row::Cell { + text: self.get_cell_text(column)?, + is_rtl: self.get_is_rtl(column), + }) + } + + fn get_cell_text(&self, column: Column) -> Result { + Ok(match column { + Column::Question => self.question_str(), + Column::Answer => self.answer_str(), + Column::Deck => self.deck_str(), + Column::Due => self.due_str(), + Column::Ease => self.ease_str(), + Column::Interval => self.interval_str(), + Column::Lapses => self.cards.iter().map(|c| c.lapses).sum::().to_string(), + Column::CardMod => self.card_mod_str(), + Column::Reps => self.cards.iter().map(|c| c.reps).sum::().to_string(), + Column::Cards => self.cards_str()?, + Column::NoteCreation => self.note_creation_str(), + Column::SortField => self.note_field_str(), + Column::NoteMod => self.note.mtime.date_string(), + Column::Tags => self.note.tags.join(" "), + Column::Notetype => self.notetype.name.to_owned(), + Column::Custom => "".to_string(), + }) + } + + 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: Column) -> bool { + match column { + Column::SortField => { + let index = self.notetype.config.sort_field_idx as usize; + self.notetype.fields[index].config.rtl + } + _ => false, + } + } + fn template(&self) -> Result<&CardTemplate> { - self.notetype.get_template(self.card.template_idx) + self.notetype.get_template(self.cards[0].template_idx) } fn answer_str(&self) -> String { @@ -326,149 +394,31 @@ impl CardRowContext { .to_string() } - fn card_due_str(&mut self) -> String { - let due = if self.card.is_filtered_deck() { + fn due_str(&self) -> String { + if self.notes_mode { + self.note_due_str() + } else { + self.card_due_str() + } + } + + fn card_due_str(&self) -> String { + let due = if self.cards[0].is_filtered_deck() { self.tr.browsing_filtered() - } else if self.card.is_new_type_or_queue() { - self.tr.statistics_due_for_new_card(self.card.due) - } else if let Some(time) = self.card.due_time(&self.timing) { + } else if self.cards[0].is_new_type_or_queue() { + self.tr.statistics_due_for_new_card(self.cards[0].due) + } else if let Some(time) = self.cards[0].due_time(&self.timing) { time.date_string().into() } else { return "".into(); }; - if self.card.is_undue_queue() { + if self.cards[0].is_undue_queue() { format!("({})", due) } else { due.into() } } - fn card_ease_str(&self) -> String { - match self.card.ctype { - CardType::New => self.tr.browsing_new().into(), - _ => format!("{}%", self.card.ease_factor / 10), - } - } - - fn card_interval_str(&self) -> String { - match self.card.ctype { - CardType::New => self.tr.browsing_new().into(), - CardType::Learn => self.tr.browsing_learning().into(), - _ => time_span((self.card.interval * 86400) as f32, &self.tr, false), - } - } - - fn deck_str(&mut self) -> String { - let deck_name = self.deck.human_name(); - if let Some(original_deck) = &self.original_deck { - format!("{} ({})", &deck_name, &original_deck.human_name()) - } else { - deck_name - } - } - - fn template_str(&self) -> Result { - let name = &self.template()?.name; - Ok(match self.notetype.config.kind() { - NotetypeKind::Normal => name.to_owned(), - NotetypeKind::Cloze => format!("{} {}", name, self.card.template_idx + 1), - }) - } - - fn question_str(&self) -> String { - html_to_text_line(&self.render_context.as_ref().unwrap().question).to_string() - } -} - -impl RowContext for CardRowContext { - fn get_cell_text(&mut self, column: Column) -> Result { - Ok(match column { - Column::Question => self.question_str(), - Column::Answer => self.answer_str(), - Column::CardDeck => self.deck_str(), - Column::CardDue => self.card_due_str(), - Column::CardEase => self.card_ease_str(), - Column::CardInterval => self.card_interval_str(), - Column::CardLapses => self.card.lapses.to_string(), - Column::CardMod => self.card.mtime.date_string(), - Column::CardReps => self.card.reps.to_string(), - Column::CardTemplate => self.template_str()?, - Column::NoteCreation => self.note_creation_str(), - Column::NoteField => self.note_field_str(), - Column::NoteMod => self.note.mtime.date_string(), - Column::NoteTags => self.note.tags.join(" "), - Column::Notetype => self.notetype.name.to_owned(), - _ => "".to_string(), - }) - } - - fn get_row_color(&self) -> Color { - match self.card.flags { - 1 => Color::FlagRed, - 2 => Color::FlagOrange, - 3 => Color::FlagGreen, - 4 => Color::FlagBlue, - _ => { - if self.note.is_marked() { - Color::Marked - } else if self.card.queue == CardQueue::Suspended { - Color::Suspended - } else { - Color::Default - } - } - } - } - - fn get_row_font(&self) -> Result { - Ok(Font { - name: self.template()?.config.browser_font_name.to_owned(), - size: self.template()?.config.browser_font_size, - }) - } - - fn note(&self) -> &Note { - &self.note - } - - fn notetype(&self) -> &Notetype { - &self.notetype - } -} - -impl NoteRowContext { - fn new(col: &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)?; - let timing = col.timing_today()?; - - Ok(NoteRowContext { - note, - notetype, - cards, - tr: col.tr.clone(), - timing, - }) - } - - /// Returns the average ease of the non-new cards or a hint if there aren't any. - fn note_ease_str(&self) -> String { - let eases: Vec = self - .cards - .iter() - .filter(|c| c.ctype != CardType::New) - .map(|c| c.ease_factor) - .collect(); - if eases.is_empty() { - self.tr.browsing_new().into() - } else { - format!("{}%", eases.iter().sum::() / eases.len() as u16 / 10) - } - } - /// Returns the due date of the next due card that is not in a filtered deck, new, suspended or /// buried or the empty string if there is no such card. fn note_due_str(&self) -> String { @@ -481,9 +431,30 @@ impl NoteRowContext { .unwrap_or_else(|| "".into()) } - /// Returns the average interval of the review and relearn cards or the empty string if there - /// aren't any. - fn note_interval_str(&self) -> String { + /// Returns the average ease of the non-new cards or a hint if there aren't any. + fn ease_str(&self) -> String { + let eases: Vec = self + .cards + .iter() + .filter(|c| c.ctype != CardType::New) + .map(|c| c.ease_factor) + .collect(); + if eases.is_empty() { + self.tr.browsing_new().into() + } else { + format!("{}%", eases.iter().sum::() / eases.len() as u16 / 10) + } + } + + /// Returns the average interval of the review and relearn cards if there are any. + fn interval_str(&self) -> String { + if !self.notes_mode { + match self.cards[0].ctype { + CardType::New => return self.tr.browsing_new().into(), + CardType::Learn => return self.tr.browsing_learning().into(), + CardType::Review | CardType::Relearn => (), + } + } let intervals: Vec = self .cards .iter() @@ -500,46 +471,79 @@ impl NoteRowContext { ) } } -} -impl RowContext for NoteRowContext { - fn get_cell_text(&mut self, column: Column) -> Result { - Ok(match column { - Column::NoteCards => self.cards.len().to_string(), - Column::NoteCreation => self.note_creation_str(), - Column::NoteDue => self.note_due_str(), - Column::NoteEase => self.note_ease_str(), - Column::NoteField => self.note_field_str(), - Column::NoteInterval => self.note_interval_str(), - Column::NoteLapses => self.cards.iter().map(|c| c.lapses).sum::().to_string(), - Column::NoteMod => self.note.mtime.date_string(), - Column::NoteReps => self.cards.iter().map(|c| c.reps).sum::().to_string(), - Column::NoteTags => self.note.tags.join(" "), - Column::Notetype => self.notetype.name.to_owned(), - _ => "".to_string(), - }) + fn card_mod_str(&self) -> String { + self.cards + .iter() + .map(|c| c.mtime) + .max() + .unwrap() + .date_string() } - fn get_row_color(&self) -> Color { - if self.note.is_marked() { - Color::Marked + fn deck_str(&self) -> String { + if self.notes_mode { + let decks = self.cards.iter().map(|c| c.deck_id).unique().count(); + if decks > 1 { + return format!("({})", decks); + } + } + let deck_name = self.deck.human_name(); + if let Some(original_deck) = &self.original_deck { + format!("{} ({})", &deck_name, &original_deck.human_name()) } else { - Color::Default + deck_name } } - fn get_row_font(&self) -> Result { - Ok(Font { - name: "".to_owned(), - size: 0, + fn cards_str(&self) -> Result { + Ok(if self.notes_mode { + self.cards.len().to_string() + } else { + let name = &self.template()?.name; + match self.notetype.config.kind() { + NotetypeKind::Normal => name.to_owned(), + NotetypeKind::Cloze => format!("{} {}", name, self.cards[0].template_idx + 1), + } }) } - fn note(&self) -> &Note { - &self.note + fn question_str(&self) -> String { + html_to_text_line(&self.render_context.as_ref().unwrap().question).to_string() } - fn notetype(&self) -> &Notetype { - &self.notetype + fn get_row_font_name(&self) -> Result { + Ok(self.template()?.config.browser_font_name.to_owned()) + } + + fn get_row_font_size(&self) -> Result { + Ok(self.template()?.config.browser_font_size) + } + + fn get_row_color(&self) -> pb::browser_row::Color { + use pb::browser_row::Color; + if self.notes_mode { + if self.note.is_marked() { + Color::Marked + } else { + Color::Default + } + } else { + match self.cards[0].flags { + 1 => Color::FlagRed, + 2 => Color::FlagOrange, + 3 => Color::FlagGreen, + 4 => Color::FlagBlue, + _ => { + if self.note.is_marked() { + Color::Marked + } else if self.cards[0].queue == CardQueue::Suspended { + Color::Suspended + } else { + Color::Default + } + } + } + } } } diff --git a/rslib/src/collection.rs b/rslib/src/collection.rs index 6e8348c9d..a2bc09ce7 100644 --- a/rslib/src/collection.rs +++ b/rslib/src/collection.rs @@ -3,6 +3,7 @@ use crate::types::Usn; use crate::{ + browser_table, decks::{Deck, DeckId}, notetype::{Notetype, NotetypeId}, prelude::*, @@ -66,6 +67,7 @@ pub struct CollectionState { pub(crate) deck_cache: HashMap>, pub(crate) scheduler_info: Option, pub(crate) card_queues: Option, + pub(crate) active_browser_columns: Option>>, /// True if legacy Python code has executed SQL that has modified the /// database, requiring modification time to be bumped. pub(crate) modified_by_dbproxy: bool, diff --git a/rslib/src/config/mod.rs b/rslib/src/config/mod.rs index a18b7f733..200d18302 100644 --- a/rslib/src/config/mod.rs +++ b/rslib/src/config/mod.rs @@ -9,10 +9,8 @@ mod string; pub(crate) mod undo; pub use self::{bool::BoolKey, string::StringKey}; -use crate::browser_table; use crate::prelude::*; use serde::{de::DeserializeOwned, Serialize}; -use serde_derive::Deserialize; use serde_repr::{Deserialize_repr, Serialize_repr}; use slog::warn; use strum::IntoStaticStr; @@ -47,10 +45,6 @@ pub(crate) enum ConfigKey { #[strum(to_string = "timeLim")] AnswerTimeLimitSecs, - #[strum(to_string = "sortType")] - BrowserSortKind, - #[strum(to_string = "noteSortType")] - BrowserNoteSortKind, #[strum(to_string = "curDeck")] CurrentDeckId, #[strum(to_string = "curModel")] @@ -65,9 +59,6 @@ pub(crate) enum ConfigKey { NextNewCardPosition, #[strum(to_string = "schedVer")] SchedulerVersion, - - DesktopBrowserCardColumns, - DesktopBrowserNoteColumns, } #[derive(PartialEq, Serialize_repr, Deserialize_repr, Clone, Copy, Debug)] @@ -132,38 +123,6 @@ impl Collection { Ok(()) } - pub(crate) fn get_browser_sort_kind(&self) -> SortKind { - 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) -> Option> { - self.get_config_optional(ConfigKey::DesktopBrowserCardColumns) - } - - pub(crate) fn set_desktop_browser_card_columns( - &mut self, - columns: Vec, - ) -> Result<()> { - self.set_config(ConfigKey::DesktopBrowserCardColumns, &columns) - .map(|_| ()) - } - - pub(crate) fn get_desktop_browser_note_columns(&self) -> Option> { - self.get_config_optional(ConfigKey::DesktopBrowserNoteColumns) - } - - pub(crate) fn set_desktop_browser_note_columns( - &mut self, - columns: Vec, - ) -> Result<()> { - self.set_config(ConfigKey::DesktopBrowserNoteColumns, &columns) - .map(|_| ()) - } - pub(crate) fn get_creation_utc_offset(&self) -> Option { self.get_config_optional(ConfigKey::CreationOffset) } @@ -280,43 +239,6 @@ impl Collection { } } -#[derive(Deserialize, PartialEq, Debug, Clone, Copy)] -#[serde(rename_all = "camelCase")] -pub enum SortKind { - NoteCards, - #[serde(rename = "noteCrt")] - NoteCreation, - NoteDue, - NoteEase, - #[serde(rename = "noteIvl")] - NoteInterval, - NoteLapses, - NoteMod, - #[serde(rename = "noteFld")] - NoteField, - NoteReps, - #[serde(rename = "note")] - Notetype, - NoteTags, - CardMod, - CardReps, - CardDue, - CardEase, - CardLapses, - #[serde(rename = "cardIvl")] - CardInterval, - #[serde(rename = "deck")] - CardDeck, - #[serde(rename = "template")] - CardTemplate, -} - -impl Default for SortKind { - fn default() -> Self { - Self::NoteCreation - } -} - // 2021 scheduler moves this into deck config pub(crate) enum NewReviewMix { Mix = 0, @@ -341,7 +263,6 @@ pub(crate) enum Weekday { #[cfg(test)] mod test { - use super::SortKind; use crate::collection::open_test_collection; use crate::decks::DeckId; @@ -349,7 +270,6 @@ mod test { fn defaults() { let col = open_test_collection(); assert_eq!(col.get_current_deck_id(), DeckId(1)); - assert_eq!(col.get_browser_sort_kind(), SortKind::NoteField); } #[test] diff --git a/rslib/src/search/card_mod_order.sql b/rslib/src/search/card_mod_order.sql new file mode 100644 index 000000000..efbaf70e3 --- /dev/null +++ b/rslib/src/search/card_mod_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 MAX(mod); \ No newline at end of file diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index 8dc6eb273..367ad6da1 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -14,19 +14,13 @@ use rusqlite::types::FromSql; use std::borrow::Cow; use crate::{ - card::CardId, - card::CardType, - collection::Collection, - config::{BoolKey, SortKind}, - error::Result, - notes::NoteId, - prelude::AnkiError, - search::parser::parse, + browser_table::Column, card::CardId, card::CardType, collection::Collection, error::Result, + notes::NoteId, prelude::AnkiError, search::parser::parse, }; use sqlwriter::{RequiredTable, SqlWriter}; #[derive(Debug, PartialEq, Clone, Copy)] -pub enum SearchItems { +pub enum ReturnItemType { Cards, Notes, } @@ -34,32 +28,31 @@ pub enum SearchItems { #[derive(Debug, PartialEq, Clone)] pub enum SortMode { NoOrder, - FromConfig, - Builtin { kind: SortKind, reverse: bool }, + Builtin { column: Column, reverse: bool }, Custom(String), } -pub trait AsSearchItems { - fn as_search_items() -> SearchItems; +pub trait AsReturnItemType { + fn as_return_item_type() -> ReturnItemType; } -impl AsSearchItems for CardId { - fn as_search_items() -> SearchItems { - SearchItems::Cards +impl AsReturnItemType for CardId { + fn as_return_item_type() -> ReturnItemType { + ReturnItemType::Cards } } -impl AsSearchItems for NoteId { - fn as_search_items() -> SearchItems { - SearchItems::Notes +impl AsReturnItemType for NoteId { + fn as_return_item_type() -> ReturnItemType { + ReturnItemType::Notes } } -impl SearchItems { +impl ReturnItemType { fn required_table(&self) -> RequiredTable { match self { - SearchItems::Cards => RequiredTable::Cards, - SearchItems::Notes => RequiredTable::Notes, + ReturnItemType::Cards => RequiredTable::Cards, + ReturnItemType::Notes => RequiredTable::Notes, } } } @@ -68,8 +61,7 @@ impl SortMode { fn required_table(&self) -> RequiredTable { match self { SortMode::NoOrder => RequiredTable::CardsOrNotes, - SortMode::FromConfig => unreachable!(), - SortMode::Builtin { kind, .. } => kind.required_table(), + SortMode::Builtin { column, .. } => column.required_table(), SortMode::Custom(ref text) => { if text.contains("n.") { if text.contains("c.") { @@ -85,44 +77,31 @@ impl SortMode { } } -impl SortKind { +impl Column { fn required_table(self) -> RequiredTable { match self { - SortKind::NoteCards - | SortKind::NoteCreation - | SortKind::NoteDue - | SortKind::NoteEase - | SortKind::NoteField - | SortKind::NoteInterval - | SortKind::NoteLapses - | SortKind::NoteMod - | SortKind::NoteReps - | SortKind::NoteTags - | SortKind::Notetype => RequiredTable::Notes, - SortKind::CardTemplate => RequiredTable::CardsAndNotes, - SortKind::CardMod - | SortKind::CardReps - | SortKind::CardDue - | SortKind::CardEase - | SortKind::CardLapses - | SortKind::CardInterval - | SortKind::CardDeck => RequiredTable::Cards, + Column::Cards + | Column::NoteCreation + | Column::NoteMod + | Column::Notetype + | Column::SortField + | Column::Tags => RequiredTable::Notes, + _ => RequiredTable::CardsOrNotes, } } } impl Collection { - pub fn search(&mut self, search: &str, mut mode: SortMode) -> Result> + pub fn search(&mut self, search: &str, mode: SortMode) -> Result> where - T: FromSql + AsSearchItems, + T: FromSql + AsReturnItemType, { - let items = T::as_search_items(); + let item_type = T::as_return_item_type(); let top_node = Node::Group(parse(search)?); - self.resolve_config_sort(items, &mut mode); - let writer = SqlWriter::new(self, items); + let writer = SqlWriter::new(self, item_type); let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?; - self.add_order(&mut sql, items, mode)?; + self.add_order(&mut sql, item_type, mode)?; let mut stmt = self.storage.db.prepare(&sql)?; let ids: Vec<_> = stmt @@ -140,14 +119,18 @@ impl Collection { self.search(search, SortMode::NoOrder) } - fn add_order(&mut self, sql: &mut String, items: SearchItems, mode: SortMode) -> Result<()> { + fn add_order( + &mut self, + sql: &mut String, + item_type: ReturnItemType, + mode: SortMode, + ) -> Result<()> { match mode { SortMode::NoOrder => (), - SortMode::FromConfig => unreachable!(), - SortMode::Builtin { kind, reverse } => { - prepare_sort(self, kind)?; + SortMode::Builtin { column, reverse } => { + prepare_sort(self, column, item_type)?; sql.push_str(" order by "); - write_order(sql, items, kind, reverse)?; + write_order(sql, item_type, column, reverse)?; } SortMode::Custom(order_clause) => { sql.push_str(" order by "); @@ -166,11 +149,11 @@ impl Collection { mode: SortMode, ) -> Result { let top_node = Node::Group(parse(search)?); - let writer = SqlWriter::new(self, SearchItems::Cards); + let writer = SqlWriter::new(self, ReturnItemType::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)?; + self.add_order(&mut sql, ReturnItemType::Cards, mode)?; if want_order { self.storage @@ -186,34 +169,23 @@ impl Collection { .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), +fn write_order( + sql: &mut String, + item_type: ReturnItemType, + column: Column, + reverse: bool, +) -> Result<()> { + let order = match item_type { + ReturnItemType::Cards => card_order_from_sort_column(column), + ReturnItemType::Notes => note_order_from_sort_column(column), }; if order.is_empty() { return Err(AnkiError::invalid_input(format!( "Can't sort {:?} by {:?}.", - items, kind + item_type, column ))); } if reverse { @@ -229,60 +201,69 @@ fn write_order(sql: &mut String, items: SearchItems, kind: SortKind, reverse: bo 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!( +fn card_order_from_sort_column(column: Column) -> Cow<'static, str> { + match column { + Column::CardMod => "c.mod asc".into(), + Column::Cards => 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(), + Column::Deck => "(select pos from sort_order where did = c.did) asc".into(), + Column::Due => "c.type asc, c.due asc".into(), + Column::Ease => format!("c.type = {} asc, c.factor asc", CardType::New as i8).into(), + Column::Interval => "c.ivl asc".into(), + Column::Lapses => "c.lapses asc".into(), + Column::NoteCreation => "n.id asc, c.ord asc".into(), + Column::NoteMod => "n.mod asc, c.ord asc".into(), + Column::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(), + Column::Reps => "c.reps asc".into(), + Column::SortField => "n.sfld collate nocase asc, c.ord asc".into(), + Column::Tags => "n.tags asc".into(), + Column::Answer | Column::Custom | Column::Question => "".into(), } } -fn note_order_from_sortkind(kind: SortKind) -> Cow<'static, str> { - match kind { - SortKind::NoteCards - | SortKind::NoteDue - | SortKind::NoteEase - | SortKind::NoteInterval - | SortKind::NoteLapses - | SortKind::NoteReps => "(select pos from sort_order where nid = n.id) asc".into(), - SortKind::NoteCreation => "n.id asc".into(), - SortKind::NoteField => "n.sfld collate nocase asc".into(), - SortKind::NoteMod => "n.mod asc".into(), - SortKind::NoteTags => "n.tags asc".into(), - SortKind::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(), - _ => "".into(), +fn note_order_from_sort_column(column: Column) -> Cow<'static, str> { + match column { + Column::CardMod + | Column::Cards + | Column::Deck + | Column::Due + | Column::Ease + | Column::Interval + | Column::Lapses + | Column::Reps => "(select pos from sort_order where nid = n.id) asc".into(), + Column::NoteCreation => "n.id asc".into(), + Column::NoteMod => "n.mod asc".into(), + Column::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(), + Column::SortField => "n.sfld collate nocase asc".into(), + Column::Tags => "n.tags asc".into(), + Column::Answer | Column::Custom | Column::Question => "".into(), } } -fn prepare_sort(col: &mut Collection, kind: SortKind) -> Result<()> { - use SortKind::*; - let sql = match kind { - CardDeck => include_str!("deck_order.sql"), - CardTemplate => include_str!("template_order.sql"), - NoteCards => include_str!("note_cards_order.sql"), - NoteDue => include_str!("note_due_order.sql"), - NoteEase => include_str!("note_ease_order.sql"), - NoteInterval => include_str!("note_interval_order.sql"), - NoteLapses => include_str!("note_lapses_order.sql"), - NoteReps => include_str!("note_reps_order.sql"), - Notetype => include_str!("notetype_order.sql"), - _ => return Ok(()), +fn prepare_sort(col: &mut Collection, column: Column, item_type: ReturnItemType) -> Result<()> { + let sql = match item_type { + ReturnItemType::Cards => match column { + Column::Cards => include_str!("template_order.sql"), + Column::Deck => include_str!("deck_order.sql"), + Column::Notetype => include_str!("notetype_order.sql"), + _ => return Ok(()), + }, + ReturnItemType::Notes => match column { + Column::Cards => include_str!("note_cards_order.sql"), + Column::CardMod => include_str!("card_mod_order.sql"), + Column::Deck => include_str!("note_decks_order.sql"), + Column::Due => include_str!("note_due_order.sql"), + Column::Ease => include_str!("note_ease_order.sql"), + Column::Interval => include_str!("note_interval_order.sql"), + Column::Lapses => include_str!("note_lapses_order.sql"), + Column::Reps => include_str!("note_reps_order.sql"), + Column::Notetype => include_str!("notetype_order.sql"), + _ => return Ok(()), + }, }; col.storage.db.execute_batch(sql)?; diff --git a/rslib/src/search/note_decks_order.sql b/rslib/src/search/note_decks_order.sql new file mode 100644 index 000000000..51e909124 --- /dev/null +++ b/rslib/src/search/note_decks_order.sql @@ -0,0 +1,18 @@ +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 + JOIN ( + SELECT id, + row_number() OVER( + ORDER BY name + ) AS pos + FROM decks + ) decks ON cards.did = decks.id +GROUP BY nid +ORDER BY COUNT(DISTINCT did), + decks.pos; \ No newline at end of file diff --git a/rslib/src/search/sqlwriter.rs b/rslib/src/search/sqlwriter.rs index 3de6cbc41..652adc0c7 100644 --- a/rslib/src/search/sqlwriter.rs +++ b/rslib/src/search/sqlwriter.rs @@ -3,7 +3,7 @@ use super::{ parser::{Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind}, - SearchItems, + ReturnItemType, }; use crate::{ card::{CardQueue, CardType}, @@ -25,24 +25,24 @@ use std::{borrow::Cow, fmt::Write}; pub(crate) struct SqlWriter<'a> { col: &'a mut Collection, sql: String, - items: SearchItems, + item_type: ReturnItemType, args: Vec, normalize_note_text: bool, table: RequiredTable, } impl SqlWriter<'_> { - pub(crate) fn new(col: &mut Collection, items: SearchItems) -> SqlWriter<'_> { + pub(crate) fn new(col: &mut Collection, item_type: ReturnItemType) -> SqlWriter<'_> { let normalize_note_text = col.get_bool(BoolKey::NormalizeNoteText); let sql = String::new(); let args = vec![]; SqlWriter { col, sql, - items, + item_type, args, normalize_note_text, - table: items.required_table(), + table: item_type.required_table(), } } @@ -61,9 +61,9 @@ impl SqlWriter<'_> { let sql = match self.table { RequiredTable::Cards => "select c.id from cards c where ", RequiredTable::Notes => "select n.id from notes n where ", - _ => match self.items { - SearchItems::Cards => "select c.id from cards c, notes n where c.nid=n.id and ", - SearchItems::Notes => { + _ => match self.item_type { + ReturnItemType::Cards => "select c.id from cards c, notes n where c.nid=n.id and ", + ReturnItemType::Notes => { "select distinct n.id from cards c, notes n where c.nid=n.id and " } }, @@ -588,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, SearchItems::Cards); + let mut writer = SqlWriter::new(req, ReturnItemType::Cards); writer.table = RequiredTable::Notes.combine(node.required_table()); writer.write_node_to_sql(&node).unwrap(); (writer.sql, writer.args)