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-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-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-any-flag = Any Flag
browsing-average-ease = Average Ease
browsing-browser-appearance = Browser Appearance browsing-browser-appearance = Browser Appearance
browsing-browser-options = Browser Options browsing-browser-options = Browser Options
browsing-buried = Buried browsing-buried = Buried
@ -100,6 +101,7 @@ browsing-toggle-suspend = Toggle Suspend
browsing-treat-input-as-regular-expression = Treat input as regular expression browsing-treat-input-as-regular-expression = Treat input as regular expression
browsing-update-saved-search = Update with Current Search browsing-update-saved-search = Update with Current Search
browsing-whole-collection = Whole Collection 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-you-must-have-at-least-one = You must have at least one column.
browsing-group = browsing-group =
{ $count -> { $count ->

View File

@ -495,55 +495,26 @@ class Collection:
query: str, query: str,
order: Union[bool, str, BuiltinSort.Kind.V] = False, order: Union[bool, str, BuiltinSort.Kind.V] = False,
reverse: bool = False, reverse: bool = False,
) -> Sequence[CardId]: ) -> List[CardId]:
"""Return card ids matching the provided search. """Return card ids matching the provided search.
To programmatically construct a search string, see .build_search_string(). To programmatically construct a search string, see .build_search_string().
To define a sort order, see _build_sort_mode().
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): mode = _build_sort_mode(order, reverse)
mode = _pb.SortOrder(custom=order) return list(map(CardId, self._backend.search_cards(search=query, order=mode)))
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)
]
def find_notes(self, *terms: Union[str, SearchNode]) -> Sequence[NoteId]: def find_notes(
"""Return note ids matching the provided search or searches. self,
query: str,
If more than one search is provided, they will be ANDed together. order: Union[bool, str, BuiltinSort.Kind.V] = False,
reverse: bool = False,
Eg: col.find_notes("test", "another") will search for "test AND another" ) -> List[NoteId]:
and return matching note ids. """Return note ids matching the provided search.
To programmatically construct a search string, see .build_search_string().
Eg: col.find_notes(SearchNode(deck="test"), "foo") will return notes To define a sort order, see _build_sort_mode().
that have a card in deck called "test", and have the text "foo".
""" """
return [ mode = _build_sort_mode(order, reverse)
NoteId(did) return list(map(NoteId, self._backend.search_notes(search=query, order=mode)))
for did in self._backend.search_notes(self.build_search_string(*terms))
]
def find_and_replace( def find_and_replace(
self, self,
@ -570,7 +541,9 @@ class Collection:
# returns array of ("dupestr", [nids]) # returns array of ("dupestr", [nids])
def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]: 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 # go through notes
vals: Dict[str, List[int]] = {} vals: Dict[str, List[int]] = {}
dupes = [] dupes = []
@ -692,10 +665,10 @@ class Collection:
# Browser rows # Browser rows
########################################################################## ##########################################################################
def browser_row_for_card( def browser_row_for_id(
self, cid: int self, id_: int
) -> Tuple[Generator[Tuple[str, bool], None, None], BrowserRow.Color.V, str, 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 ( return (
((cell.text, cell.is_rtl) for cell in row.cells), ((cell.text, cell.is_rtl) for cell in row.cells),
row.color, row.color,
@ -1089,3 +1062,34 @@ class _ReviewsUndo:
_UndoInfo = Union[_ReviewsUndo, Checkpoint, None] _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) QDialog.__init__(self, None, Qt.Window)
mw.garbage_collect_on_dialog_finish(self) mw.garbage_collect_on_dialog_finish(self)
self.mw = mw self.mw = mw
self.col = mw.col
self.form = aqt.forms.addcards.Ui_Dialog() self.form = aqt.forms.addcards.Ui_Dialog()
self.form.setupUi(self) self.form.setupUi(self)
self.setWindowTitle(tr.actions_add()) self.setWindowTitle(tr.actions_add())
@ -59,7 +60,7 @@ class AddCards(QDialog):
self.editor = aqt.editor.Editor(self.mw, self.form.fieldsArea, self, True) self.editor = aqt.editor.Editor(self.mw, self.form.fieldsArea, self, True)
def setup_choosers(self) -> None: 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 current_review_card=self.mw.reviewer.card
) )
self.notetype_chooser = NotetypeChooser( self.notetype_chooser = NotetypeChooser(
@ -112,7 +113,7 @@ class AddCards(QDialog):
def on_notetype_change(self, notetype_id: NotetypeId) -> None: def on_notetype_change(self, notetype_id: NotetypeId) -> None:
# need to adjust current deck? # 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 self.deck_chooser.selected_deck_id = deck_id
# only used for detecting changed sticky fields on close # only used for detecting changed sticky fields on close
@ -151,8 +152,8 @@ class AddCards(QDialog):
self.setAndFocusNote(note) self.setAndFocusNote(note)
def _new_note(self) -> Note: def _new_note(self) -> Note:
return self.mw.col.new_note( return self.col.new_note(
self.mw.col.models.get(self.notetype_chooser.selected_notetype_id) self.col.models.get(self.notetype_chooser.selected_notetype_id)
) )
def addHistory(self, note: Note) -> None: def addHistory(self, note: Note) -> None:
@ -163,8 +164,8 @@ class AddCards(QDialog):
def onHistory(self) -> None: def onHistory(self) -> None:
m = QMenu(self) m = QMenu(self)
for nid in self.history: for nid in self.history:
if self.mw.col.findNotes(SearchNode(nid=nid)): if self.col.find_notes(self.col.build_search_string(SearchNode(nid=nid))):
note = self.mw.col.get_note(nid) note = self.col.get_note(nid)
fields = note.fields fields = note.fields
txt = htmlToTextLine(", ".join(fields)) txt = htmlToTextLine(", ".join(fields))
if len(txt) > 30: if len(txt) > 30:

View File

@ -4,26 +4,13 @@
from __future__ import annotations from __future__ import annotations
import html import html
import time
from dataclasses import dataclass from dataclasses import dataclass
from operator import itemgetter from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from typing import (
Any,
Callable,
Dict,
Generator,
List,
Optional,
Sequence,
Tuple,
Union,
cast,
)
import aqt import aqt
import aqt.forms import aqt.forms
from anki.cards import Card, CardId 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.consts import *
from anki.errors import NotFoundError from anki.errors import NotFoundError
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
@ -31,8 +18,8 @@ from anki.models import NotetypeDict
from anki.notes import NoteId from anki.notes import NoteId
from anki.stats import CardStats from anki.stats import CardStats
from anki.tags import MARKED_TAG from anki.tags import MARKED_TAG
from anki.utils import ids2str, isMac, isWin from anki.utils import ids2str, isMac
from aqt import AnkiQt, colors, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.card_ops import set_card_deck, set_card_flag from aqt.card_ops import set_card_deck, set_card_flag
from aqt.editor import Editor from aqt.editor import Editor
from aqt.exporting import ExportDialog from aqt.exporting import ExportDialog
@ -50,8 +37,8 @@ from aqt.scheduling_ops import (
unsuspend_cards, unsuspend_cards,
) )
from aqt.sidebar import SidebarTreeView 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.tag_ops import add_tags, clear_unused_tags, remove_tags_for_notes
from aqt.theme import theme_manager
from aqt.utils import ( from aqt.utils import (
HelpPage, HelpPage,
KeyboardModifiersPressed, KeyboardModifiersPressed,
@ -66,7 +53,6 @@ from aqt.utils import (
restore_combo_history, restore_combo_history,
restore_combo_index_for_session, restore_combo_index_for_session,
restoreGeom, restoreGeom,
restoreHeader,
restoreSplitter, restoreSplitter,
restoreState, restoreState,
save_combo_history, save_combo_history,
@ -90,353 +76,15 @@ class FindDupesDialog:
browser: Browser 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 # Browser window
###################################################################### ######################################################################
class Browser(QMainWindow): class Browser(QMainWindow):
model: DataModel
mw: AnkiQt mw: AnkiQt
col: Collection col: Collection
editor: Optional[Editor] editor: Optional[Editor]
table: Table
def __init__( def __init__(
self, self,
@ -464,29 +112,18 @@ class Browser(QMainWindow):
restoreSplitter(self.form.splitter, "editor3") restoreSplitter(self.form.splitter, "editor3")
self.form.splitter.setChildrenCollapsible(False) self.form.splitter.setChildrenCollapsible(False)
self.card: Optional[Card] = None self.card: Optional[Card] = None
self.setupColumns() self.setup_table()
self.setupTable()
self.setupMenus() self.setupMenus()
self.setupHeaders()
self.setupHooks() self.setupHooks()
self.setupEditor() self.setupEditor()
self.updateFont()
self.onUndoState(self.mw.form.actionUndo.isEnabled()) self.onUndoState(self.mw.form.actionUndo.isEnabled())
self.setupSearch(card, search) self.setupSearch(card, search)
gui_hooks.browser_will_show(self) gui_hooks.browser_will_show(self)
self.show() 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: def on_operation_did_execute(self, changes: OpChanges) -> None:
focused = current_top_level_widget() == self focused = current_top_level_widget() == self
self.model.op_executed(changes, focused) self.table.op_executed(changes, focused)
self.sidebar.op_executed(changes, focused) self.sidebar.op_executed(changes, focused)
if changes.note or changes.notetype: if changes.note or changes.notetype:
if not self.editor.is_updating_note(): 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: def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None:
if current_top_level_widget() == self: if current_top_level_widget() == self:
self.setUpdatesEnabled(True) self.setUpdatesEnabled(True)
self.model.redraw_cells() self.table.redraw_cells()
self.sidebar.refresh_if_needed() self.sidebar.refresh_if_needed()
def setupMenus(self) -> None: def setupMenus(self) -> None:
@ -515,7 +152,7 @@ class Browser(QMainWindow):
f = self.form f = self.form
# edit # edit
qconnect(f.actionUndo.triggered, self.undo) 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) qconnect(f.actionSelectNotes.triggered, self.selectNotes)
if not isMac: if not isMac:
f.actionClose.setVisible(False) f.actionClose.setVisible(False)
@ -575,32 +212,6 @@ class Browser(QMainWindow):
gui_hooks.browser_menus_did_init(self) gui_hooks.browser_menus_did_init(self)
self.mw.maybeHideAccelerators(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: def closeEvent(self, evt: QCloseEvent) -> None:
if self._closeEventHasCleanedUp: if self._closeEventHasCleanedUp:
evt.accept() evt.accept()
@ -633,26 +244,6 @@ class Browser(QMainWindow):
else: else:
super().keyPressEvent(evt) 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( def reopen(
self, self,
_mw: AnkiQt, _mw: AnkiQt,
@ -720,12 +311,9 @@ class Browser(QMainWindow):
"""Search triggered programmatically. Caller must have saved note first.""" """Search triggered programmatically. Caller must have saved note first."""
try: try:
self.model.search(self._lastSearchTxt) self.table.search(self._lastSearchTxt)
except Exception as err: except Exception as err:
showWarning(str(err)) showWarning(str(err))
if not self.model.cards:
# no row change will fire
self.onRowChanged(None, None)
def update_history(self) -> None: def update_history(self) -> None:
sh = self.mw.pm.profile["searchHistory"] sh = self.mw.pm.profile["searchHistory"]
@ -737,15 +325,17 @@ class Browser(QMainWindow):
self.form.searchEdit.addItems(sh) self.form.searchEdit.addItems(sh)
self.mw.pm.profile["searchHistory"] = sh self.mw.pm.profile["searchHistory"] = sh
def updateTitle(self) -> int: def updateTitle(self) -> None:
selected = len(self.form.tableView.selectionModel().selectedRows()) selected = self.table.len_selection()
cur = len(self.model.cards) cur = self.table.len()
tr_title = (
tr.browsing_window_title
if self.table.is_card_state()
else tr.browsing_window_title_notes
)
self.setWindowTitle( self.setWindowTitle(
without_unicode_isolation( without_unicode_isolation(tr_title(total=cur, selected=selected))
tr.browsing_window_title(total=cur, selected=selected)
) )
)
return selected
def search_for_terms(self, *search_terms: Union[str, SearchNode]) -> None: def search_for_terms(self, *search_terms: Union[str, SearchNode]) -> None:
search = self.col.build_search_string(*search_terms) 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 = self.col.build_search_string(SearchNode(nid=card.nid))
search = gui_hooks.default_search(search, card) search = gui_hooks.default_search(search, card)
self.search_for(search, "") 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) self.editor.call_after_note_saved(on_show_single_card)
def onReset(self) -> None: def onReset(self) -> None:
self.sidebar.refresh() 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: def setup_table(self) -> None:
self.model = DataModel(self) self.table = Table(self)
self.form.tableView.setSortingEnabled(True) self.form.radio_cards.setChecked(self.table.is_card_state())
self.form.tableView.setModel(self.model) self.form.radio_notes.setChecked(not self.table.is_card_state())
self.form.tableView.selectionModel() self.table.set_view(self.form.tableView)
self.form.tableView.setItemDelegate(StatusDelegate(self, self.model)) qconnect(self.form.radio_cards.toggled, self.on_table_state_changed)
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 setupEditor(self) -> None: def setupEditor(self) -> None:
def add_preview_button(leftbuttons: List[str], editor: Editor) -> None: def add_preview_button(leftbuttons: List[str], editor: Editor) -> None:
@ -824,138 +408,36 @@ QTableView {{ gridline-color: {grid} }}
def onRowChanged( def onRowChanged(
self, current: Optional[QItemSelection], previous: Optional[QItemSelection] self, current: Optional[QItemSelection], previous: Optional[QItemSelection]
) -> None: ) -> None:
"""Update current note and hide/show editor.""" """Update current note and hide/show editor. """
if self._closeEventHasCleanedUp: if self._closeEventHasCleanedUp:
return 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.updateTitle()
self.editor.set_note(None) # the current card is used for context actions
self.singleCard = False self.card = self.table.get_current_card()
self._renderPreview() # if there is only one selected card, use it in the editor
else: # it might differ from the current card
self.editor.set_note(self.card.note(reload=True), focusTo=self.focusTo) 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.focusTo = None
self.editor.card = self.card self.editor.card = card
self.singleCard = True else:
self.editor.set_note(None)
self._renderPreview()
self._update_flags_menu() self._update_flags_menu()
gui_hooks.browser_did_change_row(self) 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 @ensure_editor_saved_on_trigger
def toggleField(self, type: str) -> None: def on_table_state_changed(self) -> None:
self.model.beginReset() self.mw.progress.start()
if type in self.model.activeCols: self.table.toggle_state(self.form.radio_cards.isChecked(), self._lastSearchTxt)
if len(self.model.activeCols) < 2: self.mw.progress.finish()
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 setColumnSizes(self) -> None: # Sidebar
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()
def setupSidebar(self) -> None: def setupSidebar(self) -> None:
dw = self.sidebarDockWidget = QDockWidget(tr.browsing_sidebar(), self) dw = self.sidebarDockWidget = QDockWidget(tr.browsing_sidebar(), self)
@ -1052,29 +534,13 @@ QTableView {{ gridline-color: {grid} }}
###################################################################### ######################################################################
def selected_cards(self) -> List[CardId]: def selected_cards(self) -> List[CardId]:
return [ return self.table.get_selected_card_ids()
self.model.cards[idx.row()]
for idx in self.form.tableView.selectionModel().selectedRows()
]
def selected_notes(self) -> List[NoteId]: def selected_notes(self) -> List[NoteId]:
return self.col.db.list( return self.table.get_selected_note_ids()
"""
select distinct nid from cards
where id in %s"""
% ids2str(
[
self.model.cards[idx.row()]
for idx in self.form.tableView.selectionModel().selectedRows()
]
)
)
def selectedNotesAsCards(self) -> List[CardId]: def selectedNotesAsCards(self) -> List[CardId]:
return self.col.db.list( return self.table.get_card_ids_from_selected_note_ids()
"select id from cards where nid in (%s)"
% ",".join([str(s) for s in self.selected_notes()])
)
def oneModelNotes(self) -> List[NoteId]: def oneModelNotes(self) -> List[NoteId]:
sf = self.selected_notes() sf = self.selected_notes()
@ -1154,12 +620,13 @@ where id in %s"""
return return
# nothing selected? # nothing selected?
nids = self.selected_notes() nids = self.table.get_selected_note_ids()
if not nids: if not nids:
return return
# select the next card if there is one # select the next card if there is one
self._onNextCard() self.focusTo = self.editor.currentField
self.table.to_next_row()
remove_notes( remove_notes(
mw=self.mw, mw=self.mw,
@ -1178,7 +645,7 @@ where id in %s"""
def set_deck_of_selected_cards(self) -> None: def set_deck_of_selected_cards(self) -> None:
from aqt.studydeck import StudyDeck from aqt.studydeck import StudyDeck
cids = self.selected_cards() cids = self.table.get_selected_card_ids()
if not cids: if not cids:
return return
@ -1351,27 +818,12 @@ where id in %s"""
def selectNotes(self) -> None: def selectNotes(self) -> None:
nids = self.selected_notes() nids = self.selected_notes()
# clear the selection so we don't waste energy preserving it # clear the selection so we don't waste energy preserving it
tv = self.form.tableView self.table.clear_selection()
tv.selectionModel().clear()
search = self.col.build_search_string( search = self.col.build_search_string(
SearchNode(nids=SearchNode.IdList(ids=nids)) SearchNode(nids=SearchNode.IdList(ids=nids))
) )
self.search_for(search) self.search_for(search)
self.table.select_all()
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,
),
)
# Hooks # Hooks
###################################################################### ######################################################################
@ -1380,16 +832,16 @@ where id in %s"""
gui_hooks.undo_state_did_change.append(self.onUndoState) gui_hooks.undo_state_did_change.append(self.onUndoState)
# fixme: remove this once all items are using `operation_did_execute` # fixme: remove this once all items are using `operation_did_execute`
gui_hooks.sidebar_should_refresh_notetypes.append(self.on_item_added) 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_will_block.append(self.table.on_backend_will_block)
gui_hooks.backend_did_block.append(self.on_backend_did_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.operation_did_execute.append(self.on_operation_did_execute)
gui_hooks.focus_did_change.append(self.on_focus_change) gui_hooks.focus_did_change.append(self.on_focus_change)
def teardownHooks(self) -> None: def teardownHooks(self) -> None:
gui_hooks.undo_state_did_change.remove(self.onUndoState) gui_hooks.undo_state_did_change.remove(self.onUndoState)
gui_hooks.sidebar_should_refresh_notetypes.remove(self.on_item_added) 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_will_block.remove(self.table.on_backend_will_block)
gui_hooks.backend_did_block.remove(self.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.operation_did_execute.remove(self.on_operation_did_execute)
gui_hooks.focus_did_change.remove(self.on_focus_change) 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: def _onTagDupes(self, res: List[Any]) -> None:
if not res: if not res:
return return
self.model.beginReset() self.begin_reset()
self.mw.checkpoint(tr.browsing_tag_duplicates()) self.mw.checkpoint(tr.browsing_tag_duplicates())
nids = set() nids = set()
for _, nidlist in res: for _, nidlist in res:
nids.update(nidlist) nids.update(nidlist)
self.col.tags.bulk_add(list(nids), tr.browsing_duplicate()) self.col.tags.bulk_add(list(nids), tr.browsing_duplicate())
self.mw.progress.finish() self.mw.progress.finish()
self.model.endReset() self.end_reset()
self.mw.requireReset(reason=ResetReason.BrowserTagDupes, context=self) self.mw.requireReset(reason=ResetReason.BrowserTagDupes, context=self)
tooltip(tr.browsing_notes_tagged()) tooltip(tr.browsing_notes_tagged())
@ -1532,69 +984,25 @@ where id in %s"""
# Jumping # Jumping
###################################################################### ######################################################################
def _moveCur( def has_previous_card(self) -> bool:
self, dir: Optional[QTableView.CursorAction], idx: QModelIndex = None return self.table.has_previous()
) -> None:
if not self.model.cards: def has_next_card(self) -> bool:
return return self.table.has_next()
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 onPreviousCard(self) -> None: def onPreviousCard(self) -> None:
self.focusTo = self.editor.currentField self.focusTo = self.editor.currentField
self.editor.call_after_note_saved(self._onPreviousCard) self.editor.call_after_note_saved(self.table.to_previous_row)
def _onPreviousCard(self) -> None:
self._moveCur(QAbstractItemView.MoveUp)
def onNextCard(self) -> None: def onNextCard(self) -> None:
self.focusTo = self.editor.currentField self.focusTo = self.editor.currentField
self.editor.call_after_note_saved(self._onNextCard) self.editor.call_after_note_saved(self.table.to_next_row)
def _onNextCard(self) -> None:
self._moveCur(QAbstractItemView.MoveDown)
def onFirstCard(self) -> None: def onFirstCard(self) -> None:
sm = self.form.tableView.selectionModel() self.table.to_first_row()
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,
),
)
def onLastCard(self) -> None: def onLastCard(self) -> None:
sm = self.form.tableView.selectionModel() self.table.to_last_row()
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,
),
)
def onFind(self) -> None: def onFind(self) -> None:
# workaround for PyQt focus bug # workaround for PyQt focus bug
@ -1613,14 +1021,6 @@ where id in %s"""
def onCardList(self) -> None: def onCardList(self) -> None:
self.form.tableView.setFocus() 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 # Change model dialog
###################################################################### ######################################################################
@ -1796,11 +1196,11 @@ class ChangeModel(QDialog):
b = self.browser b = self.browser
b.mw.col.modSchema(check=True) b.mw.col.modSchema(check=True)
b.mw.progress.start() b.mw.progress.start()
b.model.beginReset() b.begin_reset()
mm = b.mw.col.models mm = b.mw.col.models
mm.change(self.oldModel, self.nids, self.targetModel, fmap, cmap) mm.change(self.oldModel, self.nids, self.targetModel, fmap, cmap)
b.search() b.search()
b.model.endReset() b.end_reset()
b.mw.progress.finish() b.mw.progress.finish()
b.mw.reset() b.mw.reset()
self.cleanup() self.cleanup()

View File

@ -83,7 +83,7 @@
<number>0</number> <number>0</number>
</property> </property>
<property name="bottomMargin"> <property name="bottomMargin">
<number>12</number> <number>6</number>
</property> </property>
<property name="horizontalSpacing"> <property name="horizontalSpacing">
<number>12</number> <number>12</number>
@ -109,6 +109,30 @@
</item> </item>
</layout> </layout>
</item> </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> <item>
<widget class="QTableView" name="tableView"> <widget class="QTableView" name="tableView">
<property name="sizePolicy"> <property name="sizePolicy">
@ -144,12 +168,12 @@
<attribute name="horizontalHeaderCascadingSectionResizes"> <attribute name="horizontalHeaderCascadingSectionResizes">
<bool>false</bool> <bool>false</bool>
</attribute> </attribute>
<attribute name="horizontalHeaderMinimumSectionSize">
<number>20</number>
</attribute>
<attribute name="horizontalHeaderHighlightSections"> <attribute name="horizontalHeaderHighlightSections">
<bool>false</bool> <bool>false</bool>
</attribute> </attribute>
<attribute name="horizontalHeaderMinimumSectionSize">
<number>20</number>
</attribute>
<attribute name="horizontalHeaderShowSortIndicator" stdset="0"> <attribute name="horizontalHeaderShowSortIndicator" stdset="0">
<bool>true</bool> <bool>true</bool>
</attribute> </attribute>
@ -209,7 +233,7 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>750</width> <width>750</width>
<height>24</height> <height>21</height>
</rect> </rect>
</property> </property>
<widget class="QMenu" name="menuEdit"> <widget class="QMenu" name="menuEdit">

View File

@ -13,7 +13,6 @@ from anki.cards import Card
from anki.collection import Config from anki.collection import Config
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
from aqt.qt import ( from aqt.qt import (
QAbstractItemView,
QCheckBox, QCheckBox,
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
@ -326,23 +325,16 @@ class BrowserPreviewer(MultiCardPreviewer):
return changed return changed
def _on_prev_card(self) -> None: def _on_prev_card(self) -> None:
self._parent.editor.call_after_note_saved( self._parent.onPreviousCard()
lambda: self._parent._moveCur(QAbstractItemView.MoveUp)
)
def _on_next_card(self) -> None: def _on_next_card(self) -> None:
self._parent.editor.call_after_note_saved( self._parent.onNextCard()
lambda: self._parent._moveCur(QAbstractItemView.MoveDown)
)
def _should_enable_prev(self) -> bool: 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: def _should_enable_next(self) -> bool:
return ( return super()._should_enable_next() or self._parent.has_next_card()
super()._should_enable_next()
or self._parent.currentRow() < self._parent.model.rowCount(None) - 1
)
def _render_scheduled(self) -> None: def _render_scheduled(self) -> None:
super()._render_scheduled() 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( Hook(
name="browser_will_search", 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. 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 You can modify context.search to change the text that is sent to the
@ -401,12 +401,12 @@ hooks = [
), ),
Hook( Hook(
name="browser_did_search", 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.""", doc="""Allows you to modify the list of returned card ids from a search.""",
), ),
Hook( Hook(
name="browser_did_fetch_row", 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. 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 You can mutate the row object to change what is displayed. Any columns the

View File

@ -237,12 +237,12 @@ service TagsService {
service SearchService { service SearchService {
rpc BuildSearchString(SearchNode) returns (String); rpc BuildSearchString(SearchNode) returns (String);
rpc SearchCards(SearchCardsIn) returns (SearchCardsOut); rpc SearchCards(SearchIn) returns (SearchOut);
rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut); rpc SearchNotes(SearchIn) returns (SearchOut);
rpc JoinSearchNodes(JoinSearchNodesIn) returns (String); rpc JoinSearchNodes(JoinSearchNodesIn) returns (String);
rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String); rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String);
rpc FindAndReplace(FindAndReplaceIn) returns (OpChangesWithCount); rpc FindAndReplace(FindAndReplaceIn) returns (OpChangesWithCount);
rpc BrowserRowForCard(CardId) returns (BrowserRow); rpc BrowserRowForId(Int64) returns (BrowserRow);
} }
service StatsService { service StatsService {
@ -795,31 +795,33 @@ message OpenCollectionIn {
string log_path = 4; string log_path = 4;
} }
message SearchCardsIn { message SearchIn {
string search = 1; string search = 1;
SortOrder order = 2; SortOrder order = 2;
} }
message SearchCardsOut { message SearchOut {
repeated int64 card_ids = 1; repeated int64 ids = 1;
} }
message SortOrder { message SortOrder {
message Builtin { message Builtin {
enum Kind { enum Kind {
NOTE_CREATION = 0; NOTE_CARDS = 0;
NOTE_MOD = 1; NOTE_CREATION = 1;
NOTE_FIELD = 2; NOTE_EASE = 2;
NOTE_TAGS = 3; NOTE_MOD = 3;
NOTETYPE = 4; NOTE_FIELD = 4;
CARD_MOD = 5; NOTE_TAGS = 5;
CARD_REPS = 6; NOTETYPE = 6;
CARD_DUE = 7; CARD_MOD = 7;
CARD_EASE = 8; CARD_REPS = 8;
CARD_LAPSES = 9; CARD_DUE = 9;
CARD_INTERVAL = 10; CARD_EASE = 10;
CARD_DECK = 11; CARD_LAPSES = 11;
CARD_TEMPLATE = 12; CARD_INTERVAL = 12;
CARD_DECK = 13;
CARD_TEMPLATE = 14;
} }
Kind kind = 1; Kind kind = 1;
bool reverse = 2; bool reverse = 2;
@ -832,14 +834,6 @@ message SortOrder {
} }
} }
message SearchNotesIn {
string search = 1;
}
message SearchNotesOut {
repeated int64 note_ids = 2;
}
message SearchNode { message SearchNode {
message Dupe { message Dupe {
int64 notetype_id = 1; int64 notetype_id = 1;
@ -1351,22 +1345,24 @@ message SetDeckIn {
message Config { message Config {
message Bool { message Bool {
enum Key { enum Key {
BROWSER_SORT_BACKWARDS = 0; BROWSER_CARD_STATE = 0;
PREVIEW_BOTH_SIDES = 1; BROWSER_SORT_BACKWARDS = 1;
COLLAPSE_TAGS = 2; BROWSER_NOTE_SORT_BACKWARDS = 2;
COLLAPSE_NOTETYPES = 3; PREVIEW_BOTH_SIDES = 3;
COLLAPSE_DECKS = 4; COLLAPSE_TAGS = 4;
COLLAPSE_SAVED_SEARCHES = 5; COLLAPSE_NOTETYPES = 5;
COLLAPSE_TODAY = 6; COLLAPSE_DECKS = 6;
COLLAPSE_CARD_STATE = 7; COLLAPSE_SAVED_SEARCHES = 7;
COLLAPSE_FLAGS = 8; COLLAPSE_TODAY = 8;
SCHED_2021 = 9; COLLAPSE_CARD_STATE = 9;
ADDING_DEFAULTS_TO_CURRENT_DECK = 10; COLLAPSE_FLAGS = 10;
HIDE_AUDIO_PLAY_BUTTONS = 11; SCHED_2021 = 11;
INTERRUPT_AUDIO_WHEN_ANSWERING = 12; ADDING_DEFAULTS_TO_CURRENT_DECK = 12;
PASTE_IMAGES_AS_PNG = 13; HIDE_AUDIO_PLAY_BUTTONS = 13;
PASTE_STRIPS_FORMATTING = 14; INTERRUPT_AUDIO_WHEN_ANSWERING = 14;
NORMALIZE_NOTE_TEXT = 15; PASTE_IMAGES_AS_PNG = 15;
PASTE_STRIPS_FORMATTING = 16;
NORMALIZE_NOTE_TEXT = 17;
} }
Key key = 1; Key key = 1;
} }

View File

@ -15,7 +15,9 @@ use serde_json::Value;
impl From<BoolKeyProto> for BoolKey { impl From<BoolKeyProto> for BoolKey {
fn from(k: BoolKeyProto) -> Self { fn from(k: BoolKeyProto) -> Self {
match k { match k {
BoolKeyProto::BrowserCardState => BoolKey::BrowserCardState,
BoolKeyProto::BrowserSortBackwards => BoolKey::BrowserSortBackwards, BoolKeyProto::BrowserSortBackwards => BoolKey::BrowserSortBackwards,
BoolKeyProto::BrowserNoteSortBackwards => BoolKey::BrowserNoteSortBackwards,
BoolKeyProto::PreviewBothSides => BoolKey::PreviewBothSides, BoolKeyProto::PreviewBothSides => BoolKey::PreviewBothSides,
BoolKeyProto::CollapseTags => BoolKey::CollapseTags, BoolKeyProto::CollapseTags => BoolKey::CollapseTags,
BoolKeyProto::CollapseNotetypes => BoolKey::CollapseNotetypes, BoolKeyProto::CollapseNotetypes => BoolKey::CollapseNotetypes,

View File

@ -24,21 +24,22 @@ impl SearchService for Backend {
Ok(write_nodes(&node.into_node_list()).into()) 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| { self.with_col(|col| {
let order = input.order.unwrap_or_default().value.into(); let order = input.order.unwrap_or_default().value.into();
let cids = col.search_cards(&input.search, order)?; let cids = col.search::<CardId>(&input.search, order)?;
Ok(pb::SearchCardsOut { Ok(pb::SearchOut {
card_ids: cids.into_iter().map(|v| v.0).collect(), 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| { self.with_col(|col| {
let nids = col.search_notes(&input.search)?; let order = input.order.unwrap_or_default().value.into();
Ok(pb::SearchNotesOut { let nids = col.search::<NoteId>(&input.search, order)?;
note_ids: nids.into_iter().map(|v| v.0).collect(), 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> { fn browser_row_for_id(&self, input: pb::Int64) -> Result<pb::BrowserRow> {
self.with_col(|col| col.browser_row_for_card(input.cid.into()).map(Into::into)) self.with_col(|col| col.browser_row_for_id(input.val).map(Into::into))
} }
} }
impl From<SortKindProto> for SortKind { impl From<SortKindProto> for SortKind {
fn from(kind: SortKindProto) -> Self { fn from(kind: SortKindProto) -> Self {
match kind { match kind {
SortKindProto::NoteCards => SortKind::NoteCards,
SortKindProto::NoteCreation => SortKind::NoteCreation, SortKindProto::NoteCreation => SortKind::NoteCreation,
SortKindProto::NoteEase => SortKind::NoteEase,
SortKindProto::NoteMod => SortKind::NoteMod, SortKindProto::NoteMod => SortKind::NoteMod,
SortKindProto::NoteField => SortKind::NoteField, SortKindProto::NoteField => SortKind::NoteField,
SortKindProto::NoteTags => SortKind::NoteTags, SortKindProto::NoteTags => SortKind::NoteTags,

View File

@ -10,8 +10,9 @@ use crate::i18n::I18n;
use crate::{ use crate::{
card::{Card, CardId, CardQueue, CardType}, card::{Card, CardId, CardQueue, CardType},
collection::Collection, collection::Collection,
config::BoolKey,
decks::{Deck, DeckId}, decks::{Deck, DeckId},
notes::Note, notes::{Note, NoteId},
notetype::{CardTemplate, Notetype, NotetypeKind}, notetype::{CardTemplate, Notetype, NotetypeKind},
scheduler::{timespan::time_span, timing::SchedTimingToday}, scheduler::{timespan::time_span, timing::SchedTimingToday},
template::RenderedNode, template::RenderedNode,
@ -51,7 +52,54 @@ pub struct Font {
pub size: u32, 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, col: &'a Collection,
card: Card, card: Card,
note: Note, note: Note,
@ -70,6 +118,13 @@ struct RenderContext {
answer_nodes: Vec<RenderedNode>, 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 { fn card_render_required(columns: &[String]) -> bool {
columns columns
.iter() .iter()
@ -77,19 +132,28 @@ fn card_render_required(columns: &[String]) -> bool {
} }
impl Collection { 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 // this is inefficient; we may want to use an enum in the future
let columns = self.get_desktop_browser_card_columns(); 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 { fn get_note_maybe_with_fields(&self, id: NoteId, _with_fields: bool) -> Result<Note> {
cells: columns // todo: After note.sort_field has been modified so it can be displayed in the browser,
.iter() // we can update note_field_str() and only load the note with fields if a card render is
.map(|column| context.get_cell(column)) // necessary (see #1082).
.collect::<Result<_>>()?, if true {
color: context.get_row_color(), self.storage.get_note(id)?
font: context.get_row_font()?, } else {
}) self.storage.get_note_without_fields(id)?
}
.ok_or(AnkiError::NotFound)
} }
} }
@ -123,18 +187,13 @@ impl RenderContext {
} }
} }
impl<'a> RowContext<'a> { impl<'a> CardRowContext<'a> {
fn new(col: &'a mut Collection, id: CardId, with_card_render: bool) -> Result<Self> { fn new(col: &'a mut Collection, id: i64, with_card_render: bool) -> Result<Self> {
let card = col.storage.get_card(id)?.ok_or(AnkiError::NotFound)?; let card = col
// todo: After note.sort_field has been modified so it can be displayed in the browser, .storage
// we can update note_field_str() and only load the note with fields if a card render is .get_card(CardId(id))?
// necessary (see #1082).
let note = if true {
col.storage.get_note(card.note_id)?
} else {
col.storage.get_note_without_fields(card.note_id)?
}
.ok_or(AnkiError::NotFound)?; .ok_or(AnkiError::NotFound)?;
let note = col.get_note_maybe_with_fields(card.note_id, with_card_render)?;
let notetype = col let notetype = col
.get_notetype(note.notetype_id)? .get_notetype(note.notetype_id)?
.ok_or(AnkiError::NotFound)?; .ok_or(AnkiError::NotFound)?;
@ -145,7 +204,7 @@ impl<'a> RowContext<'a> {
None None
}; };
Ok(RowContext { Ok(CardRowContext {
col, col,
card, card,
note, note,
@ -181,34 +240,6 @@ impl<'a> RowContext<'a> {
Ok(self.original_deck.as_ref().unwrap()) 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 { fn answer_str(&self) -> String {
let render_context = self.render_context.as_ref().unwrap(); let render_context = self.render_context.as_ref().unwrap();
let answer = render_context 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> { fn template_str(&self) -> Result<String> {
let name = &self.template()?.name; let name = &self.template()?.name;
Ok(match self.notetype.config.kind() { Ok(match self.notetype.config.kind() {
@ -304,15 +326,28 @@ impl<'a> RowContext<'a> {
fn question_str(&self) -> String { fn question_str(&self) -> String {
html_to_text_line(&self.render_context.as_ref().unwrap().question).to_string() html_to_text_line(&self.render_context.as_ref().unwrap().question).to_string()
} }
}
fn get_is_rtl(&self, column: &str) -> bool { impl RowContext for CardRowContext<'_> {
match column { fn get_cell_text(&mut self, column: &str) -> Result<String> {
"noteFld" => { Ok(match column {
let index = self.notetype.config.sort_field_idx as usize; "answer" => self.answer_str(),
self.notetype.fields[index].config.rtl "cardDue" => self.card_due_str(),
} "cardEase" => self.card_ease_str(),
_ => false, "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 { fn get_row_color(&self) -> Color {
@ -344,4 +379,86 @@ impl<'a> RowContext<'a> {
size: self.template()?.config.browser_font_size, 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)] #[derive(Debug, Clone, Copy, IntoStaticStr)]
#[strum(serialize_all = "camelCase")] #[strum(serialize_all = "camelCase")]
pub enum BoolKey { pub enum BoolKey {
BrowserCardState,
BrowserNoteSortBackwards,
CardCountsSeparateInactive, CardCountsSeparateInactive,
CollapseCardState, CollapseCardState,
CollapseDecks, CollapseDecks,
@ -60,7 +62,8 @@ impl Collection {
| BoolKey::FutureDueShowBacklog | BoolKey::FutureDueShowBacklog
| BoolKey::ShowRemainingDueCountsInStudy | BoolKey::ShowRemainingDueCountsInStudy
| BoolKey::CardCountsSeparateInactive | 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 options default to false
other => self.get_config_default(other), other => self.get_config_default(other),

View File

@ -48,6 +48,8 @@ pub(crate) enum ConfigKey {
AnswerTimeLimitSecs, AnswerTimeLimitSecs,
#[strum(to_string = "sortType")] #[strum(to_string = "sortType")]
BrowserSortKind, BrowserSortKind,
#[strum(to_string = "noteSortType")]
BrowserNoteSortKind,
#[strum(to_string = "curDeck")] #[strum(to_string = "curDeck")]
CurrentDeckId, CurrentDeckId,
#[strum(to_string = "curModel")] #[strum(to_string = "curModel")]
@ -65,6 +67,8 @@ pub(crate) enum ConfigKey {
#[strum(to_string = "activeCols")] #[strum(to_string = "activeCols")]
DesktopBrowserCardColumns, DesktopBrowserCardColumns,
#[strum(to_string = "activeNoteCols")]
DesktopBrowserNoteColumns,
} }
#[derive(PartialEq, Serialize_repr, Deserialize_repr, Clone, Copy, Debug)] #[derive(PartialEq, Serialize_repr, Deserialize_repr, Clone, Copy, Debug)]
@ -132,6 +136,10 @@ impl Collection {
self.get_config_default(ConfigKey::BrowserSortKind) 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> { pub(crate) fn get_desktop_browser_card_columns(&self) -> Vec<String> {
self.get_config_optional(ConfigKey::DesktopBrowserCardColumns) self.get_config_optional(ConfigKey::DesktopBrowserCardColumns)
.unwrap_or_else(|| { .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> { pub(crate) fn get_creation_utc_offset(&self) -> Option<i32> {
self.get_config_optional(ConfigKey::CreationOffset) self.get_config_optional(ConfigKey::CreationOffset)
} }
@ -251,8 +271,10 @@ impl Collection {
#[derive(Deserialize, PartialEq, Debug, Clone, Copy)] #[derive(Deserialize, PartialEq, Debug, Clone, Copy)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum SortKind { pub enum SortKind {
NoteCards,
#[serde(rename = "noteCrt")] #[serde(rename = "noteCrt")]
NoteCreation, NoteCreation,
NoteEase,
NoteMod, NoteMod,
#[serde(rename = "noteFld")] #[serde(rename = "noteFld")]
NoteField, 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 // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod cards;
mod notes;
mod parser; mod parser;
mod sqlwriter; mod sqlwriter;
pub(crate) mod writer; pub(crate) mod writer;
pub use cards::SortMode;
pub use parser::{ pub use parser::{
parse as parse_search, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind, parse as parse_search, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind,
}; };
pub use writer::{concatenate_searches, replace_search_node, write_nodes, BoolSeparator}; 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 // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // 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::{ use crate::{
card::{CardQueue, CardType}, card::{CardQueue, CardType},
collection::Collection, collection::Collection,
@ -22,55 +25,48 @@ use std::{borrow::Cow, fmt::Write};
pub(crate) struct SqlWriter<'a> { pub(crate) struct SqlWriter<'a> {
col: &'a mut Collection, col: &'a mut Collection,
sql: String, sql: String,
items: SearchItems,
args: Vec<String>, args: Vec<String>,
normalize_note_text: bool, normalize_note_text: bool,
table: RequiredTable, table: RequiredTable,
} }
impl SqlWriter<'_> { 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 normalize_note_text = col.get_bool(BoolKey::NormalizeNoteText);
let sql = String::new(); let sql = String::new();
let args = vec![]; let args = vec![];
SqlWriter { SqlWriter {
col, col,
sql, sql,
items,
args, args,
normalize_note_text, normalize_note_text,
table: RequiredTable::CardsOrNotes, table: items.required_table(),
} }
} }
pub(super) fn build_cards_query( pub(super) fn build_query(
mut self, mut self,
node: &Node, node: &Node,
table: RequiredTable, table: RequiredTable,
) -> Result<(String, Vec<String>)> { ) -> Result<(String, Vec<String>)> {
self.table = table.combine(node.required_table()); self.table = self.table.combine(table.combine(node.required_table()));
self.write_cards_table_sql(); self.write_table_sql();
self.write_node_to_sql(&node)?; self.write_node_to_sql(&node)?;
Ok((self.sql, self.args)) Ok((self.sql, self.args))
} }
pub(super) fn build_notes_query(mut self, node: &Node) -> Result<(String, Vec<String>)> { fn write_table_sql(&mut self) {
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) {
let sql = match self.table { let sql = match self.table {
RequiredTable::Cards => "select c.id from cards c where ", 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 ", 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); self.sql.push_str(sql);
} }
@ -592,7 +588,7 @@ mod test {
// shortcut // shortcut
fn s(req: &mut Collection, search: &str) -> (String, Vec<String>) { fn s(req: &mut Collection, search: &str) -> (String, Vec<String>) {
let node = Node::Group(parse(search).unwrap()); 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.table = RequiredTable::Notes.combine(node.required_table());
writer.write_node_to_sql(&node).unwrap(); writer.write_node_to_sql(&node).unwrap();
(writer.sql, writer.args) (writer.sql, writer.args)