Merge pull request #911 from RumovZ/backend-filters

Backend filters
This commit is contained in:
Damien Elmes 2021-01-10 08:56:57 +10:00 committed by GitHub
commit 3f9604457d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 177 additions and 79 deletions

View file

@ -46,6 +46,8 @@ TagUsnTuple = pb.TagUsnTuple
NoteType = pb.NoteType NoteType = pb.NoteType
DeckTreeNode = pb.DeckTreeNode DeckTreeNode = pb.DeckTreeNode
StockNoteType = pb.StockNoteType StockNoteType = pb.StockNoteType
FilterToSearchIn = pb.FilterToSearchIn
NamedFilter = pb.FilterToSearchIn.NamedFilter
ConcatSeparator = pb.ConcatenateSearchesIn.Separator ConcatSeparator = pb.ConcatenateSearchesIn.Separator
SyncAuth = pb.SyncAuth SyncAuth = pb.SyncAuth
SyncOutput = pb.SyncCollectionOut SyncOutput = pb.SyncCollectionOut

View file

@ -4,7 +4,6 @@
from __future__ import annotations from __future__ import annotations
import html import html
import re
import time import time
from concurrent.futures import Future from concurrent.futures import Future
from dataclasses import dataclass from dataclasses import dataclass
@ -21,7 +20,13 @@ from anki.consts import *
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.models import NoteType from anki.models import NoteType
from anki.notes import Note from anki.notes import Note
from anki.rsbackend import ConcatSeparator, DeckTreeNode, InvalidInput from anki.rsbackend import (
ConcatSeparator,
DeckTreeNode,
FilterToSearchIn,
InvalidInput,
NamedFilter,
)
from anki.stats import CardStats from anki.stats import CardStats
from anki.utils import htmlToTextLine, ids2str, isMac, isWin from anki.utils import htmlToTextLine, ids2str, isMac, isWin
from aqt import AnkiQt, gui_hooks from aqt import AnkiQt, gui_hooks
@ -1107,14 +1112,14 @@ QTableView {{ gridline-color: {grid} }}
item = SidebarItem( item = SidebarItem(
tr(TR.BROWSING_WHOLE_COLLECTION), tr(TR.BROWSING_WHOLE_COLLECTION),
":/icons/collection.svg", ":/icons/collection.svg",
self._filterFunc(""), self._named_filter(NamedFilter.WHOLE_COLLECTION),
item_type=SidebarItemType.COLLECTION, item_type=SidebarItemType.COLLECTION,
) )
root.addChild(item) root.addChild(item)
item = SidebarItem( item = SidebarItem(
tr(TR.BROWSING_CURRENT_DECK), tr(TR.BROWSING_CURRENT_DECK),
":/icons/deck.svg", ":/icons/deck.svg",
self._filterFunc("deck:current"), self._named_filter(NamedFilter.CURRENT_DECK),
item_type=SidebarItemType.CURRENT_DECK, item_type=SidebarItemType.CURRENT_DECK,
) )
root.addChild(item) root.addChild(item)
@ -1126,7 +1131,7 @@ QTableView {{ gridline-color: {grid} }}
item = SidebarItem( item = SidebarItem(
name, name,
":/icons/heart.svg", ":/icons/heart.svg",
lambda s=filt: self.setFilter(s), # type: ignore self._saved_filter(filt),
item_type=SidebarItemType.FILTER, item_type=SidebarItemType.FILTER,
) )
root.addChild(item) root.addChild(item)
@ -1137,7 +1142,7 @@ QTableView {{ gridline-color: {grid} }}
item = SidebarItem( item = SidebarItem(
t, t,
":/icons/tag.svg", ":/icons/tag.svg",
lambda t=t: self.setFilter("tag", t), # type: ignore self._tag_filter(t),
item_type=SidebarItemType.TAG, item_type=SidebarItemType.TAG,
) )
root.addChild(item) root.addChild(item)
@ -1148,10 +1153,6 @@ QTableView {{ gridline-color: {grid} }}
def fillGroups(root, nodes: Sequence[DeckTreeNode], head=""): def fillGroups(root, nodes: Sequence[DeckTreeNode], head=""):
for node in nodes: for node in nodes:
def set_filter():
full_name = head + node.name # pylint: disable=cell-var-from-loop
return lambda: self.setFilter("deck", full_name)
def toggle_expand(): def toggle_expand():
did = node.deck_id # pylint: disable=cell-var-from-loop did = node.deck_id # pylint: disable=cell-var-from-loop
return lambda _: self.mw.col.decks.collapseBrowser(did) return lambda _: self.mw.col.decks.collapseBrowser(did)
@ -1159,7 +1160,7 @@ QTableView {{ gridline-color: {grid} }}
item = SidebarItem( item = SidebarItem(
node.name, node.name,
":/icons/deck.svg", ":/icons/deck.svg",
set_filter(), self._deck_filter(head + node.name),
toggle_expand(), toggle_expand(),
not node.collapsed, not node.collapsed,
item_type=SidebarItemType.DECK, item_type=SidebarItemType.DECK,
@ -1177,7 +1178,7 @@ QTableView {{ gridline-color: {grid} }}
item = SidebarItem( item = SidebarItem(
m.name, m.name,
":/icons/notetype.svg", ":/icons/notetype.svg",
lambda m=m: self.setFilter("note", m.name), # type: ignore self._note_filter(m.name),
item_type=SidebarItemType.NOTETYPE, item_type=SidebarItemType.NOTETYPE,
) )
root.addChild(item) root.addChild(item)
@ -1205,47 +1206,36 @@ QTableView {{ gridline-color: {grid} }}
ml.popupOver(self.form.filter) ml.popupOver(self.form.filter)
def setFilter(self, *args): def setFilter(self, *searches):
if len(args) == 1:
txt = args[0]
else:
txt = ""
items = []
for i, a in enumerate(args):
if i % 2 == 0:
txt += a + ":"
else:
txt += re.sub(r'["*_\\]', r"\\\g<0>", a)
txt = '"{}"'.format(txt.replace('"', '\\"'))
items.append(txt)
txt = ""
txt = " AND ".join(items)
try: try:
if self.mw.app.keyboardModifiers() & Qt.AltModifier: search = self.col.backend.concatenate_searches(
txt = self.col.backend.negate_search(txt) sep=ConcatSeparator.AND, searches=searches
)
mods = self.mw.app.keyboardModifiers()
if mods & Qt.AltModifier:
search = self.col.backend.negate_search(search)
cur = str(self.form.searchEdit.lineEdit().text()) cur = str(self.form.searchEdit.lineEdit().text())
if cur != self._searchPrompt: if cur != self._searchPrompt:
mods = self.mw.app.keyboardModifiers()
if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: if mods & Qt.ControlModifier and mods & Qt.ShiftModifier:
txt = self.col.backend.replace_search_term( search = self.col.backend.replace_search_term(
search=cur, replacement=txt search=cur, replacement=search
) )
elif mods & Qt.ControlModifier: elif mods & Qt.ControlModifier:
txt = self.col.backend.concatenate_searches( search = self.col.backend.concatenate_searches(
# pylint: disable=no-member # pylint: disable=no-member
sep=ConcatSeparator.AND, sep=ConcatSeparator.AND,
searches=[cur, txt], searches=[cur, search],
) )
elif mods & Qt.ShiftModifier: elif mods & Qt.ShiftModifier:
txt = self.col.backend.concatenate_searches( search = self.col.backend.concatenate_searches(
# pylint: disable=no-member # pylint: disable=no-member
sep=ConcatSeparator.OR, sep=ConcatSeparator.OR,
searches=[cur, txt], searches=[cur, search],
) )
except InvalidInput as e: except InvalidInput as e:
showWarning(str(e)) showWarning(str(e))
else: else:
self.form.searchEdit.lineEdit().setText(txt) self.form.searchEdit.lineEdit().setText(search)
self.onSearchActivated() self.onSearchActivated()
def _simpleFilters(self, items): def _simpleFilters(self, items):
@ -1254,18 +1244,44 @@ QTableView {{ gridline-color: {grid} }}
if row is None: if row is None:
ml.addSeparator() ml.addSeparator()
else: else:
label, filter = row label, filter_name = row
ml.addItem(label, self._filterFunc(filter)) ml.addItem(label, self._named_filter(filter_name))
return ml return ml
def _filterFunc(self, *args): def _named_filter(self, name: Any) -> Callable:
return lambda *, f=args: self.setFilter(*f) return lambda: self.setFilter(
self.col.backend.filter_to_search(FilterToSearchIn(name=name))
)
def _tag_filter(self, tag: str) -> Callable:
return lambda: self.setFilter(
self.col.backend.filter_to_search(FilterToSearchIn(tag=tag))
)
def _deck_filter(self, deck: str) -> Callable:
return lambda: self.setFilter(
self.col.backend.filter_to_search(FilterToSearchIn(deck=deck))
)
def _note_filter(self, note: str) -> Callable:
return lambda: self.setFilter(
self.col.backend.filter_to_search(FilterToSearchIn(note=note))
)
def _template_filter(self, note: str, template: int) -> Callable:
return lambda: self.setFilter(
self.col.backend.filter_to_search(FilterToSearchIn(note=note)),
self.col.backend.filter_to_search(FilterToSearchIn(template=template)),
)
def _saved_filter(self, saved: str) -> Callable:
return lambda: self.setFilter(saved)
def _commonFilters(self): def _commonFilters(self):
return self._simpleFilters( return self._simpleFilters(
( (
(tr(TR.BROWSING_WHOLE_COLLECTION), ""), (tr(TR.BROWSING_WHOLE_COLLECTION), NamedFilter.WHOLE_COLLECTION),
(tr(TR.BROWSING_CURRENT_DECK), '"deck:current"'), (tr(TR.BROWSING_CURRENT_DECK), NamedFilter.CURRENT_DECK),
) )
) )
@ -1274,9 +1290,9 @@ QTableView {{ gridline-color: {grid} }}
subm.addChild( subm.addChild(
self._simpleFilters( self._simpleFilters(
( (
(tr(TR.BROWSING_ADDED_TODAY), '"added:1"'), (tr(TR.BROWSING_ADDED_TODAY), NamedFilter.ADDED_TODAY),
(tr(TR.BROWSING_STUDIED_TODAY), '"rated:1"'), (tr(TR.BROWSING_STUDIED_TODAY), NamedFilter.STUDIED_TODAY),
(tr(TR.BROWSING_AGAIN_TODAY), '"rated:1:1"'), (tr(TR.BROWSING_AGAIN_TODAY), NamedFilter.AGAIN_TODAY),
) )
) )
) )
@ -1287,20 +1303,20 @@ QTableView {{ gridline-color: {grid} }}
subm.addChild( subm.addChild(
self._simpleFilters( self._simpleFilters(
( (
(tr(TR.ACTIONS_NEW), '"is:new"'), (tr(TR.ACTIONS_NEW), NamedFilter.NEW),
(tr(TR.SCHEDULING_LEARNING), '"is:learn"'), (tr(TR.SCHEDULING_LEARNING), NamedFilter.LEARN),
(tr(TR.SCHEDULING_REVIEW), '"is:review"'), (tr(TR.SCHEDULING_REVIEW), NamedFilter.REVIEW),
(tr(TR.FILTERING_IS_DUE), '"is:due"'), (tr(TR.FILTERING_IS_DUE), NamedFilter.DUE),
None, None,
(tr(TR.BROWSING_SUSPENDED), '"is:suspended"'), (tr(TR.BROWSING_SUSPENDED), NamedFilter.SUSPENDED),
(tr(TR.BROWSING_BURIED), '"is:buried"'), (tr(TR.BROWSING_BURIED), NamedFilter.BURIED),
None, None,
(tr(TR.ACTIONS_RED_FLAG), '"flag:1"'), (tr(TR.ACTIONS_RED_FLAG), NamedFilter.RED_FLAG),
(tr(TR.ACTIONS_ORANGE_FLAG), '"flag:2"'), (tr(TR.ACTIONS_ORANGE_FLAG), NamedFilter.ORANGE_FLAG),
(tr(TR.ACTIONS_GREEN_FLAG), '"flag:3"'), (tr(TR.ACTIONS_GREEN_FLAG), NamedFilter.GREEN_FLAG),
(tr(TR.ACTIONS_BLUE_FLAG), '"flag:4"'), (tr(TR.ACTIONS_BLUE_FLAG), NamedFilter.BLUE_FLAG),
(tr(TR.BROWSING_NO_FLAG), '"flag:0"'), (tr(TR.BROWSING_NO_FLAG), NamedFilter.NO_FLAG),
(tr(TR.BROWSING_ANY_FLAG), '"-flag:0"'), (tr(TR.BROWSING_ANY_FLAG), NamedFilter.ANY_FLAG),
) )
) )
) )
@ -1317,7 +1333,7 @@ QTableView {{ gridline-color: {grid} }}
tagList = MenuList() tagList = MenuList()
for t in sorted(self.col.tags.all(), key=lambda s: s.lower()): for t in sorted(self.col.tags.all(), key=lambda s: s.lower()):
tagList.addItem(self._escapeMenuItem(t), self._filterFunc("tag", t)) tagList.addItem(self._escapeMenuItem(t), self._tag_filter(t))
m.addChild(tagList.chunked()) m.addChild(tagList.chunked())
return m return m
@ -1330,13 +1346,11 @@ QTableView {{ gridline-color: {grid} }}
fullname = parent_prefix + node.name fullname = parent_prefix + node.name
if node.children: if node.children:
subm = parent.addMenu(escaped_name) subm = parent.addMenu(escaped_name)
subm.addItem( subm.addItem(tr(TR.ACTIONS_FILTER), self._deck_filter(fullname))
tr(TR.ACTIONS_FILTER), self._filterFunc("deck", fullname)
)
subm.addSeparator() subm.addSeparator()
addDecks(subm, node.children, fullname + "::") addDecks(subm, node.children, fullname + "::")
else: else:
parent.addItem(escaped_name, self._filterFunc("deck", fullname)) parent.addItem(escaped_name, self._deck_filter(fullname))
alldecks = self.col.decks.deck_tree() alldecks = self.col.decks.deck_tree()
ml = MenuList() ml = MenuList()
@ -1358,12 +1372,12 @@ QTableView {{ gridline-color: {grid} }}
escaped_nt_name = self._escapeMenuItem(nt["name"]) escaped_nt_name = self._escapeMenuItem(nt["name"])
# no sub menu if it's a single template # no sub menu if it's a single template
if len(nt["tmpls"]) == 1: if len(nt["tmpls"]) == 1:
noteTypes.addItem(escaped_nt_name, self._filterFunc("note", nt["name"])) noteTypes.addItem(escaped_nt_name, self._note_filter(nt["name"]))
else: else:
subm = noteTypes.addMenu(escaped_nt_name) subm = noteTypes.addMenu(escaped_nt_name)
subm.addItem( subm.addItem(
tr(TR.BROWSING_ALL_CARD_TYPES), self._filterFunc("note", nt["name"]) tr(TR.BROWSING_ALL_CARD_TYPES), self._note_filter(nt["name"])
) )
subm.addSeparator() subm.addSeparator()
@ -1376,9 +1390,7 @@ QTableView {{ gridline-color: {grid} }}
num=c + 1, num=c + 1,
name=self._escapeMenuItem(tmpl["name"]), name=self._escapeMenuItem(tmpl["name"]),
) )
subm.addItem( subm.addItem(name, self._template_filter(nt["name"], c + 1))
name, self._filterFunc("note", nt["name"], "card", str(c + 1))
)
m.addChild(noteTypes.chunked()) m.addChild(noteTypes.chunked())
return m return m
@ -1405,7 +1417,7 @@ QTableView {{ gridline-color: {grid} }}
ml.addSeparator() ml.addSeparator()
for name, filt in sorted(saved.items()): for name, filt in sorted(saved.items()):
ml.addItem(self._escapeMenuItem(name), self._filterFunc(filt)) ml.addItem(self._escapeMenuItem(name), self._saved_filter(filt))
return ml return ml

View file

@ -84,6 +84,7 @@ service BackendService {
// searching // searching
rpc FilterToSearch(FilterToSearchIn) returns (String);
rpc NormalizeSearch(String) returns (String); rpc NormalizeSearch(String) returns (String);
rpc SearchCards(SearchCardsIn) returns (SearchCardsOut); rpc SearchCards(SearchCardsIn) returns (SearchCardsOut);
rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut); rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut);
@ -754,6 +755,35 @@ message BuiltinSearchOrder {
bool reverse = 2; bool reverse = 2;
} }
message FilterToSearchIn {
enum NamedFilter {
WHOLE_COLLECTION = 0;
CURRENT_DECK = 1;
ADDED_TODAY = 2;
STUDIED_TODAY = 3;
AGAIN_TODAY = 4;
NEW = 5;
LEARN = 6;
REVIEW = 7;
DUE = 8;
SUSPENDED = 9;
BURIED = 10;
RED_FLAG = 11;
ORANGE_FLAG = 12;
GREEN_FLAG = 13;
BLUE_FLAG = 14;
NO_FLAG = 15;
ANY_FLAG = 16;
}
oneof filter {
NamedFilter name = 1;
string tag = 2;
string deck = 3;
string note = 4;
uint32 template = 5;
}
}
message ConcatenateSearchesIn { message ConcatenateSearchesIn {
enum Separator { enum Separator {
AND = 0; AND = 0;

View file

@ -36,8 +36,8 @@ use crate::{
sched::new::NewCardSortOrder, sched::new::NewCardSortOrder,
sched::timespan::{answer_button_time, time_span}, sched::timespan::{answer_button_time, time_span},
search::{ search::{
concatenate_searches, negate_search, normalize_search, replace_search_term, BoolSeparator, concatenate_searches, negate_search, normalize_search, replace_search_term, write_nodes,
SortMode, BoolSeparator, Node, SearchNode, SortMode, StateKind, TemplateKind,
}, },
stats::studied_today, stats::studied_today,
sync::{ sync::{
@ -45,7 +45,7 @@ use crate::{
SyncActionRequired, SyncAuth, SyncMeta, SyncOutput, SyncStage, SyncActionRequired, SyncAuth, SyncMeta, SyncOutput, SyncStage,
}, },
template::RenderedNode, template::RenderedNode,
text::{extract_av_tags, strip_av_tags, AVTag}, text::{escape_anki_wildcards, extract_av_tags, strip_av_tags, AVTag},
timestamp::TimestampSecs, timestamp::TimestampSecs,
types::Usn, types::Usn,
}; };
@ -277,6 +277,46 @@ impl From<pb::DeckConfigId> for DeckConfID {
} }
} }
impl From<pb::FilterToSearchIn> for Node<'_> {
fn from(msg: pb::FilterToSearchIn) -> Self {
use pb::filter_to_search_in::Filter as F;
use pb::filter_to_search_in::NamedFilter as NF;
use Node as N;
use SearchNode as SN;
match msg.filter.unwrap_or(F::Name(NF::WholeCollection as i32)) {
F::Name(name) => match NF::from_i32(name).unwrap_or(NF::WholeCollection) {
NF::WholeCollection => N::Search(SN::WholeCollection),
NF::CurrentDeck => N::Search(SN::Deck("current".into())),
NF::AddedToday => N::Search(SN::AddedInDays(1)),
NF::StudiedToday => N::Search(SN::Rated {
days: 1,
ease: None,
}),
NF::AgainToday => N::Search(SN::Rated {
days: 1,
ease: Some(1),
}),
NF::New => N::Search(SN::State(StateKind::New)),
NF::Learn => N::Search(SN::State(StateKind::Learning)),
NF::Review => N::Search(SN::State(StateKind::Review)),
NF::Due => N::Search(SN::State(StateKind::Due)),
NF::Suspended => N::Search(SN::State(StateKind::Suspended)),
NF::Buried => N::Search(SN::State(StateKind::Buried)),
NF::RedFlag => N::Search(SN::Flag(1)),
NF::OrangeFlag => N::Search(SN::Flag(2)),
NF::GreenFlag => N::Search(SN::Flag(3)),
NF::BlueFlag => N::Search(SN::Flag(4)),
NF::NoFlag => N::Search(SN::Flag(0)),
NF::AnyFlag => N::Not(Box::new(N::Search(SN::Flag(0)))),
},
F::Tag(s) => N::Search(SN::Tag(escape_anki_wildcards(&s).into_owned().into())),
F::Deck(s) => N::Search(SN::Deck(escape_anki_wildcards(&s).into_owned().into())),
F::Note(s) => N::Search(SN::NoteType(escape_anki_wildcards(&s).into_owned().into())),
F::Template(u) => N::Search(SN::CardTemplate(TemplateKind::Ordinal(u as u16))),
}
}
}
impl From<BoolSeparatorProto> for BoolSeparator { impl From<BoolSeparatorProto> for BoolSeparator {
fn from(sep: BoolSeparatorProto) -> Self { fn from(sep: BoolSeparatorProto) -> Self {
match sep { match sep {
@ -408,6 +448,10 @@ impl BackendService for Backend {
// searching // searching
//----------------------------------------------- //-----------------------------------------------
fn filter_to_search(&self, input: pb::FilterToSearchIn) -> Result<pb::String> {
Ok(write_nodes(&[input.into()]).into())
}
fn normalize_search(&self, input: pb::String) -> Result<pb::String> { fn normalize_search(&self, input: pb::String) -> Result<pb::String> {
Ok(normalize_search(&input.val)?.into()) Ok(normalize_search(&input.val)?.into())
} }

View file

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

View file

@ -42,7 +42,7 @@ impl<I> From<nom::Err<(I, ErrorKind)>> for ParseError {
type ParseResult<T> = std::result::Result<T, ParseError>; type ParseResult<T> = std::result::Result<T, ParseError>;
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub(super) enum Node<'a> { pub enum Node<'a> {
And, And,
Or, Or,
Not(Box<Node<'a>>), Not(Box<Node<'a>>),
@ -51,7 +51,7 @@ pub(super) enum Node<'a> {
} }
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub(super) enum SearchNode<'a> { pub enum SearchNode<'a> {
// text without a colon // text without a colon
UnqualifiedText(Cow<'a, str>), UnqualifiedText(Cow<'a, str>),
// foo:bar, where foo doesn't match a term below // foo:bar, where foo doesn't match a term below
@ -91,7 +91,7 @@ pub(super) enum SearchNode<'a> {
} }
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub(super) enum PropertyKind { pub enum PropertyKind {
Due(i32), Due(i32),
Interval(u32), Interval(u32),
Reps(u32), Reps(u32),
@ -101,7 +101,7 @@ pub(super) enum PropertyKind {
} }
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub(super) enum StateKind { pub enum StateKind {
New, New,
Review, Review,
Learning, Learning,
@ -113,7 +113,7 @@ pub(super) enum StateKind {
} }
#[derive(Debug, PartialEq, Clone)] #[derive(Debug, PartialEq, Clone)]
pub(super) enum TemplateKind<'a> { pub enum TemplateKind<'a> {
Ordinal(u16), Ordinal(u16),
Name(Cow<'a, str>), Name(Cow<'a, str>),
} }

View file

@ -87,7 +87,7 @@ pub fn replace_search_term(search: &str, replacement: &str) -> Result<String> {
Ok(write_nodes(&nodes)) Ok(write_nodes(&nodes))
} }
fn write_nodes<'a, I>(nodes: I) -> String pub fn write_nodes<'a, I>(nodes: I) -> String
where where
I: IntoIterator<Item = &'a Node<'a>>, I: IntoIterator<Item = &'a Node<'a>>,
{ {

View file

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