From fcc87d16ea5c4a725433e41ec91b226b88c73a02 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Tue, 22 Dec 2020 11:04:38 +0100 Subject: [PATCH 01/16] Add search writer --- rslib/src/search/writer.rs | 130 +++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 rslib/src/search/writer.rs diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs new file mode 100644 index 000000000..246f1fdc1 --- /dev/null +++ b/rslib/src/search/writer.rs @@ -0,0 +1,130 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{ + err::Result, + decks::DeckID as DeckIDType, + notetype::NoteTypeID as NoteTypeIDType, + search::parser::{parse, Node, PropertyKind, SearchNode, StateKind, TemplateKind}, +}; + +pub fn norm_search(input: &str) -> Result { + Ok(write_nodes(&parse(input)?)) +} + +fn write_nodes(nodes: &Vec) -> String { + nodes.into_iter().map(|node| write_node(node)).collect() +} + +fn write_node(node: &Node) -> String { + use Node::*; + match node { + And => " ".to_string(), + Or => " or ".to_string(), + Not(n) => format!("-{}", write_node(n)), + Group(ns) => format!("({})", write_nodes(ns)), + Search(n) => write_search_node(n), + } +} + +fn write_search_node(node: &SearchNode) -> String { + use SearchNode::*; + match node { + UnqualifiedText(s) => escape(s), + SingleField { field, text, is_re } => write_single_field(field, text, *is_re), + AddedInDays(u) => format!("added:{}", u), + EditedInDays(u) => format!("edited:{}", u), + CardTemplate(t) => write_template(t), + Deck(s) => format!("deck:{}", &escape_suffix(s)), + DeckID(DeckIDType(i)) => format!("did:{}", i), + NoteTypeID(NoteTypeIDType(i)) => format!("mid:{}", i), + NoteType(s) => format!("note:{}", &escape_suffix(s)), + Rated { days, ease } => write_rated(days, ease), + Tag(s) => format!("tag:{}", &escape_suffix(s)), + Duplicates { note_type_id, text } => format!("dupes:{},{}", note_type_id, escape_suffix(text)), + State(k) => write_state(k), + Flag(u) => format!("flag:{}", u), + NoteIDs(s) => format!("nid:{}", s), + CardIDs(s) => format!("cid:{}", s), + Property { operator, kind } => write_prop(operator, kind), + WholeCollection => "".to_string(), + Regex(s) => format!("re:{}", &escape_suffix(s)), + NoCombining(s) => format!("re:{}", &escape_suffix(s)), + WordBoundary(s) => format!("re:{}", &escape_suffix(s)), + } +} + +fn escape(txt: &str) -> String { + let txt = txt.replace("\"", "\\\"").replace(":", "\\:"); + if txt.chars().any(|c| " \u{3000}()".contains(c)) { + format!(r#""{}""#, txt) + } else if txt.chars().next() == Some('-') { + format!("\\{}", txt) + } else { + txt + } +} + +fn escape_suffix(txt: &str) -> String { + let txt = txt.replace("\"", "\\\""); + if txt.chars().any(|c| " \u{3000}()".contains(c)) { + format!(r#""{}""#, txt) + } else { + txt + } +} + +fn write_single_field(field: &str, text: &str, is_re: bool) -> String { + let field = field.replace(":", "\\:"); + let re = if is_re { "re:" } else { "" }; + let txt = format!("{}:{}{}", field, re, text).replace("\"", "\\\""); + if txt.chars().any(|c| " \u{3000}()".contains(c)) { + format!(r#""{}""#, txt) + } else if txt.chars().next() == Some('-') { + format!("\\{}", txt) + } else { + txt + } +} + +fn write_template(template: &TemplateKind) -> String { + match template { + TemplateKind::Ordinal(u) => format!("card:{}", u), + TemplateKind::Name(s) => format!("card:{}", s), + } +} + +fn write_rated(days: &u32, ease: &Option) -> String { + match ease { + Some(u) => format!("rated:{}:{}", days, u), + None => format!("rated:{}", days), + } +} + +fn write_state(kind: &StateKind) -> String { + use StateKind::*; + format!( + "is:{}", + match kind { + New => "new", + Review => "review", + Learning => "learn", + Due => "due", + Buried => "buried", + UserBuried => "buried-manually", + SchedBuried => "buried-sibling", + Suspended => "suspended", + } + ) +} + +fn write_prop(operator: &str, kind: &PropertyKind) -> String { + use PropertyKind::*; + match kind { + Due(i) => format!("prop:due{}{}", operator, i), + Interval(u) => format!("prop:ivl{}{}", operator, u), + Reps(u) => format!("prop:reps{}{}", operator, u), + Lapses(u) => format!("prop:lapses{}{}", operator, u), + Ease(f) => format!("prop:ease{}{}", operator, f), + } +} From 5b24d9e4a641657d839dfd461acea0ec64df4108 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Tue, 22 Dec 2020 11:06:55 +0100 Subject: [PATCH 02/16] Expose search writer --- rslib/backend.proto | 1 + rslib/src/backend/mod.rs | 6 +++++- rslib/src/search/mod.rs | 2 ++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/rslib/backend.proto b/rslib/backend.proto index 675170fb6..821336385 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -84,6 +84,7 @@ service BackendService { // searching + rpc NormSearch (String) returns (String); rpc SearchCards (SearchCardsIn) returns (SearchCardsOut); rpc SearchNotes (SearchNotesIn) returns (SearchNotesOut); rpc FindAndReplace (FindAndReplaceIn) returns (UInt32); diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 23b124b01..de26e0db4 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -32,7 +32,7 @@ use crate::{ }, sched::cutoff::local_minutes_west_for_stamp, sched::timespan::{answer_button_time, time_span}, - search::SortMode, + search::{SortMode, norm_search}, stats::studied_today, sync::{ get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress, @@ -393,6 +393,10 @@ impl BackendService for Backend { // searching //----------------------------------------------- + fn norm_search(&self, input: pb::String) -> Result { + Ok(norm_search(&input.val)?.into()) + } + fn search_cards(&self, input: pb::SearchCardsIn) -> Result { self.with_col(|col| { let order = if let Some(order) = input.order { diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index 4ab4dfbef..48e67666c 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -1,6 +1,8 @@ mod cards; mod notes; mod parser; +mod writer; mod sqlwriter; pub use cards::SortMode; +pub use writer::norm_search; From b712053c06dddb25f1492f0dcb0a4f979ed608f1 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Tue, 22 Dec 2020 11:08:47 +0100 Subject: [PATCH 03/16] Norm search --- qt/aqt/browser.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index b43d5b1a2..e3ff7d7f2 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -188,6 +188,7 @@ class DataModel(QAbstractTableModel): ctx = SearchContext(search=txt, browser=self.browser) gui_hooks.browser_will_search(ctx) if ctx.card_ids is None: + ctx.search = self.browser.norm_search(ctx.search) ctx.card_ids = self.col.find_cards(ctx.search, order=ctx.order) gui_hooks.browser_did_search(ctx) self.cards = ctx.card_ids @@ -818,6 +819,12 @@ class Browser(QMainWindow): # no row change will fire self._onRowChanged(None, None) + def norm_search(self, search: str) -> str: + normed = self.col.backend.norm_search(search) + self._lastSearchTxt = normed + self.form.searchEdit.lineEdit().setText(normed) + return normed + def updateTitle(self): selected = len(self.form.tableView.selectionModel().selectedRows()) cur = len(self.model.cards) From 873e96b60acb198455d77ac00ae640500847f166 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Tue, 22 Dec 2020 11:32:53 +0100 Subject: [PATCH 04/16] Fix format and tests --- rslib/src/backend/mod.rs | 2 +- rslib/src/search/mod.rs | 2 +- rslib/src/search/writer.rs | 14 ++++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index de26e0db4..2cd25a486 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -32,7 +32,7 @@ use crate::{ }, sched::cutoff::local_minutes_west_for_stamp, sched::timespan::{answer_button_time, time_span}, - search::{SortMode, norm_search}, + search::{norm_search, SortMode}, stats::studied_today, sync::{ get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress, diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index 48e67666c..4d1ff02e2 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -1,8 +1,8 @@ mod cards; mod notes; mod parser; -mod writer; mod sqlwriter; +mod writer; pub use cards::SortMode; pub use writer::norm_search; diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 246f1fdc1..cf60f271e 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -2,8 +2,8 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::{ - err::Result, decks::DeckID as DeckIDType, + err::Result, notetype::NoteTypeID as NoteTypeIDType, search::parser::{parse, Node, PropertyKind, SearchNode, StateKind, TemplateKind}, }; @@ -12,8 +12,8 @@ pub fn norm_search(input: &str) -> Result { Ok(write_nodes(&parse(input)?)) } -fn write_nodes(nodes: &Vec) -> String { - nodes.into_iter().map(|node| write_node(node)).collect() +fn write_nodes(nodes: &[Node]) -> String { + nodes.iter().map(|node| write_node(node)).collect() } fn write_node(node: &Node) -> String { @@ -41,7 +41,9 @@ fn write_search_node(node: &SearchNode) -> String { NoteType(s) => format!("note:{}", &escape_suffix(s)), Rated { days, ease } => write_rated(days, ease), Tag(s) => format!("tag:{}", &escape_suffix(s)), - Duplicates { note_type_id, text } => format!("dupes:{},{}", note_type_id, escape_suffix(text)), + Duplicates { note_type_id, text } => { + format!("dupes:{},{}", note_type_id, escape_suffix(text)) + } State(k) => write_state(k), Flag(u) => format!("flag:{}", u), NoteIDs(s) => format!("nid:{}", s), @@ -58,7 +60,7 @@ fn escape(txt: &str) -> String { let txt = txt.replace("\"", "\\\"").replace(":", "\\:"); if txt.chars().any(|c| " \u{3000}()".contains(c)) { format!(r#""{}""#, txt) - } else if txt.chars().next() == Some('-') { + } else if txt.starts_with('-') { format!("\\{}", txt) } else { txt @@ -80,7 +82,7 @@ fn write_single_field(field: &str, text: &str, is_re: bool) -> String { let txt = format!("{}:{}{}", field, re, text).replace("\"", "\\\""); if txt.chars().any(|c| " \u{3000}()".contains(c)) { format!(r#""{}""#, txt) - } else if txt.chars().next() == Some('-') { + } else if txt.starts_with('-') { format!("\\{}", txt) } else { txt From ecd04f8a59a20adc7b3d14284b0f334ffe11e03a Mon Sep 17 00:00:00 2001 From: RumovZ Date: Tue, 22 Dec 2020 11:52:17 +0100 Subject: [PATCH 05/16] Don't escape isolated - --- rslib/src/search/writer.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index cf60f271e..98e91addc 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -60,7 +60,7 @@ fn escape(txt: &str) -> String { let txt = txt.replace("\"", "\\\"").replace(":", "\\:"); if txt.chars().any(|c| " \u{3000}()".contains(c)) { format!(r#""{}""#, txt) - } else if txt.starts_with('-') { + } else if txt.len() > 1 && txt.starts_with('-') { format!("\\{}", txt) } else { txt @@ -82,7 +82,7 @@ fn write_single_field(field: &str, text: &str, is_re: bool) -> String { let txt = format!("{}:{}{}", field, re, text).replace("\"", "\\\""); if txt.chars().any(|c| " \u{3000}()".contains(c)) { format!(r#""{}""#, txt) - } else if txt.starts_with('-') { + } else if txt.len() > 1 && txt.starts_with('-') { format!("\\{}", txt) } else { txt From 62753290d83376111006c14805f85a294fa305b9 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Tue, 29 Dec 2020 11:06:53 +0100 Subject: [PATCH 06/16] Make normalized search syntax more explicit Also fix a bug with NoCombining and WordBoundary searches. --- rslib/src/search/writer.rs | 85 ++++++++++++++------------------------ 1 file changed, 31 insertions(+), 54 deletions(-) diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 98e91addc..dab60aee0 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -19,8 +19,8 @@ fn write_nodes(nodes: &[Node]) -> String { fn write_node(node: &Node) -> String { use Node::*; match node { - And => " ".to_string(), - Or => " or ".to_string(), + And => " AND ".to_string(), + Or => " OR ".to_string(), Not(n) => format!("-{}", write_node(n)), Group(ns) => format!("({})", write_nodes(ns)), Search(n) => write_search_node(n), @@ -30,83 +30,60 @@ fn write_node(node: &Node) -> String { fn write_search_node(node: &SearchNode) -> String { use SearchNode::*; match node { - UnqualifiedText(s) => escape(s), + UnqualifiedText(s) => quote(&s.replace(":", "\\:")), SingleField { field, text, is_re } => write_single_field(field, text, *is_re), - AddedInDays(u) => format!("added:{}", u), - EditedInDays(u) => format!("edited:{}", u), + AddedInDays(u) => format!("\"added:{}\"", u), + EditedInDays(u) => format!("\"edited:{}\"", u), CardTemplate(t) => write_template(t), - Deck(s) => format!("deck:{}", &escape_suffix(s)), - DeckID(DeckIDType(i)) => format!("did:{}", i), - NoteTypeID(NoteTypeIDType(i)) => format!("mid:{}", i), - NoteType(s) => format!("note:{}", &escape_suffix(s)), + Deck(s) => quote(&format!("deck:{}", s)), + DeckID(DeckIDType(i)) => format!("\"did:{}\"", i), + NoteTypeID(NoteTypeIDType(i)) => format!("\"mid:{}\"", i), + NoteType(s) => quote(&format!("note:{}", s)), Rated { days, ease } => write_rated(days, ease), - Tag(s) => format!("tag:{}", &escape_suffix(s)), + Tag(s) => quote(&format!("tag:{}", s)), Duplicates { note_type_id, text } => { - format!("dupes:{},{}", note_type_id, escape_suffix(text)) + quote(&format!("dupes:{},{}", note_type_id, text)) } State(k) => write_state(k), - Flag(u) => format!("flag:{}", u), - NoteIDs(s) => format!("nid:{}", s), - CardIDs(s) => format!("cid:{}", s), + Flag(u) => format!("\"flag:{}\"", u), + NoteIDs(s) => format!("\"nid:{}\"", s), + CardIDs(s) => format!("\"cid:{}\"", s), Property { operator, kind } => write_prop(operator, kind), WholeCollection => "".to_string(), - Regex(s) => format!("re:{}", &escape_suffix(s)), - NoCombining(s) => format!("re:{}", &escape_suffix(s)), - WordBoundary(s) => format!("re:{}", &escape_suffix(s)), + Regex(s) => quote(&format!("re:{}", s)), + NoCombining(s) => quote(&format!("nc:{}", s)), + WordBoundary(s) => quote(&format!("w:{}", s)), } } -fn escape(txt: &str) -> String { - let txt = txt.replace("\"", "\\\"").replace(":", "\\:"); - if txt.chars().any(|c| " \u{3000}()".contains(c)) { - format!(r#""{}""#, txt) - } else if txt.len() > 1 && txt.starts_with('-') { - format!("\\{}", txt) - } else { - txt - } -} - -fn escape_suffix(txt: &str) -> String { - let txt = txt.replace("\"", "\\\""); - if txt.chars().any(|c| " \u{3000}()".contains(c)) { - format!(r#""{}""#, txt) - } else { - txt - } +/// Escape and wrap in double quotes. +fn quote(txt: &str) -> String { + format!("\"{}\"", txt.replace("\"", "\\\"")) } fn write_single_field(field: &str, text: &str, is_re: bool) -> String { - let field = field.replace(":", "\\:"); let re = if is_re { "re:" } else { "" }; - let txt = format!("{}:{}{}", field, re, text).replace("\"", "\\\""); - if txt.chars().any(|c| " \u{3000}()".contains(c)) { - format!(r#""{}""#, txt) - } else if txt.len() > 1 && txt.starts_with('-') { - format!("\\{}", txt) - } else { - txt - } + quote(&format!("{}:{}{}", field.replace(":", "\\:"), re, text)) } fn write_template(template: &TemplateKind) -> String { match template { - TemplateKind::Ordinal(u) => format!("card:{}", u), - TemplateKind::Name(s) => format!("card:{}", s), + TemplateKind::Ordinal(u) => format!("\"card:{}\"", u), + TemplateKind::Name(s) => format!("\"card:{}\"", s), } } fn write_rated(days: &u32, ease: &Option) -> String { match ease { - Some(u) => format!("rated:{}:{}", days, u), - None => format!("rated:{}", days), + Some(u) => format!("\"rated:{}:{}\"", days, u), + None => format!("\"rated:{}\"\"", days), } } fn write_state(kind: &StateKind) -> String { use StateKind::*; format!( - "is:{}", + "\"is:{}\"", match kind { New => "new", Review => "review", @@ -123,10 +100,10 @@ fn write_state(kind: &StateKind) -> String { fn write_prop(operator: &str, kind: &PropertyKind) -> String { use PropertyKind::*; match kind { - Due(i) => format!("prop:due{}{}", operator, i), - Interval(u) => format!("prop:ivl{}{}", operator, u), - Reps(u) => format!("prop:reps{}{}", operator, u), - Lapses(u) => format!("prop:lapses{}{}", operator, u), - Ease(f) => format!("prop:ease{}{}", operator, f), + Due(i) => format!("\"prop:due{}{}\"", operator, i), + Interval(u) => format!("\"prop:ivl{}{}\"", operator, u), + Reps(u) => format!("\"prop:reps{}{}\"", operator, u), + Lapses(u) => format!("\"prop:lapses{}{}\"", operator, u), + Ease(f) => format!("\"prop:ease{}{}\"", operator, f), } } From 95b4e4cc84e42ba915c492c3d873d9ce4bc5fa78 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Tue, 29 Dec 2020 11:18:49 +0100 Subject: [PATCH 07/16] Make function names more explicit and add docs --- qt/aqt/browser.py | 6 +++--- rslib/backend.proto | 2 +- rslib/src/backend/mod.rs | 6 +++--- rslib/src/search/mod.rs | 2 +- rslib/src/search/writer.rs | 8 +++++--- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index e3ff7d7f2..003388a88 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -188,7 +188,7 @@ class DataModel(QAbstractTableModel): ctx = SearchContext(search=txt, browser=self.browser) gui_hooks.browser_will_search(ctx) if ctx.card_ids is None: - ctx.search = self.browser.norm_search(ctx.search) + ctx.search = self.browser.normalize_search(ctx.search) ctx.card_ids = self.col.find_cards(ctx.search, order=ctx.order) gui_hooks.browser_did_search(ctx) self.cards = ctx.card_ids @@ -819,8 +819,8 @@ class Browser(QMainWindow): # no row change will fire self._onRowChanged(None, None) - def norm_search(self, search: str) -> str: - normed = self.col.backend.norm_search(search) + def normalize_search(self, search: str) -> str: + normed = self.col.backend.normalize_search(search) self._lastSearchTxt = normed self.form.searchEdit.lineEdit().setText(normed) return normed diff --git a/rslib/backend.proto b/rslib/backend.proto index 821336385..396ec4485 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -84,7 +84,7 @@ service BackendService { // searching - rpc NormSearch (String) returns (String); + rpc NormalizeSearch (String) returns (String); rpc SearchCards (SearchCardsIn) returns (SearchCardsOut); rpc SearchNotes (SearchNotesIn) returns (SearchNotesOut); rpc FindAndReplace (FindAndReplaceIn) returns (UInt32); diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 2cd25a486..0efee24f1 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -32,7 +32,7 @@ use crate::{ }, sched::cutoff::local_minutes_west_for_stamp, sched::timespan::{answer_button_time, time_span}, - search::{norm_search, SortMode}, + search::{normalize_search, SortMode}, stats::studied_today, sync::{ get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress, @@ -393,8 +393,8 @@ impl BackendService for Backend { // searching //----------------------------------------------- - fn norm_search(&self, input: pb::String) -> Result { - Ok(norm_search(&input.val)?.into()) + fn normalize_search(&self, input: pb::String) -> Result { + Ok(normalize_search(&input.val)?.into()) } fn search_cards(&self, input: pb::SearchCardsIn) -> Result { diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index 4d1ff02e2..d409c05eb 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -5,4 +5,4 @@ mod sqlwriter; mod writer; pub use cards::SortMode; -pub use writer::norm_search; +pub use writer::normalize_search; diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index dab60aee0..8944a00a5 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -8,7 +8,9 @@ use crate::{ search::parser::{parse, Node, PropertyKind, SearchNode, StateKind, TemplateKind}, }; -pub fn norm_search(input: &str) -> Result { +/// 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)?)) } @@ -48,7 +50,7 @@ fn write_search_node(node: &SearchNode) -> String { Flag(u) => format!("\"flag:{}\"", u), NoteIDs(s) => format!("\"nid:{}\"", s), CardIDs(s) => format!("\"cid:{}\"", s), - Property { operator, kind } => write_prop(operator, kind), + Property { operator, kind } => write_property(operator, kind), WholeCollection => "".to_string(), Regex(s) => quote(&format!("re:{}", s)), NoCombining(s) => quote(&format!("nc:{}", s)), @@ -97,7 +99,7 @@ fn write_state(kind: &StateKind) -> String { ) } -fn write_prop(operator: &str, kind: &PropertyKind) -> String { +fn write_property(operator: &str, kind: &PropertyKind) -> String { use PropertyKind::*; match kind { Due(i) => format!("\"prop:due{}{}\"", operator, i), From 713db05f2706c0e127f1b43b3643d0aa1320bcfe Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 6 Jan 2021 13:57:35 +0100 Subject: [PATCH 08/16] Implement negate_search --- rslib/backend.proto | 1 + rslib/src/backend/mod.rs | 7 ++++++- rslib/src/search/mod.rs | 2 +- rslib/src/search/writer.rs | 28 ++++++++++++++++++++++++++-- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/rslib/backend.proto b/rslib/backend.proto index 396ec4485..b006b3441 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -87,6 +87,7 @@ service BackendService { rpc NormalizeSearch (String) returns (String); rpc SearchCards (SearchCardsIn) returns (SearchCardsOut); rpc SearchNotes (SearchNotesIn) returns (SearchNotesOut); + rpc NegateSearch (String) returns (String); rpc FindAndReplace (FindAndReplaceIn) returns (UInt32); // scheduling diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 0efee24f1..6abaa45ae 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -32,7 +32,9 @@ use crate::{ }, sched::cutoff::local_minutes_west_for_stamp, sched::timespan::{answer_button_time, time_span}, - search::{normalize_search, SortMode}, + search::{ + concatenate_searches, negate_search, normalize_search, replace_search_term, SortMode, + }, stats::studied_today, sync::{ get_remote_sync_meta, sync_abort, sync_login, FullSyncProgress, NormalSyncProgress, @@ -430,6 +432,9 @@ impl BackendService for Backend { }) } + fn negate_search(&self, input: pb::String) -> Result { + Ok(negate_search(&input.val)?.into()) + } fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> BackendResult { let mut search = if input.regex { input.search diff --git a/rslib/src/search/mod.rs b/rslib/src/search/mod.rs index d409c05eb..aed103b4d 100644 --- a/rslib/src/search/mod.rs +++ b/rslib/src/search/mod.rs @@ -5,4 +5,4 @@ mod sqlwriter; mod writer; pub use cards::SortMode; -pub use writer::normalize_search; +pub use writer::{concatenate_searches, negate_search, normalize_search, replace_search_term}; diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 8944a00a5..9497decd2 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -14,8 +14,32 @@ pub fn normalize_search(input: &str) -> Result { Ok(write_nodes(&parse(input)?)) } -fn write_nodes(nodes: &[Node]) -> String { - nodes.iter().map(|node| write_node(node)).collect() +/// 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)))) + }) +} +} + +} + +fn write_nodes<'a, I>(nodes: I) -> String +where + I: IntoIterator>, +{ + nodes.into_iter().map(|node| write_node(node)).collect() } fn write_node(node: &Node) -> String { From 79d0b5496b9f0203474a997406d1c0fb148ab03c Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 6 Jan 2021 14:00:47 +0100 Subject: [PATCH 09/16] Implement concatenate_searches Fix minor stuff in writer.rs. --- rslib/backend.proto | 1 + rslib/src/backend/mod.rs | 4 ++++ rslib/src/search/writer.rs | 35 ++++++++++++++++++++++++++++++----- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/rslib/backend.proto b/rslib/backend.proto index b006b3441..056536afc 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -88,6 +88,7 @@ service BackendService { rpc SearchCards (SearchCardsIn) returns (SearchCardsOut); rpc SearchNotes (SearchNotesIn) returns (SearchNotesOut); rpc NegateSearch (String) returns (String); + rpc ConcatenateSearches (ConcatenateSearchesIn) returns (String); rpc FindAndReplace (FindAndReplaceIn) returns (UInt32); // scheduling diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 6abaa45ae..b76df4359 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -435,6 +435,10 @@ 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, &input.searches)?.into()) + } + fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> BackendResult { let mut search = if input.regex { input.search diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 9497decd2..1a866b9cf 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -2,11 +2,13 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::{ + backend_proto::concatenate_searches_in::Separator, decks::DeckID as DeckIDType, err::Result, notetype::NoteTypeID as NoteTypeIDType, search::parser::{parse, Node, PropertyKind, SearchNode, StateKind, TemplateKind}, }; +use itertools::Itertools; /// Take an Anki-style search string and convert it into an equivalent /// search string with normalized syntax. @@ -31,6 +33,26 @@ pub fn negate_search(input: &str) -> Result { 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: i32, searches: &[String]) -> Result { + let bool_node = vec![if let Some(Separator::Or) = Separator::from_i32(sep) { + Node::Or + } else { + Node::And + }]; + Ok(write_nodes( + searches + .iter() + .map(|s: &String| -> Result> { parse(s) }) + .collect::>>>()? + .iter() + .filter(|v| v[0] != Node::Search(SearchNode::WholeCollection)) + .intersperse(&&bool_node) + .flat_map(|v| v.iter()), + )) } } @@ -67,9 +89,7 @@ fn write_search_node(node: &SearchNode) -> String { NoteType(s) => quote(&format!("note:{}", s)), Rated { days, ease } => write_rated(days, ease), Tag(s) => quote(&format!("tag:{}", s)), - Duplicates { note_type_id, text } => { - quote(&format!("dupes:{},{}", note_type_id, text)) - } + Duplicates { note_type_id, text } => quote(&format!("dupes:{},{}", note_type_id, text)), State(k) => write_state(k), Flag(u) => format!("\"flag:{}\"", u), NoteIDs(s) => format!("\"nid:{}\"", s), @@ -89,7 +109,12 @@ fn quote(txt: &str) -> String { fn write_single_field(field: &str, text: &str, is_re: bool) -> String { let re = if is_re { "re:" } else { "" }; - quote(&format!("{}:{}{}", field.replace(":", "\\:"), re, text)) + let text = if !is_re && text.starts_with("re:") { + text.replacen(":", "\\:", 1) + } else { + text.to_string() + }; + quote(&format!("{}:{}{}", field.replace(":", "\\:"), re, &text)) } fn write_template(template: &TemplateKind) -> String { @@ -102,7 +127,7 @@ fn write_template(template: &TemplateKind) -> String { fn write_rated(days: &u32, ease: &Option) -> String { match ease { Some(u) => format!("\"rated:{}:{}\"", days, u), - None => format!("\"rated:{}\"\"", days), + None => format!("\"rated:{}\"", days), } } From 84061a6e6dfb8b638677677d42ba0039e26bf427 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 6 Jan 2021 14:03:43 +0100 Subject: [PATCH 10/16] Implement replace_search_term --- rslib/backend.proto | 15 +++++++++++++++ rslib/src/backend/mod.rs | 5 +++++ rslib/src/search/parser.rs | 8 ++++---- rslib/src/search/writer.rs | 26 ++++++++++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/rslib/backend.proto b/rslib/backend.proto index 056536afc..2cfcd1dd7 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -89,6 +89,7 @@ service BackendService { 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); // scheduling @@ -755,6 +756,20 @@ message BuiltinSearchOrder { bool reverse = 2; } +message ConcatenateSearchesIn { + enum Separator { + AND = 0; + OR = 1; + } + Separator sep = 1; + repeated string searches = 2; +} + +message ReplaceSearchTermIn { + string search = 1; + string replacement = 2; +} + message CloseCollectionIn { bool downgrade_to_schema11 = 1; } diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index b76df4359..485666f88 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -435,10 +435,15 @@ 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, &input.searches)?.into()) } + fn replace_search_term(&self, input: pb::ReplaceSearchTermIn) -> Result { + Ok(replace_search_term(&input.search, &input.replacement)?.into()) + } + fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> BackendResult { let mut search = if input.regex { input.search diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 2be9a3d3b..b82f28237 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -50,7 +50,7 @@ pub(super) enum Node<'a> { Search(SearchNode<'a>), } -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub(super) enum SearchNode<'a> { // text without a colon UnqualifiedText(Cow<'a, str>), @@ -90,7 +90,7 @@ pub(super) enum SearchNode<'a> { WordBoundary(Cow<'a, str>), } -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub(super) enum PropertyKind { Due(i32), Interval(u32), @@ -99,7 +99,7 @@ pub(super) enum PropertyKind { Ease(f32), } -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub(super) enum StateKind { New, Review, @@ -111,7 +111,7 @@ pub(super) enum StateKind { Suspended, } -#[derive(Debug, PartialEq)] +#[derive(Debug, PartialEq, Clone)] pub(super) enum TemplateKind<'a> { Ordinal(u16), Name(Cow<'a, str>), diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 1a866b9cf..773270e2a 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -9,6 +9,7 @@ use crate::{ search::parser::{parse, Node, PropertyKind, SearchNode, StateKind, TemplateKind}, }; use itertools::Itertools; +use std::mem; /// Take an Anki-style search string and convert it into an equivalent /// search string with normalized syntax. @@ -55,6 +56,31 @@ pub fn concatenate_searches(sep: i32, searches: &[String]) -> Result { )) } +/// 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[..] { + 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 { + Node::Not(n) => update_node(n, new_node), + Node::Group(ns) => update_node_vec(ns, new_node), + Node::Search(n) => { + if mem::discriminant(n) == mem::discriminant(new_node) { + *n = new_node.clone(); + } + } + _ => (), + } + } + old_nodes.iter_mut().for_each(|n| update_node(n, new_node)); + } + update_node_vec(&mut nodes, search_node); + } + Ok(write_nodes(&nodes)) } fn write_nodes<'a, I>(nodes: I) -> String From 6d6cd87b8fee6830818db7a2211c7e49db3b7a94 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 6 Jan 2021 14:05:28 +0100 Subject: [PATCH 11/16] Adjust frontend to normalized search syntax --- qt/aqt/browser.py | 45 +++++++++++++++++++++------------------------ 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 003388a88..0771e7915 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -1214,14 +1214,11 @@ QTableView {{ gridline-color: {grid} }} if i % 2 == 0: txt += a + ":" else: - txt += re.sub(r"[*_\\]", r"\\\g<0>", a) - for c in '  ()"': - if c in txt: - txt = '"{}"'.format(txt.replace('"', '\\"')) - break + txt += re.sub(r'["*_\\]', r"\\\g<0>", a) + txt = '"{}"'.format(txt.replace('"', '\\"')) items.append(txt) txt = "" - txt = " ".join(items) + txt = " AND ".join(items) # is there something to replace or append with? if txt: if self.mw.app.keyboardModifiers() & Qt.AltModifier: @@ -1230,9 +1227,9 @@ QTableView {{ gridline-color: {grid} }} cur = str(self.form.searchEdit.lineEdit().text()) if cur and cur != self._searchPrompt: if self.mw.app.keyboardModifiers() & Qt.ControlModifier: - txt = cur + " " + txt + txt = cur + " AND " + txt elif self.mw.app.keyboardModifiers() & Qt.ShiftModifier: - txt = cur + " or " + txt + txt = cur + " OR " + txt self.form.searchEdit.lineEdit().setText(txt) self.onSearchActivated() @@ -1253,7 +1250,7 @@ QTableView {{ gridline-color: {grid} }} return self._simpleFilters( ( (tr(TR.BROWSING_WHOLE_COLLECTION), ""), - (tr(TR.BROWSING_CURRENT_DECK), "deck:current"), + (tr(TR.BROWSING_CURRENT_DECK), '"deck:current"'), ) ) @@ -1262,9 +1259,9 @@ QTableView {{ gridline-color: {grid} }} subm.addChild( self._simpleFilters( ( - (tr(TR.BROWSING_ADDED_TODAY), "added:1"), - (tr(TR.BROWSING_STUDIED_TODAY), "rated:1"), - (tr(TR.BROWSING_AGAIN_TODAY), "rated:1:1"), + (tr(TR.BROWSING_ADDED_TODAY), '"added:1"'), + (tr(TR.BROWSING_STUDIED_TODAY), '"rated:1"'), + (tr(TR.BROWSING_AGAIN_TODAY), '"rated:1:1"'), ) ) ) @@ -1275,20 +1272,20 @@ QTableView {{ gridline-color: {grid} }} subm.addChild( self._simpleFilters( ( - (tr(TR.ACTIONS_NEW), "is:new"), - (tr(TR.SCHEDULING_LEARNING), "is:learn"), - (tr(TR.SCHEDULING_REVIEW), "is:review"), - (tr(TR.FILTERING_IS_DUE), "is:due"), + (tr(TR.ACTIONS_NEW), '"is:new"'), + (tr(TR.SCHEDULING_LEARNING), '"is:learn"'), + (tr(TR.SCHEDULING_REVIEW), '"is:review"'), + (tr(TR.FILTERING_IS_DUE), '"is:due"'), None, - (tr(TR.BROWSING_SUSPENDED), "is:suspended"), - (tr(TR.BROWSING_BURIED), "is:buried"), + (tr(TR.BROWSING_SUSPENDED), '"is:suspended"'), + (tr(TR.BROWSING_BURIED), '"is:buried"'), None, - (tr(TR.ACTIONS_RED_FLAG), "flag:1"), - (tr(TR.ACTIONS_ORANGE_FLAG), "flag:2"), - (tr(TR.ACTIONS_GREEN_FLAG), "flag:3"), - (tr(TR.ACTIONS_BLUE_FLAG), "flag:4"), - (tr(TR.BROWSING_NO_FLAG), "flag:0"), - (tr(TR.BROWSING_ANY_FLAG), "-flag:0"), + (tr(TR.ACTIONS_RED_FLAG), '"flag:1"'), + (tr(TR.ACTIONS_ORANGE_FLAG), '"flag:2"'), + (tr(TR.ACTIONS_GREEN_FLAG), '"flag:3"'), + (tr(TR.ACTIONS_BLUE_FLAG), '"flag:4"'), + (tr(TR.BROWSING_NO_FLAG), '"flag:0"'), + (tr(TR.BROWSING_ANY_FLAG), '"-flag:0"'), ) ) ) From 07499dd890483e4d4bdadf0f4c3e3d491848806d Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 6 Jan 2021 18:32:29 +0100 Subject: [PATCH 12/16] Implement search replacing via Ctrl+Shift combo Also switch to using the new backend functions for concatenating and negating searches. --- qt/aqt/browser.py | 26 ++++++++++++++------------ rslib/src/backend/mod.rs | 2 +- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 0771e7915..5bfd07c93 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -20,7 +20,7 @@ from anki.consts import * from anki.lang import without_unicode_isolation from anki.models import NoteType from anki.notes import Note -from anki.rsbackend import TR, DeckTreeNode, InvalidInput +from anki.rsbackend import TR, DeckTreeNode, InvalidInput, pb from anki.stats import CardStats from anki.utils import htmlToTextLine, ids2str, isMac, isWin from aqt import AnkiQt, gui_hooks @@ -1219,17 +1219,19 @@ QTableView {{ gridline-color: {grid} }} items.append(txt) txt = "" txt = " AND ".join(items) - # is there something to replace or append with? - if txt: - if self.mw.app.keyboardModifiers() & Qt.AltModifier: - txt = "-" + txt - # is there something to replace or append to? - cur = str(self.form.searchEdit.lineEdit().text()) - if cur and cur != self._searchPrompt: - if self.mw.app.keyboardModifiers() & Qt.ControlModifier: - txt = cur + " AND " + txt - elif self.mw.app.keyboardModifiers() & Qt.ShiftModifier: - txt = cur + " OR " + txt + if self.mw.app.keyboardModifiers() & Qt.AltModifier: + txt = self.col.backend.negate_search(txt) + cur = str(self.form.searchEdit.lineEdit().text()) + if cur != self._searchPrompt: + mods = self.mw.app.keyboardModifiers() + if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: + txt = self.col.backend.replace_search_term(search=cur, replacement=txt) + elif mods & Qt.ControlModifier: + and_sep = pb.ConcatenateSearchesIn.Separator.AND + txt = self.col.backend.concatenate_searches(sep=and_sep, searches=[cur, txt]) + elif mods & Qt.ShiftModifier: + or_sep = pb.ConcatenateSearchesIn.Separator.OR + txt = self.col.backend.concatenate_searches(sep=or_sep, searches=[cur, txt]) self.form.searchEdit.lineEdit().setText(txt) self.onSearchActivated() diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 485666f88..0762ee2ce 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -435,7 +435,7 @@ 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, &input.searches)?.into()) } From d9f7d2e70d20d675add54a1e80da6a17dfbd0d8b Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 6 Jan 2021 19:39:34 +0100 Subject: [PATCH 13/16] Add tests for writer.rs Also minor adjustments to concatenate_searches. --- qt/aqt/browser.py | 10 ++-- rslib/src/search/writer.rs | 97 +++++++++++++++++++++++++++++++++++--- 2 files changed, 97 insertions(+), 10 deletions(-) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 5bfd07c93..d19dab499 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -1227,11 +1227,13 @@ QTableView {{ gridline-color: {grid} }} if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: txt = self.col.backend.replace_search_term(search=cur, replacement=txt) elif mods & Qt.ControlModifier: - and_sep = pb.ConcatenateSearchesIn.Separator.AND - txt = self.col.backend.concatenate_searches(sep=and_sep, searches=[cur, txt]) + txt = self.col.backend.concatenate_searches( + sep=pb.ConcatenateSearchesIn.Separator.AND, searches=[cur, txt] + ) elif mods & Qt.ShiftModifier: - or_sep = pb.ConcatenateSearchesIn.Separator.OR - txt = self.col.backend.concatenate_searches(sep=or_sep, searches=[cur, txt]) + txt = self.col.backend.concatenate_searches( + sep=pb.ConcatenateSearchesIn.Separator.OR, searches=[cur, txt] + ) self.form.searchEdit.lineEdit().setText(txt) self.onSearchActivated() diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 773270e2a..613dd539c 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -4,7 +4,7 @@ use crate::{ backend_proto::concatenate_searches_in::Separator, decks::DeckID as DeckIDType, - err::Result, + err::{AnkiError, Result}, notetype::NoteTypeID as NoteTypeIDType, search::parser::{parse, Node, PropertyKind, SearchNode, StateKind, TemplateKind}, }; @@ -39,15 +39,15 @@ pub fn negate_search(input: &str) -> Result { /// are separated by the provided boolean operator. /// Empty searches (whole collection) are left out. pub fn concatenate_searches(sep: i32, searches: &[String]) -> Result { - let bool_node = vec![if let Some(Separator::Or) = Separator::from_i32(sep) { - Node::Or - } else { - Node::And + let bool_node = vec![match Separator::from_i32(sep) { + Some(Separator::Or) => Node::Or, + Some(Separator::And) => Node::And, + None => return Err(AnkiError::SearchError(None)), }]; Ok(write_nodes( searches .iter() - .map(|s: &String| -> Result> { parse(s) }) + .map(|s| parse(s)) .collect::>>>()? .iter() .filter(|v| v[0] != Node::Search(SearchNode::WholeCollection)) @@ -184,3 +184,88 @@ fn write_property(operator: &str, kind: &PropertyKind) -> String { Ease(f) => format!("\"prop:ease{}{}\"", operator, f), } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn normalizing() -> Result<()> { + assert_eq!(r#""(" AND "-""#, normalize_search(r"\( \-").unwrap()); + assert_eq!(r#""deck::""#, normalize_search(r"deck:\:").unwrap()); + assert_eq!(r#""\*" OR "\:""#, normalize_search(r"\* or \:").unwrap()); + assert_eq!( + r#""field:foo""#, + normalize_search(r#"field:"foo""#).unwrap() + ); + assert_eq!( + r#""prop:ease>1""#, + normalize_search("prop:ease>1.0").unwrap() + ); + + 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!( + r#""foo" AND "bar""#, + concatenate_searches( + Separator::And as i32, + &["foo".to_string(), "bar".to_string()] + ) + .unwrap() + ); + assert_eq!( + r#""foo" OR "bar""#, + concatenate_searches( + Separator::Or as i32, + &["foo".to_string(), "".to_string(), "bar".to_string()] + ) + .unwrap() + ); + assert_eq!( + "", + concatenate_searches(Separator::Or as i32, &["".to_string()]).unwrap() + ); + assert_eq!("", concatenate_searches(Separator::Or as i32, &[]).unwrap()); + + Ok(()) + } + + #[test] + fn replacing() -> Result<()> { + assert_eq!( + r#""deck:foo" AND "bar""#, + replace_search_term("deck:baz bar", "deck:foo").unwrap() + ); + assert_eq!( + r#""tag:baz" OR "tag:baz""#, + replace_search_term("tag:foo Or tag:bar", "tag:baz").unwrap() + ); + assert_eq!( + 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() + ); + assert_eq!( + r#""added:1""#, + replace_search_term("added:1", "is:due").unwrap() + ); + + Ok(()) + } +} From 539f7015734ba97c355311ad1e2a28da9a39e4cd Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 6 Jan 2021 20:25:13 +0100 Subject: [PATCH 14/16] Add exception handling for filter modifications --- qt/aqt/browser.py | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index d19dab499..d17167abe 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -1219,23 +1219,27 @@ QTableView {{ gridline-color: {grid} }} items.append(txt) txt = "" txt = " AND ".join(items) - if self.mw.app.keyboardModifiers() & Qt.AltModifier: - txt = self.col.backend.negate_search(txt) - cur = str(self.form.searchEdit.lineEdit().text()) - if cur != self._searchPrompt: - mods = self.mw.app.keyboardModifiers() - if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: - txt = self.col.backend.replace_search_term(search=cur, replacement=txt) - elif mods & Qt.ControlModifier: - txt = self.col.backend.concatenate_searches( - sep=pb.ConcatenateSearchesIn.Separator.AND, searches=[cur, txt] - ) - elif mods & Qt.ShiftModifier: - txt = self.col.backend.concatenate_searches( - sep=pb.ConcatenateSearchesIn.Separator.OR, searches=[cur, txt] - ) - self.form.searchEdit.lineEdit().setText(txt) - self.onSearchActivated() + try: + if self.mw.app.keyboardModifiers() & Qt.AltModifier: + txt = self.col.backend.negate_search(txt) + cur = str(self.form.searchEdit.lineEdit().text()) + if cur != self._searchPrompt: + mods = self.mw.app.keyboardModifiers() + if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: + txt = self.col.backend.replace_search_term(search=cur, replacement=txt) + elif mods & Qt.ControlModifier: + txt = self.col.backend.concatenate_searches( + sep=pb.ConcatenateSearchesIn.Separator.AND, searches=[cur, txt] + ) + elif mods & Qt.ShiftModifier: + txt = self.col.backend.concatenate_searches( + sep=pb.ConcatenateSearchesIn.Separator.OR, searches=[cur, txt] + ) + except InvalidInput as e: + showWarning(str(e)) + else: + self.form.searchEdit.lineEdit().setText(txt) + self.onSearchActivated() def _simpleFilters(self, items): ml = MenuList() From cbf68da6305ff9f00ef535ff065a9e5fd940900e Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 6 Jan 2021 20:35:11 +0100 Subject: [PATCH 15/16] Format --- qt/aqt/browser.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index d17167abe..e067b3f44 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -1226,7 +1226,9 @@ QTableView {{ gridline-color: {grid} }} if cur != self._searchPrompt: mods = self.mw.app.keyboardModifiers() if mods & Qt.ControlModifier and mods & Qt.ShiftModifier: - txt = self.col.backend.replace_search_term(search=cur, replacement=txt) + txt = self.col.backend.replace_search_term( + search=cur, replacement=txt + ) elif mods & Qt.ControlModifier: txt = self.col.backend.concatenate_searches( sep=pb.ConcatenateSearchesIn.Separator.AND, searches=[cur, txt] From ebe535e42f1b3f22bc2ac00602f831f66fee82be Mon Sep 17 00:00:00 2001 From: RumovZ Date: Wed, 6 Jan 2021 23:29:09 +0100 Subject: [PATCH 16/16] Add Position in PropertyKind match of writer.rs --- rslib/src/search/writer.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 613dd539c..2867a64b8 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -182,6 +182,7 @@ fn write_property(operator: &str, kind: &PropertyKind) -> String { Reps(u) => format!("\"prop:reps{}{}\"", operator, u), Lapses(u) => format!("\"prop:lapses{}{}\"", operator, u), Ease(f) => format!("\"prop:ease{}{}\"", operator, f), + Position(u) => format!("\"prop:pos{}{}\"", operator, u), } }