mirror of
https://github.com/ankitects/anki.git
synced 2025-09-21 07:22:23 -04:00
commit
03b9f2a3f6
18 changed files with 423 additions and 323 deletions
|
@ -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
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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],
|
||||||
)
|
)
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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']}",
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
|
@ -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};
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(())
|
||||||
|
|
|
@ -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.
|
||||||
|
|
Loading…
Reference in a new issue