squash merge browser refactor

Closes #1100
This commit is contained in:
RumovZ 2021-03-29 16:12:26 +10:00 committed by Damien Elmes
parent 0a5222c400
commit 0d8b1c9d0b
20 changed files with 1897 additions and 1135 deletions

View File

@ -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 ->

View File

@ -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))

View File

@ -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:

View File

@ -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)
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.browsing_window_title(total=cur, selected=selected)
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:
@ -827,135 +411,33 @@ QTableView {{ gridline-color: {grid} }}
"""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()

View File

@ -83,7 +83,7 @@
<number>0</number>
</property>
<property name="bottomMargin">
<number>12</number>
<number>6</number>
</property>
<property name="horizontalSpacing">
<number>12</number>
@ -109,6 +109,30 @@
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="view_state" stretch="0,1">
<property name="bottomMargin">
<number>5</number>
</property>
<item>
<widget class="QRadioButton" name="radio_cards">
<property name="text">
<string>qt_accel_cards</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QRadioButton" name="radio_notes">
<property name="text">
<string>qt_accel_notes</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QTableView" name="tableView">
<property name="sizePolicy">
@ -144,12 +168,12 @@
<attribute name="horizontalHeaderCascadingSectionResizes">
<bool>false</bool>
</attribute>
<attribute name="horizontalHeaderMinimumSectionSize">
<number>20</number>
</attribute>
<attribute name="horizontalHeaderHighlightSections">
<bool>false</bool>
</attribute>
<attribute name="horizontalHeaderMinimumSectionSize">
<number>20</number>
</attribute>
<attribute name="horizontalHeaderShowSortIndicator" stdset="0">
<bool>true</bool>
</attribute>
@ -209,7 +233,7 @@
<x>0</x>
<y>0</y>
<width>750</width>
<height>24</height>
<height>21</height>
</rect>
</property>
<widget class="QMenu" name="menuEdit">

View File

@ -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()

1136
qt/aqt/table.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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;
}

View File

@ -15,7 +15,9 @@ use serde_json::Value;
impl From<BoolKeyProto> 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,

View File

@ -24,21 +24,22 @@ impl SearchService for Backend {
Ok(write_nodes(&node.into_node_list()).into())
}
fn search_cards(&self, input: pb::SearchCardsIn) -> Result<pb::SearchCardsOut> {
fn search_cards(&self, input: pb::SearchIn) -> Result<pb::SearchOut> {
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::<CardId>(&input.search, order)?;
Ok(pb::SearchOut {
ids: cids.into_iter().map(|v| v.0).collect(),
})
})
}
fn search_notes(&self, input: pb::SearchNotesIn) -> Result<pb::SearchNotesOut> {
fn search_notes(&self, input: pb::SearchIn) -> Result<pb::SearchOut> {
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::<NoteId>(&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<pb::BrowserRow> {
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<pb::BrowserRow> {
self.with_col(|col| col.browser_row_for_id(input.val).map(Into::into))
}
}
impl From<SortKindProto> 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,

View File

@ -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<String>;
fn get_row_color(&self) -> Color;
fn get_row_font(&self) -> Result<Font>;
fn note(&self) -> &Note;
fn notetype(&self) -> &Notetype;
fn get_cell(&mut self, column: &str) -> Result<Cell> {
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<Row> {
Ok(Row {
cells: columns
.iter()
.map(|column| self.get_cell(column))
.collect::<Result<_>>()?,
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<RenderedNode>,
}
struct NoteRowContext<'a> {
note: Note,
notetype: Arc<Notetype>,
cards: Vec<Card>,
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<Row> {
pub fn browser_row_for_id(&mut self, id: i64) -> Result<Row> {
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();
let mut context = RowContext::new(self, id, card_render_required(&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::<Result<_>>()?,
color: context.get_row_color(),
font: context.get_row_font()?,
})
fn get_note_maybe_with_fields(&self, id: NoteId, _with_fields: bool) -> Result<Note> {
// 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<Self> {
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)?
}
impl<'a> CardRowContext<'a> {
fn new(col: &'a mut Collection, id: i64, with_card_render: bool) -> Result<Self> {
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<Cell> {
Ok(Cell {
text: self.get_cell_text(column)?,
is_rtl: self.get_is_rtl(column),
})
}
fn get_cell_text(&mut self, column: &str) -> Result<String> {
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<String> {
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<String> {
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<Self> {
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::<Vec<&Card>>();
if cards.is_empty() {
self.tr.browsing_new().into()
} else {
let ease = cards.iter().map(|c| c.ease_factor).sum::<u16>() / cards.len() as u16;
format!("{}%", ease / 10)
}
}
}
impl RowContext for NoteRowContext<'_> {
fn get_cell_text(&mut self, column: &str) -> Result<String> {
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<Font> {
Ok(Font {
name: "".to_owned(),
size: 0,
})
}
fn note(&self) -> &Note {
&self.note
}
fn notetype(&self) -> &Notetype {
&self.notetype
}
}

View File

@ -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),

View File

@ -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<String> {
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<String> {
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<i32> {
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,

View File

@ -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<Vec<CardId>> {
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::<std::result::Result<_, _>>()?;
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<usize> {
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(())
}

View File

@ -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<T>(&mut self, search: &str, mut mode: SortMode) -> Result<Vec<T>>
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::<std::result::Result<_, _>>()?;
Ok(ids)
}
pub fn search_cards(&mut self, search: &str, mode: SortMode) -> Result<Vec<CardId>> {
self.search(search, mode)
}
pub fn search_notes(&mut self, search: &str) -> Result<Vec<NoteId>> {
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<usize> {
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(())
}

View File

@ -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(*);

View File

@ -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);

View File

@ -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<Vec<NoteId>> {
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::<std::result::Result<_, _>>()?;
Ok(ids)
}
}

View File

@ -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<String>,
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<String>)> {
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<String>)> {
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<String>) {
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)