From 8852359fa9ff46564ad4e8e5aecf0346efcb29dc Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 11 Feb 2021 11:21:33 +1000 Subject: [PATCH 1/4] expose the ability to create search groups --- rslib/backend.proto | 15 ++++++++++++--- rslib/src/backend/mod.rs | 15 +++++++++++++++ rslib/src/search/parser.rs | 2 +- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/rslib/backend.proto b/rslib/backend.proto index da55c24ee..93b3376fd 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -809,9 +809,17 @@ message SearchTerm { message IdList { repeated int64 ids = 1; } + message Group { + enum Operator { + AND = 0; + OR = 1; + } + repeated SearchTerm terms = 1; + Operator op = 2; + } oneof filter { - string tag = 1; - string deck = 2; + Group group = 1; + SearchTerm negated = 2; string note = 3; uint32 template = 4; int64 nid = 5; @@ -824,8 +832,9 @@ message SearchTerm { CardState card_state = 12; IdList nids = 13; uint32 edited_in_days = 14; - SearchTerm negated = 15; + string deck = 15; int32 due_on_day = 16; + string tag = 17; } } diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 95334ca76..7eae49dc3 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -55,6 +55,7 @@ use crate::{ }; use fluent::FluentValue; use futures::future::{AbortHandle, AbortRegistration, Abortable}; +use itertools::Itertools; use log::error; use once_cell::sync::OnceCell; use pb::{sync_status_out, BackendService}; @@ -297,6 +298,7 @@ impl From for DeckConfID { impl From for Node<'_> { fn from(msg: pb::SearchTerm) -> Self { + use pb::search_term::group::Operator; use pb::search_term::Filter; use pb::search_term::Flag; if let Some(filter) = msg.filter { @@ -359,6 +361,19 @@ impl From for Node<'_> { Flag::Blue => Node::Search(SearchNode::Flag(4)), }, Filter::Negated(term) => Node::Not(Box::new((*term).into())), + Filter::Group(group) => { + let operator = match group.op() { + Operator::And => Node::And, + Operator::Or => Node::Or, + }; + let joined = group + .terms + .into_iter() + .map(Into::into) + .intersperse(operator) + .collect(); + Node::Group(joined) + } } } else { Node::Search(SearchNode::WholeCollection) diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index ee7e2909f..33d5b6adc 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -30,7 +30,7 @@ fn parse_error(input: &str) -> nom::Err> { nom::Err::Error(ParseError::Anki(input, FailKind::Other(None))) } -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub enum Node<'a> { And, Or, From 242b4ea5050d6d0adf34b84fe2b9de01919f0c5a Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 11 Feb 2021 12:19:36 +1000 Subject: [PATCH 2/4] switch search parser to using owned values I was a bit too enthusiastic with using borrowed values in structs earlier on in the Rust porting. In this case any performance gains are dwarfed by the cost of querying the DB, and using owned values here simplifies the code, and will make it easier to parse a fragment in the From impl. --- rslib/src/backend/mod.rs | 35 ++++++++------------- rslib/src/search/parser.rs | 58 +++++++++++++++++------------------ rslib/src/search/sqlwriter.rs | 8 +++-- rslib/src/search/writer.rs | 6 ++-- rslib/src/text.rs | 4 +-- 5 files changed, 52 insertions(+), 59 deletions(-) diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 7eae49dc3..700b3a038 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -296,41 +296,32 @@ impl From for DeckConfID { } } -impl From for Node<'_> { +impl From for Node { fn from(msg: pb::SearchTerm) -> Self { use pb::search_term::group::Operator; use pb::search_term::Filter; use pb::search_term::Flag; if let Some(filter) = msg.filter { match filter { - Filter::Tag(s) => Node::Search(SearchNode::Tag( - escape_anki_wildcards(&s).into_owned().into(), - )), - Filter::Deck(s) => Node::Search(SearchNode::Deck( - if s == "*" { - s - } else { - escape_anki_wildcards(&s).into_owned() - } - .into(), - )), - Filter::Note(s) => Node::Search(SearchNode::NoteType( - escape_anki_wildcards(&s).into_owned().into(), - )), + Filter::Tag(s) => Node::Search(SearchNode::Tag(escape_anki_wildcards(&s))), + Filter::Deck(s) => Node::Search(SearchNode::Deck(if s == "*" { + s + } else { + escape_anki_wildcards(&s) + })), + Filter::Note(s) => Node::Search(SearchNode::NoteType(escape_anki_wildcards(&s))), Filter::Template(u) => { Node::Search(SearchNode::CardTemplate(TemplateKind::Ordinal(u as u16))) } - Filter::Nid(nid) => Node::Search(SearchNode::NoteIDs(nid.to_string().into())), - Filter::Nids(nids) => { - Node::Search(SearchNode::NoteIDs(nids.into_id_string().into())) - } + Filter::Nid(nid) => Node::Search(SearchNode::NoteIDs(nid.to_string())), + Filter::Nids(nids) => Node::Search(SearchNode::NoteIDs(nids.into_id_string())), Filter::Dupe(dupe) => Node::Search(SearchNode::Duplicates { note_type_id: dupe.notetype_id.into(), - text: dupe.first_field.into(), + text: dupe.first_field, }), Filter::FieldName(s) => Node::Search(SearchNode::SingleField { - field: escape_anki_wildcards(&s).into_owned().into(), - text: "*".to_string().into(), + field: escape_anki_wildcards(&s), + text: "*".to_string(), is_re: false, }), Filter::Rated(rated) => Node::Search(SearchNode::Rated { diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 33d5b6adc..427251a6d 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -17,7 +17,6 @@ use nom::{ sequence::{preceded, separated_pair}, }; use regex::{Captures, Regex}; -use std::borrow::Cow; type IResult<'a, O> = std::result::Result<(&'a str, O), nom::Err>>; type ParseResult<'a, O> = std::result::Result>>; @@ -31,52 +30,52 @@ fn parse_error(input: &str) -> nom::Err> { } #[derive(Debug, PartialEq, Clone)] -pub enum Node<'a> { +pub enum Node { And, Or, - Not(Box>), - Group(Vec>), - Search(SearchNode<'a>), + Not(Box), + Group(Vec), + Search(SearchNode), } #[derive(Debug, PartialEq, Clone)] -pub enum SearchNode<'a> { +pub enum SearchNode { // text without a colon - UnqualifiedText(Cow<'a, str>), + UnqualifiedText(String), // foo:bar, where foo doesn't match a term below SingleField { - field: Cow<'a, str>, - text: Cow<'a, str>, + field: String, + text: String, is_re: bool, }, AddedInDays(u32), EditedInDays(u32), - CardTemplate(TemplateKind<'a>), - Deck(Cow<'a, str>), + CardTemplate(TemplateKind), + Deck(String), DeckID(DeckID), NoteTypeID(NoteTypeID), - NoteType(Cow<'a, str>), + NoteType(String), Rated { days: u32, ease: RatingKind, }, - Tag(Cow<'a, str>), + Tag(String), Duplicates { note_type_id: NoteTypeID, - text: Cow<'a, str>, + text: String, }, State(StateKind), Flag(u8), - NoteIDs(Cow<'a, str>), - CardIDs(&'a str), + NoteIDs(String), + CardIDs(String), Property { operator: String, kind: PropertyKind, }, WholeCollection, - Regex(Cow<'a, str>), - NoCombining(Cow<'a, str>), - WordBoundary(Cow<'a, str>), + Regex(String), + NoCombining(String), + WordBoundary(String), } #[derive(Debug, PartialEq, Clone)] @@ -103,9 +102,9 @@ pub enum StateKind { } #[derive(Debug, PartialEq, Clone)] -pub enum TemplateKind<'a> { +pub enum TemplateKind { Ordinal(u16), - Name(Cow<'a, str>), + Name(String), } #[derive(Debug, PartialEq, Clone)] @@ -303,7 +302,7 @@ fn search_node_for_text(s: &str) -> ParseResult { fn search_node_for_text_with_argument<'a>( key: &'a str, val: &'a str, -) -> ParseResult<'a, SearchNode<'a>> { +) -> ParseResult<'a, SearchNode> { Ok(match key.to_ascii_lowercase().as_str() { "deck" => SearchNode::Deck(unescape(val)?), "note" => SearchNode::NoteType(unescape(val)?), @@ -319,7 +318,7 @@ fn search_node_for_text_with_argument<'a>( "did" => parse_did(val)?, "mid" => parse_mid(val)?, "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)), "nc" => SearchNode::NoCombining(unescape(val)?), "w" => SearchNode::WordBoundary(unescape(val)?), @@ -579,7 +578,7 @@ fn parse_dupe(s: &str) -> ParseResult { } } -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:") { SearchNode::SingleField { field: unescape(key)?, @@ -596,25 +595,25 @@ fn parse_single_field<'a>(key: &'a str, val: &'a str) -> ParseResult<'a, SearchN } /// For strings without unescaped ", convert \" to " -fn unescape_quotes(s: &str) -> Cow { +fn unescape_quotes(s: &str) -> String { if s.contains('"') { - s.replace(r#"\""#, "\"").into() + s.replace(r#"\""#, "\"") } else { s.into() } } /// For non-globs like dupe text without any assumption about the content -fn unescape_quotes_and_backslashes(s: &str) -> Cow { +fn unescape_quotes_and_backslashes(s: &str) -> String { if s.contains('"') || s.contains('\\') { - s.replace(r#"\""#, "\"").replace(r"\\", r"\").into() + s.replace(r#"\""#, "\"").replace(r"\\", r"\") } else { s.into() } } /// Unescape chars with special meaning to the parser. -fn unescape(txt: &str) -> ParseResult> { +fn unescape(txt: &str) -> ParseResult { if let Some(seq) = invalid_escape_sequence(txt) { Err(parse_failure(txt, FailKind::UnknownEscape(seq))) } else { @@ -631,6 +630,7 @@ fn unescape(txt: &str) -> ParseResult> { r"\-" => "-", _ => unreachable!(), }) + .into() } else { txt.into() }) diff --git a/rslib/src/search/sqlwriter.rs b/rslib/src/search/sqlwriter.rs index a3e009d5e..f65f93832 100644 --- a/rslib/src/search/sqlwriter.rs +++ b/rslib/src/search/sqlwriter.rs @@ -134,7 +134,9 @@ impl SqlWriter<'_> { SearchNode::EditedInDays(days) => self.write_edited(*days)?, SearchNode::CardTemplate(template) => match 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::NoteTypeID(ntid) => { @@ -532,7 +534,7 @@ impl RequiredTable { } } -impl Node<'_> { +impl Node { fn required_table(&self) -> RequiredTable { match self { Node::And => RequiredTable::CardsOrNotes, @@ -546,7 +548,7 @@ impl Node<'_> { } } -impl SearchNode<'_> { +impl SearchNode { fn required_table(&self) -> RequiredTable { match self { SearchNode::AddedInDays(_) => RequiredTable::Cards, diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index ac4490e9e..cede01ec8 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -67,8 +67,8 @@ pub fn replace_search_term(search: &str, replacement: &str) -> Result { let mut nodes = parse(search)?; let new = parse(replacement)?; if let [Node::Search(search_node)] = &new[..] { - 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>) { + fn update_node_vec(old_nodes: &mut [Node], new_node: &SearchNode) { + fn update_node(old_node: &mut Node, new_node: &SearchNode) { match old_node { Node::Not(n) => update_node(n, new_node), Node::Group(ns) => update_node_vec(ns, new_node), @@ -89,7 +89,7 @@ pub fn replace_search_term(search: &str, replacement: &str) -> Result { pub fn write_nodes<'a, I>(nodes: I) -> String where - I: IntoIterator>, + I: IntoIterator, { nodes.into_iter().map(|node| write_node(node)).collect() } diff --git a/rslib/src/text.rs b/rslib/src/text.rs index be985cbd0..003bdd967 100644 --- a/rslib/src/text.rs +++ b/rslib/src/text.rs @@ -336,11 +336,11 @@ pub(crate) fn to_text(txt: &str) -> Cow { } /// Escape Anki wildcards and the backslash for escaping them: \*_ -pub(crate) fn escape_anki_wildcards(txt: &str) -> Cow { +pub(crate) fn escape_anki_wildcards(txt: &str) -> String { lazy_static! { 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. From 59ccfe59180bce05c6c0f8e3e52c98e3e54ed16c Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 11 Feb 2021 17:11:17 +1000 Subject: [PATCH 3/4] more search bikeshedding While implementing the overdue search, I realised it would be nice to be able to construct a search string with OR and NOT searches without having to construct each part individually with build_search_string(). Changes: - Extends SearchTerm to support a text search, which will be parsed by the backend. This allows us to do things like wrap text in a group or NOT node. - Because SearchTerm->Node conversion can now fail with a parsing error, it's switched over to TryFrom - Switch concatenate_searches and replace_search_term to use SearchTerms, so that they too don't require separate string building steps. - Remove the unused normalize_search() - Remove negate_search, as this is now an operation on a Node, and users can wrap their search in SearchTerm(negated=...) - Remove the match_any and negate args from build_search_string Having done all this work, I've just realised that perhaps the original JSON idea was more feasible than I first thought - if we wrote it out to a string and re-parsed it, we would be able to leverage the existing checks that occur at parsing stage. --- pylib/anki/collection.py | 78 +++++++++++++----- pylib/rsbridge/lib.rs | 2 - qt/aqt/sidebar.py | 28 ++++--- rslib/backend.proto | 12 +-- rslib/src/backend/mod.rs | 92 +++++++++++++-------- rslib/src/search/mod.rs | 7 +- rslib/src/search/parser.rs | 22 ++++- rslib/src/search/writer.rs | 159 +++++++++++++++++-------------------- 8 files changed, 237 insertions(+), 163 deletions(-) diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index b493835aa..4c232084a 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -11,7 +11,7 @@ import sys import time import traceback 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.find @@ -526,37 +526,71 @@ class Collection: # Search Strings ########################################################################## - # pylint: disable=no-member + def group_search_terms(self, *terms: Union[str, SearchTerm]) -> SearchTerm: + """Join provided search terms and strings into a single SearchTerm. + If multiple terms provided, they will be ANDed together into a group. + If a single term is provided, it is returned as-is. + """ + assert terms + + # convert raw text to SearchTerms + search_terms = [ + term if isinstance(term, SearchTerm) else SearchTerm(unparsed_search=term) + for term in terms + ] + + # if there's more than one, wrap it in an implicit AND + if len(search_terms) > 1: + return SearchTerm(group=SearchTerm.Group(terms=search_terms)) + else: + return search_terms[0] + def build_search_string( self, *terms: Union[str, SearchTerm], - negate: bool = False, - match_any: bool = False, ) -> str: - """Helper function for the backend's search string operations. + """Join provided search terms together, and return a normalized search string. - Pass terms as strings to normalize. - Pass fields of backend.proto/FilterToSearchIn as valid SearchTerms. - Pass multiple terms to concatenate (defaults to 'and', 'or' when 'match_any=True'). - Pass 'negate=True' to negate the end result. - May raise InvalidInput. + Terms are joined by an implicit AND. You can make an explict AND or OR + by wrapping in a group: + + terms = [... one or more SearchTerms()] + group = SearchTerm.Group(op=SearchTerm.Group.OR, terms=terms) + term = SearchTerm(group=group) + + To negate, wrap in a negated search term: + + term = SearchTerm(negated=term) + + Invalid search terms will throw an exception. + """ + term = self.group_search_terms(*terms) + return self._backend.filter_to_search(term) + + # pylint: disable=no-member + def join_searches( + self, + existing_term: SearchTerm, + additional_term: SearchTerm, + operator: Literal["AND", "OR"], + ) -> str: + """ + AND or OR `additional_term` to `existing_term`, without wrapping `existing_term` in brackets. + If you're building a search query yourself, prefer using SearchTerm(group=SearchTerm.Group(...)) """ - searches = [] - for term in terms: - if isinstance(term, SearchTerm): - term = self._backend.filter_to_search(term) - searches.append(term) - if match_any: - sep = _pb.ConcatenateSearchesIn.OR - else: + if operator == "AND": sep = _pb.ConcatenateSearchesIn.AND - search_string = self._backend.concatenate_searches(sep=sep, searches=searches) - if negate: - search_string = self._backend.negate_search(search_string) + else: + sep = _pb.ConcatenateSearchesIn.OR + + search_string = self._backend.concatenate_searches( + sep=sep, existing_search=existing_term, additional_search=additional_term + ) + return search_string - def replace_search_term(self, search: str, replacement: str) -> str: + def replace_search_term(self, search: SearchTerm, replacement: SearchTerm) -> str: return self._backend.replace_search_term(search=search, replacement=replacement) # Config diff --git a/pylib/rsbridge/lib.rs b/pylib/rsbridge/lib.rs index ceeff9738..80050f3c2 100644 --- a/pylib/rsbridge/lib.rs +++ b/pylib/rsbridge/lib.rs @@ -51,8 +51,6 @@ fn want_release_gil(method: u32) -> bool { | BackendMethod::LatestProgress | BackendMethod::SetWantsAbort | BackendMethod::I18nResources - | BackendMethod::NormalizeSearch - | BackendMethod::NegateSearch | BackendMethod::ConcatenateSearches | BackendMethod::ReplaceSearchTerm | BackendMethod::FilterToSearch diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index f29e14def..71b5b6498 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -392,19 +392,27 @@ class SidebarTreeView(QTreeView): self.setExpanded(idx, True) def update_search(self, *terms: Union[str, SearchTerm]) -> None: - """Modify the current search string based on modified keys, then refresh.""" + """Modify the current search string based on modifier keys, then refresh.""" + mods = self.mw.app.keyboardModifiers() + previous = SearchTerm(unparsed_search=self.browser.current_search()) + current = self.mw.col.group_search_terms(*terms) + + # if Alt pressed, invert + if mods & Qt.AltModifier: + current = SearchTerm(negated=current) + try: - search = self.col.build_search_string(*terms) - mods = self.mw.app.keyboardModifiers() - if mods & Qt.AltModifier: - search = self.col.build_search_string(search, negate=True) - current = self.browser.current_search() 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_search_term(previous, current) 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: - 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: show_invalid_search_error(e) else: @@ -590,7 +598,7 @@ class SidebarTreeView(QTreeView): return top def _filter_func(self, *terms: Union[str, SearchTerm]) -> Callable: - return lambda: self.update_search(self.col.build_search_string(*terms)) + return lambda: self.update_search(*terms) # Tree: Saved Searches ########################### diff --git a/rslib/backend.proto b/rslib/backend.proto index 93b3376fd..ae4eeedd9 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -91,10 +91,8 @@ service BackendService { // searching rpc FilterToSearch(SearchTerm) returns (String); - rpc NormalizeSearch(String) returns (String); rpc SearchCards(SearchCardsIn) returns (SearchCardsOut); rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut); - rpc NegateSearch(String) returns (String); rpc ConcatenateSearches(ConcatenateSearchesIn) returns (String); rpc ReplaceSearchTerm(ReplaceSearchTermIn) returns (String); rpc FindAndReplace(FindAndReplaceIn) returns (UInt32); @@ -820,7 +818,7 @@ message SearchTerm { oneof filter { Group group = 1; SearchTerm negated = 2; - string note = 3; + string unparsed_search = 3; uint32 template = 4; int64 nid = 5; Dupe dupe = 6; @@ -835,6 +833,7 @@ message SearchTerm { string deck = 15; int32 due_on_day = 16; string tag = 17; + string note = 18; } } @@ -844,12 +843,13 @@ message ConcatenateSearchesIn { OR = 1; } Separator sep = 1; - repeated string searches = 2; + SearchTerm existing_search = 2; + SearchTerm additional_search = 3; } message ReplaceSearchTermIn { - string search = 1; - string replacement = 2; + SearchTerm search = 1; + SearchTerm replacement = 2; } message CloseCollectionIn { diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 700b3a038..e89926e14 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -38,9 +38,8 @@ use crate::{ timespan::{answer_button_time, time_span}, }, search::{ - concatenate_searches, negate_search, normalize_search, replace_search_term, write_nodes, - BoolSeparator, Node, PropertyKind, RatingKind, SearchNode, SortMode, StateKind, - TemplateKind, + concatenate_searches, parse_search, replace_search_term, write_nodes, BoolSeparator, Node, + PropertyKind, RatingKind, SearchNode, SortMode, StateKind, TemplateKind, }, stats::studied_today, sync::{ @@ -62,8 +61,8 @@ use pb::{sync_status_out, BackendService}; use prost::Message; use serde_json::Value as JsonValue; use slog::warn; -use std::collections::HashSet; use std::convert::TryFrom; +use std::{collections::HashSet, convert::TryInto}; use std::{ result, sync::{Arc, Mutex}, @@ -296,12 +295,14 @@ impl From for DeckConfID { } } -impl From for Node { - fn from(msg: pb::SearchTerm) -> Self { +impl TryFrom for Node { + type Error = AnkiError; + + fn try_from(msg: pb::SearchTerm) -> std::result::Result { use pb::search_term::group::Operator; use pb::search_term::Filter; use pb::search_term::Flag; - if let Some(filter) = msg.filter { + Ok(if let Some(filter) = msg.filter { match filter { Filter::Tag(s) => Node::Search(SearchNode::Tag(escape_anki_wildcards(&s))), Filter::Deck(s) => Node::Search(SearchNode::Deck(if s == "*" { @@ -351,24 +352,40 @@ impl From for Node { Flag::Green => Node::Search(SearchNode::Flag(3)), Flag::Blue => Node::Search(SearchNode::Flag(4)), }, - Filter::Negated(term) => Node::Not(Box::new((*term).into())), - Filter::Group(group) => { - let operator = match group.op() { - Operator::And => Node::And, - Operator::Or => Node::Or, - }; - let joined = group - .terms - .into_iter() - .map(Into::into) - .intersperse(operator) - .collect(); - Node::Group(joined) + Filter::Negated(term) => Node::try_from(*term)?.negated(), + Filter::Group(mut group) => { + match group.terms.len() { + 0 => return Err(AnkiError::invalid_input("empty group")), + // a group of 1 doesn't need to be a group + 1 => group.terms.pop().unwrap().try_into()?, + // 2+ nodes + _ => { + let operator = match group.op() { + Operator::And => Node::And, + Operator::Or => Node::Or, + }; + let parsed: Vec<_> = group + .terms + .into_iter() + .map(TryFrom::try_from) + .collect::>()?; + let joined = parsed.into_iter().intersperse(operator).collect(); + Node::Group(joined) + } + } + } + Filter::UnparsedSearch(text) => { + let mut nodes = parse_search(&text)?; + if nodes.len() == 1 { + nodes.pop().unwrap() + } else { + Node::Group(nodes) + } } } } else { Node::Search(SearchNode::WholeCollection) - } + }) } } @@ -532,11 +549,7 @@ impl BackendService for Backend { //----------------------------------------------- fn filter_to_search(&self, input: pb::SearchTerm) -> Result { - Ok(write_nodes(&[input.into()]).into()) - } - - fn normalize_search(&self, input: pb::String) -> Result { - Ok(normalize_search(&input.val)?.into()) + Ok(write_nodes(&[input.try_into()?]).into()) } fn search_cards(&self, input: pb::SearchCardsIn) -> Result { @@ -558,16 +571,31 @@ impl BackendService for Backend { }) } - fn negate_search(&self, input: pb::String) -> Result { - Ok(negate_search(&input.val)?.into()) - } - fn concatenate_searches(&self, input: pb::ConcatenateSearchesIn) -> Result { - Ok(concatenate_searches(input.sep().into(), &input.searches)?.into()) + let sep = input.sep().into(); + let existing_nodes = { + let node = input.existing_search.unwrap_or_default().try_into()?; + if let Node::Group(nodes) = node { + nodes + } else { + vec![node] + } + }; + let additional_node = input.additional_search.unwrap_or_default().try_into()?; + Ok(concatenate_searches(sep, existing_nodes, additional_node).into()) } fn replace_search_term(&self, input: pb::ReplaceSearchTermIn) -> Result { - Ok(replace_search_term(&input.search, &input.replacement)?.into()) + let existing = { + let node = input.search.unwrap_or_default().try_into()?; + if let Node::Group(nodes) = node { + nodes + } else { + vec![node] + } + }; + let replacement = input.replacement.unwrap_or_default().try_into()?; + Ok(replace_search_term(existing, replacement).into()) } fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> BackendResult { diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index 992f01739..bb4bc4ff6 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -8,8 +8,7 @@ mod sqlwriter; mod writer; pub use cards::SortMode; -pub use parser::{Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind}; -pub use writer::{ - concatenate_searches, negate_search, normalize_search, replace_search_term, write_nodes, - BoolSeparator, +pub use parser::{ + parse as parse_search, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind, }; +pub use writer::{concatenate_searches, replace_search_term, write_nodes, BoolSeparator}; diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 427251a6d..d44a61ad3 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -38,6 +38,16 @@ pub enum Node { 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)] pub enum SearchNode { // text without a colon @@ -115,7 +125,7 @@ pub enum RatingKind { } /// Parse the input string into a list of nodes. -pub(super) fn parse(input: &str) -> Result> { +pub fn parse(input: &str) -> Result> { let input = input.trim(); if input.is_empty() { return Ok(vec![Node::Search(SearchNode::WholeCollection)]); @@ -980,4 +990,14 @@ mod test { 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); + } } diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index cede01ec8..acd6a6411 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -3,11 +3,9 @@ use crate::{ decks::DeckID as DeckIDType, - err::Result, 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; #[derive(Debug, PartialEq)] @@ -16,57 +14,31 @@ pub enum BoolSeparator { Or, } -/// Take an Anki-style search string and convert it into an equivalent -/// search string with normalized syntax. -pub fn normalize_search(input: &str) -> Result { - Ok(write_nodes(&parse(input)?)) +/// Take an existing search, and AND/OR it with the provided additional search. +/// This is required because when the user has "a AND b" in an existing search and +/// wants to add "c", we want "a AND b AND c", not "(a AND b) AND C", which is what we'd +/// get if we tried to join the existing search string with a new SearchTerm on the +/// client side. +pub fn concatenate_searches( + sep: BoolSeparator, + mut existing: Vec, + additional: Node, +) -> String { + if !existing.is_empty() { + existing.push(match sep { + BoolSeparator::And => Node::And, + BoolSeparator::Or => Node::Or, + }); + } + existing.push(additional); + write_nodes(&existing) } -/// Take an Anki-style search string and return the negated counterpart. -/// Empty searches (whole collection) remain unchanged. -pub fn negate_search(input: &str) -> Result { - let mut nodes = parse(input)?; - use Node::*; - Ok(if nodes.len() == 1 { - 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 { - let bool_node = vec![match sep { - BoolSeparator::And => Node::And, - BoolSeparator::Or => Node::Or, - }]; - Ok(write_nodes( - searches - .iter() - .map(|s| parse(s)) - .collect::>>>()? - .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 -/// node, replace with it all search terms of the same kind in the first search. -/// Then return the possibly modified first search. -pub fn replace_search_term(search: &str, replacement: &str) -> Result { - let mut nodes = parse(search)?; - let new = parse(replacement)?; - if let [Node::Search(search_node)] = &new[..] { +/// Given an existing parsed search, if the provided `replacement` is a single search node such +/// as a deck:xxx search, replace any instances of that search in `existing` with the new value. +/// Then return the possibly modified first search as a string. +pub fn replace_search_term(mut existing: Vec, replacement: Node) -> String { + if let Node::Search(search_node) = replacement { fn update_node_vec(old_nodes: &mut [Node], new_node: &SearchNode) { fn update_node(old_node: &mut Node, new_node: &SearchNode) { match old_node { @@ -82,16 +54,13 @@ pub fn replace_search_term(search: &str, replacement: &str) -> Result { } 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 -where - I: IntoIterator, -{ - nodes.into_iter().map(|node| write_node(node)).collect() +pub fn write_nodes(nodes: &[Node]) -> String { + nodes.iter().map(|node| write_node(node)).collect() } fn write_node(node: &Node) -> String { @@ -125,7 +94,7 @@ fn write_search_node(node: &SearchNode) -> String { NoteIDs(s) => format!("\"nid:{}\"", s), CardIDs(s) => format!("\"cid:{}\"", s), Property { operator, kind } => write_property(operator, kind), - WholeCollection => "".to_string(), + WholeCollection => "\"deck:*\"".to_string(), Regex(s) => quote(&format!("re:{}", s)), NoCombining(s) => quote(&format!("nc:{}", s)), WordBoundary(s) => quote(&format!("w:{}", s)), @@ -206,6 +175,14 @@ fn write_property(operator: &str, kind: &PropertyKind) -> String { #[cfg(test)] mod test { 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 { + Ok(write_nodes(&parse(input)?)) + } #[test] fn normalizing() -> Result<()> { @@ -224,36 +201,40 @@ mod test { 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] fn concatenating() -> Result<()> { 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""#, - concatenate_searches(BoolSeparator::And, &["foo".to_string(), "bar".to_string()]) - .unwrap() ); assert_eq!( - r#""foo" OR "bar""#, concatenate_searches( BoolSeparator::Or, - &["foo".to_string(), "".to_string(), "bar".to_string()] - ) - .unwrap() + vec![Node::Search(SearchNode::UnqualifiedText("foo".to_string()))], + Node::Search(SearchNode::UnqualifiedText("bar".to_string())) + ), + r#""foo" OR "bar""#, ); assert_eq!( - "", - concatenate_searches(BoolSeparator::Or, &["".to_string()]).unwrap() + concatenate_searches( + 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(()) } @@ -261,24 +242,30 @@ mod test { #[test] fn replacing() -> Result<()> { assert_eq!( + replace_search_term(parse("deck:baz bar")?, parse("deck:foo")?.pop().unwrap()), r#""deck:foo" AND "bar""#, - replace_search_term("deck:baz bar", "deck:foo").unwrap() ); assert_eq!( + replace_search_term( + parse("tag:foo Or tag:bar")?, + parse("tag:baz")?.pop().unwrap() + ), r#""tag:baz" OR "tag:baz""#, - replace_search_term("tag:foo Or tag:bar", "tag:baz").unwrap() ); assert_eq!( + replace_search_term( + parse("foo or (-foo tag:baz)")?, + parse("bar")?.pop().unwrap() + ), r#""bar" OR (-"bar" AND "tag:baz")"#, - replace_search_term("foo or (-foo tag:baz)", "bar").unwrap() ); assert_eq!( - r#""is:due""#, - replace_search_term("is:due", "-is:new").unwrap() + replace_search_term(parse("is:due")?, parse("-is:new")?.pop().unwrap()), + r#""is:due""# ); assert_eq!( - r#""added:1""#, - replace_search_term("added:1", "is:due").unwrap() + replace_search_term(parse("added:1")?, parse("is:due")?.pop().unwrap()), + r#""added:1""# ); Ok(()) From 35840221bba077139cdd42046cb58890c2fde445 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 11 Feb 2021 19:57:19 +1000 Subject: [PATCH 4/4] tweak search wording and tidy up API - SearchTerm -> SearchNode - Operator -> Joiner; share between messages - build_search_string() supports specifying AND/OR as a convenience - group_searches() makes it easier to negate --- pylib/anki/collection.py | 112 +++++++++++++++++++++---------------- pylib/anki/tags.py | 2 +- pylib/rsbridge/lib.rs | 6 +- qt/.pylintrc | 2 +- qt/aqt/addcards.py | 6 +- qt/aqt/browser.py | 18 +++--- qt/aqt/customstudy.py | 20 +++---- qt/aqt/dyndeckconf.py | 10 ++-- qt/aqt/editor.py | 6 +- qt/aqt/mediacheck.py | 4 +- qt/aqt/sidebar.py | 68 +++++++++++----------- rslib/backend.proto | 36 ++++++------ rslib/src/backend/mod.rs | 91 +++++++++++++++--------------- rslib/src/search/mod.rs | 2 +- rslib/src/search/writer.rs | 12 ++-- 15 files changed, 202 insertions(+), 193 deletions(-) diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 4c232084a..e2b5c293b 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -43,7 +43,8 @@ from anki.utils import ( ) # public exports -SearchTerm = _pb.SearchTerm +SearchNode = _pb.SearchNode +SearchJoiner = Literal["AND", "OR"] Progress = _pb.Progress Config = _pb.Config EmptyCardsReport = _pb.EmptyCardsReport @@ -471,7 +472,7 @@ class Collection: ) 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)) def find_and_replace( @@ -487,7 +488,7 @@ class Collection: # returns array of ("dupestr", [nids]) 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 vals: Dict[str, List[int]] = {} dupes = [] @@ -526,72 +527,85 @@ class Collection: # Search Strings ########################################################################## - def group_search_terms(self, *terms: Union[str, SearchTerm]) -> SearchTerm: - """Join provided search terms and strings into a single SearchTerm. - If multiple terms provided, they will be ANDed together into a group. - If a single term is provided, it is returned as-is. - """ - assert terms - - # convert raw text to SearchTerms - search_terms = [ - term if isinstance(term, SearchTerm) else SearchTerm(unparsed_search=term) - for term in terms - ] - - # if there's more than one, wrap it in an implicit AND - if len(search_terms) > 1: - return SearchTerm(group=SearchTerm.Group(terms=search_terms)) - else: - return search_terms[0] - def build_search_string( self, - *terms: Union[str, SearchTerm], + *nodes: Union[str, SearchNode], + joiner: SearchJoiner = "AND", ) -> str: - """Join provided search terms together, and return a normalized search string. - - Terms are joined by an implicit AND. You can make an explict AND or OR - by wrapping in a group: - - terms = [... one or more SearchTerms()] - group = SearchTerm.Group(op=SearchTerm.Group.OR, terms=terms) - term = SearchTerm(group=group) + """Join one or more searches, and return a normalized search string. To negate, wrap in a negated search term: - term = SearchTerm(negated=term) + term = SearchNode(negated=col.group_searches(...)) - Invalid search terms will throw an exception. + Invalid searches will throw an exception. """ - term = self.group_search_terms(*terms) - return self._backend.filter_to_search(term) + term = self.group_searches(*nodes, joiner=joiner) + return self._backend.build_search_string(term) + + def group_searches( + self, + *nodes: Union[str, SearchNode], + joiner: SearchJoiner = "AND", + ) -> SearchNode: + """Join provided search nodes and strings into a single SearchNode. + If a single SearchNode is provided, it is returned as-is. + At least one node must be provided. + """ + assert nodes + + # convert raw text to SearchNodes + search_nodes = [ + node if isinstance(node, SearchNode) else SearchNode(parsable_text=node) + for node in nodes + ] + + # if there's more than one, wrap them in a group + if len(search_nodes) > 1: + return SearchNode( + group=SearchNode.Group( + nodes=search_nodes, joiner=self._pb_search_separator(joiner) + ) + ) + else: + return search_nodes[0] - # pylint: disable=no-member def join_searches( self, - existing_term: SearchTerm, - additional_term: SearchTerm, + 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. - If you're building a search query yourself, prefer using SearchTerm(group=SearchTerm.Group(...)) + Used by the Browse screen to avoid adding extra brackets when joining. + If you're building a search query yourself, you probably don't need this. """ - - if operator == "AND": - sep = _pb.ConcatenateSearchesIn.AND - else: - sep = _pb.ConcatenateSearchesIn.OR - - search_string = self._backend.concatenate_searches( - sep=sep, existing_search=existing_term, additional_search=additional_term + search_string = self._backend.join_search_nodes( + joiner=self._pb_search_separator(operator), + existing_node=existing_node, + additional_node=additional_node, ) return search_string - def replace_search_term(self, search: SearchTerm, replacement: SearchTerm) -> str: - return self._backend.replace_search_term(search=search, replacement=replacement) + def replace_in_search_node( + 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 ########################################################################## diff --git a/pylib/anki/tags.py b/pylib/anki/tags.py index 686cd6ad7..73715fa7e 100644 --- a/pylib/anki/tags.py +++ b/pylib/anki/tags.py @@ -90,7 +90,7 @@ class TagManager: def rename(self, old: str, new: str) -> int: "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: return 0 escaped_name = re.sub(r"[*_\\]", r"\\\g<0>", old) diff --git a/pylib/rsbridge/lib.rs b/pylib/rsbridge/lib.rs index 80050f3c2..a7c2ffc5b 100644 --- a/pylib/rsbridge/lib.rs +++ b/pylib/rsbridge/lib.rs @@ -51,9 +51,9 @@ fn want_release_gil(method: u32) -> bool { | BackendMethod::LatestProgress | BackendMethod::SetWantsAbort | BackendMethod::I18nResources - | BackendMethod::ConcatenateSearches - | BackendMethod::ReplaceSearchTerm - | BackendMethod::FilterToSearch + | BackendMethod::JoinSearchNodes + | BackendMethod::ReplaceSearchNode + | BackendMethod::BuildSearchString ) } else { false diff --git a/qt/.pylintrc b/qt/.pylintrc index 9084c2f85..3507ed84c 100644 --- a/qt/.pylintrc +++ b/qt/.pylintrc @@ -6,7 +6,7 @@ ignore = forms,hooks_gen.py [TYPECHECK] ignored-modules=win32file,pywintypes,socket,win32pipe,winrt,pyaudio ignored-classes= - SearchTerm, + SearchNode, Config, [REPORTS] diff --git a/qt/aqt/addcards.py b/qt/aqt/addcards.py index 9f695ea20..c719d4bbb 100644 --- a/qt/aqt/addcards.py +++ b/qt/aqt/addcards.py @@ -6,7 +6,7 @@ import aqt.deckchooser import aqt.editor import aqt.forms import aqt.modelchooser -from anki.collection import SearchTerm +from anki.collection import SearchNode from anki.consts import MODEL_CLOZE from anki.notes import Note from anki.utils import htmlToTextLine, isMac @@ -144,7 +144,7 @@ class AddCards(QDialog): def onHistory(self) -> None: m = QMenu(self) 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) fields = note.fields txt = htmlToTextLine(", ".join(fields)) @@ -161,7 +161,7 @@ class AddCards(QDialog): m.exec_(self.historyButton.mapToGlobal(QPoint(0, 0))) 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]: note.model()["did"] = self.deckChooser.selectedId() diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index b293c44f1..23703342b 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -12,7 +12,7 @@ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union, import aqt import aqt.forms 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.errors import InvalidInput from anki.lang import without_unicode_isolation @@ -442,7 +442,7 @@ class Browser(QMainWindow): self, mw: AnkiQt, card: Optional[Card] = None, - search: Optional[Tuple[Union[str, SearchTerm]]] = None, + search: Optional[Tuple[Union[str, SearchNode]]] = None, ) -> None: """ card : try to search for its note and select it @@ -615,7 +615,7 @@ class Browser(QMainWindow): self, _mw: AnkiQt, card: Optional[Card] = None, - search: Optional[Tuple[Union[str, SearchTerm]]] = None, + search: Optional[Tuple[Union[str, SearchNode]]] = None, ) -> None: if search is not None: self.search_for_terms(*search) @@ -630,7 +630,7 @@ class Browser(QMainWindow): def setupSearch( self, card: Optional[Card] = None, - search: Optional[Tuple[Union[str, SearchTerm]]] = None, + search: Optional[Tuple[Union[str, SearchNode]]] = None, ) -> None: qconnect(self.form.searchEdit.lineEdit().returnPressed, self.onSearchActivated) self.form.searchEdit.setCompleter(None) @@ -644,7 +644,7 @@ class Browser(QMainWindow): self.show_single_card(card) else: self.search_for( - self.col.build_search_string(SearchTerm(deck="current")), "" + self.col.build_search_string(SearchNode(deck="current")), "" ) self.form.searchEdit.setFocus() @@ -707,7 +707,7 @@ class Browser(QMainWindow): ) 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) self.form.searchEdit.setEditText(search) self.onSearchActivated() @@ -717,7 +717,7 @@ class Browser(QMainWindow): def on_show_single_card() -> None: 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) self.search_for(search, "") self.focusCid(card.id) @@ -1407,7 +1407,7 @@ where id in %s""" tv.selectionModel().clear() search = self.col.build_search_string( - SearchTerm(nids=SearchTerm.IdList(ids=nids)) + SearchNode(nids=SearchNode.IdList(ids=nids)) ) self.search_for(search) @@ -1626,7 +1626,7 @@ where id in %s""" % ( html.escape( 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)), diff --git a/qt/aqt/customstudy.py b/qt/aqt/customstudy.py index 8f85c7ddf..5533b7178 100644 --- a/qt/aqt/customstudy.py +++ b/qt/aqt/customstudy.py @@ -2,7 +2,7 @@ # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import aqt -from anki.collection import SearchTerm +from anki.collection import SearchNode from anki.consts import * from aqt.qt import * from aqt.utils import TR, disable_help_button, showInfo, showWarning, tr @@ -164,20 +164,20 @@ class CustomStudy(QDialog): # and then set various options if i == RADIO_FORGOT: search = self.mw.col.build_search_string( - SearchTerm( - rated=SearchTerm.Rated(days=spin, rating=SearchTerm.RATING_AGAIN) + SearchNode( + rated=SearchNode.Rated(days=spin, rating=SearchNode.RATING_AGAIN) ) ) dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_RANDOM] dyn["resched"] = False 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["resched"] = True elif i == RADIO_PREVIEW: search = self.mw.col.build_search_string( - SearchTerm(card_state=SearchTerm.CARD_STATE_NEW), - SearchTerm(added_in_days=spin), + SearchNode(card_state=SearchNode.CARD_STATE_NEW), + SearchNode(added_in_days=spin), ) dyn["terms"][0] = [search, DYN_MAX_SIZE, DYN_OLDEST] dyn["resched"] = False @@ -185,19 +185,19 @@ class CustomStudy(QDialog): type = f.cardType.currentRow() if type == TYPE_NEW: terms = self.mw.col.build_search_string( - SearchTerm(card_state=SearchTerm.CARD_STATE_NEW) + SearchNode(card_state=SearchNode.CARD_STATE_NEW) ) ord = DYN_ADDED dyn["resched"] = True elif type == TYPE_DUE: terms = self.mw.col.build_search_string( - SearchTerm(card_state=SearchTerm.CARD_STATE_DUE) + SearchNode(card_state=SearchNode.CARD_STATE_DUE) ) ord = DYN_DUE dyn["resched"] = True elif type == TYPE_REVIEW: 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 dyn["resched"] = True @@ -208,7 +208,7 @@ class CustomStudy(QDialog): dyn["terms"][0] = [(terms + tags).strip(), spin, ord] # add deck limit 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) # generate cards diff --git a/qt/aqt/dyndeckconf.py b/qt/aqt/dyndeckconf.py index 0904f58ca..8c88df964 100644 --- a/qt/aqt/dyndeckconf.py +++ b/qt/aqt/dyndeckconf.py @@ -3,7 +3,7 @@ from typing import Callable, List, Optional import aqt -from anki.collection import SearchTerm +from anki.collection import SearchNode from anki.decks import Deck, DeckRenameError from anki.errors import InvalidInput from anki.lang import without_unicode_isolation @@ -111,14 +111,14 @@ class DeckConf(QDialog): def set_default_searches(self, deck_name: str) -> None: self.form.search.setText( self.mw.col.build_search_string( - SearchTerm(deck=deck_name), - SearchTerm(card_state=SearchTerm.CARD_STATE_DUE), + SearchNode(deck=deck_name), + SearchNode(card_state=SearchNode.CARD_STATE_DUE), ) ) self.form.search_2.setText( self.mw.col.build_search_string( - SearchTerm(deck=deck_name), - SearchTerm(card_state=SearchTerm.CARD_STATE_NEW), + SearchNode(deck=deck_name), + SearchNode(card_state=SearchNode.CARD_STATE_NEW), ) ) diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index ee0fe2e47..1a5a54bf6 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -20,7 +20,7 @@ from bs4 import BeautifulSoup import aqt import aqt.sound from anki.cards import Card -from anki.collection import SearchTerm +from anki.collection import SearchNode from anki.consts import MODEL_CLOZE from anki.hooks import runFilter from anki.httpclient import HttpClient @@ -546,8 +546,8 @@ class Editor: "Browser", self.mw, search=( - SearchTerm( - dupe=SearchTerm.Dupe( + SearchNode( + dupe=SearchNode.Dupe( notetype_id=self.note.model()["id"], first_field=self.note.fields[0], ) diff --git a/qt/aqt/mediacheck.py b/qt/aqt/mediacheck.py index 01f049a7c..017eb2eab 100644 --- a/qt/aqt/mediacheck.py +++ b/qt/aqt/mediacheck.py @@ -9,7 +9,7 @@ from concurrent.futures import Future from typing import Iterable, List, Optional, Sequence, TypeVar import aqt -from anki.collection import SearchTerm +from anki.collection import SearchNode from anki.errors import Interrupted from anki.lang import TR from anki.media import CheckMediaOut @@ -154,7 +154,7 @@ class MediaChecker: if out is not None: 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") else: tooltip(tr(TR.MEDIA_CHECK_ALL_LATEX_RENDERED)) diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 71b5b6498..60a0ebc69 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -8,7 +8,7 @@ from enum import Enum, auto from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, cast import aqt -from anki.collection import Config, SearchTerm +from anki.collection import Config, SearchNode from anki.decks import DeckTreeNode from anki.errors import DeckRenameError, InvalidInput from anki.tags import TagTreeNode @@ -391,20 +391,20 @@ class SidebarTreeView(QTreeView): if item.is_expanded(searching): self.setExpanded(idx, True) - def update_search(self, *terms: Union[str, SearchTerm]) -> None: + def update_search(self, *terms: Union[str, SearchNode]) -> None: """Modify the current search string based on modifier keys, then refresh.""" mods = self.mw.app.keyboardModifiers() - previous = SearchTerm(unparsed_search=self.browser.current_search()) - current = self.mw.col.group_search_terms(*terms) + previous = SearchNode(parsable_text=self.browser.current_search()) + current = self.mw.col.group_searches(*terms) # if Alt pressed, invert if mods & Qt.AltModifier: - current = SearchTerm(negated=current) + current = SearchNode(negated=current) try: if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: # If Ctrl+Shift, replace searches nodes of the same type. - search = self.col.replace_search_term(previous, current) + search = self.col.replace_in_search_node(previous, current) elif mods & Qt.ControlModifier: # If Ctrl, AND with previous search = self.col.join_searches(previous, current, "AND") @@ -597,7 +597,7 @@ class SidebarTreeView(QTreeView): return top - def _filter_func(self, *terms: Union[str, SearchTerm]) -> Callable: + def _filter_func(self, *terms: Union[str, SearchNode]) -> Callable: return lambda: self.update_search(*terms) # Tree: Saved Searches @@ -648,33 +648,33 @@ class SidebarTreeView(QTreeView): name=TR.BROWSING_SIDEBAR_DUE_TODAY, icon=icon, type=type, - on_click=search(SearchTerm(due_on_day=0)), + on_click=search(SearchNode(due_on_day=0)), ) root.add_simple( name=TR.BROWSING_ADDED_TODAY, icon=icon, type=type, - on_click=search(SearchTerm(added_in_days=1)), + on_click=search(SearchNode(added_in_days=1)), ) root.add_simple( name=TR.BROWSING_EDITED_TODAY, icon=icon, type=type, - on_click=search(SearchTerm(edited_in_days=1)), + on_click=search(SearchNode(edited_in_days=1)), ) root.add_simple( name=TR.BROWSING_STUDIED_TODAY, icon=icon, type=type, - on_click=search(SearchTerm(rated=SearchTerm.Rated(days=1))), + on_click=search(SearchNode(rated=SearchNode.Rated(days=1))), ) root.add_simple( name=TR.BROWSING_AGAIN_TODAY, icon=icon, type=type, on_click=search( - SearchTerm( - rated=SearchTerm.Rated(days=1, rating=SearchTerm.RATING_AGAIN) + SearchNode( + rated=SearchNode.Rated(days=1, rating=SearchNode.RATING_AGAIN) ) ), ) @@ -683,8 +683,8 @@ class SidebarTreeView(QTreeView): icon=icon, type=type, on_click=search( - SearchTerm(card_state=SearchTerm.CARD_STATE_DUE), - SearchTerm(negated=SearchTerm(due_on_day=0)), + SearchNode(card_state=SearchNode.CARD_STATE_DUE), + SearchNode(negated=SearchNode(due_on_day=0)), ), ) @@ -707,32 +707,32 @@ class SidebarTreeView(QTreeView): TR.ACTIONS_NEW, icon=icon.with_color(colors.NEW_COUNT), 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( name=TR.SCHEDULING_LEARNING, icon=icon.with_color(colors.LEARN_COUNT), 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( name=TR.SCHEDULING_REVIEW, icon=icon.with_color(colors.REVIEW_COUNT), 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( name=TR.BROWSING_SUSPENDED, icon=icon.with_color(colors.SUSPENDED_FG), 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( name=TR.BROWSING_BURIED, icon=icon.with_color(colors.BURIED_FG), type=type, - on_click=search(SearchTerm(card_state=SearchTerm.CARD_STATE_BURIED)), + on_click=search(SearchNode(card_state=SearchNode.CARD_STATE_BURIED)), ) # Tree: Flags @@ -748,38 +748,38 @@ class SidebarTreeView(QTreeView): collapse_key=Config.Bool.COLLAPSE_FLAGS, 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 root.add_simple( TR.ACTIONS_RED_FLAG, icon=icon.with_color(colors.FLAG1_FG), type=type, - on_click=search(SearchTerm(flag=SearchTerm.FLAG_RED)), + on_click=search(SearchNode(flag=SearchNode.FLAG_RED)), ) root.add_simple( TR.ACTIONS_ORANGE_FLAG, icon=icon.with_color(colors.FLAG2_FG), type=type, - on_click=search(SearchTerm(flag=SearchTerm.FLAG_ORANGE)), + on_click=search(SearchNode(flag=SearchNode.FLAG_ORANGE)), ) root.add_simple( TR.ACTIONS_GREEN_FLAG, icon=icon.with_color(colors.FLAG3_FG), type=type, - on_click=search(SearchTerm(flag=SearchTerm.FLAG_GREEN)), + on_click=search(SearchNode(flag=SearchNode.FLAG_GREEN)), ) root.add_simple( TR.ACTIONS_BLUE_FLAG, icon=icon.with_color(colors.FLAG4_FG), type=type, - on_click=search(SearchTerm(flag=SearchTerm.FLAG_BLUE)), + on_click=search(SearchNode(flag=SearchNode.FLAG_BLUE)), ) root.add_simple( TR.BROWSING_NO_FLAG, icon=icon.with_color(colors.DISABLED), type=type, - on_click=search(SearchTerm(flag=SearchTerm.FLAG_NONE)), + on_click=search(SearchNode(flag=SearchNode.FLAG_NONE)), ) # Tree: Tags @@ -802,7 +802,7 @@ class SidebarTreeView(QTreeView): item = SidebarItem( node.name, icon, - self._filter_func(SearchTerm(tag=head + node.name)), + self._filter_func(SearchNode(tag=head + node.name)), toggle_expand(), node.expanded, item_type=SidebarItemType.TAG, @@ -820,12 +820,12 @@ class SidebarTreeView(QTreeView): collapse_key=Config.Bool.COLLAPSE_TAGS, 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( name=tr(TR.BROWSING_SIDEBAR_UNTAGGED), icon=icon, type=SidebarItemType.TAG_NONE, - on_click=self._filter_func(SearchTerm(tag="none")), + on_click=self._filter_func(SearchNode(tag="none")), ) render(root, tree.children) @@ -848,7 +848,7 @@ class SidebarTreeView(QTreeView): item = SidebarItem( node.name, icon, - self._filter_func(SearchTerm(deck=head + node.name)), + self._filter_func(SearchNode(deck=head + node.name)), toggle_expand(), not node.collapsed, item_type=SidebarItemType.DECK, @@ -867,12 +867,12 @@ class SidebarTreeView(QTreeView): collapse_key=Config.Bool.COLLAPSE_DECKS, type=SidebarItemType.DECK_ROOT, ) - root.on_click = self._filter_func(SearchTerm(deck="*")) + root.on_click = self._filter_func(SearchNode(deck="*")) current = root.add_simple( name=tr(TR.BROWSING_CURRENT_DECK), icon=icon, 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() @@ -895,7 +895,7 @@ class SidebarTreeView(QTreeView): item = SidebarItem( nt["name"], icon, - self._filter_func(SearchTerm(note=nt["name"])), + self._filter_func(SearchNode(note=nt["name"])), item_type=SidebarItemType.NOTETYPE, id=nt["id"], ) @@ -905,7 +905,7 @@ class SidebarTreeView(QTreeView): tmpl["name"], icon, self._filter_func( - SearchTerm(note=nt["name"]), SearchTerm(template=c) + SearchNode(note=nt["name"]), SearchNode(template=c) ), item_type=SidebarItemType.NOTETYPE_TEMPLATE, full_name=f"{nt['name']}::{tmpl['name']}", diff --git a/rslib/backend.proto b/rslib/backend.proto index ae4eeedd9..d5cf19d59 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -90,11 +90,11 @@ service BackendService { // searching - rpc FilterToSearch(SearchTerm) returns (String); + rpc BuildSearchString(SearchNode) returns (String); rpc SearchCards(SearchCardsIn) returns (SearchCardsOut); rpc SearchNotes(SearchNotesIn) returns (SearchNotesOut); - rpc ConcatenateSearches(ConcatenateSearchesIn) returns (String); - rpc ReplaceSearchTerm(ReplaceSearchTermIn) returns (String); + rpc JoinSearchNodes(JoinSearchNodesIn) returns (String); + rpc ReplaceSearchNode(ReplaceSearchNodeIn) returns (String); rpc FindAndReplace(FindAndReplaceIn) returns (UInt32); // scheduling @@ -771,7 +771,7 @@ message SearchNotesOut { repeated int64 note_ids = 2; } -message SearchTerm { +message SearchNode { message Dupe { int64 notetype_id = 1; string first_field = 2; @@ -808,17 +808,17 @@ message SearchTerm { repeated int64 ids = 1; } message Group { - enum Operator { + enum Joiner { AND = 0; OR = 1; } - repeated SearchTerm terms = 1; - Operator op = 2; + repeated SearchNode nodes = 1; + Joiner joiner = 2; } oneof filter { Group group = 1; - SearchTerm negated = 2; - string unparsed_search = 3; + SearchNode negated = 2; + string parsable_text = 3; uint32 template = 4; int64 nid = 5; Dupe dupe = 6; @@ -837,19 +837,15 @@ message SearchTerm { } } -message ConcatenateSearchesIn { - enum Separator { - AND = 0; - OR = 1; - } - Separator sep = 1; - SearchTerm existing_search = 2; - SearchTerm additional_search = 3; +message JoinSearchNodesIn { + SearchNode.Group.Joiner joiner = 1; + SearchNode existing_node = 2; + SearchNode additional_node = 3; } -message ReplaceSearchTermIn { - SearchTerm search = 1; - SearchTerm replacement = 2; +message ReplaceSearchNodeIn { + SearchNode existing_node = 1; + SearchNode replacement_node = 2; } message CloseCollectionIn { diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index e89926e14..8374daafb 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -6,7 +6,6 @@ use crate::{ backend::dbproxy::db_command_bytes, backend_proto as pb, backend_proto::{ - concatenate_searches_in::Separator as BoolSeparatorProto, sort_order::builtin::Kind as SortKindProto, sort_order::Value as SortOrderProto, AddOrUpdateDeckConfigLegacyIn, BackendResult, Empty, RenderedTemplateReplacement, }, @@ -38,7 +37,7 @@ use crate::{ timespan::{answer_button_time, time_span}, }, search::{ - concatenate_searches, parse_search, replace_search_term, write_nodes, BoolSeparator, Node, + concatenate_searches, parse_search, replace_search_node, write_nodes, BoolSeparator, Node, PropertyKind, RatingKind, SearchNode, SortMode, StateKind, TemplateKind, }, stats::studied_today, @@ -267,7 +266,7 @@ impl From for NoteID { } } -impl pb::search_term::IdList { +impl pb::search_node::IdList { fn into_id_string(self) -> String { self.ids .iter() @@ -295,13 +294,13 @@ impl From for DeckConfID { } } -impl TryFrom for Node { +impl TryFrom for Node { type Error = AnkiError; - fn try_from(msg: pb::SearchTerm) -> std::result::Result { - use pb::search_term::group::Operator; - use pb::search_term::Filter; - use pb::search_term::Flag; + fn try_from(msg: pb::SearchNode) -> std::result::Result { + 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 { Filter::Tag(s) => Node::Search(SearchNode::Tag(escape_anki_wildcards(&s))), @@ -340,7 +339,7 @@ impl TryFrom for Node { }), Filter::EditedInDays(u) => Node::Search(SearchNode::EditedInDays(u)), Filter::CardState(state) => Node::Search(SearchNode::State( - pb::search_term::CardState::from_i32(state) + pb::search_node::CardState::from_i32(state) .unwrap_or_default() .into(), )), @@ -354,27 +353,27 @@ impl TryFrom for Node { }, Filter::Negated(term) => Node::try_from(*term)?.negated(), Filter::Group(mut group) => { - match group.terms.len() { + 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.terms.pop().unwrap().try_into()?, + 1 => group.nodes.pop().unwrap().try_into()?, // 2+ nodes _ => { - let operator = match group.op() { - Operator::And => Node::And, - Operator::Or => Node::Or, + let joiner = match group.joiner() { + Joiner::And => Node::And, + Joiner::Or => Node::Or, }; let parsed: Vec<_> = group - .terms + .nodes .into_iter() .map(TryFrom::try_from) .collect::>()?; - let joined = parsed.into_iter().intersperse(operator).collect(); + let joined = parsed.into_iter().intersperse(joiner).collect(); Node::Group(joined) } } } - Filter::UnparsedSearch(text) => { + Filter::ParsableText(text) => { let mut nodes = parse_search(&text)?; if nodes.len() == 1 { nodes.pop().unwrap() @@ -389,37 +388,37 @@ impl TryFrom for Node { } } -impl From for BoolSeparator { - fn from(sep: BoolSeparatorProto) -> Self { +impl From for BoolSeparator { + fn from(sep: pb::search_node::group::Joiner) -> Self { match sep { - BoolSeparatorProto::And => BoolSeparator::And, - BoolSeparatorProto::Or => BoolSeparator::Or, + pb::search_node::group::Joiner::And => BoolSeparator::And, + pb::search_node::group::Joiner::Or => BoolSeparator::Or, } } } -impl From for RatingKind { - fn from(r: pb::search_term::Rating) -> Self { +impl From for RatingKind { + fn from(r: pb::search_node::Rating) -> Self { match r { - pb::search_term::Rating::Again => RatingKind::AnswerButton(1), - pb::search_term::Rating::Hard => RatingKind::AnswerButton(2), - pb::search_term::Rating::Good => RatingKind::AnswerButton(3), - pb::search_term::Rating::Easy => RatingKind::AnswerButton(4), - pb::search_term::Rating::Any => RatingKind::AnyAnswerButton, - pb::search_term::Rating::ByReschedule => RatingKind::ManualReschedule, + pb::search_node::Rating::Again => RatingKind::AnswerButton(1), + pb::search_node::Rating::Hard => RatingKind::AnswerButton(2), + pb::search_node::Rating::Good => RatingKind::AnswerButton(3), + pb::search_node::Rating::Easy => RatingKind::AnswerButton(4), + pb::search_node::Rating::Any => RatingKind::AnyAnswerButton, + pb::search_node::Rating::ByReschedule => RatingKind::ManualReschedule, } } } -impl From for StateKind { - fn from(k: pb::search_term::CardState) -> Self { +impl From for StateKind { + fn from(k: pb::search_node::CardState) -> Self { match k { - pb::search_term::CardState::New => StateKind::New, - pb::search_term::CardState::Learn => StateKind::Learning, - pb::search_term::CardState::Review => StateKind::Review, - pb::search_term::CardState::Due => StateKind::Due, - pb::search_term::CardState::Suspended => StateKind::Suspended, - pb::search_term::CardState::Buried => StateKind::Buried, + pb::search_node::CardState::New => StateKind::New, + pb::search_node::CardState::Learn => StateKind::Learning, + pb::search_node::CardState::Review => StateKind::Review, + pb::search_node::CardState::Due => StateKind::Due, + pb::search_node::CardState::Suspended => StateKind::Suspended, + pb::search_node::CardState::Buried => StateKind::Buried, } } } @@ -548,7 +547,7 @@ impl BackendService for Backend { // searching //----------------------------------------------- - fn filter_to_search(&self, input: pb::SearchTerm) -> Result { + fn build_search_string(&self, input: pb::SearchNode) -> Result { Ok(write_nodes(&[input.try_into()?]).into()) } @@ -571,31 +570,31 @@ impl BackendService for Backend { }) } - fn concatenate_searches(&self, input: pb::ConcatenateSearchesIn) -> Result { - let sep = input.sep().into(); + fn join_search_nodes(&self, input: pb::JoinSearchNodesIn) -> Result { + let sep = input.joiner().into(); let existing_nodes = { - let node = input.existing_search.unwrap_or_default().try_into()?; + let node = input.existing_node.unwrap_or_default().try_into()?; if let Node::Group(nodes) = node { nodes } else { vec![node] } }; - let additional_node = input.additional_search.unwrap_or_default().try_into()?; + let additional_node = input.additional_node.unwrap_or_default().try_into()?; Ok(concatenate_searches(sep, existing_nodes, additional_node).into()) } - fn replace_search_term(&self, input: pb::ReplaceSearchTermIn) -> Result { + fn replace_search_node(&self, input: pb::ReplaceSearchNodeIn) -> Result { let existing = { - let node = input.search.unwrap_or_default().try_into()?; + let node = input.existing_node.unwrap_or_default().try_into()?; if let Node::Group(nodes) = node { nodes } else { vec![node] } }; - let replacement = input.replacement.unwrap_or_default().try_into()?; - Ok(replace_search_term(existing, replacement).into()) + let replacement = input.replacement_node.unwrap_or_default().try_into()?; + Ok(replace_search_node(existing, replacement).into()) } fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> BackendResult { diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index bb4bc4ff6..b469542df 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -11,4 +11,4 @@ pub use cards::SortMode; pub use parser::{ parse as parse_search, Node, PropertyKind, RatingKind, SearchNode, StateKind, TemplateKind, }; -pub use writer::{concatenate_searches, replace_search_term, write_nodes, BoolSeparator}; +pub use writer::{concatenate_searches, replace_search_node, write_nodes, BoolSeparator}; diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index acd6a6411..2cb2d3a6b 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -37,7 +37,7 @@ pub fn concatenate_searches( /// Given an existing parsed search, if the provided `replacement` is a single search node such /// as a deck:xxx search, replace any instances of that search in `existing` with the new value. /// Then return the possibly modified first search as a string. -pub fn replace_search_term(mut existing: Vec, replacement: Node) -> String { +pub fn replace_search_node(mut existing: Vec, replacement: Node) -> String { if let Node::Search(search_node) = replacement { fn update_node_vec(old_nodes: &mut [Node], new_node: &SearchNode) { fn update_node(old_node: &mut Node, new_node: &SearchNode) { @@ -242,29 +242,29 @@ mod test { #[test] fn replacing() -> Result<()> { assert_eq!( - replace_search_term(parse("deck:baz bar")?, parse("deck:foo")?.pop().unwrap()), + replace_search_node(parse("deck:baz bar")?, parse("deck:foo")?.pop().unwrap()), r#""deck:foo" AND "bar""#, ); assert_eq!( - replace_search_term( + replace_search_node( parse("tag:foo Or tag:bar")?, parse("tag:baz")?.pop().unwrap() ), r#""tag:baz" OR "tag:baz""#, ); assert_eq!( - replace_search_term( + replace_search_node( parse("foo or (-foo tag:baz)")?, parse("bar")?.pop().unwrap() ), r#""bar" OR (-"bar" AND "tag:baz")"#, ); assert_eq!( - replace_search_term(parse("is:due")?, parse("-is:new")?.pop().unwrap()), + replace_search_node(parse("is:due")?, parse("-is:new")?.pop().unwrap()), r#""is:due""# ); assert_eq!( - replace_search_term(parse("added:1")?, parse("is:due")?.pop().unwrap()), + replace_search_node(parse("added:1")?, parse("is:due")?.pop().unwrap()), r#""added:1""# );