Merge branch 'more-backend-search' into main

This commit is contained in:
Damien Elmes 2021-01-31 14:21:51 +10:00
commit cb805cf355
23 changed files with 444 additions and 350 deletions

View file

@ -83,6 +83,7 @@ browsing-reposition = Reposition...
browsing-reposition-new-cards = Reposition New Cards browsing-reposition-new-cards = Reposition New Cards
browsing-reschedule = Reschedule browsing-reschedule = Reschedule
browsing-save-current-filter = Save Current Filter... browsing-save-current-filter = Save Current Filter...
browsing-search-bar-hint = Type here and press Enter to search for cards and notes.
browsing-search-in = Search in: browsing-search-in = Search in:
browsing-search-within-formatting-slow = Search within formatting (slow) browsing-search-within-formatting-slow = Search within formatting (slow)
browsing-shift-position-of-existing-cards = Shift position of existing cards browsing-shift-position-of-existing-cards = Shift position of existing cards
@ -101,7 +102,6 @@ browsing-today = Today
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
browsing-type-here-to-search = <type here to search; hit enter to show current deck>
browsing-whole-collection = Whole Collection browsing-whole-collection = Whole Collection
browsing-you-must-have-at-least-one = You must have at least one column. browsing-you-must-have-at-least-one = You must have at least one column.
browsing-group = browsing-group =

View file

@ -17,6 +17,7 @@ import anki.find
import anki.latex # sets up hook import anki.latex # sets up hook
import anki.template import anki.template
from anki import hooks from anki import hooks
from anki.backend_pb2 import SearchTerm
from anki.cards import Card from anki.cards import Card
from anki.config import ConfigManager from anki.config import ConfigManager
from anki.consts import * from anki.consts import *
@ -26,18 +27,22 @@ from anki.errors import AnkiError
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 from anki.models import ModelManager
from anki.notes import Note from anki.notes import Note
from anki.rsbackend import ( from anki.rsbackend import ( # pylint: disable=unused-import
TR, TR,
BackendNoteTypeID,
ConcatSeparator,
DBError, DBError,
FormatTimeSpanContext, FormatTimeSpanContext,
InvalidInput,
Progress, Progress,
RustBackend, RustBackend,
from_json_bytes, from_json_bytes,
pb,
) )
from anki.sched import Scheduler as V1Scheduler from anki.sched import Scheduler as V1Scheduler
from anki.schedv2 import Scheduler as V2Scheduler from anki.schedv2 import Scheduler as V2Scheduler
from anki.tags import TagManager from anki.tags import TagManager
from anki.utils import devMode, ids2str, intTime from anki.utils import devMode, ids2str, intTime, splitFields, stripHTMLMedia
ConfigBoolKey = pb.ConfigBool.Key # pylint: disable=no-member ConfigBoolKey = pb.ConfigBool.Key # pylint: disable=no-member
@ -458,8 +463,8 @@ class Collection:
) )
return self.backend.search_cards(search=query, order=mode) return self.backend.search_cards(search=query, order=mode)
def find_notes(self, query: str) -> Sequence[int]: def find_notes(self, *terms: Union[str, SearchTerm]) -> Sequence[int]:
return self.backend.search_notes(query) return self.backend.search_notes(self.build_search_string(*terms))
def find_and_replace( def find_and_replace(
self, self,
@ -472,13 +477,76 @@ class Collection:
) -> int: ) -> int:
return anki.find.findReplace(self, nids, src, dst, regex, field, fold) return anki.find.findReplace(self, nids, src, dst, regex, field, fold)
# returns array of ("dupestr", [nids])
def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]: def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]:
return anki.find.findDupes(self, fieldName, search) nids = self.findNotes(search, SearchTerm(field_name=fieldName))
# go through notes
vals: Dict[str, List[int]] = {}
dupes = []
fields: Dict[int, int] = {}
def ordForMid(mid):
if mid not in fields:
model = self.models.get(mid)
for c, f in enumerate(model["flds"]):
if f["name"].lower() == fieldName.lower():
fields[mid] = c
break
return fields[mid]
for nid, mid, flds in self.db.all(
"select id, mid, flds from notes where id in " + ids2str(nids)
):
flds = splitFields(flds)
ord = ordForMid(mid)
if ord is None:
continue
val = flds[ord]
val = stripHTMLMedia(val)
# empty does not count as duplicate
if not val:
continue
vals.setdefault(val, []).append(nid)
if len(vals[val]) == 2:
dupes.append((val, vals[val]))
return dupes
findCards = find_cards findCards = find_cards
findNotes = find_notes findNotes = find_notes
findReplace = find_and_replace findReplace = find_and_replace
# Search Strings
##########################################################################
def build_search_string(
self, *terms: Union[str, SearchTerm], negate=False, match_any=False
) -> str:
"""Helper function for the backend's search string operations.
Pass terms as strings to normalize.
Pass fields of backend.proto/FilterToSearchIn as valid SearchTerms.
Pass multiple terms to concatenate (defaults to 'and', 'or' when 'match_any=True').
Pass 'negate=True' to negate the end result.
May raise InvalidInput.
"""
searches = []
for term in terms:
if isinstance(term, SearchTerm):
term = self.backend.filter_to_search(term)
searches.append(term)
if match_any:
sep = ConcatSeparator.OR
else:
sep = ConcatSeparator.AND
search_string = self.backend.concatenate_searches(sep=sep, searches=searches)
if negate:
search_string = self.backend.negate_search(search_string)
return search_string
def replace_search_term(self, search: str, replacement: str) -> str:
return self.backend.replace_search_term(search=search, replacement=replacement)
# Config # Config
########################################################################## ##########################################################################

View file

@ -6,7 +6,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional, Set from typing import TYPE_CHECKING, Optional, Set
from anki.hooks import * from anki.hooks import *
from anki.utils import ids2str, splitFields, stripHTMLMedia
if TYPE_CHECKING: if TYPE_CHECKING:
from anki.collection import Collection from anki.collection import Collection
@ -64,43 +63,3 @@ def fieldNames(col, downcase=True) -> List:
if name not in fields: # slower w/o if name not in fields: # slower w/o
fields.add(name) fields.add(name)
return list(fields) return list(fields)
# returns array of ("dupestr", [nids])
def findDupes(
col: Collection, fieldName: str, search: str = ""
) -> List[Tuple[Any, List]]:
# limit search to notes with applicable field name
if search:
search = "(" + search + ") "
search += '"%s:*"' % fieldName.replace('"', '"')
# go through notes
vals: Dict[str, List[int]] = {}
dupes = []
fields: Dict[int, int] = {}
def ordForMid(mid):
if mid not in fields:
model = col.models.get(mid)
for c, f in enumerate(model["flds"]):
if f["name"].lower() == fieldName.lower():
fields[mid] = c
break
return fields[mid]
for nid, mid, flds in col.db.all(
"select id, mid, flds from notes where id in " + ids2str(col.findNotes(search))
):
flds = splitFields(flds)
ord = ordForMid(mid)
if ord is None:
continue
val = flds[ord]
val = stripHTMLMedia(val)
# empty does not count as duplicate
if not val:
continue
vals.setdefault(val, []).append(nid)
if len(vals[val]) == 2:
dupes.append((val, vals[val]))
return dupes

View file

@ -50,9 +50,6 @@ TagTreeNode = pb.TagTreeNode
NoteType = pb.NoteType NoteType = pb.NoteType
DeckTreeNode = pb.DeckTreeNode DeckTreeNode = pb.DeckTreeNode
StockNoteType = pb.StockNoteType StockNoteType = pb.StockNoteType
FilterToSearchIn = pb.FilterToSearchIn
NamedFilter = pb.FilterToSearchIn.NamedFilter
DupeIn = pb.FilterToSearchIn.DupeIn
BackendNoteTypeID = pb.NoteTypeID BackendNoteTypeID = pb.NoteTypeID
ConcatSeparator = pb.ConcatenateSearchesIn.Separator ConcatSeparator = pb.ConcatenateSearchesIn.Separator
SyncAuth = pb.SyncAuth SyncAuth = pb.SyncAuth

View file

@ -16,7 +16,7 @@ import re
from typing import Collection, List, Optional, Sequence, Tuple from typing import Collection, List, Optional, Sequence, Tuple
import anki # pylint: disable=unused-import import anki # pylint: disable=unused-import
from anki.rsbackend import FilterToSearchIn from anki.collection import SearchTerm
from anki.utils import ids2str from anki.utils import ids2str
@ -87,8 +87,7 @@ class TagManager:
def rename_tag(self, old: str, new: str) -> int: def rename_tag(self, old: str, new: str) -> int:
"Rename provided tag, returning number of changed notes." "Rename provided tag, returning number of changed notes."
search = self.col.backend.filter_to_search(FilterToSearchIn(tag=old)) nids = self.col.find_notes(SearchTerm(tag=old))
nids = self.col.find_notes(search)
if not nids: if not nids:
return 0 return 0
escaped_name = re.sub(r"[*_\\]", r"\\\g<0>", old) escaped_name = re.sub(r"[*_\\]", r"\\\g<0>", old)

View file

@ -5,6 +5,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=SearchTerm
[REPORTS] [REPORTS]
output-format=colorized output-format=colorized

View file

@ -7,6 +7,7 @@ import aqt.deckchooser
import aqt.editor import aqt.editor
import aqt.forms import aqt.forms
import aqt.modelchooser import aqt.modelchooser
from anki.collection import SearchTerm
from anki.consts import MODEL_CLOZE from anki.consts import MODEL_CLOZE
from anki.notes import Note from anki.notes import Note
from anki.utils import htmlToTextLine, isMac from anki.utils import htmlToTextLine, isMac
@ -144,7 +145,7 @@ class AddCards(QDialog):
def onHistory(self) -> None: def onHistory(self) -> None:
m = QMenu(self) m = QMenu(self)
for nid in self.history: for nid in self.history:
if self.mw.col.findNotes("nid:%s" % nid): if self.mw.col.findNotes(SearchTerm(nid=nid)):
note = self.mw.col.getNote(nid) note = self.mw.col.getNote(nid)
fields = note.fields fields = note.fields
txt = htmlToTextLine(", ".join(fields)) txt = htmlToTextLine(", ".join(fields))
@ -161,9 +162,7 @@ class AddCards(QDialog):
m.exec_(self.historyButton.mapToGlobal(QPoint(0, 0))) m.exec_(self.historyButton.mapToGlobal(QPoint(0, 0)))
def editHistory(self, nid): def editHistory(self, nid):
browser = aqt.dialogs.open("Browser", self.mw) self.mw.browser_search(SearchTerm(nid=nid))
browser.form.searchEdit.lineEdit().setText("nid:%d" % nid)
browser.onSearchActivated()
def addNote(self, note) -> Optional[Note]: def addNote(self, note) -> Optional[Note]:
note.model()["did"] = self.deckChooser.selectedId() note.model()["did"] = self.deckChooser.selectedId()

View file

@ -13,19 +13,11 @@ from typing import List, Optional, Sequence, Tuple, cast
import aqt import aqt
import aqt.forms import aqt.forms
from anki.cards import Card from anki.cards import Card
from anki.collection import Collection, ConfigBoolKey from anki.collection import Collection, ConfigBoolKey, InvalidInput, SearchTerm
from anki.consts import * from anki.consts import *
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.models import NoteType from anki.models import NoteType
from anki.notes import Note from anki.notes import Note
from anki.rsbackend import (
BackendNoteTypeID,
ConcatSeparator,
DupeIn,
FilterToSearchIn,
InvalidInput,
NamedFilter,
)
from anki.stats import CardStats from anki.stats import CardStats
from anki.utils import htmlToTextLine, ids2str, isMac, isWin from anki.utils import htmlToTextLine, ids2str, isMac, isWin
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
@ -193,23 +185,18 @@ class DataModel(QAbstractTableModel):
def search(self, txt: str) -> None: def search(self, txt: str) -> None:
self.beginReset() self.beginReset()
self.cards = [] self.cards = []
exception: Optional[Exception] = None
try: try:
ctx = SearchContext(search=txt, browser=self.browser) ctx = SearchContext(search=txt, browser=self.browser)
gui_hooks.browser_will_search(ctx) gui_hooks.browser_will_search(ctx)
if ctx.card_ids is None: if ctx.card_ids is None:
ctx.search = self.browser.normalize_search(ctx.search)
ctx.card_ids = self.col.find_cards(ctx.search, order=ctx.order) ctx.card_ids = self.col.find_cards(ctx.search, order=ctx.order)
gui_hooks.browser_did_search(ctx) gui_hooks.browser_did_search(ctx)
self.cards = ctx.card_ids self.cards = ctx.card_ids
except Exception as e: except Exception as err:
exception = e raise err
finally: finally:
self.endReset() self.endReset()
if exception:
show_invalid_search_error(exception)
def reset(self): def reset(self):
self.beginReset() self.beginReset()
self.endReset() self.endReset()
@ -615,16 +602,13 @@ class Browser(QMainWindow):
###################################################################### ######################################################################
def setupSearch(self): def setupSearch(self):
qconnect(self.form.searchButton.clicked, self.onSearchActivated)
qconnect(self.form.searchEdit.lineEdit().returnPressed, self.onSearchActivated) qconnect(self.form.searchEdit.lineEdit().returnPressed, self.onSearchActivated)
self.form.searchEdit.setCompleter(None) self.form.searchEdit.setCompleter(None)
self._searchPrompt = tr(TR.BROWSING_TYPE_HERE_TO_SEARCH) self.form.searchEdit.lineEdit().setPlaceholderText(
self.form.searchEdit.addItems( tr(TR.BROWSING_SEARCH_BAR_HINT)
[self._searchPrompt] + self.mw.pm.profile["searchHistory"]
) )
self.search_for("is:current", self._searchPrompt) self.form.searchEdit.addItems(self.mw.pm.profile["searchHistory"])
# then replace text for easily showing the deck self.search_for(self.col.build_search_string(SearchTerm(current_deck=True)), "")
self.form.searchEdit.lineEdit().selectAll()
self.form.searchEdit.setFocus() self.form.searchEdit.setFocus()
# search triggered by user # search triggered by user
@ -632,57 +616,48 @@ class Browser(QMainWindow):
self.editor.saveNow(self._onSearchActivated) self.editor.saveNow(self._onSearchActivated)
def _onSearchActivated(self): def _onSearchActivated(self):
# grab search text and normalize text = self.form.searchEdit.lineEdit().text()
prompt = self.form.searchEdit.lineEdit().text() try:
normed = self.col.build_search_string(text)
except InvalidInput as err:
show_invalid_search_error(err)
else:
self.search_for(normed)
self.update_history()
# convert guide text before we save history def search_for(self, search: str, prompt: Optional[str] = None):
txt = "deck:current " if prompt == self._searchPrompt else prompt """Keep track of search string so that we reuse identical search when
self.update_history(txt) refreshing, rather than whatever is currently in the search field.
Optionally set the search bar to a different text than the actual search.
"""
# keep track of search string so that we reuse identical search when self._lastSearchTxt = search
# refreshing, rather than whatever is currently in the search field prompt = search if prompt == None else prompt
self.search_for(txt) self.form.searchEdit.lineEdit().setText(prompt)
self.search()
def update_history(self, search: str) -> None: def search(self):
"""Search triggered programmatically. Caller must have saved note first."""
try:
self.model.search(self._lastSearchTxt)
except Exception as err:
show_invalid_search_error(err)
if not self.model.cards:
# no row change will fire
self._onRowChanged(None, None)
def update_history(self):
sh = self.mw.pm.profile["searchHistory"] sh = self.mw.pm.profile["searchHistory"]
if search in sh: if self._lastSearchTxt in sh:
sh.remove(search) sh.remove(self._lastSearchTxt)
sh.insert(0, search) sh.insert(0, self._lastSearchTxt)
sh = sh[:30] sh = sh[:30]
self.form.searchEdit.clear() self.form.searchEdit.clear()
self.form.searchEdit.addItems(sh) self.form.searchEdit.addItems(sh)
self.mw.pm.profile["searchHistory"] = sh self.mw.pm.profile["searchHistory"] = sh
def search_for(self, search: str, prompt: Optional[str] = None) -> None: def updateTitle(self) -> int:
self._lastSearchTxt = search
self.form.searchEdit.lineEdit().setText(prompt or search)
self.search()
# search triggered programmatically. caller must have saved note first.
def search(self) -> None:
if "is:current" in self._lastSearchTxt:
# show current card if there is one
c = self.card = self.mw.reviewer.card
nid = c and c.nid or 0
if nid:
search = "nid:%d" % nid
search = gui_hooks.default_search(search, c)
self.model.search(search)
self.focusCid(c.id)
else:
self.model.search(self._lastSearchTxt)
if not self.model.cards:
# no row change will fire
self._onRowChanged(None, None)
def normalize_search(self, search: str) -> str:
normed = self.col.backend.normalize_search(search)
self._lastSearchTxt = normed
self.form.searchEdit.lineEdit().setText(normed)
return normed
def updateTitle(self):
selected = len(self.form.tableView.selectionModel().selectedRows()) selected = len(self.form.tableView.selectionModel().selectedRows())
cur = len(self.model.cards) cur = len(self.model.cards)
self.setWindowTitle( self.setWindowTitle(
@ -692,6 +667,21 @@ class Browser(QMainWindow):
) )
return selected return selected
def show_single_card(self, card: Optional[Card]):
"""Try to search for the according note and select the given card."""
nid: Optional[int] = card and card.nid or 0
if nid:
def on_show_single_card():
self.card = card
search = self.col.build_search_string(SearchTerm(nid=nid))
search = gui_hooks.default_search(search, card)
self.search_for(search, "")
self.focusCid(card.id)
self.editor.saveNow(on_show_single_card)
def onReset(self): def onReset(self):
self.sidebar.refresh() self.sidebar.refresh()
self.editor.setNote(None) self.editor.setNote(None)
@ -972,33 +962,20 @@ QTableView {{ gridline-color: {grid} }}
ml.popupOver(self.form.filter) ml.popupOver(self.form.filter)
def update_search(self, *terms: str): def update_search(self, *terms: Union[str, SearchTerm]):
"Modify the current search string based on modified keys, then refresh." """Modify the current search string based on modified keys, then refresh."""
try: try:
search = self.col.backend.concatenate_searches( search = self.col.build_search_string(*terms)
sep=ConcatSeparator.AND, searches=terms
)
mods = self.mw.app.keyboardModifiers() mods = self.mw.app.keyboardModifiers()
if mods & Qt.AltModifier: if mods & Qt.AltModifier:
search = self.col.backend.negate_search(search) search = self.col.build_search_string(search, negate=True)
cur = str(self.form.searchEdit.lineEdit().text()) cur = str(self.form.searchEdit.lineEdit().text())
if cur != self._searchPrompt:
if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: if mods & Qt.ControlModifier and mods & Qt.ShiftModifier:
search = self.col.backend.replace_search_term( search = self.col.replace_search_term(cur, search)
search=cur, replacement=search
)
elif mods & Qt.ControlModifier: elif mods & Qt.ControlModifier:
search = self.col.backend.concatenate_searches( search = self.col.build_search_string(cur, search)
# pylint: disable=no-member
sep=ConcatSeparator.AND,
searches=[cur, search],
)
elif mods & Qt.ShiftModifier: elif mods & Qt.ShiftModifier:
search = self.col.backend.concatenate_searches( search = self.col.build_search_string(cur, search, match_any=True)
# pylint: disable=no-member
sep=ConcatSeparator.OR,
searches=[cur, search],
)
except InvalidInput as e: except InvalidInput as e:
show_invalid_search_error(e) show_invalid_search_error(e)
else: else:
@ -1016,7 +993,7 @@ QTableView {{ gridline-color: {grid} }}
ml.addSeparator() ml.addSeparator()
else: else:
label, filter_name = row label, filter_name = row
ml.addItem(label, self.sidebar._named_filter(filter_name)) ml.addItem(label, self.sidebar._filter_func(filter_name))
return ml return ml
def _todayFilters(self): def _todayFilters(self):
@ -1024,9 +1001,19 @@ QTableView {{ gridline-color: {grid} }}
subm.addChild( subm.addChild(
self._simpleFilters( self._simpleFilters(
( (
(tr(TR.BROWSING_ADDED_TODAY), NamedFilter.ADDED_TODAY), (tr(TR.BROWSING_ADDED_TODAY), SearchTerm(added_in_days=1)),
(tr(TR.BROWSING_STUDIED_TODAY), NamedFilter.STUDIED_TODAY), (
(tr(TR.BROWSING_AGAIN_TODAY), NamedFilter.AGAIN_TODAY), tr(TR.BROWSING_STUDIED_TODAY),
SearchTerm(rated=SearchTerm.Rated(days=1)),
),
(
tr(TR.BROWSING_AGAIN_TODAY),
SearchTerm(
rated=SearchTerm.Rated(
days=1, rating=SearchTerm.RATING_AGAIN
)
),
),
) )
) )
) )
@ -1037,20 +1024,41 @@ QTableView {{ gridline-color: {grid} }}
subm.addChild( subm.addChild(
self._simpleFilters( self._simpleFilters(
( (
(tr(TR.ACTIONS_NEW), NamedFilter.NEW), (
(tr(TR.SCHEDULING_LEARNING), NamedFilter.LEARN), tr(TR.ACTIONS_NEW),
(tr(TR.SCHEDULING_REVIEW), NamedFilter.REVIEW), SearchTerm(card_state=SearchTerm.CARD_STATE_NEW),
(tr(TR.FILTERING_IS_DUE), NamedFilter.DUE), ),
(
tr(TR.SCHEDULING_LEARNING),
SearchTerm(card_state=SearchTerm.CARD_STATE_LEARN),
),
(
tr(TR.SCHEDULING_REVIEW),
SearchTerm(card_state=SearchTerm.CARD_STATE_REVIEW),
),
(
tr(TR.FILTERING_IS_DUE),
SearchTerm(card_state=SearchTerm.CARD_STATE_DUE),
),
None, None,
(tr(TR.BROWSING_SUSPENDED), NamedFilter.SUSPENDED), (
(tr(TR.BROWSING_BURIED), NamedFilter.BURIED), tr(TR.BROWSING_SUSPENDED),
SearchTerm(card_state=SearchTerm.CARD_STATE_SUSPENDED),
),
(
tr(TR.BROWSING_BURIED),
SearchTerm(card_state=SearchTerm.CARD_STATE_BURIED),
),
None, None,
(tr(TR.ACTIONS_RED_FLAG), NamedFilter.RED_FLAG), (tr(TR.ACTIONS_RED_FLAG), SearchTerm(flag=SearchTerm.FLAG_RED)),
(tr(TR.ACTIONS_ORANGE_FLAG), NamedFilter.ORANGE_FLAG), (
(tr(TR.ACTIONS_GREEN_FLAG), NamedFilter.GREEN_FLAG), tr(TR.ACTIONS_ORANGE_FLAG),
(tr(TR.ACTIONS_BLUE_FLAG), NamedFilter.BLUE_FLAG), SearchTerm(flag=SearchTerm.FLAG_ORANGE),
(tr(TR.BROWSING_NO_FLAG), NamedFilter.NO_FLAG), ),
(tr(TR.BROWSING_ANY_FLAG), NamedFilter.ANY_FLAG), (tr(TR.ACTIONS_GREEN_FLAG), SearchTerm(flag=SearchTerm.FLAG_GREEN)),
(tr(TR.ACTIONS_BLUE_FLAG), SearchTerm(flag=SearchTerm.FLAG_BLUE)),
(tr(TR.BROWSING_NO_FLAG), SearchTerm(flag=SearchTerm.FLAG_NONE)),
(tr(TR.BROWSING_ANY_FLAG), SearchTerm(flag=SearchTerm.FLAG_ANY)),
) )
) )
) )
@ -1450,7 +1458,9 @@ where id in %s"""
tv = self.form.tableView tv = self.form.tableView
tv.selectionModel().clear() tv.selectionModel().clear()
search = "nid:" + ",".join([str(x) for x in nids]) search = self.col.build_search_string(
SearchTerm(nids=SearchTerm.IdList(ids=nids))
)
self.search_for(search) self.search_for(search)
tv.selectAll() tv.selectAll()
@ -1593,17 +1603,6 @@ where id in %s"""
# Edit: finding dupes # Edit: finding dupes
###################################################################### ######################################################################
# filter called by the editor
def search_dupe(self, mid: int, text: str):
self.form.searchEdit.lineEdit().setText(
self.col.backend.filter_to_search(
FilterToSearchIn(
dupe=DupeIn(mid=BackendNoteTypeID(ntid=mid), text=text)
)
)
)
self.onSearchActivated()
def onFindDupes(self): def onFindDupes(self):
self.editor.saveNow(self._onFindDupes) self.editor.saveNow(self._onFindDupes)
@ -1648,7 +1647,12 @@ where id in %s"""
def duplicatesReport(self, web, fname, search, frm, web_context): def duplicatesReport(self, web, fname, search, frm, web_context):
self.mw.progress.start() self.mw.progress.start()
try:
res = self.mw.col.findDupes(fname, search) res = self.mw.col.findDupes(fname, search)
except InvalidInput as e:
self.mw.progress.finish()
show_invalid_search_error(e)
return
if not self._dupesButton: if not self._dupesButton:
self._dupesButton = b = frm.buttonBox.addButton( self._dupesButton = b = frm.buttonBox.addButton(
tr(TR.BROWSING_TAG_DUPLICATES), QDialogButtonBox.ActionRole tr(TR.BROWSING_TAG_DUPLICATES), QDialogButtonBox.ActionRole
@ -1665,7 +1669,11 @@ where id in %s"""
t += ( t += (
"""<li><a href=# onclick="pycmd('%s');return false;">%s</a>: %s</a>""" """<li><a href=# onclick="pycmd('%s');return false;">%s</a>: %s</a>"""
% ( % (
"nid:" + ",".join(str(id) for id in nids), html.escape(
self.col.build_search_string(
SearchTerm(nids=SearchTerm.IdList(ids=nids))
)
),
tr(TR.BROWSING_NOTE_COUNT, count=len(nids)), tr(TR.BROWSING_NOTE_COUNT, count=len(nids)),
html.escape(val), html.escape(val),
) )
@ -1761,9 +1769,10 @@ where id in %s"""
def focusCid(self, cid): def focusCid(self, cid):
try: try:
row = self.model.cards.index(cid) row = list(self.model.cards).index(cid)
except: except ValueError:
return return
self.form.tableView.clearSelection()
self.form.tableView.selectRow(row) self.form.tableView.selectRow(row)

View file

@ -2,6 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# 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
import aqt import aqt
from anki.collection import SearchTerm
from anki.consts import * from anki.consts import *
from aqt.qt import * from aqt.qt import *
from aqt.utils import TR, disable_help_button, showInfo, showWarning, tr from aqt.utils import TR, disable_help_button, showInfo, showWarning, tr
@ -159,26 +160,42 @@ class CustomStudy(QDialog):
dyn = self.mw.col.decks.get(did) dyn = self.mw.col.decks.get(did)
# and then set various options # and then set various options
if i == RADIO_FORGOT: if i == RADIO_FORGOT:
dyn["terms"][0] = ["rated:%d:1" % spin, DYN_MAX_SIZE, DYN_RANDOM] search = self.mw.col.build_search_string(
SearchTerm(
rated=SearchTerm.Rated(days=spin, rating=SearchTerm.RATING_AGAIN)
)
)
dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_RANDOM]
dyn["resched"] = False dyn["resched"] = False
elif i == RADIO_AHEAD: elif i == RADIO_AHEAD:
dyn["terms"][0] = ["prop:due<=%d" % spin, DYN_MAX_SIZE, DYN_DUE] search = self.mw.col.build_search_string(SearchTerm(due_in_days=spin))
dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_DUE]
dyn["resched"] = True dyn["resched"] = True
elif i == RADIO_PREVIEW: elif i == RADIO_PREVIEW:
dyn["terms"][0] = ["is:new added:%s" % spin, DYN_MAX_SIZE, DYN_OLDEST] search = self.mw.col.build_search_string(
SearchTerm(card_state=SearchTerm.CARD_STATE_NEW),
SearchTerm(added_in_days=spin),
)
dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_OLDEST]
dyn["resched"] = False dyn["resched"] = False
elif i == RADIO_CRAM: elif i == RADIO_CRAM:
type = f.cardType.currentRow() type = f.cardType.currentRow()
if type == TYPE_NEW: if type == TYPE_NEW:
terms = "is:new " terms = self.mw.col.build_search_string(
SearchTerm(card_state=SearchTerm.CARD_STATE_NEW)
)
ord = DYN_ADDED ord = DYN_ADDED
dyn["resched"] = True dyn["resched"] = True
elif type == TYPE_DUE: elif type == TYPE_DUE:
terms = "is:due " terms = self.mw.col.build_search_string(
SearchTerm(card_state=SearchTerm.CARD_STATE_DUE)
)
ord = DYN_DUE ord = DYN_DUE
dyn["resched"] = True dyn["resched"] = True
elif type == TYPE_REVIEW: elif type == TYPE_REVIEW:
terms = "-is:new " terms = self.mw.col.build_search_string(
SearchTerm(card_state=SearchTerm.CARD_STATE_NEW), negate=True
)
ord = DYN_RANDOM ord = DYN_RANDOM
dyn["resched"] = True dyn["resched"] = True
else: else:
@ -187,7 +204,9 @@ class CustomStudy(QDialog):
dyn["resched"] = False dyn["resched"] = False
dyn["terms"][0] = [(terms + tags).strip(), spin, ord] dyn["terms"][0] = [(terms + tags).strip(), spin, ord]
# add deck limit # add deck limit
dyn["terms"][0][0] = 'deck:"%s" %s ' % (self.deck["name"], dyn["terms"][0][0]) dyn["terms"][0][0] = self.mw.col.build_search_string(
dyn["terms"][0][0], SearchTerm(deck=self.deck["name"])
)
self.mw.col.decks.save(dyn) self.mw.col.decks.save(dyn)
# generate cards # generate cards
self.created_custom_study = True self.created_custom_study = True

View file

@ -4,8 +4,8 @@
from typing import List, Optional from typing import List, Optional
import aqt import aqt
from anki.collection import InvalidInput, SearchTerm
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.rsbackend import InvalidInput
from aqt.qt import * from aqt.qt import *
from aqt.utils import ( from aqt.utils import (
TR, TR,
@ -47,8 +47,14 @@ class DeckConf(QDialog):
self.initialSetup() self.initialSetup()
self.loadConf() self.loadConf()
if search: if search:
self.form.search.setText(search + " is:due") search = self.mw.col.build_search_string(
self.form.search_2.setText(search + " is:new") search, SearchTerm(card_state=SearchTerm.CARD_STATE_DUE)
)
self.form.search.setText(search)
search_2 = self.mw.col.build_search_string(
search, SearchTerm(card_state=SearchTerm.CARD_STATE_NEW)
)
self.form.search_2.setText(search_2)
self.form.search.selectAll() self.form.search.selectAll()
if self.mw.col.schedVer() == 1: if self.mw.col.schedVer() == 1:
@ -119,11 +125,11 @@ class DeckConf(QDialog):
else: else:
d["delays"] = None d["delays"] = None
search = self.mw.col.backend.normalize_search(f.search.text()) search = self.mw.col.build_search_string(f.search.text())
terms = [[search, f.limit.value(), f.order.currentIndex()]] terms = [[search, f.limit.value(), f.order.currentIndex()]]
if f.secondFilter.isChecked(): if f.secondFilter.isChecked():
search_2 = self.mw.col.backend.normalize_search(f.search_2.text()) search_2 = self.mw.col.build_search_string(f.search_2.text())
terms.append([search_2, f.limit_2.value(), f.order_2.currentIndex()]) terms.append([search_2, f.limit_2.value(), f.order_2.currentIndex()])
d["terms"] = terms d["terms"] = terms

View file

@ -21,6 +21,7 @@ from bs4 import BeautifulSoup
import aqt import aqt
import aqt.sound import aqt.sound
from anki.cards import Card from anki.cards import Card
from anki.collection import SearchTerm
from anki.consts import MODEL_CLOZE from anki.consts import MODEL_CLOZE
from anki.hooks import runFilter from anki.hooks import runFilter
from anki.httpclient import HttpClient from anki.httpclient import HttpClient
@ -537,8 +538,13 @@ class Editor:
self.web.eval("setBackgrounds(%s);" % json.dumps(cols)) self.web.eval("setBackgrounds(%s);" % json.dumps(cols))
def showDupes(self): def showDupes(self):
browser = aqt.dialogs.open("Browser", self.mw) self.mw.browser_search(
browser.search_dupe(self.note.model()["id"], self.note.fields[0]) SearchTerm(
dupe=SearchTerm.Dupe(
notetype_id=self.note.model()["id"], first_field=self.note.fields[0]
)
)
)
def fieldsAreBlank(self, previousNote=None): def fieldsAreBlank(self, previousNote=None):
if not self.note: if not self.note:

View file

@ -66,9 +66,7 @@ class EmptyCardsDialog(QDialog):
self._delete_button.clicked.connect(self._on_delete) self._delete_button.clicked.connect(self._on_delete)
def _on_note_link_clicked(self, link): def _on_note_link_clicked(self, link):
browser = aqt.dialogs.open("Browser", self.mw) self.mw.browser_search(link)
browser.form.searchEdit.lineEdit().setText(link)
browser.onSearchActivated()
def _on_delete(self): def _on_delete(self):
self.mw.progress.start() self.mw.progress.start()

View file

@ -107,13 +107,6 @@
</property> </property>
</widget> </widget>
</item> </item>
<item row="0" column="2">
<widget class="QPushButton" name="searchButton">
<property name="text">
<string>ACTIONS_SEARCH</string>
</property>
</widget>
</item>
<item row="0" column="0"> <item row="0" column="0">
<widget class="QPushButton" name="filter"> <widget class="QPushButton" name="filter">
<property name="text"> <property name="text">
@ -158,12 +151,12 @@
<attribute name="horizontalHeaderCascadingSectionResizes"> <attribute name="horizontalHeaderCascadingSectionResizes">
<bool>false</bool> <bool>false</bool>
</attribute> </attribute>
<attribute name="horizontalHeaderMinimumSectionSize">
<number>20</number>
</attribute>
<attribute name="horizontalHeaderHighlightSections"> <attribute name="horizontalHeaderHighlightSections">
<bool>false</bool> <bool>false</bool>
</attribute> </attribute>
<attribute name="horizontalHeaderMinimumSectionSize">
<number>20</number>
</attribute>
<attribute name="horizontalHeaderShowSortIndicator" stdset="0"> <attribute name="horizontalHeaderShowSortIndicator" stdset="0">
<bool>true</bool> <bool>true</bool>
</attribute> </attribute>
@ -223,7 +216,7 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>750</width> <width>750</width>
<height>22</height> <height>21</height>
</rect> </rect>
</property> </property>
<widget class="QMenu" name="menuEdit"> <widget class="QMenu" name="menuEdit">

View file

@ -26,7 +26,7 @@ import aqt.stats
import aqt.toolbar import aqt.toolbar
import aqt.webview import aqt.webview
from anki import hooks from anki import hooks
from anki.collection import Collection from anki.collection import Collection, SearchTerm
from anki.decks import Deck from anki.decks import Deck
from anki.hooks import runHook from anki.hooks import runHook
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
@ -1045,7 +1045,8 @@ title="%s" %s>%s</button>""" % (
aqt.dialogs.open("AddCards", self) aqt.dialogs.open("AddCards", self)
def onBrowse(self) -> None: def onBrowse(self) -> None:
aqt.dialogs.open("Browser", self) browser = aqt.dialogs.open("Browser", self)
browser.show_single_card(self.reviewer.card)
def onEditCurrent(self): def onEditCurrent(self):
aqt.dialogs.open("EditCurrent", self) aqt.dialogs.open("EditCurrent", self)
@ -1141,7 +1142,7 @@ title="%s" %s>%s</button>""" % (
deck = self.col.decks.current() deck = self.col.decks.current()
if not search: if not search:
if not deck["dyn"]: if not deck["dyn"]:
search = 'deck:"%s" ' % deck["name"] search = self.col.build_search_string(SearchTerm(deck=deck["name"]))
while self.col.decks.id_for_name( while self.col.decks.id_for_name(
without_unicode_isolation(tr(TR.QT_MISC_FILTERED_DECK, val=n)) without_unicode_isolation(tr(TR.QT_MISC_FILTERED_DECK, val=n))
): ):
@ -1617,3 +1618,14 @@ title="%s" %s>%s</button>""" % (
def serverURL(self) -> str: def serverURL(self) -> str:
return "http://127.0.0.1:%d/" % self.mediaServer.getPort() return "http://127.0.0.1:%d/" % self.mediaServer.getPort()
# Helpers for all windows
##########################################################################
def browser_search(self, *terms: Union[str, SearchTerm]) -> None:
"""Wrapper for col.build_search_string() to look up the result in the browser."""
search = self.col.build_search_string(*terms)
browser = aqt.dialogs.open("Browser", self)
browser.form.searchEdit.lineEdit().setText(search)
browser.onSearchActivated()

View file

@ -9,6 +9,7 @@ from concurrent.futures import Future
from typing import Iterable, List, Optional, Sequence, TypeVar from typing import Iterable, List, Optional, Sequence, TypeVar
import aqt import aqt
from anki.collection import SearchTerm
from anki.rsbackend import TR, Interrupted, ProgressKind, pb from anki.rsbackend import TR, Interrupted, ProgressKind, pb
from aqt.qt import * from aqt.qt import *
from aqt.utils import ( from aqt.utils import (
@ -145,9 +146,7 @@ class MediaChecker:
if out is not None: if out is not None:
nid, err = out nid, err = out
browser = aqt.dialogs.open("Browser", self.mw) self.mw.browser_search(SearchTerm(nid=nid))
browser.form.searchEdit.lineEdit().setText("nid:%d" % nid)
browser.onSearchActivated()
showText(err, type="html") showText(err, type="html")
else: else:
tooltip(tr(TR.MEDIA_CHECK_ALL_LATEX_RENDERED)) tooltip(tr(TR.MEDIA_CHECK_ALL_LATEX_RENDERED))

View file

@ -7,6 +7,7 @@ from dataclasses import dataclass
from typing import Optional from typing import Optional
import aqt import aqt
from anki.collection import SearchTerm
from aqt import gui_hooks from aqt import gui_hooks
from aqt.sound import av_player from aqt.sound import av_player
from aqt.toolbar import BottomBar from aqt.toolbar import BottomBar
@ -71,8 +72,8 @@ class Overview:
elif url == "opts": elif url == "opts":
self.mw.onDeckConf() self.mw.onDeckConf()
elif url == "cram": elif url == "cram":
deck = self.mw.col.decks.current() deck = self.mw.col.decks.current()["name"]
self.mw.onCram("'deck:%s'" % deck["name"]) self.mw.onCram(self.mw.col.build_search_string(SearchTerm(deck=deck)))
elif url == "refresh": elif url == "refresh":
self.mw.col.sched.rebuild_filtered_deck(self.mw.col.decks.selected()) self.mw.col.sched.rebuild_filtered_deck(self.mw.col.decks.selected())
self.mw.reset() self.mw.reset()

View file

@ -9,15 +9,9 @@ from enum import Enum
from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Sequence, Tuple, cast from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Sequence, Tuple, cast
import aqt import aqt
from anki.collection import ConfigBoolKey from anki.collection import ConfigBoolKey, InvalidInput, SearchTerm
from anki.errors import DeckRenameError from anki.errors import DeckRenameError
from anki.rsbackend import ( from anki.rsbackend import DeckTreeNode, TagTreeNode
DeckTreeNode,
FilterToSearchIn,
InvalidInput,
NamedFilter,
TagTreeNode,
)
from aqt import gui_hooks from aqt import gui_hooks
from aqt.main import ResetReason from aqt.main import ResetReason
from aqt.models import Models from aqt.models import Models
@ -42,6 +36,7 @@ class SidebarItemType(Enum):
COLLECTION = 1 COLLECTION = 1
CURRENT_DECK = 2 CURRENT_DECK = 2
SAVED_SEARCH = 3 SAVED_SEARCH = 3
FILTER = 3 # legacy alias for SAVED_SEARCH
DECK = 4 DECK = 4
NOTETYPE = 5 NOTETYPE = 5
TAG = 6 TAG = 6
@ -466,14 +461,12 @@ class SidebarTreeView(QTreeView):
item = SidebarItem( item = SidebarItem(
tr(TR.BROWSING_WHOLE_COLLECTION), tr(TR.BROWSING_WHOLE_COLLECTION),
":/icons/collection.svg", ":/icons/collection.svg",
self._named_filter(NamedFilter.WHOLE_COLLECTION),
item_type=SidebarItemType.COLLECTION, item_type=SidebarItemType.COLLECTION,
) )
root.addChild(item) root.addChild(item)
item = SidebarItem( item = SidebarItem(
tr(TR.BROWSING_CURRENT_DECK), tr(TR.BROWSING_CURRENT_DECK),
":/icons/deck.svg", ":/icons/deck.svg",
self._named_filter(NamedFilter.CURRENT_DECK),
item_type=SidebarItemType.CURRENT_DECK, item_type=SidebarItemType.CURRENT_DECK,
) )
root.addChild(item) root.addChild(item)
@ -499,8 +492,8 @@ class SidebarTreeView(QTreeView):
item = SidebarItem( item = SidebarItem(
name, name,
icon, icon,
self._saved_filter(filt), self._filter_func(filt),
item_type=SidebarItemType.SAVED_SEARCH, item_type=SidebarItemType.FILTER,
) )
root.addChild(item) root.addChild(item)
@ -519,7 +512,7 @@ class SidebarTreeView(QTreeView):
item = SidebarItem( item = SidebarItem(
node.name, node.name,
icon, icon,
self._tag_filter(head + node.name), self._filter_func(SearchTerm(tag=head + node.name)),
toggle_expand(), toggle_expand(),
not node.collapsed, not node.collapsed,
item_type=SidebarItemType.TAG, item_type=SidebarItemType.TAG,
@ -551,7 +544,7 @@ class SidebarTreeView(QTreeView):
item = SidebarItem( item = SidebarItem(
node.name, node.name,
icon, icon,
self._deck_filter(head + node.name), self._filter_func(SearchTerm(deck=head + node.name)),
toggle_expand(), toggle_expand(),
not node.collapsed, not node.collapsed,
item_type=SidebarItemType.DECK, item_type=SidebarItemType.DECK,
@ -584,8 +577,8 @@ class SidebarTreeView(QTreeView):
for nt in sorted(self.col.models.all(), key=lambda nt: nt["name"].lower()): for nt in sorted(self.col.models.all(), key=lambda nt: nt["name"].lower()):
item = SidebarItem( item = SidebarItem(
nt["name"], nt["name"],
icon=icon, icon,
onClick=self._note_filter(nt["name"]), self._filter_func(SearchTerm(note=nt["name"])),
item_type=SidebarItemType.NOTETYPE, item_type=SidebarItemType.NOTETYPE,
id=nt["id"], id=nt["id"],
) )
@ -594,7 +587,9 @@ class SidebarTreeView(QTreeView):
child = SidebarItem( child = SidebarItem(
tmpl["name"], tmpl["name"],
icon, icon,
onClick=self._template_filter(nt["name"], c), self._filter_func(
SearchTerm(note=nt["name"]), SearchTerm(template=c)
),
item_type=SidebarItemType.TEMPLATE, item_type=SidebarItemType.TEMPLATE,
full_name=nt["name"] + "::" + tmpl["name"], full_name=nt["name"] + "::" + tmpl["name"],
) )
@ -602,34 +597,8 @@ class SidebarTreeView(QTreeView):
root.addChild(item) root.addChild(item)
def _named_filter(self, name: "FilterToSearchIn.NamedFilterValue") -> Callable: def _filter_func(self, *terms: Union[str, SearchTerm]) -> Callable:
return lambda: self.browser.update_search( return lambda: self.browser.update_search(self.col.build_search_string(*terms))
self.col.backend.filter_to_search(FilterToSearchIn(name=name))
)
def _tag_filter(self, tag: str) -> Callable:
return lambda: self.browser.update_search(
self.col.backend.filter_to_search(FilterToSearchIn(tag=tag))
)
def _deck_filter(self, deck: str) -> Callable:
return lambda: self.browser.update_search(
self.col.backend.filter_to_search(FilterToSearchIn(deck=deck))
)
def _note_filter(self, note: str) -> Callable:
return lambda: self.browser.update_search(
self.col.backend.filter_to_search(FilterToSearchIn(note=note))
)
def _template_filter(self, note: str, template: int) -> Callable:
return lambda: self.browser.update_search(
self.col.backend.filter_to_search(FilterToSearchIn(note=note)),
self.col.backend.filter_to_search(FilterToSearchIn(template=template)),
)
def _saved_filter(self, saved: str) -> Callable:
return lambda: self.browser.update_search(saved)
# Context menu actions # Context menu actions
########################### ###########################
@ -807,7 +776,7 @@ class SidebarTreeView(QTreeView):
def save_current_search(self, _item=None) -> None: def save_current_search(self, _item=None) -> None:
try: try:
filt = self.col.backend.normalize_search( filt = self.col.build_search_string(
self.browser.form.searchEdit.lineEdit().text() self.browser.form.searchEdit.lineEdit().text()
) )
except InvalidInput as e: except InvalidInput as e:

View file

@ -84,7 +84,7 @@ service BackendService {
// searching // searching
rpc FilterToSearch(FilterToSearchIn) returns (String); rpc FilterToSearch(SearchTerm) returns (String);
rpc NormalizeSearch(String) returns (String); rpc NormalizeSearch(String) returns (String);
rpc SearchCards(SearchCardsIn) returns (SearchCardsOut); rpc SearchCards(SearchCardsIn) returns (SearchCardsOut);
rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut); rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut);
@ -765,37 +765,58 @@ message BuiltinSearchOrder {
bool reverse = 2; bool reverse = 2;
} }
message FilterToSearchIn { message SearchTerm {
enum NamedFilter { message Dupe {
WHOLE_COLLECTION = 0; int64 notetype_id = 1;
CURRENT_DECK = 1; string first_field = 2;
ADDED_TODAY = 2;
STUDIED_TODAY = 3;
AGAIN_TODAY = 4;
NEW = 5;
LEARN = 6;
REVIEW = 7;
DUE = 8;
SUSPENDED = 9;
BURIED = 10;
RED_FLAG = 11;
ORANGE_FLAG = 12;
GREEN_FLAG = 13;
BLUE_FLAG = 14;
NO_FLAG = 15;
ANY_FLAG = 16;
} }
message DupeIn { enum Flag {
NoteTypeID mid = 1; FLAG_NONE = 0;
string text = 2; FLAG_ANY = 1;
FLAG_RED = 2;
FLAG_ORANGE = 3;
FLAG_GREEN = 4;
FLAG_BLUE = 5;
}
enum Rating {
RATING_ANY = 0;
RATING_AGAIN = 1;
RATING_HARD = 2;
RATING_GOOD = 3;
RATING_EASY = 4;
RATING_BY_RESCHEDULE = 5;
}
message Rated {
uint32 days = 1;
Rating rating = 2;
}
enum CardState {
CARD_STATE_NEW = 0;
CARD_STATE_LEARN = 1;
CARD_STATE_REVIEW = 2;
CARD_STATE_DUE = 3;
CARD_STATE_SUSPENDED = 4;
CARD_STATE_BURIED = 5;
}
message IdList {
repeated int64 ids = 1;
} }
oneof filter { oneof filter {
NamedFilter name = 1; string tag = 1;
string tag = 2; string deck = 2;
string deck = 3; string note = 3;
string note = 4; uint32 template = 4;
uint32 template = 5; int64 nid = 5;
DupeIn dupe = 6; Dupe dupe = 6;
string field_name = 7;
Rated rated = 8;
uint32 added_in_days = 9;
int32 due_in_days = 10;
bool whole_collection = 11;
bool current_deck = 12;
Flag flag = 13;
CardState card_state = 14;
IdList nids = 15;
} }
} }

View file

@ -36,7 +36,8 @@ use crate::{
sched::timespan::{answer_button_time, time_span}, sched::timespan::{answer_button_time, time_span},
search::{ search::{
concatenate_searches, negate_search, normalize_search, replace_search_term, write_nodes, concatenate_searches, negate_search, normalize_search, replace_search_term, write_nodes,
BoolSeparator, EaseKind, Node, SearchNode, SortMode, StateKind, TemplateKind, BoolSeparator, Node, PropertyKind, RatingKind, SearchNode, SortMode, StateKind,
TemplateKind,
}, },
stats::studied_today, stats::studied_today,
sync::{ sync::{
@ -262,6 +263,16 @@ impl From<pb::NoteId> for NoteID {
} }
} }
impl pb::search_term::IdList {
fn into_id_string(self) -> String {
self.ids
.iter()
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(",")
}
}
impl From<pb::NoteTypeId> for NoteTypeID { impl From<pb::NoteTypeId> for NoteTypeID {
fn from(ntid: pb::NoteTypeId) -> Self { fn from(ntid: pb::NoteTypeId) -> Self {
NoteTypeID(ntid.ntid) NoteTypeID(ntid.ntid)
@ -280,41 +291,11 @@ impl From<pb::DeckConfigId> for DeckConfID {
} }
} }
impl From<pb::FilterToSearchIn> for Node<'_> { impl From<pb::SearchTerm> for Node<'_> {
fn from(msg: pb::FilterToSearchIn) -> Self { fn from(msg: pb::SearchTerm) -> Self {
use pb::filter_to_search_in::Filter; use pb::search_term::Filter;
use pb::filter_to_search_in::NamedFilter; use pb::search_term::Flag;
match msg match msg.filter.unwrap_or(Filter::WholeCollection(true)) {
.filter
.unwrap_or(Filter::Name(NamedFilter::WholeCollection as i32))
{
Filter::Name(name) => {
match NamedFilter::from_i32(name).unwrap_or(NamedFilter::WholeCollection) {
NamedFilter::WholeCollection => Node::Search(SearchNode::WholeCollection),
NamedFilter::CurrentDeck => Node::Search(SearchNode::Deck("current".into())),
NamedFilter::AddedToday => Node::Search(SearchNode::AddedInDays(1)),
NamedFilter::StudiedToday => Node::Search(SearchNode::Rated {
days: 1,
ease: EaseKind::AnyAnswerButton,
}),
NamedFilter::AgainToday => Node::Search(SearchNode::Rated {
days: 1,
ease: EaseKind::AnswerButton(1),
}),
NamedFilter::New => Node::Search(SearchNode::State(StateKind::New)),
NamedFilter::Learn => Node::Search(SearchNode::State(StateKind::Learning)),
NamedFilter::Review => Node::Search(SearchNode::State(StateKind::Review)),
NamedFilter::Due => Node::Search(SearchNode::State(StateKind::Due)),
NamedFilter::Suspended => Node::Search(SearchNode::State(StateKind::Suspended)),
NamedFilter::Buried => Node::Search(SearchNode::State(StateKind::Buried)),
NamedFilter::RedFlag => Node::Search(SearchNode::Flag(1)),
NamedFilter::OrangeFlag => Node::Search(SearchNode::Flag(2)),
NamedFilter::GreenFlag => Node::Search(SearchNode::Flag(3)),
NamedFilter::BlueFlag => Node::Search(SearchNode::Flag(4)),
NamedFilter::NoFlag => Node::Search(SearchNode::Flag(0)),
NamedFilter::AnyFlag => Node::Not(Box::new(Node::Search(SearchNode::Flag(0)))),
}
}
Filter::Tag(s) => Node::Search(SearchNode::Tag( Filter::Tag(s) => Node::Search(SearchNode::Tag(
escape_anki_wildcards(&s).into_owned().into(), escape_anki_wildcards(&s).into_owned().into(),
)), )),
@ -327,10 +308,41 @@ impl From<pb::FilterToSearchIn> for Node<'_> {
Filter::Template(u) => { Filter::Template(u) => {
Node::Search(SearchNode::CardTemplate(TemplateKind::Ordinal(u as u16))) Node::Search(SearchNode::CardTemplate(TemplateKind::Ordinal(u as u16)))
} }
Filter::Nid(nid) => Node::Search(SearchNode::NoteIDs(nid.to_string().into())),
Filter::Nids(nids) => Node::Search(SearchNode::NoteIDs(nids.into_id_string().into())),
Filter::Dupe(dupe) => Node::Search(SearchNode::Duplicates { Filter::Dupe(dupe) => Node::Search(SearchNode::Duplicates {
note_type_id: dupe.mid.unwrap_or(pb::NoteTypeId { ntid: 0 }).into(), note_type_id: dupe.notetype_id.into(),
text: dupe.text.into(), text: dupe.first_field.into(),
}), }),
Filter::FieldName(s) => Node::Search(SearchNode::SingleField {
field: escape_anki_wildcards(&s).into_owned().into(),
text: "*".to_string().into(),
is_re: false,
}),
Filter::Rated(rated) => Node::Search(SearchNode::Rated {
days: rated.days,
ease: rated.rating().into(),
}),
Filter::AddedInDays(u) => Node::Search(SearchNode::AddedInDays(u)),
Filter::DueInDays(i) => Node::Search(SearchNode::Property {
operator: "<=".to_string(),
kind: PropertyKind::Due(i),
}),
Filter::WholeCollection(_) => Node::Search(SearchNode::WholeCollection),
Filter::CurrentDeck(_) => Node::Search(SearchNode::Deck("current".into())),
Filter::CardState(state) => Node::Search(SearchNode::State(
pb::search_term::CardState::from_i32(state)
.unwrap_or_default()
.into(),
)),
Filter::Flag(flag) => match Flag::from_i32(flag).unwrap_or(Flag::Any) {
Flag::None => Node::Search(SearchNode::Flag(0)),
Flag::Any => Node::Not(Box::new(Node::Search(SearchNode::Flag(0)))),
Flag::Red => Node::Search(SearchNode::Flag(1)),
Flag::Orange => Node::Search(SearchNode::Flag(2)),
Flag::Green => Node::Search(SearchNode::Flag(3)),
Flag::Blue => Node::Search(SearchNode::Flag(4)),
},
} }
} }
} }
@ -344,6 +356,32 @@ impl From<BoolSeparatorProto> for BoolSeparator {
} }
} }
impl From<pb::search_term::Rating> for RatingKind {
fn from(r: pb::search_term::Rating) -> Self {
match r {
pb::search_term::Rating::Again => RatingKind::AnswerButton(1),
pb::search_term::Rating::Hard => RatingKind::AnswerButton(2),
pb::search_term::Rating::Good => RatingKind::AnswerButton(3),
pb::search_term::Rating::Easy => RatingKind::AnswerButton(4),
pb::search_term::Rating::Any => RatingKind::AnyAnswerButton,
pb::search_term::Rating::ByReschedule => RatingKind::ManualReschedule,
}
}
}
impl From<pb::search_term::CardState> for StateKind {
fn from(k: pb::search_term::CardState) -> Self {
match k {
pb::search_term::CardState::New => StateKind::New,
pb::search_term::CardState::Learn => StateKind::Learning,
pb::search_term::CardState::Review => StateKind::Review,
pb::search_term::CardState::Due => StateKind::Due,
pb::search_term::CardState::Suspended => StateKind::Suspended,
pb::search_term::CardState::Buried => StateKind::Buried,
}
}
}
impl BackendService for Backend { impl BackendService for Backend {
fn latest_progress(&self, _input: Empty) -> BackendResult<pb::Progress> { fn latest_progress(&self, _input: Empty) -> BackendResult<pb::Progress> {
let progress = self.progress_state.lock().unwrap().last_progress; let progress = self.progress_state.lock().unwrap().last_progress;
@ -466,7 +504,7 @@ impl BackendService for Backend {
// searching // searching
//----------------------------------------------- //-----------------------------------------------
fn filter_to_search(&self, input: pb::FilterToSearchIn) -> Result<pb::String> { fn filter_to_search(&self, input: pb::SearchTerm) -> Result<pb::String> {
Ok(write_nodes(&[input.into()]).into()) Ok(write_nodes(&[input.into()]).into())
} }

View file

@ -5,7 +5,7 @@ mod sqlwriter;
mod writer; mod writer;
pub use cards::SortMode; pub use cards::SortMode;
pub use parser::{EaseKind, Node, PropertyKind, SearchNode, StateKind, TemplateKind}; pub use parser::{Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind};
pub use writer::{ pub use writer::{
concatenate_searches, negate_search, normalize_search, replace_search_term, write_nodes, concatenate_searches, negate_search, normalize_search, replace_search_term, write_nodes,
BoolSeparator, BoolSeparator,

View file

@ -58,7 +58,7 @@ pub enum SearchNode<'a> {
NoteType(Cow<'a, str>), NoteType(Cow<'a, str>),
Rated { Rated {
days: u32, days: u32,
ease: EaseKind, ease: RatingKind,
}, },
Tag(Cow<'a, str>), Tag(Cow<'a, str>),
Duplicates { Duplicates {
@ -67,7 +67,7 @@ pub enum SearchNode<'a> {
}, },
State(StateKind), State(StateKind),
Flag(u8), Flag(u8),
NoteIDs(&'a str), NoteIDs(Cow<'a, str>),
CardIDs(&'a str), CardIDs(&'a str),
Property { Property {
operator: String, operator: String,
@ -87,7 +87,7 @@ pub enum PropertyKind {
Lapses(u32), Lapses(u32),
Ease(f32), Ease(f32),
Position(u32), Position(u32),
Rated(i32, EaseKind), Rated(i32, RatingKind),
} }
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
@ -109,7 +109,7 @@ pub enum TemplateKind<'a> {
} }
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub enum EaseKind { pub enum RatingKind {
AnswerButton(u8), AnswerButton(u8),
AnyAnswerButton, AnyAnswerButton,
ManualReschedule, ManualReschedule,
@ -318,7 +318,7 @@ fn search_node_for_text_with_argument<'a>(
"is" => parse_state(val)?, "is" => parse_state(val)?,
"did" => parse_did(val)?, "did" => parse_did(val)?,
"mid" => parse_mid(val)?, "mid" => parse_mid(val)?,
"nid" => SearchNode::NoteIDs(check_id_list(val, key)?), "nid" => SearchNode::NoteIDs(check_id_list(val, key)?.into()),
"cid" => SearchNode::CardIDs(check_id_list(val, key)?), "cid" => SearchNode::CardIDs(check_id_list(val, key)?),
"re" => SearchNode::Regex(unescape_quotes(val)), "re" => SearchNode::Regex(unescape_quotes(val)),
"nc" => SearchNode::NoCombining(unescape(val)?), "nc" => SearchNode::NoCombining(unescape(val)?),
@ -353,7 +353,7 @@ fn parse_flag(s: &str) -> ParseResult<SearchNode> {
fn parse_resched(s: &str) -> ParseResult<SearchNode> { fn parse_resched(s: &str) -> ParseResult<SearchNode> {
parse_u32(s, "resched:").map(|days| SearchNode::Rated { parse_u32(s, "resched:").map(|days| SearchNode::Rated {
days, days,
ease: EaseKind::ManualReschedule, ease: RatingKind::ManualReschedule,
}) })
} }
@ -392,7 +392,7 @@ fn parse_prop(prop_clause: &str) -> ParseResult<SearchNode> {
"rated" => parse_prop_rated(num, prop_clause)?, "rated" => parse_prop_rated(num, prop_clause)?,
"resched" => PropertyKind::Rated( "resched" => PropertyKind::Rated(
parse_negative_i32(num, prop_clause)?, parse_negative_i32(num, prop_clause)?,
EaseKind::ManualReschedule, RatingKind::ManualReschedule,
), ),
"ivl" => PropertyKind::Interval(parse_u32(num, prop_clause)?), "ivl" => PropertyKind::Interval(parse_u32(num, prop_clause)?),
"reps" => PropertyKind::Reps(parse_u32(num, prop_clause)?), "reps" => PropertyKind::Reps(parse_u32(num, prop_clause)?),
@ -470,9 +470,9 @@ fn parse_i64<'a>(num: &str, context: &'a str) -> ParseResult<'a, i64> {
}) })
} }
fn parse_answer_button<'a>(num: Option<&str>, context: &'a str) -> ParseResult<'a, EaseKind> { fn parse_answer_button<'a>(num: Option<&str>, context: &'a str) -> ParseResult<'a, RatingKind> {
Ok(if let Some(num) = num { Ok(if let Some(num) = num {
EaseKind::AnswerButton( RatingKind::AnswerButton(
num.parse() num.parse()
.map_err(|_| ()) .map_err(|_| ())
.and_then(|n| if matches!(n, 1..=4) { Ok(n) } else { Err(()) }) .and_then(|n| if matches!(n, 1..=4) { Ok(n) } else { Err(()) })
@ -487,7 +487,7 @@ fn parse_answer_button<'a>(num: Option<&str>, context: &'a str) -> ParseResult<'
})?, })?,
) )
} else { } else {
EaseKind::AnyAnswerButton RatingKind::AnyAnswerButton
}) })
} }
@ -813,7 +813,7 @@ mod test {
assert_eq!(parse("tag:hard")?, vec![Search(Tag("hard".into()))]); assert_eq!(parse("tag:hard")?, vec![Search(Tag("hard".into()))]);
assert_eq!( assert_eq!(
parse("nid:1237123712,2,3")?, parse("nid:1237123712,2,3")?,
vec![Search(NoteIDs("1237123712,2,3"))] vec![Search(NoteIDs("1237123712,2,3".into()))]
); );
assert_eq!(parse("is:due")?, vec![Search(State(StateKind::Due))]); assert_eq!(parse("is:due")?, vec![Search(State(StateKind::Due))]);
assert_eq!(parse("flag:3")?, vec![Search(Flag(3))]); assert_eq!(parse("flag:3")?, vec![Search(Flag(3))]);

View file

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use super::parser::{EaseKind, Node, PropertyKind, SearchNode, StateKind, TemplateKind}; use super::parser::{Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind};
use crate::{ use crate::{
card::{CardQueue, CardType}, card::{CardQueue, CardType},
collection::Collection, collection::Collection,
@ -211,7 +211,7 @@ impl SqlWriter<'_> {
Ok(()) Ok(())
} }
fn write_rated(&mut self, op: &str, days: i64, ease: &EaseKind) -> Result<()> { fn write_rated(&mut self, op: &str, days: i64, ease: &RatingKind) -> Result<()> {
let today_cutoff = self.col.timing_today()?.next_day_at; let today_cutoff = self.col.timing_today()?.next_day_at;
let target_cutoff_ms = (today_cutoff + 86_400 * days) * 1_000; let target_cutoff_ms = (today_cutoff + 86_400 * days) * 1_000;
let day_before_cutoff_ms = (today_cutoff + 86_400 * (days - 1)) * 1_000; let day_before_cutoff_ms = (today_cutoff + 86_400 * (days - 1)) * 1_000;
@ -240,9 +240,9 @@ impl SqlWriter<'_> {
.unwrap(); .unwrap();
match ease { match ease {
EaseKind::AnswerButton(u) => write!(self.sql, " and ease = {})", u), RatingKind::AnswerButton(u) => write!(self.sql, " and ease = {})", u),
EaseKind::AnyAnswerButton => write!(self.sql, " and ease > 0)"), RatingKind::AnyAnswerButton => write!(self.sql, " and ease > 0)"),
EaseKind::ManualReschedule => write!(self.sql, " and ease = 0)"), RatingKind::ManualReschedule => write!(self.sql, " and ease = 0)"),
} }
.unwrap(); .unwrap();

View file

@ -5,7 +5,7 @@ use crate::{
decks::DeckID as DeckIDType, decks::DeckID as DeckIDType,
err::Result, err::Result,
notetype::NoteTypeID as NoteTypeIDType, notetype::NoteTypeID as NoteTypeIDType,
search::parser::{parse, EaseKind, Node, PropertyKind, SearchNode, StateKind, TemplateKind}, search::parser::{parse, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind},
}; };
use itertools::Itertools; use itertools::Itertools;
use std::mem; use std::mem;
@ -154,8 +154,8 @@ fn write_template(template: &TemplateKind) -> String {
} }
} }
fn write_rated(days: &u32, ease: &EaseKind) -> String { fn write_rated(days: &u32, ease: &RatingKind) -> String {
use EaseKind::*; use RatingKind::*;
match ease { match ease {
AnswerButton(n) => format!("\"rated:{}:{}\"", days, n), AnswerButton(n) => format!("\"rated:{}:{}\"", days, n),
AnyAnswerButton => format!("\"rated:{}\"", days), AnyAnswerButton => format!("\"rated:{}\"", days),
@ -196,9 +196,9 @@ fn write_property(operator: &str, kind: &PropertyKind) -> String {
Ease(f) => format!("\"prop:ease{}{}\"", operator, f), Ease(f) => format!("\"prop:ease{}{}\"", operator, f),
Position(u) => format!("\"prop:pos{}{}\"", operator, u), Position(u) => format!("\"prop:pos{}{}\"", operator, u),
Rated(u, ease) => match ease { Rated(u, ease) => match ease {
EaseKind::AnswerButton(val) => format!("\"prop:rated{}{}:{}\"", operator, u, val), RatingKind::AnswerButton(val) => format!("\"prop:rated{}{}:{}\"", operator, u, val),
EaseKind::AnyAnswerButton => format!("\"prop:rated{}{}\"", operator, u), RatingKind::AnyAnswerButton => format!("\"prop:rated{}{}\"", operator, u),
EaseKind::ManualReschedule => format!("\"prop:resched{}{}\"", operator, u), RatingKind::ManualReschedule => format!("\"prop:resched{}{}\"", operator, u),
}, },
} }
} }