Merge pull request #1113 from RumovZ/backend-columns

Backend columns
This commit is contained in:
Damien Elmes 2021-04-12 16:05:11 +10:00 committed by GitHub
commit 3f3c509bad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 696 additions and 794 deletions

View file

@ -7,8 +7,8 @@ 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-average-ease = Avg. Ease
browsing-average-interval = Average Interval browsing-average-interval = Avg. Interval
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
@ -101,7 +101,7 @@ browsing-suspended = Suspended
browsing-tag-duplicates = Tag Duplicates browsing-tag-duplicates = Tag Duplicates
browsing-tag-rename-warning-empty = You can't rename a tag that has no notes. browsing-tag-rename-warning-empty = You can't rename a tag that has no notes.
browsing-target-field = Target field: browsing-target-field = Target field:
browsing-toggle-cards-notes-mode = Toggle Cards/Notes Mode browsing-toggle-showing-cards-notes = Toggle Showing Cards/Notes
browsing-toggle-mark = Toggle Mark browsing-toggle-mark = Toggle Mark
browsing-toggle-suspend = Toggle Suspend 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

View file

@ -4,6 +4,7 @@ persistent = no
[TYPECHECK] [TYPECHECK]
ignored-classes= ignored-classes=
BrowserColumns,
BrowserRow, BrowserRow,
FormatTimespanIn, FormatTimespanIn,
AnswerCardIn, AnswerCardIn,

View file

@ -12,7 +12,6 @@ SearchNode = _pb.SearchNode
Progress = _pb.Progress Progress = _pb.Progress
EmptyCardsReport = _pb.EmptyCardsReport EmptyCardsReport = _pb.EmptyCardsReport
GraphPreferences = _pb.GraphPreferences GraphPreferences = _pb.GraphPreferences
BuiltinSort = _pb.SortOrder.Builtin
Preferences = _pb.Preferences Preferences = _pb.Preferences
UndoStatus = _pb.UndoStatus UndoStatus = _pb.UndoStatus
OpChanges = _pb.OpChanges OpChanges = _pb.OpChanges
@ -21,6 +20,7 @@ OpChangesWithId = _pb.OpChangesWithId
OpChangesAfterUndo = _pb.OpChangesAfterUndo OpChangesAfterUndo = _pb.OpChangesAfterUndo
DefaultsForAdding = _pb.DeckAndNotetype DefaultsForAdding = _pb.DeckAndNotetype
BrowserRow = _pb.BrowserRow BrowserRow = _pb.BrowserRow
BrowserColumns = _pb.BrowserColumns
import copy import copy
import os import os
@ -40,7 +40,7 @@ from anki.config import Config, ConfigManager
from anki.consts import * from anki.consts import *
from anki.dbproxy import DBProxy from anki.dbproxy import DBProxy
from anki.decks import Deck, DeckConfig, DeckConfigId, DeckId, DeckManager from anki.decks import Deck, DeckConfig, DeckConfigId, DeckId, DeckManager
from anki.errors import AbortSchemaModification, DBError from anki.errors import AbortSchemaModification, DBError, InvalidInput
from anki.lang import FormatTimeSpan from anki.lang import FormatTimeSpan
from anki.media import MediaManager, media_paths_from_col_path from anki.media import MediaManager, media_paths_from_col_path
from anki.models import ModelManager, Notetype, NotetypeDict, NotetypeId from anki.models import ModelManager, Notetype, NotetypeDict, NotetypeId
@ -505,7 +505,7 @@ class Collection:
def find_cards( def find_cards(
self, self,
query: str, query: str,
order: Union[bool, str, BuiltinSort.Kind.V] = False, order: Union[bool, str, BrowserColumns.Column] = False,
reverse: bool = False, reverse: bool = False,
) -> Sequence[CardId]: ) -> Sequence[CardId]:
"""Return card ids matching the provided search. """Return card ids matching the provided search.
@ -520,13 +520,15 @@ class Collection:
desc and vice versa when reverse is set in the collection config, eg desc and vice versa when reverse is set in the collection config, eg
order="c.ivl asc, c.due desc". order="c.ivl asc, c.due desc".
If order is a BuiltinSort.Kind value, sort using that builtin sort, eg If order is a BrowserColumns.Column that supports sorting, sort using that
col.find_cards("", order=BuiltinSort.Kind.CARD_DUE) column. All available columns are available through col.all_browser_columns()
or browser.table._model.columns and support sorting unless column.sorting
is set to BrowserColumns.SORTING_NONE.
The reverse argument only applies when a BuiltinSort.Kind is provided; The reverse argument only applies when a BrowserColumns.Column is provided;
otherwise the collection config defines whether reverse is set or not. otherwise the collection config defines whether reverse is set or not.
""" """
mode = _build_sort_mode(order, reverse) mode = self._build_sort_mode(order, reverse, False)
return cast( return cast(
Sequence[CardId], self._backend.search_cards(search=query, order=mode) Sequence[CardId], self._backend.search_cards(search=query, order=mode)
) )
@ -534,7 +536,7 @@ class Collection:
def find_notes( def find_notes(
self, self,
query: str, query: str,
order: Union[bool, str, BuiltinSort.Kind.V] = False, order: Union[bool, str, BrowserColumns.Column] = False,
reverse: bool = False, reverse: bool = False,
) -> Sequence[NoteId]: ) -> Sequence[NoteId]:
"""Return note ids matching the provided search. """Return note ids matching the provided search.
@ -542,11 +544,38 @@ class Collection:
To programmatically construct a search string, see .build_search_string(). To programmatically construct a search string, see .build_search_string().
The order parameter is documented in .find_cards(). The order parameter is documented in .find_cards().
""" """
mode = _build_sort_mode(order, reverse) mode = self._build_sort_mode(order, reverse, True)
return cast( return cast(
Sequence[NoteId], self._backend.search_notes(search=query, order=mode) Sequence[NoteId], self._backend.search_notes(search=query, order=mode)
) )
def _build_sort_mode(
self,
order: Union[bool, str, BrowserColumns.Column],
reverse: bool,
finding_notes: bool,
) -> _pb.SortOrder:
if isinstance(order, str):
return _pb.SortOrder(custom=order)
if isinstance(order, bool):
if order is False:
return _pb.SortOrder(none=_pb.Empty())
# order=True: set args to sort column and reverse from config
sort_key = "noteSortType" if finding_notes else "sortType"
order = self.get_browser_column(self.get_config(sort_key))
reverse_key = (
Config.Bool.BROWSER_NOTE_SORT_BACKWARDS
if finding_notes
else Config.Bool.BROWSER_SORT_BACKWARDS
)
reverse = self.get_config_bool(reverse_key)
if isinstance(order, BrowserColumns.Column):
if order.sorting != BrowserColumns.SORTING_NONE:
return _pb.SortOrder(
builtin=_pb.SortOrder.Builtin(column=order.key, reverse=reverse)
)
raise InvalidInput(f"{order} is not a valid sort order.")
def find_and_replace( def find_and_replace(
self, self,
*, *,
@ -696,6 +725,15 @@ class Collection:
# Browser Table # Browser Table
########################################################################## ##########################################################################
def all_browser_columns(self) -> Sequence[BrowserColumns.Column]:
return self._backend.all_browser_columns()
def get_browser_column(self, key: str) -> Optional[BrowserColumns.Column]:
for column in self._backend.all_browser_columns():
if column.key == key:
return column
return None
def browser_row_for_id( def browser_row_for_id(
self, id_: 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]:
@ -712,24 +750,24 @@ class Collection:
columns = self.get_config( columns = self.get_config(
"activeCols", ["noteFld", "template", "cardDue", "deck"] "activeCols", ["noteFld", "template", "cardDue", "deck"]
) )
self._backend.set_desktop_browser_card_columns(columns) self._backend.set_active_browser_columns(columns)
return columns return columns
def set_browser_card_columns(self, columns: List[str]) -> None: def set_browser_card_columns(self, columns: List[str]) -> None:
self.set_config("activeCols", columns) self.set_config("activeCols", columns)
self._backend.set_desktop_browser_card_columns(columns) self._backend.set_active_browser_columns(columns)
def load_browser_note_columns(self) -> List[str]: def load_browser_note_columns(self) -> List[str]:
"""Return the stored note column names and ensure the backend columns are set and in sync.""" """Return the stored note column names and ensure the backend columns are set and in sync."""
columns = self.get_config( columns = self.get_config(
"activeNoteCols", ["noteFld", "note", "noteCards", "noteTags"] "activeNoteCols", ["noteFld", "note", "noteCards", "noteTags"]
) )
self._backend.set_desktop_browser_note_columns(columns) self._backend.set_active_browser_columns(columns)
return columns return columns
def set_browser_note_columns(self, columns: List[str]) -> None: def set_browser_note_columns(self, columns: List[str]) -> None:
self.set_config("activeNoteCols", columns) self.set_config("activeNoteCols", columns)
self._backend.set_desktop_browser_note_columns(columns) self._backend.set_active_browser_columns(columns)
# Config # Config
########################################################################## ##########################################################################
@ -1111,18 +1149,3 @@ class _ReviewsUndo:
_UndoInfo = Union[_ReviewsUndo, LegacyCheckpoint, None] _UndoInfo = Union[_ReviewsUndo, LegacyCheckpoint, None]
def _build_sort_mode(
order: Union[bool, str, BuiltinSort.Kind.V],
reverse: bool,
) -> _pb.SortOrder:
if isinstance(order, str):
return _pb.SortOrder(custom=order)
elif isinstance(order, bool):
if order is True:
return _pb.SortOrder(from_config=_pb.Empty())
else:
return _pb.SortOrder(none=_pb.Empty())
else:
return _pb.SortOrder(builtin=_pb.SortOrder.Builtin(kind=order, reverse=reverse))

View file

@ -1,7 +1,7 @@
# coding: utf-8 # coding: utf-8
import pytest import pytest
from anki.collection import BuiltinSort, Config from anki.collection import Config
from anki.consts import * from anki.consts import *
from tests.shared import getEmptyCol, isNearCutoff from tests.shared import getEmptyCol, isNearCutoff
@ -125,10 +125,12 @@ def test_findCards():
col.flush() col.flush()
assert col.findCards("", order=True)[0] in latestCardIds assert col.findCards("", order=True)[0] in latestCardIds
assert ( assert (
col.find_cards("", order=BuiltinSort.CARD_DUE, reverse=False)[0] == firstCardId col.find_cards("", order=col.get_browser_column("cardDue"), reverse=False)[0]
== firstCardId
) )
assert ( assert (
col.find_cards("", order=BuiltinSort.CARD_DUE, reverse=True)[0] != firstCardId col.find_cards("", order=col.get_browser_column("cardDue"), reverse=True)[0]
!= firstCardId
) )
# model # model
assert len(col.findCards("note:basic")) == 3 assert len(col.findCards("note:basic")) == 3

View file

@ -6,6 +6,7 @@ ignore = forms,hooks_gen.py
[TYPECHECK] [TYPECHECK]
ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio
ignored-classes= ignored-classes=
BrowserColumns,
BrowserRow, BrowserRow,
SearchNode, SearchNode,
Config, Config,

View file

@ -64,7 +64,6 @@ from aqt.utils import (
save_combo_history, save_combo_history,
save_combo_index_for_session, save_combo_index_for_session,
saveGeom, saveGeom,
saveHeader,
saveSplitter, saveSplitter,
saveState, saveState,
shortcut, shortcut,
@ -228,10 +227,10 @@ class Browser(QMainWindow):
def _closeWindow(self) -> None: def _closeWindow(self) -> None:
self._cleanup_preview() self._cleanup_preview()
self.editor.cleanup() self.editor.cleanup()
self.table.cleanup()
saveSplitter(self.form.splitter, "editor3") saveSplitter(self.form.splitter, "editor3")
saveGeom(self, "editor") saveGeom(self, "editor")
saveState(self, "editor") saveState(self, "editor")
saveHeader(self.form.tableView.horizontalHeader(), "editor")
self.teardownHooks() self.teardownHooks()
self.mw.maybeReset() self.mw.maybeReset()
aqt.dialogs.markClosed("Browser") aqt.dialogs.markClosed("Browser")
@ -383,7 +382,7 @@ class Browser(QMainWindow):
self.table.set_view(self.form.tableView) self.table.set_view(self.form.tableView)
switch = Switch(11, tr.browsing_card_initial(), tr.browsing_note_initial()) switch = Switch(11, tr.browsing_card_initial(), tr.browsing_note_initial())
switch.setChecked(self.table.is_notes_mode()) switch.setChecked(self.table.is_notes_mode())
switch.setToolTip(tr.browsing_toggle_cards_notes_mode()) switch.setToolTip(tr.browsing_toggle_showing_cards_notes())
qconnect(self.form.action_toggle_mode.triggered, switch.toggle) qconnect(self.form.action_toggle_mode.triggered, switch.toggle)
qconnect(switch.toggled, self.on_table_state_changed) qconnect(switch.toggled, self.on_table_state_changed)
self.form.gridLayout.addWidget(switch, 0, 0) self.form.gridLayout.addWidget(switch, 0, 0)

View file

@ -607,10 +607,10 @@
</action> </action>
<action name="action_toggle_mode"> <action name="action_toggle_mode">
<property name="text"> <property name="text">
<string>browsing_toggle_cards_notes_mode</string> <string>browsing_toggle_showing_cards_notes</string>
</property> </property>
<property name="shortcut"> <property name="shortcut">
<string notr="true">Ctrl+M</string> <string notr="true">Alt+T</string>
</property> </property>
</action> </action>
</widget> </widget>

View file

@ -6,7 +6,6 @@ from __future__ import annotations
import time import time
from abc import ABC, abstractmethod, abstractproperty from abc import ABC, abstractmethod, abstractproperty
from dataclasses import dataclass from dataclasses import dataclass
from operator import itemgetter
from typing import ( from typing import (
Any, Any,
Callable, Callable,
@ -23,6 +22,7 @@ from typing import (
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 BrowserColumns as Columns
from anki.collection import BrowserRow, Collection, Config, OpChanges from anki.collection import BrowserRow, Collection, Config, OpChanges
from anki.consts import * from anki.consts import *
from anki.errors import NotFoundError from anki.errors import NotFoundError
@ -35,10 +35,12 @@ from aqt.utils import (
KeyboardModifiersPressed, KeyboardModifiersPressed,
qtMenuShortcutWorkaround, qtMenuShortcutWorkaround,
restoreHeader, restoreHeader,
saveHeader,
showInfo, showInfo,
tr, tr,
) )
Column = Columns.Column
ItemId = Union[CardId, NoteId] ItemId = Union[CardId, NoteId]
ItemList = Union[Sequence[CardId], Sequence[NoteId]] ItemList = Union[Sequence[CardId], Sequence[NoteId]]
@ -47,7 +49,8 @@ ItemList = Union[Sequence[CardId], Sequence[NoteId]]
class SearchContext: class SearchContext:
search: str search: str
browser: aqt.browser.Browser browser: aqt.browser.Browser
order: Union[bool, str] = True order: Union[bool, str, Column] = True
reverse: bool = False
# if set, provided ids will be used instead of the regular search # if set, provided ids will be used instead of the regular search
ids: Optional[Sequence[ItemId]] = None ids: Optional[Sequence[ItemId]] = None
@ -73,6 +76,9 @@ class Table:
self._setup_view() self._setup_view()
self._setup_headers() self._setup_headers()
def cleanup(self) -> None:
self._save_header()
# Public Methods # Public Methods
###################################################################### ######################################################################
@ -148,11 +154,8 @@ class Table:
def select_single_card(self, card_id: CardId) -> None: def select_single_card(self, card_id: CardId) -> None:
"""Try to set the selection to the item corresponding to the given card.""" """Try to set the selection to the item corresponding to the given card."""
self.clear_selection() self.clear_selection()
if self.is_notes_mode(): if (row := self._model.get_card_row(card_id)) is not None:
self._view.selectRow(0) self._view.selectRow(row)
else:
if (row := self._model.get_card_row(card_id)) is not None:
self._view.selectRow(row)
# Reset # Reset
@ -198,6 +201,7 @@ class Table:
def toggle_state(self, is_notes_mode: bool, last_search: str) -> None: def toggle_state(self, is_notes_mode: bool, last_search: str) -> None:
if is_notes_mode == self.is_notes_mode(): if is_notes_mode == self.is_notes_mode():
return return
self._save_header()
self._save_selection() self._save_selection()
self._state = self._model.toggle_state( self._state = self._model.toggle_state(
SearchContext(search=last_search, browser=self.browser) SearchContext(search=last_search, browser=self.browser)
@ -205,8 +209,7 @@ class Table:
self.col.set_config_bool( self.col.set_config_bool(
Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE, self.is_notes_mode() Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE, self.is_notes_mode()
) )
self._set_sort_indicator() self._restore_header()
self._set_column_sizes()
self._restore_selection(self._toggled_selection) self._restore_selection(self._toggled_selection)
# Move cursor # Move cursor
@ -273,6 +276,12 @@ class Table:
# this must be set post-resize or it doesn't work # this must be set post-resize or it doesn't work
hh.setCascadingSectionResizes(False) hh.setCascadingSectionResizes(False)
def _save_header(self) -> None:
saveHeader(self._view.horizontalHeader(), self._state.config_key_prefix)
def _restore_header(self) -> None:
restoreHeader(self._view.horizontalHeader(), self._state.config_key_prefix)
# Setup # Setup
def _setup_view(self) -> None: def _setup_view(self) -> None:
@ -314,14 +323,14 @@ class Table:
if not isWin: if not isWin:
vh.hide() vh.hide()
hh.show() hh.show()
restoreHeader(hh, "editor")
hh.setHighlightSections(False) hh.setHighlightSections(False)
hh.setMinimumSectionSize(50) hh.setMinimumSectionSize(50)
hh.setSectionsMovable(True) hh.setSectionsMovable(True)
self._set_column_sizes()
hh.setContextMenuPolicy(Qt.CustomContextMenu) hh.setContextMenuPolicy(Qt.CustomContextMenu)
qconnect(hh.customContextMenuRequested, self._on_header_context) self._restore_header()
self._set_column_sizes()
self._set_sort_indicator() self._set_sort_indicator()
qconnect(hh.customContextMenuRequested, self._on_header_context)
qconnect(hh.sortIndicatorChanged, self._on_sort_column_changed) qconnect(hh.sortIndicatorChanged, self._on_sort_column_changed)
qconnect(hh.sectionMoved, self._on_column_moved) qconnect(hh.sectionMoved, self._on_column_moved)
@ -350,13 +359,13 @@ class Table:
def _on_header_context(self, pos: QPoint) -> None: def _on_header_context(self, pos: QPoint) -> None:
gpos = self._view.mapToGlobal(pos) gpos = self._view.mapToGlobal(pos)
m = QMenu() m = QMenu()
for column, name in self._state.columns: for key, column in self._model.columns.items():
a = m.addAction(name) a = m.addAction(self._state.column_label(column))
a.setCheckable(True) a.setCheckable(True)
a.setChecked(self._model.active_column_index(column) is not None) a.setChecked(self._model.active_column_index(key) is not None)
qconnect( qconnect(
a.toggled, a.toggled,
lambda checked, column=column: self._on_column_toggled(checked, column), lambda checked, key=key: self._on_column_toggled(checked, key),
) )
gui_hooks.browser_header_will_show_context_menu(self.browser, m) gui_hooks.browser_header_will_show_context_menu(self.browser, m)
m.exec_(gpos) m.exec_(gpos)
@ -375,16 +384,18 @@ class Table:
if checked: if checked:
self._scroll_to_column(self._model.len_columns() - 1) self._scroll_to_column(self._model.len_columns() - 1)
def _on_sort_column_changed(self, index: int, order: int) -> None: def _on_sort_column_changed(self, section: int, order: int) -> None:
order = bool(order) order = bool(order)
sort_column = self._model.active_column(index) column = self._model.column_at_section(section)
if sort_column in ("question", "answer"): if column.sorting == Columns.SORTING_NONE:
showInfo(tr.browsing_sorting_on_this_column_is_not()) showInfo(tr.browsing_sorting_on_this_column_is_not())
sort_column = self._state.sort_column sort_key = self._state.sort_column
if self._state.sort_column != sort_column: else:
self._state.sort_column = sort_column sort_key = column.key
if self._state.sort_column != sort_key:
self._state.sort_column = sort_key
# default to descending for non-text fields # default to descending for non-text fields
if sort_column == "noteFld": if column.sorting == Columns.SORTING_REVERSED:
order = not order order = not order
self._state.sort_backwards = order self._state.sort_backwards = order
self.browser.search() self.browser.search()
@ -523,7 +534,7 @@ class Table:
class ItemState(ABC): class ItemState(ABC):
_columns: List[Tuple[str, str]] config_key_prefix: str
_active_columns: List[str] _active_columns: List[str]
_sort_column: str _sort_column: str
_sort_backwards: bool _sort_backwards: bool
@ -545,14 +556,18 @@ class ItemState(ABC):
def card_ids_from_note_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]: def card_ids_from_note_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]:
return self.col.db.list(f"select id from cards where nid in {ids2str(items)}") return self.col.db.list(f"select id from cards where nid in {ids2str(items)}")
def column_key_at(self, index: int) -> str:
return self._active_columns[index]
def column_label(self, column: Column) -> str:
return (
column.notes_mode_label if self.is_notes_mode() else column.cards_mode_label
)
# Columns and sorting # Columns and sorting
# abstractproperty is deprecated but used due to mypy limitations # abstractproperty is deprecated but used due to mypy limitations
# (https://github.com/python/mypy/issues/1362) # (https://github.com/python/mypy/issues/1362)
@abstractproperty
def columns(self) -> List[Tuple[str, str]]:
"""Return all for the state available columns."""
@abstractproperty @abstractproperty
def active_columns(self) -> List[str]: def active_columns(self) -> List[str]:
"""Return the saved or default columns for the state.""" """Return the saved or default columns for the state."""
@ -590,7 +605,9 @@ class ItemState(ABC):
# Get ids # Get ids
@abstractmethod @abstractmethod
def find_items(self, search: str, order: Union[bool, str]) -> Sequence[ItemId]: def find_items(
self, search: str, order: Union[bool, str, Column], reverse: bool
) -> Sequence[ItemId]:
"""Return the item ids fitting the given search and order.""" """Return the item ids fitting the given search and order."""
@abstractmethod @abstractmethod
@ -619,37 +636,13 @@ class ItemState(ABC):
class CardState(ItemState): class CardState(ItemState):
def __init__(self, col: Collection) -> None: def __init__(self, col: Collection) -> None:
super().__init__(col) super().__init__(col)
self._load_columns() self.config_key_prefix = "editor"
self._active_columns = self.col.load_browser_card_columns() self._active_columns = self.col.load_browser_card_columns()
self._sort_column = self.col.get_config("sortType") self._sort_column = self.col.get_config("sortType")
self._sort_backwards = self.col.get_config_bool( self._sort_backwards = self.col.get_config_bool(
Config.Bool.BROWSER_SORT_BACKWARDS Config.Bool.BROWSER_SORT_BACKWARDS
) )
def _load_columns(self) -> None:
self._columns = [
("question", tr.browsing_question()),
("answer", tr.browsing_answer()),
("template", tr.browsing_card()),
("deck", tr.decks_deck()),
("noteFld", tr.browsing_sort_field()),
("noteCrt", tr.browsing_created()),
("noteMod", tr.search_note_modified()),
("cardMod", tr.search_card_modified()),
("cardDue", tr.statistics_due_date()),
("cardIvl", tr.browsing_interval()),
("cardEase", tr.browsing_ease()),
("cardReps", tr.scheduling_reviews()),
("cardLapses", tr.scheduling_lapses()),
("noteTags", tr.editing_tags()),
("note", tr.browsing_note()),
]
self._columns.sort(key=itemgetter(1))
@property
def columns(self) -> List[Tuple[str, str]]:
return self._columns
@property @property
def active_columns(self) -> List[str]: def active_columns(self) -> List[str]:
return self._active_columns return self._active_columns
@ -685,8 +678,10 @@ class CardState(ItemState):
def get_note(self, item: ItemId) -> Note: def get_note(self, item: ItemId) -> Note:
return self.get_card(item).note() return self.get_card(item).note()
def find_items(self, search: str, order: Union[bool, str]) -> Sequence[ItemId]: def find_items(
return self.col.find_cards(search, order) self, search: str, order: Union[bool, str, Column], reverse: bool
) -> Sequence[ItemId]:
return self.col.find_cards(search, order, reverse)
def get_item_from_card_id(self, card: CardId) -> ItemId: def get_item_from_card_id(self, card: CardId) -> ItemId:
return card return card
@ -707,33 +702,13 @@ class CardState(ItemState):
class NoteState(ItemState): class NoteState(ItemState):
def __init__(self, col: Collection) -> None: def __init__(self, col: Collection) -> None:
super().__init__(col) super().__init__(col)
self._load_columns() self.config_key_prefix = "editorNotesMode"
self._active_columns = self.col.load_browser_note_columns() self._active_columns = self.col.load_browser_note_columns()
self._sort_column = self.col.get_config("noteSortType") self._sort_column = self.col.get_config("noteSortType")
self._sort_backwards = self.col.get_config_bool( self._sort_backwards = self.col.get_config_bool(
Config.Bool.BROWSER_NOTE_SORT_BACKWARDS Config.Bool.BROWSER_NOTE_SORT_BACKWARDS
) )
def _load_columns(self) -> None:
self._columns = [
("note", tr.browsing_note()),
("noteCards", tr.editing_cards()),
("noteCrt", tr.browsing_created()),
("noteDue", tr.statistics_due_date()),
("noteEase", tr.browsing_average_ease()),
("noteFld", tr.browsing_sort_field()),
("noteIvl", tr.browsing_average_interval()),
("noteLapses", tr.scheduling_lapses()),
("noteMod", tr.search_note_modified()),
("noteReps", tr.scheduling_reviews()),
("noteTags", tr.editing_tags()),
]
self._columns.sort(key=itemgetter(1))
@property
def columns(self) -> List[Tuple[str, str]]:
return self._columns
@property @property
def active_columns(self) -> List[str]: def active_columns(self) -> List[str]:
return self._active_columns return self._active_columns
@ -769,11 +744,13 @@ class NoteState(ItemState):
def get_note(self, item: ItemId) -> Note: def get_note(self, item: ItemId) -> Note:
return self.col.get_note(NoteId(item)) return self.col.get_note(NoteId(item))
def find_items(self, search: str, order: Union[bool, str]) -> Sequence[ItemId]: def find_items(
return self.col.find_notes(search, order) self, search: str, order: Union[bool, str, Column], reverse: bool
) -> Sequence[ItemId]:
return self.col.find_notes(search, order, reverse)
def get_item_from_card_id(self, card: CardId) -> ItemId: def get_item_from_card_id(self, card: CardId) -> ItemId:
return self.get_card(card).note().id return self.col.get_card(card).note().id
def get_card_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]: def get_card_ids(self, items: Sequence[ItemId]) -> Sequence[CardId]:
return super().card_ids_from_note_ids(items) return super().card_ids_from_note_ids(items)
@ -854,13 +831,27 @@ def backend_color_to_aqt_color(color: BrowserRow.Color.V) -> Optional[Tuple[str,
class DataModel(QAbstractTableModel): class DataModel(QAbstractTableModel):
"""Data manager for the browser table.
_items -- The card or note ids currently hold and corresponding to the
table's rows.
_rows -- The cached data objects to render items to rows.
columns -- The data objects of all available columns, used to define the display
of active columns and list all toggleable columns to the user.
_block_updates -- If True, serve stale content to avoid hitting the DB.
_stale_cutoff -- A threshold to decide whether a cached row has gone stale.
"""
def __init__(self, col: Collection, state: ItemState) -> None: def __init__(self, col: Collection, state: ItemState) -> None:
QAbstractTableModel.__init__(self) QAbstractTableModel.__init__(self)
self.col: Collection = col self.col: Collection = col
self.columns: Dict[str, Column] = dict(
((c.key, c) for c in self.col.all_browser_columns())
)
gui_hooks.browser_did_fetch_columns(self.columns)
self._state: ItemState = state self._state: ItemState = state
self._items: Sequence[ItemId] = [] self._items: Sequence[ItemId] = []
self._rows: Dict[int, CellRow] = {} self._rows: Dict[int, CellRow] = {}
# serve stale content to avoid hitting the DB?
self._block_updates = False self._block_updates = False
self._stale_cutoff = 0.0 self._stale_cutoff = 0.0
@ -1010,9 +1001,18 @@ class DataModel(QAbstractTableModel):
def search(self, context: SearchContext) -> None: def search(self, context: SearchContext) -> None:
self.begin_reset() self.begin_reset()
try: try:
if context.order is True:
try:
context.order = self.columns[self._state.sort_column]
except KeyError:
# invalid sort column in config
context.order = self.columns["noteCrt"]
context.reverse = self._state.sort_backwards
gui_hooks.browser_will_search(context) gui_hooks.browser_will_search(context)
if context.ids is None: if context.ids is None:
context.ids = self._state.find_items(context.search, context.order) context.ids = self._state.find_items(
context.search, context.order, context.reverse
)
gui_hooks.browser_did_search(context) gui_hooks.browser_did_search(context)
self._items = context.ids self._items = context.ids
self._rows = {} self._rows = {}
@ -1026,8 +1026,19 @@ class DataModel(QAbstractTableModel):
# Columns # Columns
def active_column(self, index: int) -> str: def column_at(self, index: QModelIndex) -> Column:
return self._state.active_columns[index] return self.column_at_section(index.column())
def column_at_section(self, section: int) -> Column:
"""Returns the column object corresponding to the active column at index or the default
column object if no data is associated with the active column.
"""
key = self._state.column_key_at(section)
try:
return self.columns[key]
except KeyError:
self.columns[key] = addon_column_fillin(key)
return self.columns[key]
def active_column_index(self, column: str) -> Optional[int]: def active_column_index(self, column: str) -> Optional[int]:
return ( return (
@ -1058,11 +1069,7 @@ class DataModel(QAbstractTableModel):
if not index.isValid(): if not index.isValid():
return QVariant() return QVariant()
if role == Qt.FontRole: if role == Qt.FontRole:
if self.active_column(index.column()) not in ( if not self.column_at(index).uses_cell_font:
"question",
"answer",
"noteFld",
):
return QVariant() return QVariant()
qfont = QFont() qfont = QFont()
row = self.get_row(index) row = self.get_row(index)
@ -1071,15 +1078,7 @@ class DataModel(QAbstractTableModel):
return qfont return qfont
if role == Qt.TextAlignmentRole: if role == Qt.TextAlignmentRole:
align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter align: Union[Qt.AlignmentFlag, int] = Qt.AlignVCenter
if self.active_column(index.column()) not in ( if self.column_at(index).alignment == Columns.ALIGNMENT_CENTER:
"question",
"answer",
"template",
"deck",
"noteFld",
"note",
"noteTags",
):
align |= Qt.AlignHCenter align |= Qt.AlignHCenter
return align return align
if role in (Qt.DisplayRole, Qt.ToolTipRole): if role in (Qt.DisplayRole, Qt.ToolTipRole):
@ -1089,21 +1088,9 @@ class DataModel(QAbstractTableModel):
def headerData( def headerData(
self, section: int, orientation: Qt.Orientation, role: int = 0 self, section: int, orientation: Qt.Orientation, role: int = 0
) -> Optional[str]: ) -> Optional[str]:
if orientation == Qt.Vertical: if orientation == Qt.Horizontal and role == Qt.DisplayRole:
return None return self._state.column_label(self.column_at_section(section))
elif role == Qt.DisplayRole and section < self.len_columns(): return None
column = self.active_column(section)
txt = None
for stype, name in self._state.columns:
if column == stype:
txt = name
break
# give the user a hint an invalid column was added by an add-on
if not txt:
txt = tr.browsing_addon()
return txt
else:
return None
def flags(self, index: QModelIndex) -> Qt.ItemFlags: def flags(self, index: QModelIndex) -> Qt.ItemFlags:
if self.get_row(index).is_deleted: if self.get_row(index).is_deleted:
@ -1131,3 +1118,17 @@ class StatusDelegate(QItemDelegate):
painter.fillRect(option.rect, brush) painter.fillRect(option.rect, brush)
painter.restore() painter.restore()
return QItemDelegate.paint(self, painter, option, index) return QItemDelegate.paint(self, painter, option, index)
def addon_column_fillin(key: str) -> Column:
"""Return a column with generic fields and a label indicating to the user that this column was
added by an add-on.
"""
return Column(
key=key,
cards_mode_label=tr.browsing_addon(),
notes_mode_label=tr.browsing_addon(),
sorting=Columns.SORTING_NONE,
uses_cell_font=False,
alignment=Columns.ALIGNMENT_CENTER,
)

View file

@ -16,7 +16,7 @@ prefix = """\
from __future__ import annotations from __future__ import annotations
from typing import Any, Callable, List, Sequence, Tuple, Optional, Union from typing import Any, Callable, Dict, List, Sequence, Tuple, Optional, Union
import anki import anki
import aqt import aqt
@ -422,6 +422,18 @@ hooks = [
represents. represents.
""", """,
), ),
Hook(
name="browser_did_fetch_columns",
args=["columns: Dict[str, aqt.table.Column]"],
doc="""Allows you to add custom columns to the browser.
columns is a dictionary of data obejcts. You can add an entry with a custom
column to describe how it should be displayed in the browser or modify
existing entries.
Every column in the dictionary will be toggleable by the user.
""",
),
# Main window states # Main window states
################### ###################
# these refer to things like deckbrowser, overview and reviewer state, # these refer to things like deckbrowser, overview and reviewer state,

View file

@ -250,9 +250,9 @@ service SearchService {
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 AllBrowserColumns(Empty) returns (BrowserColumns);
rpc BrowserRowForId(Int64) returns (BrowserRow); rpc BrowserRowForId(Int64) returns (BrowserRow);
rpc SetDesktopBrowserCardColumns(StringList) returns (Empty); rpc SetActiveBrowserColumns(StringList) returns (Empty);
rpc SetDesktopBrowserNoteColumns(StringList) returns (Empty);
} }
service StatsService { service StatsService {
@ -797,35 +797,13 @@ message SearchOut {
message SortOrder { message SortOrder {
message Builtin { message Builtin {
enum Kind { string column = 1;
NOTE_CARDS = 0;
NOTE_CREATION = 1;
NOTE_DUE = 2;
NOTE_EASE = 3;
NOTE_FIELD = 4;
NOTE_INTERVAL = 5;
NOTE_LAPSES = 6;
NOTE_MOD = 7;
NOTE_REPS = 8;
NOTE_TAGS = 9;
NOTETYPE = 10;
CARD_MOD = 11;
CARD_REPS = 12;
CARD_DUE = 13;
CARD_EASE = 14;
CARD_LAPSES = 15;
CARD_INTERVAL = 16;
CARD_DECK = 17;
CARD_TEMPLATE = 18;
}
Kind kind = 1;
bool reverse = 2; bool reverse = 2;
} }
oneof value { oneof value {
Empty from_config = 1; Empty none = 1;
Empty none = 2; string custom = 2;
string custom = 3; Builtin builtin = 3;
Builtin builtin = 4;
} }
} }
@ -1054,6 +1032,27 @@ message FindAndReplaceIn {
string field_name = 6; string field_name = 6;
} }
message BrowserColumns {
enum Sorting {
SORTING_NONE = 0;
SORTING_NORMAL = 1;
SORTING_REVERSED = 2;
}
enum Alignment {
ALIGNMENT_START = 0;
ALIGNMENT_CENTER = 1;
}
message Column {
string key = 1;
string cards_mode_label = 2;
string notes_mode_label = 3;
Sorting sorting = 4;
bool uses_cell_font = 5;
Alignment alignment = 6;
}
repeated Column columns = 1;
}
message BrowserRow { message BrowserRow {
message Cell { message Cell {
string text = 1; string text = 1;

View file

@ -1,73 +1,29 @@
// 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 crate::{backend_proto as pb, browser_table}; use std::str::FromStr;
use crate::{backend_proto as pb, browser_table, i18n::I18n};
impl browser_table::Column {
pub fn to_pb_column(self, i18n: &I18n) -> pb::browser_columns::Column {
pb::browser_columns::Column {
key: self.to_string(),
cards_mode_label: self.cards_mode_label(i18n),
notes_mode_label: self.notes_mode_label(i18n),
sorting: self.sorting() as i32,
uses_cell_font: self.uses_cell_font(),
alignment: self.alignment() as i32,
}
}
}
impl From<pb::StringList> for Vec<browser_table::Column> { impl From<pb::StringList> for Vec<browser_table::Column> {
fn from(input: pb::StringList) -> Self { fn from(input: pb::StringList) -> Self {
input.vals.into_iter().map(Into::into).collect() input
} .vals
} .iter()
.map(|c| browser_table::Column::from_str(c).unwrap_or_default())
impl From<String> for browser_table::Column { .collect()
fn from(text: String) -> Self {
match text.as_str() {
"question" => browser_table::Column::Question,
"answer" => browser_table::Column::Answer,
"deck" => browser_table::Column::CardDeck,
"cardDue" => browser_table::Column::CardDue,
"cardEase" => browser_table::Column::CardEase,
"cardLapses" => browser_table::Column::CardLapses,
"cardIvl" => browser_table::Column::CardInterval,
"cardMod" => browser_table::Column::CardMod,
"cardReps" => browser_table::Column::CardReps,
"template" => browser_table::Column::CardTemplate,
"noteCards" => browser_table::Column::NoteCards,
"noteCrt" => browser_table::Column::NoteCreation,
"noteDue" => browser_table::Column::NoteDue,
"noteEase" => browser_table::Column::NoteEase,
"noteFld" => browser_table::Column::NoteField,
"noteIvl" => browser_table::Column::NoteInterval,
"noteLapses" => browser_table::Column::NoteLapses,
"noteMod" => browser_table::Column::NoteMod,
"noteReps" => browser_table::Column::NoteReps,
"noteTags" => browser_table::Column::NoteTags,
"note" => browser_table::Column::Notetype,
_ => browser_table::Column::Custom,
}
}
}
impl From<browser_table::Row> for pb::BrowserRow {
fn from(row: browser_table::Row) -> Self {
pb::BrowserRow {
cells: row.cells.into_iter().map(Into::into).collect(),
color: row.color.into(),
font_name: row.font.name,
font_size: row.font.size,
}
}
}
impl From<browser_table::Cell> for pb::browser_row::Cell {
fn from(cell: browser_table::Cell) -> Self {
pb::browser_row::Cell {
text: cell.text,
is_rtl: cell.is_rtl,
}
}
}
impl From<browser_table::Color> for i32 {
fn from(color: browser_table::Color) -> Self {
match color {
browser_table::Color::Default => pb::browser_row::Color::Default as i32,
browser_table::Color::Marked => pb::browser_row::Color::Marked as i32,
browser_table::Color::Suspended => pb::browser_row::Color::Suspended as i32,
browser_table::Color::FlagRed => pb::browser_row::Color::FlagRed as i32,
browser_table::Color::FlagOrange => pb::browser_row::Color::FlagOrange as i32,
browser_table::Color::FlagGreen => pb::browser_row::Color::FlagGreen as i32,
browser_table::Color::FlagBlue => pb::browser_row::Color::FlagBlue as i32,
}
} }
} }

View file

@ -4,15 +4,13 @@
mod browser_table; mod browser_table;
mod search_node; mod search_node;
use std::convert::TryInto; use std::{convert::TryInto, str::FromStr, sync::Arc};
use super::Backend; use super::Backend;
use crate::{ use crate::{
backend_proto as pb, backend_proto as pb,
backend_proto::{ backend_proto::sort_order::Value as SortOrderProto,
sort_order::builtin::Kind as SortKindProto, sort_order::Value as SortOrderProto, browser_table::Column,
},
config::SortKind,
prelude::*, prelude::*,
search::{concatenate_searches, replace_search_node, write_nodes, Node, SortMode}, search::{concatenate_searches, replace_search_node, write_nodes, Node, SortMode},
}; };
@ -89,56 +87,31 @@ impl SearchService for Backend {
}) })
} }
fn all_browser_columns(&self, _input: pb::Empty) -> Result<pb::BrowserColumns> {
self.with_col(|col| Ok(col.all_browser_columns()))
}
fn set_active_browser_columns(&self, input: pb::StringList) -> Result<pb::Empty> {
self.with_col(|col| {
col.state.active_browser_columns = Some(Arc::new(input.into()));
Ok(())
})
.map(Into::into)
}
fn browser_row_for_id(&self, input: pb::Int64) -> Result<pb::BrowserRow> { 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)) self.with_col(|col| col.browser_row_for_id(input.val).map(Into::into))
} }
fn set_desktop_browser_card_columns(&self, input: pb::StringList) -> Result<pb::Empty> {
self.with_col(|col| col.set_desktop_browser_card_columns(input.into()))?;
Ok(().into())
}
fn set_desktop_browser_note_columns(&self, input: pb::StringList) -> Result<pb::Empty> {
self.with_col(|col| col.set_desktop_browser_note_columns(input.into()))?;
Ok(().into())
}
}
impl From<SortKindProto> for SortKind {
fn from(kind: SortKindProto) -> Self {
match kind {
SortKindProto::NoteCards => SortKind::NoteCards,
SortKindProto::NoteCreation => SortKind::NoteCreation,
SortKindProto::NoteDue => SortKind::NoteDue,
SortKindProto::NoteEase => SortKind::NoteEase,
SortKindProto::NoteInterval => SortKind::NoteInterval,
SortKindProto::NoteLapses => SortKind::NoteLapses,
SortKindProto::NoteMod => SortKind::NoteMod,
SortKindProto::NoteField => SortKind::NoteField,
SortKindProto::NoteReps => SortKind::NoteReps,
SortKindProto::NoteTags => SortKind::NoteTags,
SortKindProto::Notetype => SortKind::Notetype,
SortKindProto::CardMod => SortKind::CardMod,
SortKindProto::CardReps => SortKind::CardReps,
SortKindProto::CardDue => SortKind::CardDue,
SortKindProto::CardEase => SortKind::CardEase,
SortKindProto::CardLapses => SortKind::CardLapses,
SortKindProto::CardInterval => SortKind::CardInterval,
SortKindProto::CardDeck => SortKind::CardDeck,
SortKindProto::CardTemplate => SortKind::CardTemplate,
}
}
} }
impl From<Option<SortOrderProto>> for SortMode { impl From<Option<SortOrderProto>> for SortMode {
fn from(order: Option<SortOrderProto>) -> Self { fn from(order: Option<SortOrderProto>) -> Self {
use pb::sort_order::Value as V; use pb::sort_order::Value as V;
match order.unwrap_or(V::FromConfig(pb::Empty {})) { match order.unwrap_or(V::None(pb::Empty {})) {
V::None(_) => SortMode::NoOrder, V::None(_) => SortMode::NoOrder,
V::Custom(s) => SortMode::Custom(s), V::Custom(s) => SortMode::Custom(s),
V::FromConfig(_) => SortMode::FromConfig,
V::Builtin(b) => SortMode::Builtin { V::Builtin(b) => SortMode::Builtin {
kind: b.kind().into(), column: Column::from_str(&b.column).unwrap_or_default(),
reverse: b.reverse, reverse: b.reverse,
}, },
} }

View file

@ -4,11 +4,12 @@
use std::sync::Arc; use std::sync::Arc;
use itertools::Itertools; use itertools::Itertools;
use serde_repr::{Deserialize_repr, Serialize_repr}; use strum::{Display, EnumIter, EnumString, IntoEnumIterator};
use crate::error::{AnkiError, Result}; use crate::error::{AnkiError, Result};
use crate::i18n::I18n; use crate::i18n::I18n;
use crate::{ use crate::{
backend_proto as pb,
card::{Card, CardId, CardQueue, CardType}, card::{Card, CardId, CardQueue, CardType},
collection::Collection, collection::Collection,
config::BoolKey, config::BoolKey,
@ -21,112 +22,47 @@ use crate::{
timestamp::{TimestampMillis, TimestampSecs}, timestamp::{TimestampMillis, TimestampSecs},
}; };
#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Clone, Copy)] #[derive(Debug, PartialEq, Clone, Copy, Display, EnumIter, EnumString)]
#[repr(u8)] #[strum(serialize_all = "camelCase")]
pub enum Column { pub enum Column {
#[strum(serialize = "")]
Custom, Custom,
Question,
Answer, Answer,
CardDeck,
CardDue,
CardEase,
CardLapses,
CardInterval,
CardMod, CardMod,
CardReps, #[strum(serialize = "template")]
CardTemplate, Cards,
NoteCards, Deck,
#[strum(serialize = "cardDue")]
Due,
#[strum(serialize = "cardEase")]
Ease,
#[strum(serialize = "cardLapses")]
Lapses,
#[strum(serialize = "cardIvl")]
Interval,
#[strum(serialize = "noteCrt")]
NoteCreation, NoteCreation,
NoteDue,
NoteEase,
NoteField,
NoteInterval,
NoteLapses,
NoteMod, NoteMod,
NoteReps, #[strum(serialize = "note")]
NoteTags,
Notetype, Notetype,
Question,
#[strum(serialize = "cardReps")]
Reps,
#[strum(serialize = "noteFld")]
SortField,
#[strum(serialize = "noteTags")]
Tags,
} }
#[derive(Debug, PartialEq)] impl Default for Column {
pub struct Row { fn default() -> Self {
pub cells: Vec<Cell>, Column::Custom
pub color: Color,
pub font: Font,
}
#[derive(Debug, PartialEq)]
pub struct Cell {
pub text: String,
pub is_rtl: bool,
}
#[derive(Debug, PartialEq)]
pub enum Color {
Default,
Marked,
Suspended,
FlagRed,
FlagOrange,
FlagGreen,
FlagBlue,
}
#[derive(Debug, PartialEq)]
pub struct Font {
pub name: String,
pub size: u32,
}
trait RowContext {
fn get_cell_text(&mut self, column: Column) -> Result<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: Column) -> 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: Column) -> bool {
match column {
Column::NoteField => {
let index = self.notetype().config.sort_field_idx as usize;
self.notetype().fields[index].config.rtl
}
_ => false,
}
}
fn browser_row_for_id(&mut self, columns: &[Column]) -> Result<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 { struct RowContext {
card: Card, notes_mode: bool,
cards: Vec<Card>,
note: Note, note: Note,
notetype: Arc<Notetype>, notetype: Arc<Notetype>,
deck: Arc<Deck>, deck: Arc<Deck>,
@ -143,14 +79,6 @@ struct RenderContext {
answer_nodes: Vec<RenderedNode>, answer_nodes: Vec<RenderedNode>,
} }
struct NoteRowContext {
note: Note,
notetype: Arc<Notetype>,
cards: Vec<Card>,
tr: I18n,
timing: SchedTimingToday,
}
fn card_render_required(columns: &[Column]) -> bool { fn card_render_required(columns: &[Column]) -> bool {
columns columns
.iter() .iter()
@ -200,20 +128,88 @@ impl Note {
} }
} }
impl Collection { impl Column {
pub fn browser_row_for_id(&mut self, id: i64) -> Result<Row> { pub fn cards_mode_label(self, i18n: &I18n) -> String {
if self.get_bool(BoolKey::BrowserTableShowNotesMode) { match self {
let columns = self Self::Answer => i18n.browsing_answer(),
.get_desktop_browser_note_columns() Self::CardMod => i18n.search_card_modified(),
.ok_or_else(|| AnkiError::invalid_input("Note columns not set."))?; Self::Cards => i18n.browsing_card(),
NoteRowContext::new(self, id)?.browser_row_for_id(&columns) Self::Deck => i18n.decks_deck(),
} else { Self::Due => i18n.statistics_due_date(),
let columns = self Self::Custom => i18n.browsing_addon(),
.get_desktop_browser_card_columns() Self::Ease => i18n.browsing_ease(),
.ok_or_else(|| AnkiError::invalid_input("Card columns not set."))?; Self::Interval => i18n.browsing_interval(),
CardRowContext::new(self, id, card_render_required(&columns))? Self::Lapses => i18n.scheduling_lapses(),
.browser_row_for_id(&columns) Self::NoteCreation => i18n.browsing_created(),
Self::NoteMod => i18n.search_note_modified(),
Self::Notetype => i18n.browsing_note(),
Self::Question => i18n.browsing_question(),
Self::Reps => i18n.scheduling_reviews(),
Self::SortField => i18n.browsing_sort_field(),
Self::Tags => i18n.editing_tags(),
} }
.into()
}
pub fn notes_mode_label(self, i18n: &I18n) -> String {
match self {
Self::CardMod => i18n.search_card_modified(),
Self::Cards => i18n.editing_cards(),
Self::Ease => i18n.browsing_average_ease(),
Self::Interval => i18n.browsing_average_interval(),
Self::Reps => i18n.scheduling_reviews(),
_ => return self.cards_mode_label(i18n),
}
.into()
}
pub fn sorting(self) -> pb::browser_columns::Sorting {
use pb::browser_columns::Sorting;
match self {
Self::Question | Self::Answer | Self::Custom => Sorting::None,
Self::SortField => Sorting::Reversed,
_ => Sorting::Normal,
}
}
pub fn uses_cell_font(self) -> bool {
matches!(self, Self::Question | Self::Answer | Self::SortField)
}
pub fn alignment(self) -> pb::browser_columns::Alignment {
use pb::browser_columns::Alignment;
match self {
Self::Question
| Self::Answer
| Self::Cards
| Self::Deck
| Self::SortField
| Self::Notetype
| Self::Tags => Alignment::Start,
_ => Alignment::Center,
}
}
}
impl Collection {
pub fn all_browser_columns(&self) -> pb::BrowserColumns {
let mut columns: Vec<pb::browser_columns::Column> = Column::iter()
.filter(|&c| c != Column::Custom)
.map(|c| c.to_pb_column(&self.tr))
.collect();
columns.sort_by(|c1, c2| c1.cards_mode_label.cmp(&c2.cards_mode_label));
pb::BrowserColumns { columns }
}
pub fn browser_row_for_id(&mut self, id: i64) -> Result<pb::BrowserRow> {
let notes_mode = self.get_bool(BoolKey::BrowserTableShowNotesMode);
let columns = Arc::clone(
self.state
.active_browser_columns
.as_ref()
.ok_or_else(|| AnkiError::invalid_input("Active browser columns not set."))?,
);
RowContext::new(self, id, notes_mode, card_render_required(&columns))?.browser_row(&columns)
} }
fn get_note_maybe_with_fields(&self, id: NoteId, _with_fields: bool) -> Result<Note> { fn get_note_maybe_with_fields(&self, id: NoteId, _with_fields: bool) -> Result<Note> {
@ -259,20 +255,32 @@ impl RenderContext {
} }
} }
impl CardRowContext { impl RowContext {
fn new(col: &mut Collection, id: i64, with_card_render: bool) -> Result<Self> { fn new(
let card = col col: &mut Collection,
.storage id: i64,
.get_card(CardId(id))? notes_mode: bool,
.ok_or(AnkiError::NotFound)?; with_card_render: bool,
let note = col.get_note_maybe_with_fields(card.note_id, with_card_render)?; ) -> Result<Self> {
let cards;
let note;
if notes_mode {
note = col.get_note_maybe_with_fields(NoteId(id), with_card_render)?;
cards = col.storage.all_cards_of_note(note.id)?;
} else {
cards = vec![col
.storage
.get_card(CardId(id))?
.ok_or(AnkiError::NotFound)?];
note = col.get_note_maybe_with_fields(cards[0].note_id, with_card_render)?;
}
let notetype = col let notetype = col
.get_notetype(note.notetype_id)? .get_notetype(note.notetype_id)?
.ok_or(AnkiError::NotFound)?; .ok_or(AnkiError::NotFound)?;
let deck = col.get_deck(card.deck_id)?.ok_or(AnkiError::NotFound)?; let deck = col.get_deck(cards[0].deck_id)?.ok_or(AnkiError::NotFound)?;
let original_deck = if card.original_deck_id.0 != 0 { let original_deck = if cards[0].original_deck_id.0 != 0 {
Some( Some(
col.get_deck(card.original_deck_id)? col.get_deck(cards[0].original_deck_id)?
.ok_or(AnkiError::NotFound)?, .ok_or(AnkiError::NotFound)?,
) )
} else { } else {
@ -280,13 +288,14 @@ impl CardRowContext {
}; };
let timing = col.timing_today()?; let timing = col.timing_today()?;
let render_context = if with_card_render { let render_context = if with_card_render {
Some(RenderContext::new(col, &card, &note, &notetype)?) Some(RenderContext::new(col, &cards[0], &note, &notetype)?)
} else { } else {
None None
}; };
Ok(CardRowContext { Ok(RowContext {
card, notes_mode,
cards,
note, note,
notetype, notetype,
deck, deck,
@ -297,8 +306,67 @@ impl CardRowContext {
}) })
} }
fn browser_row(&self, columns: &[Column]) -> Result<pb::BrowserRow> {
Ok(pb::BrowserRow {
cells: columns
.iter()
.map(|&column| self.get_cell(column))
.collect::<Result<_>>()?,
color: self.get_row_color() as i32,
font_name: self.get_row_font_name()?,
font_size: self.get_row_font_size()?,
})
}
fn get_cell(&self, column: Column) -> Result<pb::browser_row::Cell> {
Ok(pb::browser_row::Cell {
text: self.get_cell_text(column)?,
is_rtl: self.get_is_rtl(column),
})
}
fn get_cell_text(&self, column: Column) -> Result<String> {
Ok(match column {
Column::Question => self.question_str(),
Column::Answer => self.answer_str(),
Column::Deck => self.deck_str(),
Column::Due => self.due_str(),
Column::Ease => self.ease_str(),
Column::Interval => self.interval_str(),
Column::Lapses => self.cards.iter().map(|c| c.lapses).sum::<u32>().to_string(),
Column::CardMod => self.card_mod_str(),
Column::Reps => self.cards.iter().map(|c| c.reps).sum::<u32>().to_string(),
Column::Cards => self.cards_str()?,
Column::NoteCreation => self.note_creation_str(),
Column::SortField => self.note_field_str(),
Column::NoteMod => self.note.mtime.date_string(),
Column::Tags => self.note.tags.join(" "),
Column::Notetype => self.notetype.name.to_owned(),
Column::Custom => "".to_string(),
})
}
fn note_creation_str(&self) -> String {
TimestampMillis(self.note.id.into()).as_secs().date_string()
}
fn note_field_str(&self) -> String {
let index = self.notetype.config.sort_field_idx as usize;
html_to_text_line(&self.note.fields()[index]).into()
}
fn get_is_rtl(&self, column: Column) -> bool {
match column {
Column::SortField => {
let index = self.notetype.config.sort_field_idx as usize;
self.notetype.fields[index].config.rtl
}
_ => false,
}
}
fn template(&self) -> Result<&CardTemplate> { fn template(&self) -> Result<&CardTemplate> {
self.notetype.get_template(self.card.template_idx) self.notetype.get_template(self.cards[0].template_idx)
} }
fn answer_str(&self) -> String { fn answer_str(&self) -> String {
@ -326,149 +394,31 @@ impl CardRowContext {
.to_string() .to_string()
} }
fn card_due_str(&mut self) -> String { fn due_str(&self) -> String {
let due = if self.card.is_filtered_deck() { if self.notes_mode {
self.note_due_str()
} else {
self.card_due_str()
}
}
fn card_due_str(&self) -> String {
let due = if self.cards[0].is_filtered_deck() {
self.tr.browsing_filtered() self.tr.browsing_filtered()
} else if self.card.is_new_type_or_queue() { } else if self.cards[0].is_new_type_or_queue() {
self.tr.statistics_due_for_new_card(self.card.due) self.tr.statistics_due_for_new_card(self.cards[0].due)
} else if let Some(time) = self.card.due_time(&self.timing) { } else if let Some(time) = self.cards[0].due_time(&self.timing) {
time.date_string().into() time.date_string().into()
} else { } else {
return "".into(); return "".into();
}; };
if self.card.is_undue_queue() { if self.cards[0].is_undue_queue() {
format!("({})", due) format!("({})", due)
} else { } else {
due.into() due.into()
} }
} }
fn card_ease_str(&self) -> String {
match self.card.ctype {
CardType::New => self.tr.browsing_new().into(),
_ => format!("{}%", self.card.ease_factor / 10),
}
}
fn card_interval_str(&self) -> String {
match self.card.ctype {
CardType::New => self.tr.browsing_new().into(),
CardType::Learn => self.tr.browsing_learning().into(),
_ => time_span((self.card.interval * 86400) as f32, &self.tr, false),
}
}
fn deck_str(&mut self) -> String {
let deck_name = self.deck.human_name();
if let Some(original_deck) = &self.original_deck {
format!("{} ({})", &deck_name, &original_deck.human_name())
} else {
deck_name
}
}
fn template_str(&self) -> Result<String> {
let name = &self.template()?.name;
Ok(match self.notetype.config.kind() {
NotetypeKind::Normal => name.to_owned(),
NotetypeKind::Cloze => format!("{} {}", name, self.card.template_idx + 1),
})
}
fn question_str(&self) -> String {
html_to_text_line(&self.render_context.as_ref().unwrap().question).to_string()
}
}
impl RowContext for CardRowContext {
fn get_cell_text(&mut self, column: Column) -> Result<String> {
Ok(match column {
Column::Question => self.question_str(),
Column::Answer => self.answer_str(),
Column::CardDeck => self.deck_str(),
Column::CardDue => self.card_due_str(),
Column::CardEase => self.card_ease_str(),
Column::CardInterval => self.card_interval_str(),
Column::CardLapses => self.card.lapses.to_string(),
Column::CardMod => self.card.mtime.date_string(),
Column::CardReps => self.card.reps.to_string(),
Column::CardTemplate => self.template_str()?,
Column::NoteCreation => self.note_creation_str(),
Column::NoteField => self.note_field_str(),
Column::NoteMod => self.note.mtime.date_string(),
Column::NoteTags => self.note.tags.join(" "),
Column::Notetype => self.notetype.name.to_owned(),
_ => "".to_string(),
})
}
fn get_row_color(&self) -> Color {
match self.card.flags {
1 => Color::FlagRed,
2 => Color::FlagOrange,
3 => Color::FlagGreen,
4 => Color::FlagBlue,
_ => {
if self.note.is_marked() {
Color::Marked
} else if self.card.queue == CardQueue::Suspended {
Color::Suspended
} else {
Color::Default
}
}
}
}
fn get_row_font(&self) -> Result<Font> {
Ok(Font {
name: self.template()?.config.browser_font_name.to_owned(),
size: self.template()?.config.browser_font_size,
})
}
fn note(&self) -> &Note {
&self.note
}
fn notetype(&self) -> &Notetype {
&self.notetype
}
}
impl NoteRowContext {
fn new(col: &mut Collection, id: i64) -> Result<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)?;
let timing = col.timing_today()?;
Ok(NoteRowContext {
note,
notetype,
cards,
tr: col.tr.clone(),
timing,
})
}
/// Returns the average ease of the non-new cards or a hint if there aren't any.
fn note_ease_str(&self) -> String {
let eases: Vec<u16> = self
.cards
.iter()
.filter(|c| c.ctype != CardType::New)
.map(|c| c.ease_factor)
.collect();
if eases.is_empty() {
self.tr.browsing_new().into()
} else {
format!("{}%", eases.iter().sum::<u16>() / eases.len() as u16 / 10)
}
}
/// Returns the due date of the next due card that is not in a filtered deck, new, suspended or /// Returns the due date of the next due card that is not in a filtered deck, new, suspended or
/// buried or the empty string if there is no such card. /// buried or the empty string if there is no such card.
fn note_due_str(&self) -> String { fn note_due_str(&self) -> String {
@ -481,9 +431,30 @@ impl NoteRowContext {
.unwrap_or_else(|| "".into()) .unwrap_or_else(|| "".into())
} }
/// Returns the average interval of the review and relearn cards or the empty string if there /// Returns the average ease of the non-new cards or a hint if there aren't any.
/// aren't any. fn ease_str(&self) -> String {
fn note_interval_str(&self) -> String { let eases: Vec<u16> = self
.cards
.iter()
.filter(|c| c.ctype != CardType::New)
.map(|c| c.ease_factor)
.collect();
if eases.is_empty() {
self.tr.browsing_new().into()
} else {
format!("{}%", eases.iter().sum::<u16>() / eases.len() as u16 / 10)
}
}
/// Returns the average interval of the review and relearn cards if there are any.
fn interval_str(&self) -> String {
if !self.notes_mode {
match self.cards[0].ctype {
CardType::New => return self.tr.browsing_new().into(),
CardType::Learn => return self.tr.browsing_learning().into(),
CardType::Review | CardType::Relearn => (),
}
}
let intervals: Vec<u32> = self let intervals: Vec<u32> = self
.cards .cards
.iter() .iter()
@ -500,46 +471,79 @@ impl NoteRowContext {
) )
} }
} }
}
impl RowContext for NoteRowContext { fn card_mod_str(&self) -> String {
fn get_cell_text(&mut self, column: Column) -> Result<String> { self.cards
Ok(match column { .iter()
Column::NoteCards => self.cards.len().to_string(), .map(|c| c.mtime)
Column::NoteCreation => self.note_creation_str(), .max()
Column::NoteDue => self.note_due_str(), .unwrap()
Column::NoteEase => self.note_ease_str(), .date_string()
Column::NoteField => self.note_field_str(),
Column::NoteInterval => self.note_interval_str(),
Column::NoteLapses => self.cards.iter().map(|c| c.lapses).sum::<u32>().to_string(),
Column::NoteMod => self.note.mtime.date_string(),
Column::NoteReps => self.cards.iter().map(|c| c.reps).sum::<u32>().to_string(),
Column::NoteTags => self.note.tags.join(" "),
Column::Notetype => self.notetype.name.to_owned(),
_ => "".to_string(),
})
} }
fn get_row_color(&self) -> Color { fn deck_str(&self) -> String {
if self.note.is_marked() { if self.notes_mode {
Color::Marked let decks = self.cards.iter().map(|c| c.deck_id).unique().count();
if decks > 1 {
return format!("({})", decks);
}
}
let deck_name = self.deck.human_name();
if let Some(original_deck) = &self.original_deck {
format!("{} ({})", &deck_name, &original_deck.human_name())
} else { } else {
Color::Default deck_name
} }
} }
fn get_row_font(&self) -> Result<Font> { fn cards_str(&self) -> Result<String> {
Ok(Font { Ok(if self.notes_mode {
name: "".to_owned(), self.cards.len().to_string()
size: 0, } else {
let name = &self.template()?.name;
match self.notetype.config.kind() {
NotetypeKind::Normal => name.to_owned(),
NotetypeKind::Cloze => format!("{} {}", name, self.cards[0].template_idx + 1),
}
}) })
} }
fn note(&self) -> &Note { fn question_str(&self) -> String {
&self.note html_to_text_line(&self.render_context.as_ref().unwrap().question).to_string()
} }
fn notetype(&self) -> &Notetype { fn get_row_font_name(&self) -> Result<String> {
&self.notetype Ok(self.template()?.config.browser_font_name.to_owned())
}
fn get_row_font_size(&self) -> Result<u32> {
Ok(self.template()?.config.browser_font_size)
}
fn get_row_color(&self) -> pb::browser_row::Color {
use pb::browser_row::Color;
if self.notes_mode {
if self.note.is_marked() {
Color::Marked
} else {
Color::Default
}
} else {
match self.cards[0].flags {
1 => Color::FlagRed,
2 => Color::FlagOrange,
3 => Color::FlagGreen,
4 => Color::FlagBlue,
_ => {
if self.note.is_marked() {
Color::Marked
} else if self.cards[0].queue == CardQueue::Suspended {
Color::Suspended
} else {
Color::Default
}
}
}
}
} }
} }

View file

@ -3,6 +3,7 @@
use crate::types::Usn; use crate::types::Usn;
use crate::{ use crate::{
browser_table,
decks::{Deck, DeckId}, decks::{Deck, DeckId},
notetype::{Notetype, NotetypeId}, notetype::{Notetype, NotetypeId},
prelude::*, prelude::*,
@ -66,6 +67,7 @@ pub struct CollectionState {
pub(crate) deck_cache: HashMap<DeckId, Arc<Deck>>, pub(crate) deck_cache: HashMap<DeckId, Arc<Deck>>,
pub(crate) scheduler_info: Option<SchedulerInfo>, pub(crate) scheduler_info: Option<SchedulerInfo>,
pub(crate) card_queues: Option<CardQueues>, pub(crate) card_queues: Option<CardQueues>,
pub(crate) active_browser_columns: Option<Arc<Vec<browser_table::Column>>>,
/// True if legacy Python code has executed SQL that has modified the /// True if legacy Python code has executed SQL that has modified the
/// database, requiring modification time to be bumped. /// database, requiring modification time to be bumped.
pub(crate) modified_by_dbproxy: bool, pub(crate) modified_by_dbproxy: bool,

View file

@ -9,10 +9,8 @@ mod string;
pub(crate) mod undo; pub(crate) mod undo;
pub use self::{bool::BoolKey, string::StringKey}; pub use self::{bool::BoolKey, string::StringKey};
use crate::browser_table;
use crate::prelude::*; use crate::prelude::*;
use serde::{de::DeserializeOwned, Serialize}; use serde::{de::DeserializeOwned, Serialize};
use serde_derive::Deserialize;
use serde_repr::{Deserialize_repr, Serialize_repr}; use serde_repr::{Deserialize_repr, Serialize_repr};
use slog::warn; use slog::warn;
use strum::IntoStaticStr; use strum::IntoStaticStr;
@ -47,10 +45,6 @@ pub(crate) enum ConfigKey {
#[strum(to_string = "timeLim")] #[strum(to_string = "timeLim")]
AnswerTimeLimitSecs, AnswerTimeLimitSecs,
#[strum(to_string = "sortType")]
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,9 +59,6 @@ pub(crate) enum ConfigKey {
NextNewCardPosition, NextNewCardPosition,
#[strum(to_string = "schedVer")] #[strum(to_string = "schedVer")]
SchedulerVersion, SchedulerVersion,
DesktopBrowserCardColumns,
DesktopBrowserNoteColumns,
} }
#[derive(PartialEq, Serialize_repr, Deserialize_repr, Clone, Copy, Debug)] #[derive(PartialEq, Serialize_repr, Deserialize_repr, Clone, Copy, Debug)]
@ -132,38 +123,6 @@ impl Collection {
Ok(()) Ok(())
} }
pub(crate) fn get_browser_sort_kind(&self) -> SortKind {
self.get_config_default(ConfigKey::BrowserSortKind)
}
pub(crate) fn get_browser_note_sort_kind(&self) -> SortKind {
self.get_config_default(ConfigKey::BrowserNoteSortKind)
}
pub(crate) fn get_desktop_browser_card_columns(&self) -> Option<Vec<browser_table::Column>> {
self.get_config_optional(ConfigKey::DesktopBrowserCardColumns)
}
pub(crate) fn set_desktop_browser_card_columns(
&mut self,
columns: Vec<browser_table::Column>,
) -> Result<()> {
self.set_config(ConfigKey::DesktopBrowserCardColumns, &columns)
.map(|_| ())
}
pub(crate) fn get_desktop_browser_note_columns(&self) -> Option<Vec<browser_table::Column>> {
self.get_config_optional(ConfigKey::DesktopBrowserNoteColumns)
}
pub(crate) fn set_desktop_browser_note_columns(
&mut self,
columns: Vec<browser_table::Column>,
) -> Result<()> {
self.set_config(ConfigKey::DesktopBrowserNoteColumns, &columns)
.map(|_| ())
}
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)
} }
@ -280,43 +239,6 @@ impl Collection {
} }
} }
#[derive(Deserialize, PartialEq, Debug, Clone, Copy)]
#[serde(rename_all = "camelCase")]
pub enum SortKind {
NoteCards,
#[serde(rename = "noteCrt")]
NoteCreation,
NoteDue,
NoteEase,
#[serde(rename = "noteIvl")]
NoteInterval,
NoteLapses,
NoteMod,
#[serde(rename = "noteFld")]
NoteField,
NoteReps,
#[serde(rename = "note")]
Notetype,
NoteTags,
CardMod,
CardReps,
CardDue,
CardEase,
CardLapses,
#[serde(rename = "cardIvl")]
CardInterval,
#[serde(rename = "deck")]
CardDeck,
#[serde(rename = "template")]
CardTemplate,
}
impl Default for SortKind {
fn default() -> Self {
Self::NoteCreation
}
}
// 2021 scheduler moves this into deck config // 2021 scheduler moves this into deck config
pub(crate) enum NewReviewMix { pub(crate) enum NewReviewMix {
Mix = 0, Mix = 0,
@ -341,7 +263,6 @@ pub(crate) enum Weekday {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::SortKind;
use crate::collection::open_test_collection; use crate::collection::open_test_collection;
use crate::decks::DeckId; use crate::decks::DeckId;
@ -349,7 +270,6 @@ mod test {
fn defaults() { fn defaults() {
let col = open_test_collection(); let col = open_test_collection();
assert_eq!(col.get_current_deck_id(), DeckId(1)); assert_eq!(col.get_current_deck_id(), DeckId(1));
assert_eq!(col.get_browser_sort_kind(), SortKind::NoteField);
} }
#[test] #[test]

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 MAX(mod);

View file

@ -14,19 +14,13 @@ use rusqlite::types::FromSql;
use std::borrow::Cow; use std::borrow::Cow;
use crate::{ use crate::{
card::CardId, browser_table::Column, card::CardId, card::CardType, collection::Collection, error::Result,
card::CardType, notes::NoteId, prelude::AnkiError, search::parser::parse,
collection::Collection,
config::{BoolKey, SortKind},
error::Result,
notes::NoteId,
prelude::AnkiError,
search::parser::parse,
}; };
use sqlwriter::{RequiredTable, SqlWriter}; use sqlwriter::{RequiredTable, SqlWriter};
#[derive(Debug, PartialEq, Clone, Copy)] #[derive(Debug, PartialEq, Clone, Copy)]
pub enum SearchItems { pub enum ReturnItemType {
Cards, Cards,
Notes, Notes,
} }
@ -34,32 +28,31 @@ pub enum SearchItems {
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub enum SortMode { pub enum SortMode {
NoOrder, NoOrder,
FromConfig, Builtin { column: Column, reverse: bool },
Builtin { kind: SortKind, reverse: bool },
Custom(String), Custom(String),
} }
pub trait AsSearchItems { pub trait AsReturnItemType {
fn as_search_items() -> SearchItems; fn as_return_item_type() -> ReturnItemType;
} }
impl AsSearchItems for CardId { impl AsReturnItemType for CardId {
fn as_search_items() -> SearchItems { fn as_return_item_type() -> ReturnItemType {
SearchItems::Cards ReturnItemType::Cards
} }
} }
impl AsSearchItems for NoteId { impl AsReturnItemType for NoteId {
fn as_search_items() -> SearchItems { fn as_return_item_type() -> ReturnItemType {
SearchItems::Notes ReturnItemType::Notes
} }
} }
impl SearchItems { impl ReturnItemType {
fn required_table(&self) -> RequiredTable { fn required_table(&self) -> RequiredTable {
match self { match self {
SearchItems::Cards => RequiredTable::Cards, ReturnItemType::Cards => RequiredTable::Cards,
SearchItems::Notes => RequiredTable::Notes, ReturnItemType::Notes => RequiredTable::Notes,
} }
} }
} }
@ -68,8 +61,7 @@ impl SortMode {
fn required_table(&self) -> RequiredTable { fn required_table(&self) -> RequiredTable {
match self { match self {
SortMode::NoOrder => RequiredTable::CardsOrNotes, SortMode::NoOrder => RequiredTable::CardsOrNotes,
SortMode::FromConfig => unreachable!(), SortMode::Builtin { column, .. } => column.required_table(),
SortMode::Builtin { kind, .. } => kind.required_table(),
SortMode::Custom(ref text) => { SortMode::Custom(ref text) => {
if text.contains("n.") { if text.contains("n.") {
if text.contains("c.") { if text.contains("c.") {
@ -85,44 +77,31 @@ impl SortMode {
} }
} }
impl SortKind { impl Column {
fn required_table(self) -> RequiredTable { fn required_table(self) -> RequiredTable {
match self { match self {
SortKind::NoteCards Column::Cards
| SortKind::NoteCreation | Column::NoteCreation
| SortKind::NoteDue | Column::NoteMod
| SortKind::NoteEase | Column::Notetype
| SortKind::NoteField | Column::SortField
| SortKind::NoteInterval | Column::Tags => RequiredTable::Notes,
| SortKind::NoteLapses _ => RequiredTable::CardsOrNotes,
| SortKind::NoteMod
| SortKind::NoteReps
| SortKind::NoteTags
| SortKind::Notetype => RequiredTable::Notes,
SortKind::CardTemplate => RequiredTable::CardsAndNotes,
SortKind::CardMod
| SortKind::CardReps
| SortKind::CardDue
| SortKind::CardEase
| SortKind::CardLapses
| SortKind::CardInterval
| SortKind::CardDeck => RequiredTable::Cards,
} }
} }
} }
impl Collection { impl Collection {
pub fn search<T>(&mut self, search: &str, mut mode: SortMode) -> Result<Vec<T>> pub fn search<T>(&mut self, search: &str, mode: SortMode) -> Result<Vec<T>>
where where
T: FromSql + AsSearchItems, T: FromSql + AsReturnItemType,
{ {
let items = T::as_search_items(); let item_type = T::as_return_item_type();
let top_node = Node::Group(parse(search)?); let top_node = Node::Group(parse(search)?);
self.resolve_config_sort(items, &mut mode); let writer = SqlWriter::new(self, item_type);
let writer = SqlWriter::new(self, items);
let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?; let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?;
self.add_order(&mut sql, items, mode)?; self.add_order(&mut sql, item_type, mode)?;
let mut stmt = self.storage.db.prepare(&sql)?; let mut stmt = self.storage.db.prepare(&sql)?;
let ids: Vec<_> = stmt let ids: Vec<_> = stmt
@ -140,14 +119,18 @@ impl Collection {
self.search(search, SortMode::NoOrder) self.search(search, SortMode::NoOrder)
} }
fn add_order(&mut self, sql: &mut String, items: SearchItems, mode: SortMode) -> Result<()> { fn add_order(
&mut self,
sql: &mut String,
item_type: ReturnItemType,
mode: SortMode,
) -> Result<()> {
match mode { match mode {
SortMode::NoOrder => (), SortMode::NoOrder => (),
SortMode::FromConfig => unreachable!(), SortMode::Builtin { column, reverse } => {
SortMode::Builtin { kind, reverse } => { prepare_sort(self, column, item_type)?;
prepare_sort(self, kind)?;
sql.push_str(" order by "); sql.push_str(" order by ");
write_order(sql, items, kind, reverse)?; write_order(sql, item_type, column, reverse)?;
} }
SortMode::Custom(order_clause) => { SortMode::Custom(order_clause) => {
sql.push_str(" order by "); sql.push_str(" order by ");
@ -166,11 +149,11 @@ impl Collection {
mode: SortMode, mode: SortMode,
) -> Result<usize> { ) -> Result<usize> {
let top_node = Node::Group(parse(search)?); let top_node = Node::Group(parse(search)?);
let writer = SqlWriter::new(self, SearchItems::Cards); let writer = SqlWriter::new(self, ReturnItemType::Cards);
let want_order = mode != SortMode::NoOrder; let want_order = mode != SortMode::NoOrder;
let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?; let (mut sql, args) = writer.build_query(&top_node, mode.required_table())?;
self.add_order(&mut sql, SearchItems::Cards, mode)?; self.add_order(&mut sql, ReturnItemType::Cards, mode)?;
if want_order { if want_order {
self.storage self.storage
@ -186,34 +169,23 @@ impl Collection {
.execute(&args) .execute(&args)
.map_err(Into::into) .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. /// Add the order clause to the sql.
fn write_order(sql: &mut String, items: SearchItems, kind: SortKind, reverse: bool) -> Result<()> { fn write_order(
let order = match items { sql: &mut String,
SearchItems::Cards => card_order_from_sortkind(kind), item_type: ReturnItemType,
SearchItems::Notes => note_order_from_sortkind(kind), column: Column,
reverse: bool,
) -> Result<()> {
let order = match item_type {
ReturnItemType::Cards => card_order_from_sort_column(column),
ReturnItemType::Notes => note_order_from_sort_column(column),
}; };
if order.is_empty() { if order.is_empty() {
return Err(AnkiError::invalid_input(format!( return Err(AnkiError::invalid_input(format!(
"Can't sort {:?} by {:?}.", "Can't sort {:?} by {:?}.",
items, kind item_type, column
))); )));
} }
if reverse { if reverse {
@ -229,60 +201,69 @@ fn write_order(sql: &mut String, items: SearchItems, kind: SortKind, reverse: bo
Ok(()) Ok(())
} }
fn card_order_from_sortkind(kind: SortKind) -> Cow<'static, str> { fn card_order_from_sort_column(column: Column) -> Cow<'static, str> {
match kind { match column {
SortKind::NoteCreation => "n.id asc, c.ord asc".into(), Column::CardMod => "c.mod asc".into(),
SortKind::NoteMod => "n.mod asc, c.ord asc".into(), Column::Cards => concat!(
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),", "coalesce((select pos from sort_order where ntid = n.mid and ord = c.ord),",
// need to fall back on ord 0 for cloze cards // need to fall back on ord 0 for cloze cards
"(select pos from sort_order where ntid = n.mid and ord = 0)) asc" "(select pos from sort_order where ntid = n.mid and ord = 0)) asc"
) )
.into(), .into(),
_ => "".into(), Column::Deck => "(select pos from sort_order where did = c.did) asc".into(),
Column::Due => "c.type asc, c.due asc".into(),
Column::Ease => format!("c.type = {} asc, c.factor asc", CardType::New as i8).into(),
Column::Interval => "c.ivl asc".into(),
Column::Lapses => "c.lapses asc".into(),
Column::NoteCreation => "n.id asc, c.ord asc".into(),
Column::NoteMod => "n.mod asc, c.ord asc".into(),
Column::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(),
Column::Reps => "c.reps asc".into(),
Column::SortField => "n.sfld collate nocase asc, c.ord asc".into(),
Column::Tags => "n.tags asc".into(),
Column::Answer | Column::Custom | Column::Question => "".into(),
} }
} }
fn note_order_from_sortkind(kind: SortKind) -> Cow<'static, str> { fn note_order_from_sort_column(column: Column) -> Cow<'static, str> {
match kind { match column {
SortKind::NoteCards Column::CardMod
| SortKind::NoteDue | Column::Cards
| SortKind::NoteEase | Column::Deck
| SortKind::NoteInterval | Column::Due
| SortKind::NoteLapses | Column::Ease
| SortKind::NoteReps => "(select pos from sort_order where nid = n.id) asc".into(), | Column::Interval
SortKind::NoteCreation => "n.id asc".into(), | Column::Lapses
SortKind::NoteField => "n.sfld collate nocase asc".into(), | Column::Reps => "(select pos from sort_order where nid = n.id) asc".into(),
SortKind::NoteMod => "n.mod asc".into(), Column::NoteCreation => "n.id asc".into(),
SortKind::NoteTags => "n.tags asc".into(), Column::NoteMod => "n.mod asc".into(),
SortKind::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(), Column::Notetype => "(select pos from sort_order where ntid = n.mid) asc".into(),
_ => "".into(), Column::SortField => "n.sfld collate nocase asc".into(),
Column::Tags => "n.tags asc".into(),
Column::Answer | Column::Custom | Column::Question => "".into(),
} }
} }
fn prepare_sort(col: &mut Collection, kind: SortKind) -> Result<()> { fn prepare_sort(col: &mut Collection, column: Column, item_type: ReturnItemType) -> Result<()> {
use SortKind::*; let sql = match item_type {
let sql = match kind { ReturnItemType::Cards => match column {
CardDeck => include_str!("deck_order.sql"), Column::Cards => include_str!("template_order.sql"),
CardTemplate => include_str!("template_order.sql"), Column::Deck => include_str!("deck_order.sql"),
NoteCards => include_str!("note_cards_order.sql"), Column::Notetype => include_str!("notetype_order.sql"),
NoteDue => include_str!("note_due_order.sql"), _ => return Ok(()),
NoteEase => include_str!("note_ease_order.sql"), },
NoteInterval => include_str!("note_interval_order.sql"), ReturnItemType::Notes => match column {
NoteLapses => include_str!("note_lapses_order.sql"), Column::Cards => include_str!("note_cards_order.sql"),
NoteReps => include_str!("note_reps_order.sql"), Column::CardMod => include_str!("card_mod_order.sql"),
Notetype => include_str!("notetype_order.sql"), Column::Deck => include_str!("note_decks_order.sql"),
_ => return Ok(()), Column::Due => include_str!("note_due_order.sql"),
Column::Ease => include_str!("note_ease_order.sql"),
Column::Interval => include_str!("note_interval_order.sql"),
Column::Lapses => include_str!("note_lapses_order.sql"),
Column::Reps => include_str!("note_reps_order.sql"),
Column::Notetype => include_str!("notetype_order.sql"),
_ => return Ok(()),
},
}; };
col.storage.db.execute_batch(sql)?; col.storage.db.execute_batch(sql)?;

View file

@ -0,0 +1,18 @@
DROP TABLE IF EXISTS sort_order;
CREATE TEMPORARY TABLE sort_order (
pos integer PRIMARY KEY,
nid integer NOT NULL UNIQUE
);
INSERT INTO sort_order (nid)
SELECT nid
FROM cards
JOIN (
SELECT id,
row_number() OVER(
ORDER BY name
) AS pos
FROM decks
) decks ON cards.did = decks.id
GROUP BY nid
ORDER BY COUNT(DISTINCT did),
decks.pos;

View file

@ -3,7 +3,7 @@
use super::{ use super::{
parser::{Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind}, parser::{Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind},
SearchItems, ReturnItemType,
}; };
use crate::{ use crate::{
card::{CardQueue, CardType}, card::{CardQueue, CardType},
@ -25,24 +25,24 @@ 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, item_type: ReturnItemType,
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, items: SearchItems) -> SqlWriter<'_> { pub(crate) fn new(col: &mut Collection, item_type: ReturnItemType) -> 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, item_type,
args, args,
normalize_note_text, normalize_note_text,
table: items.required_table(), table: item_type.required_table(),
} }
} }
@ -61,9 +61,9 @@ impl SqlWriter<'_> {
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 ",
RequiredTable::Notes => "select n.id from notes n where ", RequiredTable::Notes => "select n.id from notes n where ",
_ => match self.items { _ => match self.item_type {
SearchItems::Cards => "select c.id from cards c, notes n where c.nid=n.id and ", ReturnItemType::Cards => "select c.id from cards c, notes n where c.nid=n.id and ",
SearchItems::Notes => { ReturnItemType::Notes => {
"select distinct n.id from cards c, notes n where c.nid=n.id and " "select distinct n.id from cards c, notes n where c.nid=n.id and "
} }
}, },
@ -588,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, SearchItems::Cards); let mut writer = SqlWriter::new(req, ReturnItemType::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)