tweak search wording and tidy up API

- SearchTerm -> SearchNode
- Operator -> Joiner; share between messages
- build_search_string() supports specifying AND/OR as a convenience
- group_searches() makes it easier to negate
This commit is contained in:
Damien Elmes 2021-02-11 19:57:19 +10:00
parent 59ccfe5918
commit 35840221bb
15 changed files with 202 additions and 193 deletions

View file

@ -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,72 +527,85 @@ class Collection:
# Search Strings # Search Strings
########################################################################## ##########################################################################
def group_search_terms(self, *terms: Union[str, SearchTerm]) -> SearchTerm:
"""Join provided search terms and strings into a single SearchTerm.
If multiple terms provided, they will be ANDed together into a group.
If a single term is provided, it is returned as-is.
"""
assert terms
# convert raw text to SearchTerms
search_terms = [
term if isinstance(term, SearchTerm) else SearchTerm(unparsed_search=term)
for term in terms
]
# if there's more than one, wrap it in an implicit AND
if len(search_terms) > 1:
return SearchTerm(group=SearchTerm.Group(terms=search_terms))
else:
return search_terms[0]
def build_search_string( def build_search_string(
self, self,
*terms: Union[str, SearchTerm], *nodes: Union[str, SearchNode],
joiner: SearchJoiner = "AND",
) -> str: ) -> str:
"""Join provided search terms together, and return a normalized search string. """Join one or more searches, and return a normalized search string.
Terms are joined by an implicit AND. You can make an explict AND or OR
by wrapping in a group:
terms = [... one or more SearchTerms()]
group = SearchTerm.Group(op=SearchTerm.Group.OR, terms=terms)
term = SearchTerm(group=group)
To negate, wrap in a negated search term: To negate, wrap in a negated search term:
term = SearchTerm(negated=term) term = SearchNode(negated=col.group_searches(...))
Invalid search terms will throw an exception. Invalid searches will throw an exception.
""" """
term = self.group_search_terms(*terms) term = self.group_searches(*nodes, joiner=joiner)
return self._backend.filter_to_search(term) return self._backend.build_search_string(term)
def group_searches(
self,
*nodes: Union[str, SearchNode],
joiner: SearchJoiner = "AND",
) -> SearchNode:
"""Join provided search nodes and strings into a single SearchNode.
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:
return search_nodes[0]
# pylint: disable=no-member
def join_searches( def join_searches(
self, self,
existing_term: SearchTerm, existing_node: SearchNode,
additional_term: SearchTerm, additional_node: SearchNode,
operator: Literal["AND", "OR"], operator: Literal["AND", "OR"],
) -> str: ) -> str:
""" """
AND or OR `additional_term` to `existing_term`, without wrapping `existing_term` in brackets. AND or OR `additional_term` to `existing_term`, without wrapping `existing_term` in brackets.
If you're building a search query yourself, prefer using SearchTerm(group=SearchTerm.Group(...)) 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(
if operator == "AND": joiner=self._pb_search_separator(operator),
sep = _pb.ConcatenateSearchesIn.AND existing_node=existing_node,
else: additional_node=additional_node,
sep = _pb.ConcatenateSearchesIn.OR
search_string = self._backend.concatenate_searches(
sep=sep, existing_search=existing_term, additional_search=additional_term
) )
return search_string return search_string
def replace_search_term(self, search: SearchTerm, replacement: SearchTerm) -> 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,9 +51,9 @@ fn want_release_gil(method: u32) -> bool {
| BackendMethod::LatestProgress | BackendMethod::LatestProgress
| BackendMethod::SetWantsAbort | BackendMethod::SetWantsAbort
| BackendMethod::I18nResources | BackendMethod::I18nResources
| BackendMethod::ConcatenateSearches | BackendMethod::JoinSearchNodes
| BackendMethod::ReplaceSearchTerm | BackendMethod::ReplaceSearchNode
| BackendMethod::FilterToSearch | BackendMethod::BuildSearchString
) )
} 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,20 @@ 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 modifier keys, then refresh.""" """Modify the current search string based on modifier keys, then refresh."""
mods = self.mw.app.keyboardModifiers() mods = self.mw.app.keyboardModifiers()
previous = SearchTerm(unparsed_search=self.browser.current_search()) previous = SearchNode(parsable_text=self.browser.current_search())
current = self.mw.col.group_search_terms(*terms) current = self.mw.col.group_searches(*terms)
# if Alt pressed, invert # if Alt pressed, invert
if mods & Qt.AltModifier: if mods & Qt.AltModifier:
current = SearchTerm(negated=current) current = SearchNode(negated=current)
try: try:
if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: if mods & Qt.ControlModifier and mods & Qt.ShiftModifier:
# If Ctrl+Shift, replace searches nodes of the same type. # If Ctrl+Shift, replace searches nodes of the same type.
search = self.col.replace_search_term(previous, current) search = self.col.replace_in_search_node(previous, current)
elif mods & Qt.ControlModifier: elif mods & Qt.ControlModifier:
# If Ctrl, AND with previous # If Ctrl, AND with previous
search = self.col.join_searches(previous, current, "AND") search = self.col.join_searches(previous, current, "AND")
@ -597,7 +597,7 @@ 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(*terms) return lambda: self.update_search(*terms)
# Tree: Saved Searches # Tree: Saved Searches
@ -648,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)
) )
), ),
) )
@ -683,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)),
), ),
) )
@ -707,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
@ -748,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
@ -802,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,
@ -820,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)
@ -848,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,
@ -867,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()
@ -895,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"],
) )
@ -905,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,11 +90,11 @@ service BackendService {
// searching // searching
rpc FilterToSearch(SearchTerm) returns (String); rpc BuildSearchString(SearchNode) returns (String);
rpc SearchCards(SearchCardsIn) returns (SearchCardsOut); rpc SearchCards(SearchCardsIn) returns (SearchCardsOut);
rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut); rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut);
rpc ConcatenateSearches(ConcatenateSearchesIn) returns (String); rpc JoinSearchNodes(JoinSearchNodesIn) returns (String);
rpc ReplaceSearchTerm(ReplaceSearchTermIn) returns (String); rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String);
rpc FindAndReplace(FindAndReplaceIn) returns (UInt32); rpc FindAndReplace(FindAndReplaceIn) returns (UInt32);
// scheduling // scheduling
@ -771,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;
@ -808,17 +808,17 @@ message SearchTerm {
repeated int64 ids = 1; repeated int64 ids = 1;
} }
message Group { message Group {
enum Operator { enum Joiner {
AND = 0; AND = 0;
OR = 1; OR = 1;
} }
repeated SearchTerm terms = 1; repeated SearchNode nodes = 1;
Operator op = 2; Joiner joiner = 2;
} }
oneof filter { oneof filter {
Group group = 1; Group group = 1;
SearchTerm negated = 2; SearchNode negated = 2;
string unparsed_search = 3; string parsable_text = 3;
uint32 template = 4; uint32 template = 4;
int64 nid = 5; int64 nid = 5;
Dupe dupe = 6; Dupe dupe = 6;
@ -837,19 +837,15 @@ message SearchTerm {
} }
} }
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;
SearchTerm existing_search = 2;
SearchTerm additional_search = 3;
} }
message ReplaceSearchTermIn { message ReplaceSearchNodeIn {
SearchTerm search = 1; SearchNode existing_node = 1;
SearchTerm 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,7 +37,7 @@ use crate::{
timespan::{answer_button_time, time_span}, timespan::{answer_button_time, time_span},
}, },
search::{ search::{
concatenate_searches, parse_search, replace_search_term, write_nodes, BoolSeparator, Node, concatenate_searches, parse_search, replace_search_node, write_nodes, BoolSeparator, Node,
PropertyKind, RatingKind, SearchNode, SortMode, StateKind, TemplateKind, PropertyKind, RatingKind, SearchNode, SortMode, StateKind, TemplateKind,
}, },
stats::studied_today, stats::studied_today,
@ -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,13 +294,13 @@ impl From<pb::DeckConfigId> for DeckConfID {
} }
} }
impl TryFrom<pb::SearchTerm> for Node { impl TryFrom<pb::SearchNode> for Node {
type Error = AnkiError; type Error = AnkiError;
fn try_from(msg: pb::SearchTerm) -> std::result::Result<Self, Self::Error> { fn try_from(msg: pb::SearchNode) -> std::result::Result<Self, Self::Error> {
use pb::search_term::group::Operator; use pb::search_node::group::Joiner;
use pb::search_term::Filter; use pb::search_node::Filter;
use pb::search_term::Flag; use pb::search_node::Flag;
Ok(if let Some(filter) = msg.filter { Ok(if let Some(filter) = msg.filter {
match filter { match filter {
Filter::Tag(s) => Node::Search(SearchNode::Tag(escape_anki_wildcards(&s))), Filter::Tag(s) => Node::Search(SearchNode::Tag(escape_anki_wildcards(&s))),
@ -340,7 +339,7 @@ impl TryFrom<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(),
)), )),
@ -354,27 +353,27 @@ impl TryFrom<pb::SearchTerm> for Node {
}, },
Filter::Negated(term) => Node::try_from(*term)?.negated(), Filter::Negated(term) => Node::try_from(*term)?.negated(),
Filter::Group(mut group) => { Filter::Group(mut group) => {
match group.terms.len() { match group.nodes.len() {
0 => return Err(AnkiError::invalid_input("empty group")), 0 => return Err(AnkiError::invalid_input("empty group")),
// a group of 1 doesn't need to be a group // a group of 1 doesn't need to be a group
1 => group.terms.pop().unwrap().try_into()?, 1 => group.nodes.pop().unwrap().try_into()?,
// 2+ nodes // 2+ nodes
_ => { _ => {
let operator = match group.op() { let joiner = match group.joiner() {
Operator::And => Node::And, Joiner::And => Node::And,
Operator::Or => Node::Or, Joiner::Or => Node::Or,
}; };
let parsed: Vec<_> = group let parsed: Vec<_> = group
.terms .nodes
.into_iter() .into_iter()
.map(TryFrom::try_from) .map(TryFrom::try_from)
.collect::<Result<_>>()?; .collect::<Result<_>>()?;
let joined = parsed.into_iter().intersperse(operator).collect(); let joined = parsed.into_iter().intersperse(joiner).collect();
Node::Group(joined) Node::Group(joined)
} }
} }
} }
Filter::UnparsedSearch(text) => { Filter::ParsableText(text) => {
let mut nodes = parse_search(&text)?; let mut nodes = parse_search(&text)?;
if nodes.len() == 1 { if nodes.len() == 1 {
nodes.pop().unwrap() nodes.pop().unwrap()
@ -389,37 +388,37 @@ impl TryFrom<pb::SearchTerm> for Node {
} }
} }
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,
} }
} }
} }
@ -548,7 +547,7 @@ 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.try_into()?]).into()) Ok(write_nodes(&[input.try_into()?]).into())
} }
@ -571,31 +570,31 @@ impl BackendService for Backend {
}) })
} }
fn concatenate_searches(&self, input: pb::ConcatenateSearchesIn) -> Result<pb::String> { fn join_search_nodes(&self, input: pb::JoinSearchNodesIn) -> Result<pb::String> {
let sep = input.sep().into(); let sep = input.joiner().into();
let existing_nodes = { let existing_nodes = {
let node = input.existing_search.unwrap_or_default().try_into()?; let node = input.existing_node.unwrap_or_default().try_into()?;
if let Node::Group(nodes) = node { if let Node::Group(nodes) = node {
nodes nodes
} else { } else {
vec![node] vec![node]
} }
}; };
let additional_node = input.additional_search.unwrap_or_default().try_into()?; let additional_node = input.additional_node.unwrap_or_default().try_into()?;
Ok(concatenate_searches(sep, existing_nodes, additional_node).into()) Ok(concatenate_searches(sep, existing_nodes, additional_node).into())
} }
fn replace_search_term(&self, input: pb::ReplaceSearchTermIn) -> Result<pb::String> { fn replace_search_node(&self, input: pb::ReplaceSearchNodeIn) -> Result<pb::String> {
let existing = { let existing = {
let node = input.search.unwrap_or_default().try_into()?; let node = input.existing_node.unwrap_or_default().try_into()?;
if let Node::Group(nodes) = node { if let Node::Group(nodes) = node {
nodes nodes
} else { } else {
vec![node] vec![node]
} }
}; };
let replacement = input.replacement.unwrap_or_default().try_into()?; let replacement = input.replacement_node.unwrap_or_default().try_into()?;
Ok(replace_search_term(existing, 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

@ -11,4 +11,4 @@ pub use cards::SortMode;
pub use parser::{ pub use parser::{
parse as parse_search, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind, parse as parse_search, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind,
}; };
pub use writer::{concatenate_searches, replace_search_term, write_nodes, BoolSeparator}; pub use writer::{concatenate_searches, replace_search_node, write_nodes, BoolSeparator};

View file

@ -37,7 +37,7 @@ pub fn concatenate_searches(
/// Given an existing parsed search, if the provided `replacement` is a single search node such /// Given an existing parsed search, if the provided `replacement` is a single search node such
/// as a deck:xxx search, replace any instances of that search in `existing` with the new value. /// as a deck:xxx search, replace any instances of that search in `existing` with the new value.
/// Then return the possibly modified first search as a string. /// Then return the possibly modified first search as a string.
pub fn replace_search_term(mut existing: Vec<Node>, replacement: Node) -> String { pub fn replace_search_node(mut existing: Vec<Node>, replacement: Node) -> String {
if let Node::Search(search_node) = replacement { if let Node::Search(search_node) = replacement {
fn update_node_vec(old_nodes: &mut [Node], new_node: &SearchNode) { fn update_node_vec(old_nodes: &mut [Node], new_node: &SearchNode) {
fn update_node(old_node: &mut Node, new_node: &SearchNode) { fn update_node(old_node: &mut Node, new_node: &SearchNode) {
@ -242,29 +242,29 @@ mod test {
#[test] #[test]
fn replacing() -> Result<()> { fn replacing() -> Result<()> {
assert_eq!( assert_eq!(
replace_search_term(parse("deck:baz bar")?, parse("deck:foo")?.pop().unwrap()), replace_search_node(parse("deck:baz bar")?, parse("deck:foo")?.pop().unwrap()),
r#""deck:foo" AND "bar""#, r#""deck:foo" AND "bar""#,
); );
assert_eq!( assert_eq!(
replace_search_term( replace_search_node(
parse("tag:foo Or tag:bar")?, parse("tag:foo Or tag:bar")?,
parse("tag:baz")?.pop().unwrap() parse("tag:baz")?.pop().unwrap()
), ),
r#""tag:baz" OR "tag:baz""#, r#""tag:baz" OR "tag:baz""#,
); );
assert_eq!( assert_eq!(
replace_search_term( replace_search_node(
parse("foo or (-foo tag:baz)")?, parse("foo or (-foo tag:baz)")?,
parse("bar")?.pop().unwrap() parse("bar")?.pop().unwrap()
), ),
r#""bar" OR (-"bar" AND "tag:baz")"#, r#""bar" OR (-"bar" AND "tag:baz")"#,
); );
assert_eq!( assert_eq!(
replace_search_term(parse("is:due")?, parse("-is:new")?.pop().unwrap()), replace_search_node(parse("is:due")?, parse("-is:new")?.pop().unwrap()),
r#""is:due""# r#""is:due""#
); );
assert_eq!( assert_eq!(
replace_search_term(parse("added:1")?, parse("is:due")?.pop().unwrap()), replace_search_node(parse("added:1")?, parse("is:due")?.pop().unwrap()),
r#""added:1""# r#""added:1""#
); );