parent
0a5222c400
commit
0d8b1c9d0b
@ -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 ->
|
||||
|
@ -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))
|
||||
|
@ -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:
|
||||
|
@ -4,26 +4,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from operator import itemgetter
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Generator,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import aqt
|
||||
import aqt.forms
|
||||
from anki.cards import Card, CardId
|
||||
from anki.collection import BrowserRow, Collection, Config, OpChanges, SearchNode
|
||||
from anki.collection import Collection, Config, OpChanges, SearchNode
|
||||
from anki.consts import *
|
||||
from anki.errors import NotFoundError
|
||||
from anki.lang import without_unicode_isolation
|
||||
@ -31,8 +18,8 @@ from anki.models import NotetypeDict
|
||||
from anki.notes import NoteId
|
||||
from anki.stats import CardStats
|
||||
from anki.tags import MARKED_TAG
|
||||
from anki.utils import ids2str, isMac, isWin
|
||||
from aqt import AnkiQt, colors, gui_hooks
|
||||
from anki.utils import ids2str, isMac
|
||||
from aqt import AnkiQt, gui_hooks
|
||||
from aqt.card_ops import set_card_deck, set_card_flag
|
||||
from aqt.editor import Editor
|
||||
from aqt.exporting import ExportDialog
|
||||
@ -50,8 +37,8 @@ from aqt.scheduling_ops import (
|
||||
unsuspend_cards,
|
||||
)
|
||||
from aqt.sidebar import SidebarTreeView
|
||||
from aqt.table import Table
|
||||
from aqt.tag_ops import add_tags, clear_unused_tags, remove_tags_for_notes
|
||||
from aqt.theme import theme_manager
|
||||
from aqt.utils import (
|
||||
HelpPage,
|
||||
KeyboardModifiersPressed,
|
||||
@ -66,7 +53,6 @@ from aqt.utils import (
|
||||
restore_combo_history,
|
||||
restore_combo_index_for_session,
|
||||
restoreGeom,
|
||||
restoreHeader,
|
||||
restoreSplitter,
|
||||
restoreState,
|
||||
save_combo_history,
|
||||
@ -90,353 +76,15 @@ class FindDupesDialog:
|
||||
browser: Browser
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchContext:
|
||||
search: str
|
||||
browser: Browser
|
||||
order: Union[bool, str] = True
|
||||
# if set, provided card ids will be used instead of the regular search
|
||||
card_ids: Optional[Sequence[CardId]] = None
|
||||
|
||||
|
||||
# Data model
|
||||
##########################################################################
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cell:
|
||||
text: str
|
||||
is_rtl: bool
|
||||
|
||||
|
||||
class CellRow:
|
||||
def __init__(
|
||||
self,
|
||||
cells: Generator[Tuple[str, bool], None, None],
|
||||
color: BrowserRow.Color.V,
|
||||
font_name: str,
|
||||
font_size: int,
|
||||
) -> None:
|
||||
self.refreshed_at: float = time.time()
|
||||
self.cells: Tuple[Cell, ...] = tuple(Cell(*cell) for cell in cells)
|
||||
self.color: Optional[Tuple[str, str]] = backend_color_to_aqt_color(color)
|
||||
self.font_name: str = font_name or "arial"
|
||||
self.font_size: int = font_size if font_size > 0 else 12
|
||||
|
||||
def is_stale(self, threshold: float) -> bool:
|
||||
return self.refreshed_at < threshold
|
||||
|
||||
@staticmethod
|
||||
def generic(length: int, cell_text: str) -> CellRow:
|
||||
return CellRow(
|
||||
((cell_text, False) for cell in range(length)),
|
||||
BrowserRow.COLOR_DEFAULT,
|
||||
"arial",
|
||||
12,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def placeholder(length: int) -> CellRow:
|
||||
return CellRow.generic(length, "...")
|
||||
|
||||
@staticmethod
|
||||
def deleted(length: int) -> CellRow:
|
||||
return CellRow.generic(length, tr.browsing_row_deleted())
|
||||
|
||||
|
||||
def backend_color_to_aqt_color(color: BrowserRow.Color.V) -> Optional[Tuple[str, str]]:
|
||||
if color == BrowserRow.COLOR_MARKED:
|
||||
return colors.MARKED_BG
|
||||
if color == BrowserRow.COLOR_SUSPENDED:
|
||||
return colors.SUSPENDED_BG
|
||||
if color == BrowserRow.COLOR_FLAG_RED:
|
||||
return colors.FLAG1_BG
|
||||
if color == BrowserRow.COLOR_FLAG_ORANGE:
|
||||
return colors.FLAG2_BG
|
||||
if color == BrowserRow.COLOR_FLAG_GREEN:
|
||||
return colors.FLAG3_BG
|
||||
if color == BrowserRow.COLOR_FLAG_BLUE:
|
||||
return colors.FLAG4_BG
|
||||
return None
|
||||
|
||||
|
||||
class DataModel(QAbstractTableModel):
|
||||
def __init__(self, browser: Browser) -> None:
|
||||
QAbstractTableModel.__init__(self)
|
||||
self.browser = browser
|
||||
self.col = browser.col
|
||||
self.sortKey = None
|
||||
self.activeCols: List[str] = self.col.get_config(
|
||||
"activeCols", ["noteFld", "template", "cardDue", "deck"]
|
||||
)
|
||||
self.cards: Sequence[CardId] = []
|
||||
self._rows: Dict[int, CellRow] = {}
|
||||
self._last_refresh = 0.0
|
||||
# serve stale content to avoid hitting the DB?
|
||||
self.block_updates = False
|
||||
|
||||
def get_id(self, index: QModelIndex) -> CardId:
|
||||
return self.cards[index.row()]
|
||||
|
||||
def get_cell(self, index: QModelIndex) -> Cell:
|
||||
return self.get_row(index).cells[index.column()]
|
||||
|
||||
def get_row(self, index: QModelIndex) -> CellRow:
|
||||
cid = self.get_id(index)
|
||||
if row := self._rows.get(cid):
|
||||
if not self.block_updates and row.is_stale(self._last_refresh):
|
||||
# need to refresh
|
||||
self._rows[cid] = self._fetch_row_from_backend(cid)
|
||||
return self._rows[cid]
|
||||
# return row, even if it's stale
|
||||
return row
|
||||
if self.block_updates:
|
||||
# blank row until we unblock
|
||||
return CellRow.placeholder(len(self.activeCols))
|
||||
# missing row, need to build
|
||||
self._rows[cid] = self._fetch_row_from_backend(cid)
|
||||
return self._rows[cid]
|
||||
|
||||
def _fetch_row_from_backend(self, cid: CardId) -> CellRow:
|
||||
try:
|
||||
row = CellRow(*self.col.browser_row_for_card(cid))
|
||||
except NotFoundError:
|
||||
return CellRow.deleted(len(self.activeCols))
|
||||
except Exception as e:
|
||||
return CellRow.generic(len(self.activeCols), str(e))
|
||||
|
||||
gui_hooks.browser_did_fetch_row(cid, row, self.activeCols)
|
||||
return row
|
||||
|
||||
def getCard(self, index: QModelIndex) -> Optional[Card]:
|
||||
"""Try to return the indicated, possibly deleted card."""
|
||||
try:
|
||||
return self.col.getCard(self.get_id(index))
|
||||
except:
|
||||
return None
|
||||
|
||||
# Model interface
|
||||
######################################################################
|
||||
|
||||
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
if parent and parent.isValid():
|
||||
return 0
|
||||
return len(self.cards)
|
||||
|
||||
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
|
||||
if parent and parent.isValid():
|
||||
return 0
|
||||
return len(self.activeCols)
|
||||
|
||||
def data(self, index: QModelIndex = QModelIndex(), role: int = 0) -> Any:
|
||||
if not index.isValid():
|
||||
return QVariant()
|
||||
if role == Qt.FontRole:
|
||||
if self.activeCols[index.column()] not in ("question", "answer", "noteFld"):
|
||||
return QVariant()
|
||||
qfont = QFont()
|
||||
row = self.get_row(index)
|
||||
qfont.setFamily(row.font_name)
|
||||
qfont.setPixelSize(row.font_size)
|
||||
return qfont
|
||||
if role == Qt.TextAlignmentRole:
|
||||
align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter
|
||||
if self.activeCols[index.column()] not in (
|
||||
"question",
|
||||
"answer",
|
||||
"template",
|
||||
"deck",
|
||||
"noteFld",
|
||||
"note",
|
||||
"noteTags",
|
||||
):
|
||||
align |= Qt.AlignHCenter
|
||||
return align
|
||||
if role in (Qt.DisplayRole, Qt.ToolTipRole):
|
||||
return self.get_cell(index).text
|
||||
return QVariant()
|
||||
|
||||
def headerData(
|
||||
self, section: int, orientation: Qt.Orientation, role: int = 0
|
||||
) -> Optional[str]:
|
||||
if orientation == Qt.Vertical:
|
||||
return None
|
||||
elif role == Qt.DisplayRole and section < len(self.activeCols):
|
||||
type = self.activeCols[section]
|
||||
txt = None
|
||||
for stype, name in self.browser.columns:
|
||||
if type == stype:
|
||||
txt = name
|
||||
break
|
||||
# give the user a hint an invalid column was added by an add-on
|
||||
if not txt:
|
||||
txt = tr.browsing_addon()
|
||||
return txt
|
||||
else:
|
||||
return None
|
||||
|
||||
def flags(self, index: QModelIndex) -> Qt.ItemFlags:
|
||||
return cast(Qt.ItemFlags, Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||
|
||||
# Filtering
|
||||
######################################################################
|
||||
|
||||
def search(self, txt: str) -> None:
|
||||
self.beginReset()
|
||||
self.cards = []
|
||||
try:
|
||||
ctx = SearchContext(search=txt, browser=self.browser)
|
||||
gui_hooks.browser_will_search(ctx)
|
||||
if ctx.card_ids is None:
|
||||
ctx.card_ids = self.col.find_cards(ctx.search, order=ctx.order)
|
||||
gui_hooks.browser_did_search(ctx)
|
||||
self.cards = ctx.card_ids
|
||||
except Exception as err:
|
||||
raise err
|
||||
finally:
|
||||
self.endReset()
|
||||
|
||||
def redraw_cells(self) -> None:
|
||||
"Update cell contents, without changing search count/columns/sorting."
|
||||
if not self.cards:
|
||||
return
|
||||
top_left = self.index(0, 0)
|
||||
bottom_right = self.index(len(self.cards) - 1, len(self.activeCols) - 1)
|
||||
self._last_refresh = time.time()
|
||||
self.dataChanged.emit(top_left, bottom_right) # type: ignore
|
||||
|
||||
def reset(self) -> None:
|
||||
self.beginReset()
|
||||
self.endReset()
|
||||
|
||||
# caller must have called editor.saveNow() before calling this or .reset()
|
||||
def beginReset(self) -> None:
|
||||
self.browser.editor.set_note(None, hide=False)
|
||||
self.browser.mw.progress.start()
|
||||
self.saveSelection()
|
||||
self.beginResetModel()
|
||||
self._rows = {}
|
||||
|
||||
def endReset(self) -> None:
|
||||
self.endResetModel()
|
||||
self.restoreSelection()
|
||||
self.browser.mw.progress.finish()
|
||||
|
||||
def reverse(self) -> None:
|
||||
self.browser.editor.call_after_note_saved(self._reverse)
|
||||
|
||||
def _reverse(self) -> None:
|
||||
self.beginReset()
|
||||
self.cards = list(reversed(self.cards))
|
||||
self.endReset()
|
||||
|
||||
def saveSelection(self) -> None:
|
||||
cards = self.browser.selected_cards()
|
||||
self.selectedCards = {id: True for id in cards}
|
||||
if getattr(self.browser, "card", None):
|
||||
self.focusedCard = self.browser.card.id
|
||||
else:
|
||||
self.focusedCard = None
|
||||
|
||||
def restoreSelection(self) -> None:
|
||||
if not self.cards:
|
||||
return
|
||||
sm = self.browser.form.tableView.selectionModel()
|
||||
sm.clear()
|
||||
# restore selection
|
||||
items = QItemSelection()
|
||||
count = 0
|
||||
firstIdx = None
|
||||
focusedIdx = None
|
||||
for row, id in enumerate(self.cards):
|
||||
# if the id matches the focused card, note the index
|
||||
if self.focusedCard == id:
|
||||
focusedIdx = self.index(row, 0)
|
||||
items.select(focusedIdx, focusedIdx)
|
||||
self.focusedCard = None
|
||||
# if the card was previously selected, select again
|
||||
if id in self.selectedCards:
|
||||
count += 1
|
||||
idx = self.index(row, 0)
|
||||
items.select(idx, idx)
|
||||
# note down the first card of the selection, in case we don't
|
||||
# have a focused card
|
||||
if not firstIdx:
|
||||
firstIdx = idx
|
||||
# focus previously focused or first in selection
|
||||
idx = focusedIdx or firstIdx
|
||||
tv = self.browser.form.tableView
|
||||
if idx:
|
||||
row = idx.row()
|
||||
pos = tv.rowViewportPosition(row)
|
||||
visible = pos >= 0 and pos < tv.viewport().height()
|
||||
tv.selectRow(row)
|
||||
|
||||
# we save and then restore the horizontal scroll position because
|
||||
# scrollTo() also scrolls horizontally which is confusing
|
||||
if not visible:
|
||||
h = tv.horizontalScrollBar().value()
|
||||
tv.scrollTo(idx, tv.PositionAtCenter)
|
||||
tv.horizontalScrollBar().setValue(h)
|
||||
if count < 500:
|
||||
# discard large selections; they're too slow
|
||||
sm.select(
|
||||
items,
|
||||
cast(
|
||||
QItemSelectionModel.SelectionFlags,
|
||||
QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows,
|
||||
),
|
||||
)
|
||||
else:
|
||||
tv.selectRow(0)
|
||||
|
||||
def op_executed(self, op: OpChanges, focused: bool) -> None:
|
||||
print("op executed")
|
||||
if op.card or op.note or op.deck or op.notetype:
|
||||
self._rows = {}
|
||||
if focused:
|
||||
self.redraw_cells()
|
||||
|
||||
def begin_blocking(self) -> None:
|
||||
self.block_updates = True
|
||||
|
||||
def end_blocking(self) -> None:
|
||||
self.block_updates = False
|
||||
self.redraw_cells()
|
||||
|
||||
|
||||
# Line painter
|
||||
######################################################################
|
||||
|
||||
|
||||
class StatusDelegate(QItemDelegate):
|
||||
def __init__(self, browser: Browser, model: DataModel) -> None:
|
||||
QItemDelegate.__init__(self, browser)
|
||||
self.browser = browser
|
||||
self.model = model
|
||||
|
||||
def paint(
|
||||
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
|
||||
) -> None:
|
||||
if self.model.get_cell(index).is_rtl:
|
||||
option.direction = Qt.RightToLeft
|
||||
if row_color := self.model.get_row(index).color:
|
||||
brush = QBrush(theme_manager.qcolor(row_color))
|
||||
painter.save()
|
||||
painter.fillRect(option.rect, brush)
|
||||
painter.restore()
|
||||
return QItemDelegate.paint(self, painter, option, index)
|
||||
|
||||
|
||||
# Browser window
|
||||
######################################################################
|
||||
|
||||
|
||||
class Browser(QMainWindow):
|
||||
model: DataModel
|
||||
mw: AnkiQt
|
||||
col: Collection
|
||||
editor: Optional[Editor]
|
||||
table: Table
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -464,29 +112,18 @@ class Browser(QMainWindow):
|
||||
restoreSplitter(self.form.splitter, "editor3")
|
||||
self.form.splitter.setChildrenCollapsible(False)
|
||||
self.card: Optional[Card] = None
|
||||
self.setupColumns()
|
||||
self.setupTable()
|
||||
self.setup_table()
|
||||
self.setupMenus()
|
||||
self.setupHeaders()
|
||||
self.setupHooks()
|
||||
self.setupEditor()
|
||||
self.updateFont()
|
||||
self.onUndoState(self.mw.form.actionUndo.isEnabled())
|
||||
self.setupSearch(card, search)
|
||||
gui_hooks.browser_will_show(self)
|
||||
self.show()
|
||||
|
||||
def on_backend_will_block(self) -> None:
|
||||
# make sure the card list doesn't try to refresh itself during the operation,
|
||||
# as that will block the UI
|
||||
self.model.begin_blocking()
|
||||
|
||||
def on_backend_did_block(self) -> None:
|
||||
self.model.end_blocking()
|
||||
|
||||
def on_operation_did_execute(self, changes: OpChanges) -> None:
|
||||
focused = current_top_level_widget() == self
|
||||
self.model.op_executed(changes, focused)
|
||||
self.table.op_executed(changes, focused)
|
||||
self.sidebar.op_executed(changes, focused)
|
||||
if changes.note or changes.notetype:
|
||||
if not self.editor.is_updating_note():
|
||||
@ -506,7 +143,7 @@ class Browser(QMainWindow):
|
||||
def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None:
|
||||
if current_top_level_widget() == self:
|
||||
self.setUpdatesEnabled(True)
|
||||
self.model.redraw_cells()
|
||||
self.table.redraw_cells()
|
||||
self.sidebar.refresh_if_needed()
|
||||
|
||||
def setupMenus(self) -> None:
|
||||
@ -515,7 +152,7 @@ class Browser(QMainWindow):
|
||||
f = self.form
|
||||
# edit
|
||||
qconnect(f.actionUndo.triggered, self.undo)
|
||||
qconnect(f.actionInvertSelection.triggered, self.invertSelection)
|
||||
qconnect(f.actionInvertSelection.triggered, self.table.invert_selection)
|
||||
qconnect(f.actionSelectNotes.triggered, self.selectNotes)
|
||||
if not isMac:
|
||||
f.actionClose.setVisible(False)
|
||||
@ -575,32 +212,6 @@ class Browser(QMainWindow):
|
||||
gui_hooks.browser_menus_did_init(self)
|
||||
self.mw.maybeHideAccelerators(self)
|
||||
|
||||
# context menu
|
||||
self.form.tableView.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
qconnect(self.form.tableView.customContextMenuRequested, self.onContextMenu)
|
||||
|
||||
def onContextMenu(self, _point: QPoint) -> None:
|
||||
m = QMenu()
|
||||
for act in self.form.menu_Cards.actions():
|
||||
m.addAction(act)
|
||||
m.addSeparator()
|
||||
for act in self.form.menu_Notes.actions():
|
||||
m.addAction(act)
|
||||
gui_hooks.browser_will_show_context_menu(self, m)
|
||||
qtMenuShortcutWorkaround(m)
|
||||
m.exec_(QCursor.pos())
|
||||
|
||||
def updateFont(self) -> None:
|
||||
# we can't choose different line heights efficiently, so we need
|
||||
# to pick a line height big enough for any card template
|
||||
curmax = 16
|
||||
for m in self.col.models.all():
|
||||
for t in m["tmpls"]:
|
||||
bsize = t.get("bsize", 0)
|
||||
if bsize > curmax:
|
||||
curmax = bsize
|
||||
self.form.tableView.verticalHeader().setDefaultSectionSize(curmax + 6)
|
||||
|
||||
def closeEvent(self, evt: QCloseEvent) -> None:
|
||||
if self._closeEventHasCleanedUp:
|
||||
evt.accept()
|
||||
@ -633,26 +244,6 @@ class Browser(QMainWindow):
|
||||
else:
|
||||
super().keyPressEvent(evt)
|
||||
|
||||
def setupColumns(self) -> None:
|
||||
self.columns = [
|
||||
("question", tr.browsing_question()),
|
||||
("answer", tr.browsing_answer()),
|
||||
("template", tr.browsing_card()),
|
||||
("deck", tr.decks_deck()),
|
||||
("noteFld", tr.browsing_sort_field()),
|
||||
("noteCrt", tr.browsing_created()),
|
||||
("noteMod", tr.search_note_modified()),
|
||||
("cardMod", tr.search_card_modified()),
|
||||
("cardDue", tr.statistics_due_date()),
|
||||
("cardIvl", tr.browsing_interval()),
|
||||
("cardEase", tr.browsing_ease()),
|
||||
("cardReps", tr.scheduling_reviews()),
|
||||
("cardLapses", tr.scheduling_lapses()),
|
||||
("noteTags", tr.editing_tags()),
|
||||
("note", tr.browsing_note()),
|
||||
]
|
||||
self.columns.sort(key=itemgetter(1))
|
||||
|
||||
def reopen(
|
||||
self,
|
||||
_mw: AnkiQt,
|
||||
@ -720,12 +311,9 @@ class Browser(QMainWindow):
|
||||
"""Search triggered programmatically. Caller must have saved note first."""
|
||||
|
||||
try:
|
||||
self.model.search(self._lastSearchTxt)
|
||||
self.table.search(self._lastSearchTxt)
|
||||
except Exception as err:
|
||||
showWarning(str(err))
|
||||
if not self.model.cards:
|
||||
# no row change will fire
|
||||
self.onRowChanged(None, None)
|
||||
|
||||
def update_history(self) -> None:
|
||||
sh = self.mw.pm.profile["searchHistory"]
|
||||
@ -737,15 +325,17 @@ class Browser(QMainWindow):
|
||||
self.form.searchEdit.addItems(sh)
|
||||
self.mw.pm.profile["searchHistory"] = sh
|
||||
|
||||
def updateTitle(self) -> int:
|
||||
selected = len(self.form.tableView.selectionModel().selectedRows())
|
||||
cur = len(self.model.cards)
|
||||
self.setWindowTitle(
|
||||
without_unicode_isolation(
|
||||
tr.browsing_window_title(total=cur, selected=selected)
|
||||
)
|
||||
def updateTitle(self) -> None:
|
||||
selected = self.table.len_selection()
|
||||
cur = self.table.len()
|
||||
tr_title = (
|
||||
tr.browsing_window_title
|
||||
if self.table.is_card_state()
|
||||
else tr.browsing_window_title_notes
|
||||
)
|
||||
self.setWindowTitle(
|
||||
without_unicode_isolation(tr_title(total=cur, selected=selected))
|
||||
)
|
||||
return selected
|
||||
|
||||
def search_for_terms(self, *search_terms: Union[str, SearchNode]) -> None:
|
||||
search = self.col.build_search_string(*search_terms)
|
||||
@ -760,40 +350,34 @@ class Browser(QMainWindow):
|
||||
search = self.col.build_search_string(SearchNode(nid=card.nid))
|
||||
search = gui_hooks.default_search(search, card)
|
||||
self.search_for(search, "")
|
||||
self.focusCid(card.id)
|
||||
self.table.select_single_card(card.id)
|
||||
|
||||
self.editor.call_after_note_saved(on_show_single_card)
|
||||
|
||||
def onReset(self) -> None:
|
||||
self.sidebar.refresh()
|
||||
self.model.reset()
|
||||
self.begin_reset()
|
||||
self.end_reset()
|
||||
|
||||
# Table view & editor
|
||||
# caller must have called editor.saveNow() before calling this or .reset()
|
||||
def begin_reset(self) -> None:
|
||||
self.editor.set_note(None, hide=False)
|
||||
self.mw.progress.start()
|
||||
self.table.begin_reset()
|
||||
|
||||
def end_reset(self) -> None:
|
||||
self.table.end_reset()
|
||||
self.mw.progress.finish()
|
||||
|
||||
# Table & Editor
|
||||
######################################################################
|
||||
|
||||
def setupTable(self) -> None:
|
||||
self.model = DataModel(self)
|
||||
self.form.tableView.setSortingEnabled(True)
|
||||
self.form.tableView.setModel(self.model)
|
||||
self.form.tableView.selectionModel()
|
||||
self.form.tableView.setItemDelegate(StatusDelegate(self, self.model))
|
||||
qconnect(
|
||||
self.form.tableView.selectionModel().selectionChanged, self.onRowChanged
|
||||
)
|
||||
self.form.tableView.setWordWrap(False)
|
||||
if not theme_manager.night_mode:
|
||||
self.form.tableView.setStyleSheet(
|
||||
"QTableView{ selection-background-color: rgba(150, 150, 150, 50); "
|
||||
"selection-color: black; }"
|
||||
)
|
||||
elif theme_manager.macos_dark_mode():
|
||||
grid = colors.FRAME_BG
|
||||
self.form.tableView.setStyleSheet(
|
||||
f"""
|
||||
QTableView {{ gridline-color: {grid} }}
|
||||
"""
|
||||
)
|
||||
self.singleCard = False
|
||||
def setup_table(self) -> None:
|
||||
self.table = Table(self)
|
||||
self.form.radio_cards.setChecked(self.table.is_card_state())
|
||||
self.form.radio_notes.setChecked(not self.table.is_card_state())
|
||||
self.table.set_view(self.form.tableView)
|
||||
qconnect(self.form.radio_cards.toggled, self.on_table_state_changed)
|
||||
|
||||
def setupEditor(self) -> None:
|
||||
def add_preview_button(leftbuttons: List[str], editor: Editor) -> None:
|
||||
@ -824,138 +408,36 @@ QTableView {{ gridline-color: {grid} }}
|
||||
def onRowChanged(
|
||||
self, current: Optional[QItemSelection], previous: Optional[QItemSelection]
|
||||
) -> None:
|
||||
"""Update current note and hide/show editor."""
|
||||
"""Update current note and hide/show editor. """
|
||||
if self._closeEventHasCleanedUp:
|
||||
return
|
||||
update = self.updateTitle()
|
||||
show = self.model.cards and update == 1
|
||||
idx = self.form.tableView.selectionModel().currentIndex()
|
||||
if idx.isValid():
|
||||
self.card = self.model.getCard(idx)
|
||||
show = show and self.card is not None
|
||||
self.form.splitter.widget(1).setVisible(bool(show))
|
||||
|
||||
if not show:
|
||||
self.editor.set_note(None)
|
||||
self.singleCard = False
|
||||
self._renderPreview()
|
||||
else:
|
||||
self.editor.set_note(self.card.note(reload=True), focusTo=self.focusTo)
|
||||
self.updateTitle()
|
||||
# the current card is used for context actions
|
||||
self.card = self.table.get_current_card()
|
||||
# if there is only one selected card, use it in the editor
|
||||
# it might differ from the current card
|
||||
card = self.table.get_single_selected_card()
|
||||
self.singleCard = bool(card)
|
||||
self.form.splitter.widget(1).setVisible(self.singleCard)
|
||||
if self.singleCard:
|
||||
self.editor.set_note(card.note(), focusTo=self.focusTo)
|
||||
self.focusTo = None
|
||||
self.editor.card = self.card
|
||||
self.singleCard = True
|
||||
self.editor.card = card
|
||||
else:
|
||||
self.editor.set_note(None)
|
||||
self._renderPreview()
|
||||
self._update_flags_menu()
|
||||
gui_hooks.browser_did_change_row(self)
|
||||
|
||||
def currentRow(self) -> int:
|
||||
idx = self.form.tableView.selectionModel().currentIndex()
|
||||
return idx.row()
|
||||
|
||||
# Headers & sorting
|
||||
######################################################################
|
||||
|
||||
def setupHeaders(self) -> None:
|
||||
vh = self.form.tableView.verticalHeader()
|
||||
hh = self.form.tableView.horizontalHeader()
|
||||
if not isWin:
|
||||
vh.hide()
|
||||
hh.show()
|
||||
restoreHeader(hh, "editor")
|
||||
hh.setHighlightSections(False)
|
||||
hh.setMinimumSectionSize(50)
|
||||
hh.setSectionsMovable(True)
|
||||
self.setColumnSizes()
|
||||
hh.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
qconnect(hh.customContextMenuRequested, self.onHeaderContext)
|
||||
self.setSortIndicator()
|
||||
qconnect(hh.sortIndicatorChanged, self.onSortChanged)
|
||||
qconnect(hh.sectionMoved, self.onColumnMoved)
|
||||
|
||||
@ensure_editor_saved
|
||||
def onSortChanged(self, idx: int, ord: int) -> None:
|
||||
ord = bool(ord)
|
||||
type = self.model.activeCols[idx]
|
||||
noSort = ("question", "answer")
|
||||
if type in noSort:
|
||||
showInfo(tr.browsing_sorting_on_this_column_is_not())
|
||||
type = self.col.conf["sortType"]
|
||||
if self.col.conf["sortType"] != type:
|
||||
self.col.conf["sortType"] = type
|
||||
# default to descending for non-text fields
|
||||
if type == "noteFld":
|
||||
ord = not ord
|
||||
self.col.set_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS, ord)
|
||||
self.col.save()
|
||||
self.search()
|
||||
else:
|
||||
if self.col.get_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS) != ord:
|
||||
self.col.set_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS, ord)
|
||||
self.col.save()
|
||||
self.model.reverse()
|
||||
self.setSortIndicator()
|
||||
|
||||
def setSortIndicator(self) -> None:
|
||||
hh = self.form.tableView.horizontalHeader()
|
||||
type = self.col.conf["sortType"]
|
||||
if type not in self.model.activeCols:
|
||||
hh.setSortIndicatorShown(False)
|
||||
return
|
||||
idx = self.model.activeCols.index(type)
|
||||
if self.col.get_config_bool(Config.Bool.BROWSER_SORT_BACKWARDS):
|
||||
ord = Qt.DescendingOrder
|
||||
else:
|
||||
ord = Qt.AscendingOrder
|
||||
hh.blockSignals(True)
|
||||
hh.setSortIndicator(idx, ord)
|
||||
hh.blockSignals(False)
|
||||
hh.setSortIndicatorShown(True)
|
||||
|
||||
def onHeaderContext(self, pos: QPoint) -> None:
|
||||
gpos = self.form.tableView.mapToGlobal(pos)
|
||||
m = QMenu()
|
||||
for type, name in self.columns:
|
||||
a = m.addAction(name)
|
||||
a.setCheckable(True)
|
||||
a.setChecked(type in self.model.activeCols)
|
||||
qconnect(a.toggled, lambda b, t=type: self.toggleField(t))
|
||||
gui_hooks.browser_header_will_show_context_menu(self, m)
|
||||
m.exec_(gpos)
|
||||
|
||||
@ensure_editor_saved_on_trigger
|
||||
def toggleField(self, type: str) -> None:
|
||||
self.model.beginReset()
|
||||
if type in self.model.activeCols:
|
||||
if len(self.model.activeCols) < 2:
|
||||
self.model.endReset()
|
||||
showInfo(tr.browsing_you_must_have_at_least_one())
|
||||
return
|
||||
self.model.activeCols.remove(type)
|
||||
adding = False
|
||||
else:
|
||||
self.model.activeCols.append(type)
|
||||
adding = True
|
||||
self.col.conf["activeCols"] = self.model.activeCols
|
||||
# sorted field may have been hidden
|
||||
self.setSortIndicator()
|
||||
self.setColumnSizes()
|
||||
self.model.endReset()
|
||||
# if we added a column, scroll to it
|
||||
if adding:
|
||||
row = self.currentRow()
|
||||
idx = self.model.index(row, len(self.model.activeCols) - 1)
|
||||
self.form.tableView.scrollTo(idx)
|
||||
def on_table_state_changed(self) -> None:
|
||||
self.mw.progress.start()
|
||||
self.table.toggle_state(self.form.radio_cards.isChecked(), self._lastSearchTxt)
|
||||
self.mw.progress.finish()
|
||||
|
||||
def setColumnSizes(self) -> None:
|
||||
hh = self.form.tableView.horizontalHeader()
|
||||
hh.setSectionResizeMode(QHeaderView.Interactive)
|
||||
hh.setSectionResizeMode(
|
||||
hh.logicalIndex(len(self.model.activeCols) - 1), QHeaderView.Stretch
|
||||
)
|
||||
# this must be set post-resize or it doesn't work
|
||||
hh.setCascadingSectionResizes(False)
|
||||
|
||||
def onColumnMoved(self, *args: Any) -> None:
|
||||
self.setColumnSizes()
|
||||
# Sidebar
|
||||
######################################################################
|
||||
|
||||
def setupSidebar(self) -> None:
|
||||
dw = self.sidebarDockWidget = QDockWidget(tr.browsing_sidebar(), self)
|
||||
@ -1052,29 +534,13 @@ QTableView {{ gridline-color: {grid} }}
|
||||
######################################################################
|
||||
|
||||
def selected_cards(self) -> List[CardId]:
|
||||
return [
|
||||
self.model.cards[idx.row()]
|
||||
for idx in self.form.tableView.selectionModel().selectedRows()
|
||||
]
|
||||
return self.table.get_selected_card_ids()
|
||||
|
||||
def selected_notes(self) -> List[NoteId]:
|
||||
return self.col.db.list(
|
||||
"""
|
||||
select distinct nid from cards
|
||||
where id in %s"""
|
||||
% ids2str(
|
||||
[
|
||||
self.model.cards[idx.row()]
|
||||
for idx in self.form.tableView.selectionModel().selectedRows()
|
||||
]
|
||||
)
|
||||
)
|
||||
return self.table.get_selected_note_ids()
|
||||
|
||||
def selectedNotesAsCards(self) -> List[CardId]:
|
||||
return self.col.db.list(
|
||||
"select id from cards where nid in (%s)"
|
||||
% ",".join([str(s) for s in self.selected_notes()])
|
||||
)
|
||||
return self.table.get_card_ids_from_selected_note_ids()
|
||||
|
||||
def oneModelNotes(self) -> List[NoteId]:
|
||||
sf = self.selected_notes()
|
||||
@ -1154,12 +620,13 @@ where id in %s"""
|
||||
return
|
||||
|
||||
# nothing selected?
|
||||
nids = self.selected_notes()
|
||||
nids = self.table.get_selected_note_ids()
|
||||
if not nids:
|
||||
return
|
||||
|
||||
# select the next card if there is one
|
||||
self._onNextCard()
|
||||
self.focusTo = self.editor.currentField
|
||||
self.table.to_next_row()
|
||||
|
||||
remove_notes(
|
||||
mw=self.mw,
|
||||
@ -1178,7 +645,7 @@ where id in %s"""
|
||||
def set_deck_of_selected_cards(self) -> None:
|
||||
from aqt.studydeck import StudyDeck
|
||||
|
||||
cids = self.selected_cards()
|
||||
cids = self.table.get_selected_card_ids()
|
||||
if not cids:
|
||||
return
|
||||
|
||||
@ -1351,27 +818,12 @@ where id in %s"""
|
||||
def selectNotes(self) -> None:
|
||||
nids = self.selected_notes()
|
||||
# clear the selection so we don't waste energy preserving it
|
||||
tv = self.form.tableView
|
||||
tv.selectionModel().clear()
|
||||
|
||||
self.table.clear_selection()
|
||||
search = self.col.build_search_string(
|
||||
SearchNode(nids=SearchNode.IdList(ids=nids))
|
||||
)
|
||||
self.search_for(search)
|
||||
|
||||
tv.selectAll()
|
||||
|
||||
def invertSelection(self) -> None:
|
||||
sm = self.form.tableView.selectionModel()
|
||||
items = sm.selection()
|
||||
self.form.tableView.selectAll()
|
||||
sm.select(
|
||||
items,
|
||||
cast(
|
||||
QItemSelectionModel.SelectionFlags,
|
||||
QItemSelectionModel.Deselect | QItemSelectionModel.Rows,
|
||||
),
|
||||
)
|
||||
self.table.select_all()
|
||||
|
||||
# Hooks
|
||||
######################################################################
|
||||
@ -1380,16 +832,16 @@ where id in %s"""
|
||||
gui_hooks.undo_state_did_change.append(self.onUndoState)
|
||||
# fixme: remove this once all items are using `operation_did_execute`
|
||||
gui_hooks.sidebar_should_refresh_notetypes.append(self.on_item_added)
|
||||
gui_hooks.backend_will_block.append(self.on_backend_will_block)
|
||||
gui_hooks.backend_did_block.append(self.on_backend_did_block)
|
||||
gui_hooks.backend_will_block.append(self.table.on_backend_will_block)
|
||||
gui_hooks.backend_did_block.append(self.table.on_backend_did_block)
|
||||
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
|
||||
gui_hooks.focus_did_change.append(self.on_focus_change)
|
||||
|
||||
def teardownHooks(self) -> None:
|
||||
gui_hooks.undo_state_did_change.remove(self.onUndoState)
|
||||
gui_hooks.sidebar_should_refresh_notetypes.remove(self.on_item_added)
|
||||
gui_hooks.backend_will_block.remove(self.on_backend_will_block)
|
||||
gui_hooks.backend_did_block.remove(self.on_backend_will_block)
|
||||
gui_hooks.backend_will_block.remove(self.table.on_backend_will_block)
|
||||
gui_hooks.backend_did_block.remove(self.table.on_backend_will_block)
|
||||
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
|
||||
gui_hooks.focus_did_change.remove(self.on_focus_change)
|
||||
|
||||
@ -1514,14 +966,14 @@ where id in %s"""
|
||||
def _onTagDupes(self, res: List[Any]) -> None:
|
||||
if not res:
|
||||
return
|
||||
self.model.beginReset()
|
||||
self.begin_reset()
|
||||
self.mw.checkpoint(tr.browsing_tag_duplicates())
|
||||
nids = set()
|
||||
for _, nidlist in res:
|
||||
nids.update(nidlist)
|
||||
self.col.tags.bulk_add(list(nids), tr.browsing_duplicate())
|
||||
self.mw.progress.finish()
|
||||
self.model.endReset()
|
||||
self.end_reset()
|
||||
self.mw.requireReset(reason=ResetReason.BrowserTagDupes, context=self)
|
||||
tooltip(tr.browsing_notes_tagged())
|
||||
|
||||
@ -1532,69 +984,25 @@ where id in %s"""
|
||||
# Jumping
|
||||
######################################################################
|
||||
|
||||
def _moveCur(
|
||||
self, dir: Optional[QTableView.CursorAction], idx: QModelIndex = None
|
||||
) -> None:
|
||||
if not self.model.cards:
|
||||
return
|
||||
tv = self.form.tableView
|
||||
if dir is not None:
|
||||
idx = tv.moveCursor(dir, self.mw.app.keyboardModifiers())
|
||||
tv.selectionModel().setCurrentIndex(
|
||||
idx,
|
||||
cast(
|
||||
QItemSelectionModel.SelectionFlags,
|
||||
QItemSelectionModel.Clear
|
||||
| QItemSelectionModel.Select
|
||||
| QItemSelectionModel.Rows,
|
||||
),
|
||||
)
|
||||
def has_previous_card(self) -> bool:
|
||||
return self.table.has_previous()
|
||||
|
||||
def has_next_card(self) -> bool:
|
||||
return self.table.has_next()
|
||||
|
||||
def onPreviousCard(self) -> None:
|
||||
self.focusTo = self.editor.currentField
|
||||
self.editor.call_after_note_saved(self._onPreviousCard)
|
||||
|
||||
def _onPreviousCard(self) -> None:
|
||||
self._moveCur(QAbstractItemView.MoveUp)
|
||||
self.editor.call_after_note_saved(self.table.to_previous_row)
|
||||
|
||||
def onNextCard(self) -> None:
|
||||
self.focusTo = self.editor.currentField
|
||||
self.editor.call_after_note_saved(self._onNextCard)
|
||||
|
||||
def _onNextCard(self) -> None:
|
||||
self._moveCur(QAbstractItemView.MoveDown)
|
||||
self.editor.call_after_note_saved(self.table.to_next_row)
|
||||
|
||||
def onFirstCard(self) -> None:
|
||||
sm = self.form.tableView.selectionModel()
|
||||
idx = sm.currentIndex()
|
||||
self._moveCur(None, self.model.index(0, 0))
|
||||
if not KeyboardModifiersPressed().shift:
|
||||
return
|
||||
idx2 = sm.currentIndex()
|
||||
item = QItemSelection(idx2, idx)
|
||||
sm.select(
|
||||
item,
|
||||
cast(
|
||||
QItemSelectionModel.SelectionFlags,
|
||||
QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows,
|
||||
),
|
||||
)
|
||||
self.table.to_first_row()
|
||||
|
||||
def onLastCard(self) -> None:
|
||||
sm = self.form.tableView.selectionModel()
|
||||
idx = sm.currentIndex()
|
||||
self._moveCur(None, self.model.index(len(self.model.cards) - 1, 0))
|
||||
if not KeyboardModifiersPressed().shift:
|
||||
return
|
||||
idx2 = sm.currentIndex()
|
||||
item = QItemSelection(idx, idx2)
|
||||
sm.select(
|
||||
item,
|
||||
cast(
|
||||
QItemSelectionModel.SelectionFlags,
|
||||
QItemSelectionModel.SelectCurrent | QItemSelectionModel.Rows,
|
||||
),
|
||||
)
|
||||
self.table.to_last_row()
|
||||
|
||||
def onFind(self) -> None:
|
||||
# workaround for PyQt focus bug
|
||||
@ -1613,14 +1021,6 @@ where id in %s"""
|
||||
def onCardList(self) -> None:
|
||||
self.form.tableView.setFocus()
|
||||
|
||||
def focusCid(self, cid: CardId) -> None:
|
||||
try:
|
||||
row = list(self.model.cards).index(cid)
|
||||
except ValueError:
|
||||
return
|
||||
self.form.tableView.clearSelection()
|
||||
self.form.tableView.selectRow(row)
|
||||
|
||||
|
||||
# Change model dialog
|
||||
######################################################################
|
||||
@ -1796,11 +1196,11 @@ class ChangeModel(QDialog):
|
||||
b = self.browser
|
||||
b.mw.col.modSchema(check=True)
|
||||
b.mw.progress.start()
|
||||
b.model.beginReset()
|
||||
b.begin_reset()
|
||||
mm = b.mw.col.models
|
||||
mm.change(self.oldModel, self.nids, self.targetModel, fmap, cmap)
|
||||
b.search()
|
||||
b.model.endReset()
|
||||
b.end_reset()
|
||||
b.mw.progress.finish()
|
||||
b.mw.reset()
|
||||
self.cleanup()
|
||||
|
@ -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">
|
||||
|
@ -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
1136
qt/aqt/table.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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> {
|
||||
// this is inefficient; we may want to use an enum in the future
|
||||
let columns = self.get_desktop_browser_card_columns();
|
||||
let mut context = RowContext::new(self, id, card_render_required(&columns))?;
|
||||
pub fn browser_row_for_id(&mut self, id: i64) -> Result<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();
|
||||
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)?
|
||||
}
|
||||
.ok_or(AnkiError::NotFound)?;
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
|
@ -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(())
|
||||
}
|
@ -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(())
|
||||
}
|
||||
|
10
rslib/src/search/note_cards_order.sql
Normal file
10
rslib/src/search/note_cards_order.sql
Normal 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(*);
|
11
rslib/src/search/note_ease_order.sql
Normal file
11
rslib/src/search/note_ease_order.sql
Normal 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);
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user