Merge pull request #1015 from ankitects/search

Search API bikeshedding
This commit is contained in:
Damien Elmes 2021-02-12 09:19:24 +10:00 committed by GitHub
commit 03b9f2a3f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 423 additions and 323 deletions

View file

@ -11,7 +11,7 @@ import sys
import time import time
import traceback import traceback
import weakref import weakref
from typing import Any, List, Optional, Sequence, Tuple, Union from typing import Any, List, Literal, Optional, Sequence, Tuple, Union
import anki._backend.backend_pb2 as _pb import anki._backend.backend_pb2 as _pb
import anki.find import anki.find
@ -43,7 +43,8 @@ from anki.utils import (
) )
# public exports # public exports
SearchTerm = _pb.SearchTerm SearchNode = _pb.SearchNode
SearchJoiner = Literal["AND", "OR"]
Progress = _pb.Progress Progress = _pb.Progress
Config = _pb.Config Config = _pb.Config
EmptyCardsReport = _pb.EmptyCardsReport EmptyCardsReport = _pb.EmptyCardsReport
@ -471,7 +472,7 @@ class Collection:
) )
return self._backend.search_cards(search=query, order=mode) return self._backend.search_cards(search=query, order=mode)
def find_notes(self, *terms: Union[str, SearchTerm]) -> Sequence[int]: def find_notes(self, *terms: Union[str, SearchNode]) -> Sequence[int]:
return self._backend.search_notes(self.build_search_string(*terms)) return self._backend.search_notes(self.build_search_string(*terms))
def find_and_replace( def find_and_replace(
@ -487,7 +488,7 @@ class Collection:
# returns array of ("dupestr", [nids]) # returns array of ("dupestr", [nids])
def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]: def findDupes(self, fieldName: str, search: str = "") -> List[Tuple[Any, list]]:
nids = self.findNotes(search, SearchTerm(field_name=fieldName)) nids = self.findNotes(search, SearchNode(field_name=fieldName))
# go through notes # go through notes
vals: Dict[str, List[int]] = {} vals: Dict[str, List[int]] = {}
dupes = [] dupes = []
@ -526,38 +527,85 @@ class Collection:
# Search Strings # Search Strings
########################################################################## ##########################################################################
# pylint: disable=no-member
def build_search_string( def build_search_string(
self, self,
*terms: Union[str, SearchTerm], *nodes: Union[str, SearchNode],
negate: bool = False, joiner: SearchJoiner = "AND",
match_any: bool = False,
) -> str: ) -> str:
"""Helper function for the backend's search string operations. """Join one or more searches, and return a normalized search string.
Pass terms as strings to normalize. To negate, wrap in a negated search term:
Pass fields of backend.proto/FilterToSearchIn as valid SearchTerms.
Pass multiple terms to concatenate (defaults to 'and', 'or' when 'match_any=True'). term = SearchNode(negated=col.group_searches(...))
Pass 'negate=True' to negate the end result.
May raise InvalidInput. Invalid searches will throw an exception.
""" """
term = self.group_searches(*nodes, joiner=joiner)
return self._backend.build_search_string(term)
searches = [] def group_searches(
for term in terms: self,
if isinstance(term, SearchTerm): *nodes: Union[str, SearchNode],
term = self._backend.filter_to_search(term) joiner: SearchJoiner = "AND",
searches.append(term) ) -> SearchNode:
if match_any: """Join provided search nodes and strings into a single SearchNode.
sep = _pb.ConcatenateSearchesIn.OR If a single SearchNode is provided, it is returned as-is.
At least one node must be provided.
"""
assert nodes
# convert raw text to SearchNodes
search_nodes = [
node if isinstance(node, SearchNode) else SearchNode(parsable_text=node)
for node in nodes
]
# if there's more than one, wrap them in a group
if len(search_nodes) > 1:
return SearchNode(
group=SearchNode.Group(
nodes=search_nodes, joiner=self._pb_search_separator(joiner)
)
)
else: else:
sep = _pb.ConcatenateSearchesIn.AND return search_nodes[0]
search_string = self._backend.concatenate_searches(sep=sep, searches=searches)
if negate: def join_searches(
search_string = self._backend.negate_search(search_string) self,
existing_node: SearchNode,
additional_node: SearchNode,
operator: Literal["AND", "OR"],
) -> str:
"""
AND or OR `additional_term` to `existing_term`, without wrapping `existing_term` in brackets.
Used by the Browse screen to avoid adding extra brackets when joining.
If you're building a search query yourself, you probably don't need this.
"""
search_string = self._backend.join_search_nodes(
joiner=self._pb_search_separator(operator),
existing_node=existing_node,
additional_node=additional_node,
)
return search_string return search_string
def replace_search_term(self, search: str, replacement: str) -> str: def replace_in_search_node(
return self._backend.replace_search_term(search=search, replacement=replacement) self, existing_node: SearchNode, replacement_node: SearchNode
) -> str:
"""If nodes of the same type as `replacement_node` are found in existing_node, replace them.
You can use this to replace any "deck" clauses in a search with a different deck for example.
"""
return self._backend.replace_search_node(
existing_node=existing_node, replacement_node=replacement_node
)
def _pb_search_separator(self, operator: SearchJoiner) -> SearchNode.Group.Joiner.V:
# pylint: disable=no-member
if operator == "AND":
return SearchNode.Group.Joiner.AND
else:
return SearchNode.Group.Joiner.OR
# Config # Config
########################################################################## ##########################################################################

View file

@ -90,7 +90,7 @@ class TagManager:
def rename(self, old: str, new: str) -> int: def rename(self, old: str, new: str) -> int:
"Rename provided tag, returning number of changed notes." "Rename provided tag, returning number of changed notes."
nids = self.col.find_notes(anki.collection.SearchTerm(tag=old)) nids = self.col.find_notes(anki.collection.SearchNode(tag=old))
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

@ -51,11 +51,9 @@ fn want_release_gil(method: u32) -> bool {
| BackendMethod::LatestProgress | BackendMethod::LatestProgress
| BackendMethod::SetWantsAbort | BackendMethod::SetWantsAbort
| BackendMethod::I18nResources | BackendMethod::I18nResources
| BackendMethod::NormalizeSearch | BackendMethod::JoinSearchNodes
| BackendMethod::NegateSearch | BackendMethod::ReplaceSearchNode
| BackendMethod::ConcatenateSearches | BackendMethod::BuildSearchString
| BackendMethod::ReplaceSearchTerm
| BackendMethod::FilterToSearch
) )
} else { } else {
false false

View file

@ -6,7 +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=
SearchTerm, SearchNode,
Config, Config,
[REPORTS] [REPORTS]

View file

@ -6,7 +6,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.collection import SearchNode
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 +144,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(SearchTerm(nid=nid)): if self.mw.col.findNotes(SearchNode(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,7 +161,7 @@ class AddCards(QDialog):
m.exec_(self.historyButton.mapToGlobal(QPoint(0, 0))) m.exec_(self.historyButton.mapToGlobal(QPoint(0, 0)))
def editHistory(self, nid: int) -> None: def editHistory(self, nid: int) -> None:
aqt.dialogs.open("Browser", self.mw, search=(SearchTerm(nid=nid),)) aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),))
def addNote(self, note: Note) -> Optional[Note]: def addNote(self, note: Note) -> Optional[Note]:
note.model()["did"] = self.deckChooser.selectedId() note.model()["did"] = self.deckChooser.selectedId()

View file

@ -12,7 +12,7 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union,
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, Config, SearchTerm from anki.collection import Collection, Config, SearchNode
from anki.consts import * from anki.consts import *
from anki.errors import InvalidInput from anki.errors import InvalidInput
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
@ -442,7 +442,7 @@ class Browser(QMainWindow):
self, self,
mw: AnkiQt, mw: AnkiQt,
card: Optional[Card] = None, card: Optional[Card] = None,
search: Optional[Tuple[Union[str, SearchTerm]]] = None, search: Optional[Tuple[Union[str, SearchNode]]] = None,
) -> None: ) -> None:
""" """
card : try to search for its note and select it card : try to search for its note and select it
@ -615,7 +615,7 @@ class Browser(QMainWindow):
self, self,
_mw: AnkiQt, _mw: AnkiQt,
card: Optional[Card] = None, card: Optional[Card] = None,
search: Optional[Tuple[Union[str, SearchTerm]]] = None, search: Optional[Tuple[Union[str, SearchNode]]] = None,
) -> None: ) -> None:
if search is not None: if search is not None:
self.search_for_terms(*search) self.search_for_terms(*search)
@ -630,7 +630,7 @@ class Browser(QMainWindow):
def setupSearch( def setupSearch(
self, self,
card: Optional[Card] = None, card: Optional[Card] = None,
search: Optional[Tuple[Union[str, SearchTerm]]] = None, search: Optional[Tuple[Union[str, SearchNode]]] = None,
) -> None: ) -> None:
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)
@ -644,7 +644,7 @@ class Browser(QMainWindow):
self.show_single_card(card) self.show_single_card(card)
else: else:
self.search_for( self.search_for(
self.col.build_search_string(SearchTerm(deck="current")), "" self.col.build_search_string(SearchNode(deck="current")), ""
) )
self.form.searchEdit.setFocus() self.form.searchEdit.setFocus()
@ -707,7 +707,7 @@ class Browser(QMainWindow):
) )
return selected return selected
def search_for_terms(self, *search_terms: Union[str, SearchTerm]) -> None: def search_for_terms(self, *search_terms: Union[str, SearchNode]) -> None:
search = self.col.build_search_string(*search_terms) search = self.col.build_search_string(*search_terms)
self.form.searchEdit.setEditText(search) self.form.searchEdit.setEditText(search)
self.onSearchActivated() self.onSearchActivated()
@ -717,7 +717,7 @@ class Browser(QMainWindow):
def on_show_single_card() -> None: def on_show_single_card() -> None:
self.card = card self.card = card
search = self.col.build_search_string(SearchTerm(nid=card.nid)) search = self.col.build_search_string(SearchNode(nid=card.nid))
search = gui_hooks.default_search(search, card) search = gui_hooks.default_search(search, card)
self.search_for(search, "") self.search_for(search, "")
self.focusCid(card.id) self.focusCid(card.id)
@ -1407,7 +1407,7 @@ where id in %s"""
tv.selectionModel().clear() tv.selectionModel().clear()
search = self.col.build_search_string( search = self.col.build_search_string(
SearchTerm(nids=SearchTerm.IdList(ids=nids)) SearchNode(nids=SearchNode.IdList(ids=nids))
) )
self.search_for(search) self.search_for(search)
@ -1626,7 +1626,7 @@ where id in %s"""
% ( % (
html.escape( html.escape(
self.col.build_search_string( self.col.build_search_string(
SearchTerm(nids=SearchTerm.IdList(ids=nids)) SearchNode(nids=SearchNode.IdList(ids=nids))
) )
), ),
tr(TR.BROWSING_NOTE_COUNT, count=len(nids)), tr(TR.BROWSING_NOTE_COUNT, count=len(nids)),

View file

@ -2,7 +2,7 @@
# 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.collection import SearchNode
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
@ -164,20 +164,20 @@ class CustomStudy(QDialog):
# and then set various options # and then set various options
if i == RADIO_FORGOT: if i == RADIO_FORGOT:
search = self.mw.col.build_search_string( search = self.mw.col.build_search_string(
SearchTerm( SearchNode(
rated=SearchTerm.Rated(days=spin, rating=SearchTerm.RATING_AGAIN) rated=SearchNode.Rated(days=spin, rating=SearchNode.RATING_AGAIN)
) )
) )
dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_RANDOM] dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_RANDOM]
dyn["resched"] = False dyn["resched"] = False
elif i == RADIO_AHEAD: elif i == RADIO_AHEAD:
search = self.mw.col.build_search_string(SearchTerm(due_in_days=spin)) search = self.mw.col.build_search_string(SearchNode(due_in_days=spin))
dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_DUE] dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_DUE]
dyn["resched"] = True dyn["resched"] = True
elif i == RADIO_PREVIEW: elif i == RADIO_PREVIEW:
search = self.mw.col.build_search_string( search = self.mw.col.build_search_string(
SearchTerm(card_state=SearchTerm.CARD_STATE_NEW), SearchNode(card_state=SearchNode.CARD_STATE_NEW),
SearchTerm(added_in_days=spin), SearchNode(added_in_days=spin),
) )
dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_OLDEST] dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_OLDEST]
dyn["resched"] = False dyn["resched"] = False
@ -185,19 +185,19 @@ class CustomStudy(QDialog):
type = f.cardType.currentRow() type = f.cardType.currentRow()
if type == TYPE_NEW: if type == TYPE_NEW:
terms = self.mw.col.build_search_string( terms = self.mw.col.build_search_string(
SearchTerm(card_state=SearchTerm.CARD_STATE_NEW) SearchNode(card_state=SearchNode.CARD_STATE_NEW)
) )
ord = DYN_ADDED ord = DYN_ADDED
dyn["resched"] = True dyn["resched"] = True
elif type == TYPE_DUE: elif type == TYPE_DUE:
terms = self.mw.col.build_search_string( terms = self.mw.col.build_search_string(
SearchTerm(card_state=SearchTerm.CARD_STATE_DUE) SearchNode(card_state=SearchNode.CARD_STATE_DUE)
) )
ord = DYN_DUE ord = DYN_DUE
dyn["resched"] = True dyn["resched"] = True
elif type == TYPE_REVIEW: elif type == TYPE_REVIEW:
terms = self.mw.col.build_search_string( terms = self.mw.col.build_search_string(
SearchTerm(negated=SearchTerm(card_state=SearchTerm.CARD_STATE_NEW)) SearchNode(negated=SearchNode(card_state=SearchNode.CARD_STATE_NEW))
) )
ord = DYN_RANDOM ord = DYN_RANDOM
dyn["resched"] = True dyn["resched"] = True
@ -208,7 +208,7 @@ class CustomStudy(QDialog):
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] = self.mw.col.build_search_string( dyn["terms"][0][0] = self.mw.col.build_search_string(
dyn["terms"][0][0], SearchTerm(deck=self.deck["name"]) dyn["terms"][0][0], SearchNode(deck=self.deck["name"])
) )
self.mw.col.decks.save(dyn) self.mw.col.decks.save(dyn)
# generate cards # generate cards

View file

@ -3,7 +3,7 @@
from typing import Callable, List, Optional from typing import Callable, List, Optional
import aqt import aqt
from anki.collection import SearchTerm from anki.collection import SearchNode
from anki.decks import Deck, DeckRenameError from anki.decks import Deck, DeckRenameError
from anki.errors import InvalidInput from anki.errors import InvalidInput
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
@ -111,14 +111,14 @@ class DeckConf(QDialog):
def set_default_searches(self, deck_name: str) -> None: def set_default_searches(self, deck_name: str) -> None:
self.form.search.setText( self.form.search.setText(
self.mw.col.build_search_string( self.mw.col.build_search_string(
SearchTerm(deck=deck_name), SearchNode(deck=deck_name),
SearchTerm(card_state=SearchTerm.CARD_STATE_DUE), SearchNode(card_state=SearchNode.CARD_STATE_DUE),
) )
) )
self.form.search_2.setText( self.form.search_2.setText(
self.mw.col.build_search_string( self.mw.col.build_search_string(
SearchTerm(deck=deck_name), SearchNode(deck=deck_name),
SearchTerm(card_state=SearchTerm.CARD_STATE_NEW), SearchNode(card_state=SearchNode.CARD_STATE_NEW),
) )
) )

View file

@ -20,7 +20,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.collection import SearchNode
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
@ -546,8 +546,8 @@ class Editor:
"Browser", "Browser",
self.mw, self.mw,
search=( search=(
SearchTerm( SearchNode(
dupe=SearchTerm.Dupe( dupe=SearchNode.Dupe(
notetype_id=self.note.model()["id"], notetype_id=self.note.model()["id"],
first_field=self.note.fields[0], first_field=self.note.fields[0],
) )

View file

@ -9,7 +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.collection import SearchNode
from anki.errors import Interrupted from anki.errors import Interrupted
from anki.lang import TR from anki.lang import TR
from anki.media import CheckMediaOut from anki.media import CheckMediaOut
@ -154,7 +154,7 @@ class MediaChecker:
if out is not None: if out is not None:
nid, err = out nid, err = out
aqt.dialogs.open("Browser", self.mw, search=(SearchTerm(nid=nid),)) aqt.dialogs.open("Browser", self.mw, search=(SearchNode(nid=nid),))
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

@ -8,7 +8,7 @@ from enum import Enum, auto
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, cast from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, cast
import aqt import aqt
from anki.collection import Config, SearchTerm from anki.collection import Config, SearchNode
from anki.decks import DeckTreeNode from anki.decks import DeckTreeNode
from anki.errors import DeckRenameError, InvalidInput from anki.errors import DeckRenameError, InvalidInput
from anki.tags import TagTreeNode from anki.tags import TagTreeNode
@ -391,20 +391,28 @@ class SidebarTreeView(QTreeView):
if item.is_expanded(searching): if item.is_expanded(searching):
self.setExpanded(idx, True) self.setExpanded(idx, True)
def update_search(self, *terms: Union[str, SearchTerm]) -> None: def update_search(self, *terms: Union[str, SearchNode]) -> None:
"""Modify the current search string based on modified keys, then refresh.""" """Modify the current search string based on modifier keys, then refresh."""
try:
search = self.col.build_search_string(*terms)
mods = self.mw.app.keyboardModifiers() mods = self.mw.app.keyboardModifiers()
previous = SearchNode(parsable_text=self.browser.current_search())
current = self.mw.col.group_searches(*terms)
# if Alt pressed, invert
if mods & Qt.AltModifier: if mods & Qt.AltModifier:
search = self.col.build_search_string(search, negate=True) current = SearchNode(negated=current)
current = self.browser.current_search()
try:
if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: if mods & Qt.ControlModifier and mods & Qt.ShiftModifier:
search = self.col.replace_search_term(current, search) # If Ctrl+Shift, replace searches nodes of the same type.
search = self.col.replace_in_search_node(previous, current)
elif mods & Qt.ControlModifier: elif mods & Qt.ControlModifier:
search = self.col.build_search_string(current, search) # If Ctrl, AND with previous
search = self.col.join_searches(previous, current, "AND")
elif mods & Qt.ShiftModifier: elif mods & Qt.ShiftModifier:
search = self.col.build_search_string(current, search, match_any=True) # If Shift, OR with previous
search = self.col.join_searches(previous, current, "OR")
else:
search = self.col.build_search_string(current)
except InvalidInput as e: except InvalidInput as e:
show_invalid_search_error(e) show_invalid_search_error(e)
else: else:
@ -589,8 +597,8 @@ class SidebarTreeView(QTreeView):
return top return top
def _filter_func(self, *terms: Union[str, SearchTerm]) -> Callable: def _filter_func(self, *terms: Union[str, SearchNode]) -> Callable:
return lambda: self.update_search(self.col.build_search_string(*terms)) return lambda: self.update_search(*terms)
# Tree: Saved Searches # Tree: Saved Searches
########################### ###########################
@ -640,33 +648,33 @@ class SidebarTreeView(QTreeView):
name=TR.BROWSING_SIDEBAR_DUE_TODAY, name=TR.BROWSING_SIDEBAR_DUE_TODAY,
icon=icon, icon=icon,
type=type, type=type,
on_click=search(SearchTerm(due_on_day=0)), on_click=search(SearchNode(due_on_day=0)),
) )
root.add_simple( root.add_simple(
name=TR.BROWSING_ADDED_TODAY, name=TR.BROWSING_ADDED_TODAY,
icon=icon, icon=icon,
type=type, type=type,
on_click=search(SearchTerm(added_in_days=1)), on_click=search(SearchNode(added_in_days=1)),
) )
root.add_simple( root.add_simple(
name=TR.BROWSING_EDITED_TODAY, name=TR.BROWSING_EDITED_TODAY,
icon=icon, icon=icon,
type=type, type=type,
on_click=search(SearchTerm(edited_in_days=1)), on_click=search(SearchNode(edited_in_days=1)),
) )
root.add_simple( root.add_simple(
name=TR.BROWSING_STUDIED_TODAY, name=TR.BROWSING_STUDIED_TODAY,
icon=icon, icon=icon,
type=type, type=type,
on_click=search(SearchTerm(rated=SearchTerm.Rated(days=1))), on_click=search(SearchNode(rated=SearchNode.Rated(days=1))),
) )
root.add_simple( root.add_simple(
name=TR.BROWSING_AGAIN_TODAY, name=TR.BROWSING_AGAIN_TODAY,
icon=icon, icon=icon,
type=type, type=type,
on_click=search( on_click=search(
SearchTerm( SearchNode(
rated=SearchTerm.Rated(days=1, rating=SearchTerm.RATING_AGAIN) rated=SearchNode.Rated(days=1, rating=SearchNode.RATING_AGAIN)
) )
), ),
) )
@ -675,8 +683,8 @@ class SidebarTreeView(QTreeView):
icon=icon, icon=icon,
type=type, type=type,
on_click=search( on_click=search(
SearchTerm(card_state=SearchTerm.CARD_STATE_DUE), SearchNode(card_state=SearchNode.CARD_STATE_DUE),
SearchTerm(negated=SearchTerm(due_on_day=0)), SearchNode(negated=SearchNode(due_on_day=0)),
), ),
) )
@ -699,32 +707,32 @@ class SidebarTreeView(QTreeView):
TR.ACTIONS_NEW, TR.ACTIONS_NEW,
icon=icon.with_color(colors.NEW_COUNT), icon=icon.with_color(colors.NEW_COUNT),
type=type, type=type,
on_click=search(SearchTerm(card_state=SearchTerm.CARD_STATE_NEW)), on_click=search(SearchNode(card_state=SearchNode.CARD_STATE_NEW)),
) )
root.add_simple( root.add_simple(
name=TR.SCHEDULING_LEARNING, name=TR.SCHEDULING_LEARNING,
icon=icon.with_color(colors.LEARN_COUNT), icon=icon.with_color(colors.LEARN_COUNT),
type=type, type=type,
on_click=search(SearchTerm(card_state=SearchTerm.CARD_STATE_LEARN)), on_click=search(SearchNode(card_state=SearchNode.CARD_STATE_LEARN)),
) )
root.add_simple( root.add_simple(
name=TR.SCHEDULING_REVIEW, name=TR.SCHEDULING_REVIEW,
icon=icon.with_color(colors.REVIEW_COUNT), icon=icon.with_color(colors.REVIEW_COUNT),
type=type, type=type,
on_click=search(SearchTerm(card_state=SearchTerm.CARD_STATE_REVIEW)), on_click=search(SearchNode(card_state=SearchNode.CARD_STATE_REVIEW)),
) )
root.add_simple( root.add_simple(
name=TR.BROWSING_SUSPENDED, name=TR.BROWSING_SUSPENDED,
icon=icon.with_color(colors.SUSPENDED_FG), icon=icon.with_color(colors.SUSPENDED_FG),
type=type, type=type,
on_click=search(SearchTerm(card_state=SearchTerm.CARD_STATE_SUSPENDED)), on_click=search(SearchNode(card_state=SearchNode.CARD_STATE_SUSPENDED)),
) )
root.add_simple( root.add_simple(
name=TR.BROWSING_BURIED, name=TR.BROWSING_BURIED,
icon=icon.with_color(colors.BURIED_FG), icon=icon.with_color(colors.BURIED_FG),
type=type, type=type,
on_click=search(SearchTerm(card_state=SearchTerm.CARD_STATE_BURIED)), on_click=search(SearchNode(card_state=SearchNode.CARD_STATE_BURIED)),
) )
# Tree: Flags # Tree: Flags
@ -740,38 +748,38 @@ class SidebarTreeView(QTreeView):
collapse_key=Config.Bool.COLLAPSE_FLAGS, collapse_key=Config.Bool.COLLAPSE_FLAGS,
type=SidebarItemType.FLAG_ROOT, type=SidebarItemType.FLAG_ROOT,
) )
root.on_click = search(SearchTerm(flag=SearchTerm.FLAG_ANY)) root.on_click = search(SearchNode(flag=SearchNode.FLAG_ANY))
type = SidebarItemType.FLAG type = SidebarItemType.FLAG
root.add_simple( root.add_simple(
TR.ACTIONS_RED_FLAG, TR.ACTIONS_RED_FLAG,
icon=icon.with_color(colors.FLAG1_FG), icon=icon.with_color(colors.FLAG1_FG),
type=type, type=type,
on_click=search(SearchTerm(flag=SearchTerm.FLAG_RED)), on_click=search(SearchNode(flag=SearchNode.FLAG_RED)),
) )
root.add_simple( root.add_simple(
TR.ACTIONS_ORANGE_FLAG, TR.ACTIONS_ORANGE_FLAG,
icon=icon.with_color(colors.FLAG2_FG), icon=icon.with_color(colors.FLAG2_FG),
type=type, type=type,
on_click=search(SearchTerm(flag=SearchTerm.FLAG_ORANGE)), on_click=search(SearchNode(flag=SearchNode.FLAG_ORANGE)),
) )
root.add_simple( root.add_simple(
TR.ACTIONS_GREEN_FLAG, TR.ACTIONS_GREEN_FLAG,
icon=icon.with_color(colors.FLAG3_FG), icon=icon.with_color(colors.FLAG3_FG),
type=type, type=type,
on_click=search(SearchTerm(flag=SearchTerm.FLAG_GREEN)), on_click=search(SearchNode(flag=SearchNode.FLAG_GREEN)),
) )
root.add_simple( root.add_simple(
TR.ACTIONS_BLUE_FLAG, TR.ACTIONS_BLUE_FLAG,
icon=icon.with_color(colors.FLAG4_FG), icon=icon.with_color(colors.FLAG4_FG),
type=type, type=type,
on_click=search(SearchTerm(flag=SearchTerm.FLAG_BLUE)), on_click=search(SearchNode(flag=SearchNode.FLAG_BLUE)),
) )
root.add_simple( root.add_simple(
TR.BROWSING_NO_FLAG, TR.BROWSING_NO_FLAG,
icon=icon.with_color(colors.DISABLED), icon=icon.with_color(colors.DISABLED),
type=type, type=type,
on_click=search(SearchTerm(flag=SearchTerm.FLAG_NONE)), on_click=search(SearchNode(flag=SearchNode.FLAG_NONE)),
) )
# Tree: Tags # Tree: Tags
@ -794,7 +802,7 @@ class SidebarTreeView(QTreeView):
item = SidebarItem( item = SidebarItem(
node.name, node.name,
icon, icon,
self._filter_func(SearchTerm(tag=head + node.name)), self._filter_func(SearchNode(tag=head + node.name)),
toggle_expand(), toggle_expand(),
node.expanded, node.expanded,
item_type=SidebarItemType.TAG, item_type=SidebarItemType.TAG,
@ -812,12 +820,12 @@ class SidebarTreeView(QTreeView):
collapse_key=Config.Bool.COLLAPSE_TAGS, collapse_key=Config.Bool.COLLAPSE_TAGS,
type=SidebarItemType.TAG_ROOT, type=SidebarItemType.TAG_ROOT,
) )
root.on_click = self._filter_func(SearchTerm(negated=SearchTerm(tag="none"))) root.on_click = self._filter_func(SearchNode(negated=SearchNode(tag="none")))
root.add_simple( root.add_simple(
name=tr(TR.BROWSING_SIDEBAR_UNTAGGED), name=tr(TR.BROWSING_SIDEBAR_UNTAGGED),
icon=icon, icon=icon,
type=SidebarItemType.TAG_NONE, type=SidebarItemType.TAG_NONE,
on_click=self._filter_func(SearchTerm(tag="none")), on_click=self._filter_func(SearchNode(tag="none")),
) )
render(root, tree.children) render(root, tree.children)
@ -840,7 +848,7 @@ class SidebarTreeView(QTreeView):
item = SidebarItem( item = SidebarItem(
node.name, node.name,
icon, icon,
self._filter_func(SearchTerm(deck=head + node.name)), self._filter_func(SearchNode(deck=head + node.name)),
toggle_expand(), toggle_expand(),
not node.collapsed, not node.collapsed,
item_type=SidebarItemType.DECK, item_type=SidebarItemType.DECK,
@ -859,12 +867,12 @@ class SidebarTreeView(QTreeView):
collapse_key=Config.Bool.COLLAPSE_DECKS, collapse_key=Config.Bool.COLLAPSE_DECKS,
type=SidebarItemType.DECK_ROOT, type=SidebarItemType.DECK_ROOT,
) )
root.on_click = self._filter_func(SearchTerm(deck="*")) root.on_click = self._filter_func(SearchNode(deck="*"))
current = root.add_simple( current = root.add_simple(
name=tr(TR.BROWSING_CURRENT_DECK), name=tr(TR.BROWSING_CURRENT_DECK),
icon=icon, icon=icon,
type=SidebarItemType.DECK, type=SidebarItemType.DECK,
on_click=self._filter_func(SearchTerm(deck="current")), on_click=self._filter_func(SearchNode(deck="current")),
) )
current.id = self.mw.col.decks.selected() current.id = self.mw.col.decks.selected()
@ -887,7 +895,7 @@ class SidebarTreeView(QTreeView):
item = SidebarItem( item = SidebarItem(
nt["name"], nt["name"],
icon, icon,
self._filter_func(SearchTerm(note=nt["name"])), self._filter_func(SearchNode(note=nt["name"])),
item_type=SidebarItemType.NOTETYPE, item_type=SidebarItemType.NOTETYPE,
id=nt["id"], id=nt["id"],
) )
@ -897,7 +905,7 @@ class SidebarTreeView(QTreeView):
tmpl["name"], tmpl["name"],
icon, icon,
self._filter_func( self._filter_func(
SearchTerm(note=nt["name"]), SearchTerm(template=c) SearchNode(note=nt["name"]), SearchNode(template=c)
), ),
item_type=SidebarItemType.NOTETYPE_TEMPLATE, item_type=SidebarItemType.NOTETYPE_TEMPLATE,
full_name=f"{nt['name']}::{tmpl['name']}", full_name=f"{nt['name']}::{tmpl['name']}",

View file

@ -90,13 +90,11 @@ service BackendService {
// searching // searching
rpc FilterToSearch(SearchTerm) returns (String); rpc BuildSearchString(SearchNode) 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);
rpc NegateSearch(String) returns (String); rpc JoinSearchNodes(JoinSearchNodesIn) returns (String);
rpc ConcatenateSearches(ConcatenateSearchesIn) returns (String); rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String);
rpc ReplaceSearchTerm(ReplaceSearchTermIn) returns (String);
rpc FindAndReplace(FindAndReplaceIn) returns (UInt32); rpc FindAndReplace(FindAndReplaceIn) returns (UInt32);
// scheduling // scheduling
@ -773,7 +771,7 @@ message SearchNotesOut {
repeated int64 note_ids = 2; repeated int64 note_ids = 2;
} }
message SearchTerm { message SearchNode {
message Dupe { message Dupe {
int64 notetype_id = 1; int64 notetype_id = 1;
string first_field = 2; string first_field = 2;
@ -809,10 +807,18 @@ message SearchTerm {
message IdList { message IdList {
repeated int64 ids = 1; repeated int64 ids = 1;
} }
message Group {
enum Joiner {
AND = 0;
OR = 1;
}
repeated SearchNode nodes = 1;
Joiner joiner = 2;
}
oneof filter { oneof filter {
string tag = 1; Group group = 1;
string deck = 2; SearchNode negated = 2;
string note = 3; string parsable_text = 3;
uint32 template = 4; uint32 template = 4;
int64 nid = 5; int64 nid = 5;
Dupe dupe = 6; Dupe dupe = 6;
@ -824,23 +830,22 @@ message SearchTerm {
CardState card_state = 12; CardState card_state = 12;
IdList nids = 13; IdList nids = 13;
uint32 edited_in_days = 14; uint32 edited_in_days = 14;
SearchTerm negated = 15; string deck = 15;
int32 due_on_day = 16; int32 due_on_day = 16;
string tag = 17;
string note = 18;
} }
} }
message ConcatenateSearchesIn { message JoinSearchNodesIn {
enum Separator { SearchNode.Group.Joiner joiner = 1;
AND = 0; SearchNode existing_node = 2;
OR = 1; SearchNode additional_node = 3;
}
Separator sep = 1;
repeated string searches = 2;
} }
message ReplaceSearchTermIn { message ReplaceSearchNodeIn {
string search = 1; SearchNode existing_node = 1;
string replacement = 2; SearchNode replacement_node = 2;
} }
message CloseCollectionIn { message CloseCollectionIn {

View file

@ -6,7 +6,6 @@ use crate::{
backend::dbproxy::db_command_bytes, backend::dbproxy::db_command_bytes,
backend_proto as pb, backend_proto as pb,
backend_proto::{ backend_proto::{
concatenate_searches_in::Separator as BoolSeparatorProto,
sort_order::builtin::Kind as SortKindProto, sort_order::Value as SortOrderProto, sort_order::builtin::Kind as SortKindProto, sort_order::Value as SortOrderProto,
AddOrUpdateDeckConfigLegacyIn, BackendResult, Empty, RenderedTemplateReplacement, AddOrUpdateDeckConfigLegacyIn, BackendResult, Empty, RenderedTemplateReplacement,
}, },
@ -38,9 +37,8 @@ use crate::{
timespan::{answer_button_time, time_span}, timespan::{answer_button_time, time_span},
}, },
search::{ search::{
concatenate_searches, negate_search, normalize_search, replace_search_term, write_nodes, concatenate_searches, parse_search, replace_search_node, write_nodes, BoolSeparator, Node,
BoolSeparator, Node, PropertyKind, RatingKind, SearchNode, SortMode, StateKind, PropertyKind, RatingKind, SearchNode, SortMode, StateKind, TemplateKind,
TemplateKind,
}, },
stats::studied_today, stats::studied_today,
sync::{ sync::{
@ -55,14 +53,15 @@ use crate::{
}; };
use fluent::FluentValue; use fluent::FluentValue;
use futures::future::{AbortHandle, AbortRegistration, Abortable}; use futures::future::{AbortHandle, AbortRegistration, Abortable};
use itertools::Itertools;
use log::error; use log::error;
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use pb::{sync_status_out, BackendService}; use pb::{sync_status_out, BackendService};
use prost::Message; use prost::Message;
use serde_json::Value as JsonValue; use serde_json::Value as JsonValue;
use slog::warn; use slog::warn;
use std::collections::HashSet;
use std::convert::TryFrom; use std::convert::TryFrom;
use std::{collections::HashSet, convert::TryInto};
use std::{ use std::{
result, result,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
@ -267,7 +266,7 @@ impl From<pb::NoteId> for NoteID {
} }
} }
impl pb::search_term::IdList { impl pb::search_node::IdList {
fn into_id_string(self) -> String { fn into_id_string(self) -> String {
self.ids self.ids
.iter() .iter()
@ -295,40 +294,34 @@ impl From<pb::DeckConfigId> for DeckConfID {
} }
} }
impl From<pb::SearchTerm> for Node<'_> { impl TryFrom<pb::SearchNode> for Node {
fn from(msg: pb::SearchTerm) -> Self { type Error = AnkiError;
use pb::search_term::Filter;
use pb::search_term::Flag; fn try_from(msg: pb::SearchNode) -> std::result::Result<Self, Self::Error> {
if let Some(filter) = msg.filter { use pb::search_node::group::Joiner;
use pb::search_node::Filter;
use pb::search_node::Flag;
Ok(if let Some(filter) = msg.filter {
match filter { match filter {
Filter::Tag(s) => Node::Search(SearchNode::Tag( Filter::Tag(s) => Node::Search(SearchNode::Tag(escape_anki_wildcards(&s))),
escape_anki_wildcards(&s).into_owned().into(), Filter::Deck(s) => Node::Search(SearchNode::Deck(if s == "*" {
)),
Filter::Deck(s) => Node::Search(SearchNode::Deck(
if s == "*" {
s s
} else { } else {
escape_anki_wildcards(&s).into_owned() escape_anki_wildcards(&s)
} })),
.into(), Filter::Note(s) => Node::Search(SearchNode::NoteType(escape_anki_wildcards(&s))),
)),
Filter::Note(s) => Node::Search(SearchNode::NoteType(
escape_anki_wildcards(&s).into_owned().into(),
)),
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::Nid(nid) => Node::Search(SearchNode::NoteIDs(nid.to_string())),
Filter::Nids(nids) => { Filter::Nids(nids) => Node::Search(SearchNode::NoteIDs(nids.into_id_string())),
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.notetype_id.into(), note_type_id: dupe.notetype_id.into(),
text: dupe.first_field.into(), text: dupe.first_field,
}), }),
Filter::FieldName(s) => Node::Search(SearchNode::SingleField { Filter::FieldName(s) => Node::Search(SearchNode::SingleField {
field: escape_anki_wildcards(&s).into_owned().into(), field: escape_anki_wildcards(&s),
text: "*".to_string().into(), text: "*".to_string(),
is_re: false, is_re: false,
}), }),
Filter::Rated(rated) => Node::Search(SearchNode::Rated { Filter::Rated(rated) => Node::Search(SearchNode::Rated {
@ -346,7 +339,7 @@ impl From<pb::SearchTerm> for Node<'_> {
}), }),
Filter::EditedInDays(u) => Node::Search(SearchNode::EditedInDays(u)), Filter::EditedInDays(u) => Node::Search(SearchNode::EditedInDays(u)),
Filter::CardState(state) => Node::Search(SearchNode::State( Filter::CardState(state) => Node::Search(SearchNode::State(
pb::search_term::CardState::from_i32(state) pb::search_node::CardState::from_i32(state)
.unwrap_or_default() .unwrap_or_default()
.into(), .into(),
)), )),
@ -358,45 +351,74 @@ impl From<pb::SearchTerm> for Node<'_> {
Flag::Green => Node::Search(SearchNode::Flag(3)), Flag::Green => Node::Search(SearchNode::Flag(3)),
Flag::Blue => Node::Search(SearchNode::Flag(4)), Flag::Blue => Node::Search(SearchNode::Flag(4)),
}, },
Filter::Negated(term) => Node::Not(Box::new((*term).into())), Filter::Negated(term) => Node::try_from(*term)?.negated(),
Filter::Group(mut group) => {
match group.nodes.len() {
0 => return Err(AnkiError::invalid_input("empty group")),
// a group of 1 doesn't need to be a group
1 => group.nodes.pop().unwrap().try_into()?,
// 2+ nodes
_ => {
let joiner = match group.joiner() {
Joiner::And => Node::And,
Joiner::Or => Node::Or,
};
let parsed: Vec<_> = group
.nodes
.into_iter()
.map(TryFrom::try_from)
.collect::<Result<_>>()?;
let joined = parsed.into_iter().intersperse(joiner).collect();
Node::Group(joined)
}
}
}
Filter::ParsableText(text) => {
let mut nodes = parse_search(&text)?;
if nodes.len() == 1 {
nodes.pop().unwrap()
} else {
Node::Group(nodes)
}
}
} }
} else { } else {
Node::Search(SearchNode::WholeCollection) Node::Search(SearchNode::WholeCollection)
} })
} }
} }
impl From<BoolSeparatorProto> for BoolSeparator { impl From<pb::search_node::group::Joiner> for BoolSeparator {
fn from(sep: BoolSeparatorProto) -> Self { fn from(sep: pb::search_node::group::Joiner) -> Self {
match sep { match sep {
BoolSeparatorProto::And => BoolSeparator::And, pb::search_node::group::Joiner::And => BoolSeparator::And,
BoolSeparatorProto::Or => BoolSeparator::Or, pb::search_node::group::Joiner::Or => BoolSeparator::Or,
} }
} }
} }
impl From<pb::search_term::Rating> for RatingKind { impl From<pb::search_node::Rating> for RatingKind {
fn from(r: pb::search_term::Rating) -> Self { fn from(r: pb::search_node::Rating) -> Self {
match r { match r {
pb::search_term::Rating::Again => RatingKind::AnswerButton(1), pb::search_node::Rating::Again => RatingKind::AnswerButton(1),
pb::search_term::Rating::Hard => RatingKind::AnswerButton(2), pb::search_node::Rating::Hard => RatingKind::AnswerButton(2),
pb::search_term::Rating::Good => RatingKind::AnswerButton(3), pb::search_node::Rating::Good => RatingKind::AnswerButton(3),
pb::search_term::Rating::Easy => RatingKind::AnswerButton(4), pb::search_node::Rating::Easy => RatingKind::AnswerButton(4),
pb::search_term::Rating::Any => RatingKind::AnyAnswerButton, pb::search_node::Rating::Any => RatingKind::AnyAnswerButton,
pb::search_term::Rating::ByReschedule => RatingKind::ManualReschedule, pb::search_node::Rating::ByReschedule => RatingKind::ManualReschedule,
} }
} }
} }
impl From<pb::search_term::CardState> for StateKind { impl From<pb::search_node::CardState> for StateKind {
fn from(k: pb::search_term::CardState) -> Self { fn from(k: pb::search_node::CardState) -> Self {
match k { match k {
pb::search_term::CardState::New => StateKind::New, pb::search_node::CardState::New => StateKind::New,
pb::search_term::CardState::Learn => StateKind::Learning, pb::search_node::CardState::Learn => StateKind::Learning,
pb::search_term::CardState::Review => StateKind::Review, pb::search_node::CardState::Review => StateKind::Review,
pb::search_term::CardState::Due => StateKind::Due, pb::search_node::CardState::Due => StateKind::Due,
pb::search_term::CardState::Suspended => StateKind::Suspended, pb::search_node::CardState::Suspended => StateKind::Suspended,
pb::search_term::CardState::Buried => StateKind::Buried, pb::search_node::CardState::Buried => StateKind::Buried,
} }
} }
} }
@ -525,12 +547,8 @@ impl BackendService for Backend {
// searching // searching
//----------------------------------------------- //-----------------------------------------------
fn filter_to_search(&self, input: pb::SearchTerm) -> Result<pb::String> { fn build_search_string(&self, input: pb::SearchNode) -> Result<pb::String> {
Ok(write_nodes(&[input.into()]).into()) Ok(write_nodes(&[input.try_into()?]).into())
}
fn normalize_search(&self, input: pb::String) -> Result<pb::String> {
Ok(normalize_search(&input.val)?.into())
} }
fn search_cards(&self, input: pb::SearchCardsIn) -> Result<pb::SearchCardsOut> { fn search_cards(&self, input: pb::SearchCardsIn) -> Result<pb::SearchCardsOut> {
@ -552,16 +570,31 @@ impl BackendService for Backend {
}) })
} }
fn negate_search(&self, input: pb::String) -> Result<pb::String> { fn join_search_nodes(&self, input: pb::JoinSearchNodesIn) -> Result<pb::String> {
Ok(negate_search(&input.val)?.into()) let sep = input.joiner().into();
let existing_nodes = {
let node = input.existing_node.unwrap_or_default().try_into()?;
if let Node::Group(nodes) = node {
nodes
} else {
vec![node]
}
};
let additional_node = input.additional_node.unwrap_or_default().try_into()?;
Ok(concatenate_searches(sep, existing_nodes, additional_node).into())
} }
fn concatenate_searches(&self, input: pb::ConcatenateSearchesIn) -> Result<pb::String> { fn replace_search_node(&self, input: pb::ReplaceSearchNodeIn) -> Result<pb::String> {
Ok(concatenate_searches(input.sep().into(), &input.searches)?.into()) let existing = {
let node = input.existing_node.unwrap_or_default().try_into()?;
if let Node::Group(nodes) = node {
nodes
} else {
vec![node]
} }
};
fn replace_search_term(&self, input: pb::ReplaceSearchTermIn) -> Result<pb::String> { let replacement = input.replacement_node.unwrap_or_default().try_into()?;
Ok(replace_search_term(&input.search, &input.replacement)?.into()) Ok(replace_search_node(existing, replacement).into())
} }
fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> BackendResult<pb::UInt32> { fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> BackendResult<pb::UInt32> {

View file

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

View file

@ -17,7 +17,6 @@ use nom::{
sequence::{preceded, separated_pair}, sequence::{preceded, separated_pair},
}; };
use regex::{Captures, Regex}; use regex::{Captures, Regex};
use std::borrow::Cow;
type IResult<'a, O> = std::result::Result<(&'a str, O), nom::Err<ParseError<'a>>>; type IResult<'a, O> = std::result::Result<(&'a str, O), nom::Err<ParseError<'a>>>;
type ParseResult<'a, O> = std::result::Result<O, nom::Err<ParseError<'a>>>; type ParseResult<'a, O> = std::result::Result<O, nom::Err<ParseError<'a>>>;
@ -30,53 +29,63 @@ fn parse_error(input: &str) -> nom::Err<ParseError<'_>> {
nom::Err::Error(ParseError::Anki(input, FailKind::Other(None))) nom::Err::Error(ParseError::Anki(input, FailKind::Other(None)))
} }
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq, Clone)]
pub enum Node<'a> { pub enum Node {
And, And,
Or, Or,
Not(Box<Node<'a>>), Not(Box<Node>),
Group(Vec<Node<'a>>), Group(Vec<Node>),
Search(SearchNode<'a>), Search(SearchNode),
}
impl Node {
pub fn negated(self) -> Node {
if let Node::Not(inner) = self {
*inner
} else {
Node::Not(Box::new(self))
}
}
} }
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub enum SearchNode<'a> { pub enum SearchNode {
// text without a colon // text without a colon
UnqualifiedText(Cow<'a, str>), UnqualifiedText(String),
// foo:bar, where foo doesn't match a term below // foo:bar, where foo doesn't match a term below
SingleField { SingleField {
field: Cow<'a, str>, field: String,
text: Cow<'a, str>, text: String,
is_re: bool, is_re: bool,
}, },
AddedInDays(u32), AddedInDays(u32),
EditedInDays(u32), EditedInDays(u32),
CardTemplate(TemplateKind<'a>), CardTemplate(TemplateKind),
Deck(Cow<'a, str>), Deck(String),
DeckID(DeckID), DeckID(DeckID),
NoteTypeID(NoteTypeID), NoteTypeID(NoteTypeID),
NoteType(Cow<'a, str>), NoteType(String),
Rated { Rated {
days: u32, days: u32,
ease: RatingKind, ease: RatingKind,
}, },
Tag(Cow<'a, str>), Tag(String),
Duplicates { Duplicates {
note_type_id: NoteTypeID, note_type_id: NoteTypeID,
text: Cow<'a, str>, text: String,
}, },
State(StateKind), State(StateKind),
Flag(u8), Flag(u8),
NoteIDs(Cow<'a, str>), NoteIDs(String),
CardIDs(&'a str), CardIDs(String),
Property { Property {
operator: String, operator: String,
kind: PropertyKind, kind: PropertyKind,
}, },
WholeCollection, WholeCollection,
Regex(Cow<'a, str>), Regex(String),
NoCombining(Cow<'a, str>), NoCombining(String),
WordBoundary(Cow<'a, str>), WordBoundary(String),
} }
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
@ -103,9 +112,9 @@ pub enum StateKind {
} }
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub enum TemplateKind<'a> { pub enum TemplateKind {
Ordinal(u16), Ordinal(u16),
Name(Cow<'a, str>), Name(String),
} }
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
@ -116,7 +125,7 @@ pub enum RatingKind {
} }
/// Parse the input string into a list of nodes. /// Parse the input string into a list of nodes.
pub(super) fn parse(input: &str) -> Result<Vec<Node>> { pub fn parse(input: &str) -> Result<Vec<Node>> {
let input = input.trim(); let input = input.trim();
if input.is_empty() { if input.is_empty() {
return Ok(vec![Node::Search(SearchNode::WholeCollection)]); return Ok(vec![Node::Search(SearchNode::WholeCollection)]);
@ -303,7 +312,7 @@ fn search_node_for_text(s: &str) -> ParseResult<SearchNode> {
fn search_node_for_text_with_argument<'a>( fn search_node_for_text_with_argument<'a>(
key: &'a str, key: &'a str,
val: &'a str, val: &'a str,
) -> ParseResult<'a, SearchNode<'a>> { ) -> ParseResult<'a, SearchNode> {
Ok(match key.to_ascii_lowercase().as_str() { Ok(match key.to_ascii_lowercase().as_str() {
"deck" => SearchNode::Deck(unescape(val)?), "deck" => SearchNode::Deck(unescape(val)?),
"note" => SearchNode::NoteType(unescape(val)?), "note" => SearchNode::NoteType(unescape(val)?),
@ -319,7 +328,7 @@ fn search_node_for_text_with_argument<'a>(
"did" => parse_did(val)?, "did" => parse_did(val)?,
"mid" => parse_mid(val)?, "mid" => parse_mid(val)?,
"nid" => SearchNode::NoteIDs(check_id_list(val, key)?.into()), "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)?.into()),
"re" => SearchNode::Regex(unescape_quotes(val)), "re" => SearchNode::Regex(unescape_quotes(val)),
"nc" => SearchNode::NoCombining(unescape(val)?), "nc" => SearchNode::NoCombining(unescape(val)?),
"w" => SearchNode::WordBoundary(unescape(val)?), "w" => SearchNode::WordBoundary(unescape(val)?),
@ -579,7 +588,7 @@ fn parse_dupe(s: &str) -> ParseResult<SearchNode> {
} }
} }
fn parse_single_field<'a>(key: &'a str, val: &'a str) -> ParseResult<'a, SearchNode<'a>> { fn parse_single_field<'a>(key: &'a str, val: &'a str) -> ParseResult<'a, SearchNode> {
Ok(if let Some(stripped) = val.strip_prefix("re:") { Ok(if let Some(stripped) = val.strip_prefix("re:") {
SearchNode::SingleField { SearchNode::SingleField {
field: unescape(key)?, field: unescape(key)?,
@ -596,25 +605,25 @@ fn parse_single_field<'a>(key: &'a str, val: &'a str) -> ParseResult<'a, SearchN
} }
/// For strings without unescaped ", convert \" to " /// For strings without unescaped ", convert \" to "
fn unescape_quotes(s: &str) -> Cow<str> { fn unescape_quotes(s: &str) -> String {
if s.contains('"') { if s.contains('"') {
s.replace(r#"\""#, "\"").into() s.replace(r#"\""#, "\"")
} else { } else {
s.into() s.into()
} }
} }
/// For non-globs like dupe text without any assumption about the content /// For non-globs like dupe text without any assumption about the content
fn unescape_quotes_and_backslashes(s: &str) -> Cow<str> { fn unescape_quotes_and_backslashes(s: &str) -> String {
if s.contains('"') || s.contains('\\') { if s.contains('"') || s.contains('\\') {
s.replace(r#"\""#, "\"").replace(r"\\", r"\").into() s.replace(r#"\""#, "\"").replace(r"\\", r"\")
} else { } else {
s.into() s.into()
} }
} }
/// Unescape chars with special meaning to the parser. /// Unescape chars with special meaning to the parser.
fn unescape(txt: &str) -> ParseResult<Cow<str>> { fn unescape(txt: &str) -> ParseResult<String> {
if let Some(seq) = invalid_escape_sequence(txt) { if let Some(seq) = invalid_escape_sequence(txt) {
Err(parse_failure(txt, FailKind::UnknownEscape(seq))) Err(parse_failure(txt, FailKind::UnknownEscape(seq)))
} else { } else {
@ -631,6 +640,7 @@ fn unescape(txt: &str) -> ParseResult<Cow<str>> {
r"\-" => "-", r"\-" => "-",
_ => unreachable!(), _ => unreachable!(),
}) })
.into()
} else { } else {
txt.into() txt.into()
}) })
@ -980,4 +990,14 @@ mod test {
Ok(()) Ok(())
} }
#[test]
fn negating() {
let node = Node::Search(SearchNode::UnqualifiedText("foo".to_string()));
let neg_node = Node::Not(Box::new(Node::Search(SearchNode::UnqualifiedText(
"foo".to_string(),
))));
assert_eq!(node.clone().negated(), neg_node);
assert_eq!(node.clone().negated().negated(), node);
}
} }

View file

@ -134,7 +134,9 @@ impl SqlWriter<'_> {
SearchNode::EditedInDays(days) => self.write_edited(*days)?, SearchNode::EditedInDays(days) => self.write_edited(*days)?,
SearchNode::CardTemplate(template) => match template { SearchNode::CardTemplate(template) => match template {
TemplateKind::Ordinal(_) => self.write_template(template)?, TemplateKind::Ordinal(_) => self.write_template(template)?,
TemplateKind::Name(name) => self.write_template(&TemplateKind::Name(norm(name)))?, TemplateKind::Name(name) => {
self.write_template(&TemplateKind::Name(norm(name).into()))?
}
}, },
SearchNode::Deck(deck) => self.write_deck(&norm(deck))?, SearchNode::Deck(deck) => self.write_deck(&norm(deck))?,
SearchNode::NoteTypeID(ntid) => { SearchNode::NoteTypeID(ntid) => {
@ -532,7 +534,7 @@ impl RequiredTable {
} }
} }
impl Node<'_> { impl Node {
fn required_table(&self) -> RequiredTable { fn required_table(&self) -> RequiredTable {
match self { match self {
Node::And => RequiredTable::CardsOrNotes, Node::And => RequiredTable::CardsOrNotes,
@ -546,7 +548,7 @@ impl Node<'_> {
} }
} }
impl SearchNode<'_> { impl SearchNode {
fn required_table(&self) -> RequiredTable { fn required_table(&self) -> RequiredTable {
match self { match self {
SearchNode::AddedInDays(_) => RequiredTable::Cards, SearchNode::AddedInDays(_) => RequiredTable::Cards,

View file

@ -3,11 +3,9 @@
use crate::{ use crate::{
decks::DeckID as DeckIDType, decks::DeckID as DeckIDType,
err::Result,
notetype::NoteTypeID as NoteTypeIDType, notetype::NoteTypeID as NoteTypeIDType,
search::parser::{parse, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind}, search::parser::{Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind},
}; };
use itertools::Itertools;
use std::mem; use std::mem;
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
@ -16,59 +14,33 @@ pub enum BoolSeparator {
Or, Or,
} }
/// Take an Anki-style search string and convert it into an equivalent /// Take an existing search, and AND/OR it with the provided additional search.
/// search string with normalized syntax. /// This is required because when the user has "a AND b" in an existing search and
pub fn normalize_search(input: &str) -> Result<String> { /// wants to add "c", we want "a AND b AND c", not "(a AND b) AND C", which is what we'd
Ok(write_nodes(&parse(input)?)) /// get if we tried to join the existing search string with a new SearchTerm on the
} /// client side.
pub fn concatenate_searches(
/// Take an Anki-style search string and return the negated counterpart. sep: BoolSeparator,
/// Empty searches (whole collection) remain unchanged. mut existing: Vec<Node>,
pub fn negate_search(input: &str) -> Result<String> { additional: Node,
let mut nodes = parse(input)?; ) -> String {
use Node::*; if !existing.is_empty() {
Ok(if nodes.len() == 1 { existing.push(match sep {
let node = nodes.remove(0);
match node {
Not(n) => write_node(&n),
Search(SearchNode::WholeCollection) => "".to_string(),
Group(_) | Search(_) => write_node(&Not(Box::new(node))),
_ => unreachable!(),
}
} else {
write_node(&Not(Box::new(Group(nodes))))
})
}
/// Take arbitrary Anki-style search strings and return their concatenation where they
/// are separated by the provided boolean operator.
/// Empty searches (whole collection) are left out.
pub fn concatenate_searches(sep: BoolSeparator, searches: &[String]) -> Result<String> {
let bool_node = vec![match sep {
BoolSeparator::And => Node::And, BoolSeparator::And => Node::And,
BoolSeparator::Or => Node::Or, BoolSeparator::Or => Node::Or,
}]; });
Ok(write_nodes( }
searches existing.push(additional);
.iter() write_nodes(&existing)
.map(|s| parse(s))
.collect::<Result<Vec<Vec<Node>>>>()?
.iter()
.filter(|v| v[0] != Node::Search(SearchNode::WholeCollection))
.intersperse(&&bool_node)
.flat_map(|v| v.iter()),
))
} }
/// Take two Anki-style search strings. If the second one evaluates to a single search /// Given an existing parsed search, if the provided `replacement` is a single search node such
/// node, replace with it all search terms of the same kind in the first search. /// as a deck:xxx search, replace any instances of that search in `existing` with the new value.
/// Then return the possibly modified first search. /// Then return the possibly modified first search as a string.
pub fn replace_search_term(search: &str, replacement: &str) -> Result<String> { pub fn replace_search_node(mut existing: Vec<Node>, replacement: Node) -> String {
let mut nodes = parse(search)?; if let Node::Search(search_node) = replacement {
let new = parse(replacement)?; fn update_node_vec(old_nodes: &mut [Node], new_node: &SearchNode) {
if let [Node::Search(search_node)] = &new[..] { fn update_node(old_node: &mut Node, new_node: &SearchNode) {
fn update_node_vec<'a>(old_nodes: &mut [Node<'a>], new_node: &SearchNode<'a>) {
fn update_node<'a>(old_node: &mut Node<'a>, new_node: &SearchNode<'a>) {
match old_node { match old_node {
Node::Not(n) => update_node(n, new_node), Node::Not(n) => update_node(n, new_node),
Node::Group(ns) => update_node_vec(ns, new_node), Node::Group(ns) => update_node_vec(ns, new_node),
@ -82,16 +54,13 @@ pub fn replace_search_term(search: &str, replacement: &str) -> Result<String> {
} }
old_nodes.iter_mut().for_each(|n| update_node(n, new_node)); old_nodes.iter_mut().for_each(|n| update_node(n, new_node));
} }
update_node_vec(&mut nodes, search_node); update_node_vec(&mut existing, &search_node);
} }
Ok(write_nodes(&nodes)) write_nodes(&existing)
} }
pub fn write_nodes<'a, I>(nodes: I) -> String pub fn write_nodes(nodes: &[Node]) -> String {
where nodes.iter().map(|node| write_node(node)).collect()
I: IntoIterator<Item = &'a Node<'a>>,
{
nodes.into_iter().map(|node| write_node(node)).collect()
} }
fn write_node(node: &Node) -> String { fn write_node(node: &Node) -> String {
@ -125,7 +94,7 @@ fn write_search_node(node: &SearchNode) -> String {
NoteIDs(s) => format!("\"nid:{}\"", s), NoteIDs(s) => format!("\"nid:{}\"", s),
CardIDs(s) => format!("\"cid:{}\"", s), CardIDs(s) => format!("\"cid:{}\"", s),
Property { operator, kind } => write_property(operator, kind), Property { operator, kind } => write_property(operator, kind),
WholeCollection => "".to_string(), WholeCollection => "\"deck:*\"".to_string(),
Regex(s) => quote(&format!("re:{}", s)), Regex(s) => quote(&format!("re:{}", s)),
NoCombining(s) => quote(&format!("nc:{}", s)), NoCombining(s) => quote(&format!("nc:{}", s)),
WordBoundary(s) => quote(&format!("w:{}", s)), WordBoundary(s) => quote(&format!("w:{}", s)),
@ -206,6 +175,14 @@ fn write_property(operator: &str, kind: &PropertyKind) -> String {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use crate::err::Result;
use crate::search::parse_search as parse;
/// Take an Anki-style search string and convert it into an equivalent
/// search string with normalized syntax.
fn normalize_search(input: &str) -> Result<String> {
Ok(write_nodes(&parse(input)?))
}
#[test] #[test]
fn normalizing() -> Result<()> { fn normalizing() -> Result<()> {
@ -224,36 +201,40 @@ mod test {
Ok(()) Ok(())
} }
#[test]
fn negating() -> Result<()> {
assert_eq!(r#"-("foo" AND "bar")"#, negate_search("foo bar").unwrap());
assert_eq!(r#""foo""#, negate_search("-foo").unwrap());
assert_eq!(r#"("foo")"#, negate_search("-(foo)").unwrap());
assert_eq!("", negate_search("").unwrap());
Ok(())
}
#[test] #[test]
fn concatenating() -> Result<()> { fn concatenating() -> Result<()> {
assert_eq!( assert_eq!(
concatenate_searches(
BoolSeparator::And,
vec![Node::Search(SearchNode::UnqualifiedText("foo".to_string()))],
Node::Search(SearchNode::UnqualifiedText("bar".to_string()))
),
r#""foo" AND "bar""#, r#""foo" AND "bar""#,
concatenate_searches(BoolSeparator::And, &["foo".to_string(), "bar".to_string()])
.unwrap()
); );
assert_eq!( assert_eq!(
r#""foo" OR "bar""#,
concatenate_searches( concatenate_searches(
BoolSeparator::Or, BoolSeparator::Or,
&["foo".to_string(), "".to_string(), "bar".to_string()] vec![Node::Search(SearchNode::UnqualifiedText("foo".to_string()))],
) Node::Search(SearchNode::UnqualifiedText("bar".to_string()))
.unwrap() ),
r#""foo" OR "bar""#,
); );
assert_eq!( assert_eq!(
"", concatenate_searches(
concatenate_searches(BoolSeparator::Or, &["".to_string()]).unwrap() BoolSeparator::Or,
vec![Node::Search(SearchNode::WholeCollection)],
Node::Search(SearchNode::UnqualifiedText("bar".to_string()))
),
r#""deck:*" OR "bar""#,
);
assert_eq!(
concatenate_searches(
BoolSeparator::Or,
vec![],
Node::Search(SearchNode::UnqualifiedText("bar".to_string()))
),
r#""bar""#,
); );
assert_eq!("", concatenate_searches(BoolSeparator::Or, &[]).unwrap());
Ok(()) Ok(())
} }
@ -261,24 +242,30 @@ mod test {
#[test] #[test]
fn replacing() -> Result<()> { fn replacing() -> Result<()> {
assert_eq!( assert_eq!(
replace_search_node(parse("deck:baz bar")?, parse("deck:foo")?.pop().unwrap()),
r#""deck:foo" AND "bar""#, r#""deck:foo" AND "bar""#,
replace_search_term("deck:baz bar", "deck:foo").unwrap()
); );
assert_eq!( assert_eq!(
replace_search_node(
parse("tag:foo Or tag:bar")?,
parse("tag:baz")?.pop().unwrap()
),
r#""tag:baz" OR "tag:baz""#, r#""tag:baz" OR "tag:baz""#,
replace_search_term("tag:foo Or tag:bar", "tag:baz").unwrap()
); );
assert_eq!( assert_eq!(
replace_search_node(
parse("foo or (-foo tag:baz)")?,
parse("bar")?.pop().unwrap()
),
r#""bar" OR (-"bar" AND "tag:baz")"#, r#""bar" OR (-"bar" AND "tag:baz")"#,
replace_search_term("foo or (-foo tag:baz)", "bar").unwrap()
); );
assert_eq!( assert_eq!(
r#""is:due""#, replace_search_node(parse("is:due")?, parse("-is:new")?.pop().unwrap()),
replace_search_term("is:due", "-is:new").unwrap() r#""is:due""#
); );
assert_eq!( assert_eq!(
r#""added:1""#, replace_search_node(parse("added:1")?, parse("is:due")?.pop().unwrap()),
replace_search_term("added:1", "is:due").unwrap() r#""added:1""#
); );
Ok(()) Ok(())

View file

@ -336,11 +336,11 @@ pub(crate) fn to_text(txt: &str) -> Cow<str> {
} }
/// Escape Anki wildcards and the backslash for escaping them: \*_ /// Escape Anki wildcards and the backslash for escaping them: \*_
pub(crate) fn escape_anki_wildcards(txt: &str) -> Cow<str> { pub(crate) fn escape_anki_wildcards(txt: &str) -> String {
lazy_static! { lazy_static! {
static ref RE: Regex = Regex::new(r"[\\*_]").unwrap(); static ref RE: Regex = Regex::new(r"[\\*_]").unwrap();
} }
RE.replace_all(&txt, r"\$0") RE.replace_all(&txt, r"\$0").into()
} }
/// Compare text with a possible glob, folding case. /// Compare text with a possible glob, folding case.