diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 496f11855..1f6c7e308 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -240,6 +240,7 @@ Thomas Rixen Siyuan Mattuwu Yan Lee Doughty <32392044+leedoughty@users.noreply.github.com> memchr +Max Romanowski ******************** diff --git a/rslib/src/search/builder.rs b/rslib/src/search/builder.rs index a76af0560..73b08ff62 100644 --- a/rslib/src/search/builder.rs +++ b/rslib/src/search/builder.rs @@ -175,6 +175,7 @@ impl SearchNode { Self::Tag { tag: escape_anki_wildcards_for_search_node(name), is_re: false, + is_nc: false, } } diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 33c1a4622..df4d497bc 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -57,6 +57,7 @@ pub enum SearchNode { field: String, text: String, is_re: bool, + is_nc: bool, }, AddedInDays(u32), EditedInDays(u32), @@ -78,6 +79,7 @@ pub enum SearchNode { Tag { tag: String, is_re: bool, + is_nc: bool, }, Duplicates { notetype_id: NotetypeId, @@ -374,11 +376,13 @@ fn parse_tag(s: &str) -> ParseResult<'_, SearchNode> { SearchNode::Tag { tag: unescape_quotes(re), is_re: true, + is_nc: false, } } else { SearchNode::Tag { tag: unescape(s)?, is_re: false, + is_nc: false, } }) } @@ -671,12 +675,21 @@ fn parse_single_field<'a>(key: &'a str, val: &'a str) -> ParseResult<'a, SearchN field: unescape(key)?, text: unescape_quotes(stripped), is_re: true, + is_nc: false, + } + } else if let Some(stripped) = val.strip_prefix("nc:") { + SearchNode::SingleField { + field: unescape(key)?, + text: unescape_quotes(stripped), + is_re: false, + is_nc: true, } } else { SearchNode::SingleField { field: unescape(key)?, text: unescape(val)?, is_re: false, + is_nc: false, } }) } @@ -807,6 +820,7 @@ mod test { field: "foo".into(), text: "bar baz".into(), is_re: false, + is_nc: false, }) ]))), Or, @@ -819,7 +833,18 @@ mod test { vec![Search(SingleField { field: "foo".into(), text: "bar".into(), - is_re: true + is_re: true, + is_nc: false + })] + ); + + assert_eq!( + parse("foo:nc:bar")?, + vec![Search(SingleField { + field: "foo".into(), + text: "bar".into(), + is_re: false, + is_nc: true })] ); @@ -829,7 +854,8 @@ mod test { vec![Search(SingleField { field: "field".into(), text: "va\"lue".into(), - is_re: false + is_re: false, + is_nc: false })] ); assert_eq!(parse(r#""field:va\"lue""#)?, parse(r#"field:"va\"lue""#)?,); @@ -906,14 +932,16 @@ mod test { parse("tag:hard")?, vec![Search(Tag { tag: "hard".into(), - is_re: false + is_re: false, + is_nc: false })] ); assert_eq!( parse(r"tag:re:\\")?, vec![Search(Tag { tag: r"\\".into(), - is_re: true + is_re: true, + is_nc: false })] ); assert_eq!( diff --git a/rslib/src/search/service/search_node.rs b/rslib/src/search/service/search_node.rs index 1851a28f7..712a6ab54 100644 --- a/rslib/src/search/service/search_node.rs +++ b/rslib/src/search/service/search_node.rs @@ -41,6 +41,7 @@ impl TryFrom for Node { field: escape_anki_wildcards_for_search_node(&s), text: "_*".to_string(), is_re: false, + is_nc: false, }), Filter::Rated(rated) => Node::Search(SearchNode::Rated { days: rated.days, @@ -108,6 +109,7 @@ impl TryFrom for Node { field: escape_anki_wildcards(&field.field_name), text: escape_anki_wildcards(&field.text), is_re: field.is_re, + is_nc: false, }), Filter::LiteralText(text) => { let text = escape_anki_wildcards(&text); diff --git a/rslib/src/search/sqlwriter.rs b/rslib/src/search/sqlwriter.rs index 542dba4fc..3d7f1e3dc 100644 --- a/rslib/src/search/sqlwriter.rs +++ b/rslib/src/search/sqlwriter.rs @@ -138,9 +138,12 @@ impl SqlWriter<'_> { false, )? } - SearchNode::SingleField { field, text, is_re } => { - self.write_field(&norm(field), &self.norm_note(text), *is_re)? - } + SearchNode::SingleField { + field, + text, + is_re, + is_nc, + } => self.write_field(&norm(field), &self.norm_note(text), *is_re, *is_nc)?, SearchNode::Duplicates { notetype_id, text } => { self.write_dupe(*notetype_id, &self.norm_note(text))? } @@ -180,7 +183,7 @@ impl SqlWriter<'_> { SearchNode::Notetype(notetype) => self.write_notetype(&norm(notetype)), SearchNode::Rated { days, ease } => self.write_rated(">", -i64::from(*days), ease)?, - SearchNode::Tag { tag, is_re } => self.write_tag(&norm(tag), *is_re), + SearchNode::Tag { tag, is_re, is_nc } => self.write_tag(&norm(tag), *is_re, *is_nc), SearchNode::State(state) => self.write_state(state)?, SearchNode::Flag(flag) => { write!(self.sql, "(c.flags & 7) == {flag}").unwrap(); @@ -296,7 +299,7 @@ impl SqlWriter<'_> { Ok(()) } - fn write_tag(&mut self, tag: &str, is_re: bool) { + fn write_tag(&mut self, tag: &str, is_re: bool, _is_nc: bool) { if is_re { self.args.push(format!("(?i){tag}")); write!(self.sql, "regexp_tags(?{}, n.tags)", self.args.len()).unwrap(); @@ -567,7 +570,7 @@ impl SqlWriter<'_> { } } - fn write_field(&mut self, field_name: &str, val: &str, is_re: bool) -> Result<()> { + fn write_field(&mut self, field_name: &str, val: &str, is_re: bool, is_nc: bool) -> Result<()> { if matches!(field_name, "*" | "_*" | "*_") { if is_re { self.write_all_fields_regexp(val); @@ -577,6 +580,8 @@ impl SqlWriter<'_> { Ok(()) } else if is_re { self.write_single_field_regexp(field_name, val) + } else if is_nc { + self.write_single_field_nc(field_name, val) } else { self.write_single_field(field_name, val) } @@ -592,6 +597,58 @@ impl SqlWriter<'_> { write!(self.sql, "regexp_fields(?{}, n.flds)", self.args.len()).unwrap(); } + fn write_single_field_nc(&mut self, field_name: &str, val: &str) -> Result<()> { + let field_indicies_by_notetype = self.num_fields_and_fields_indices_by_notetype( + field_name, + matches!(val, "*" | "_*" | "*_"), + )?; + if field_indicies_by_notetype.is_empty() { + write!(self.sql, "false").unwrap(); + return Ok(()); + } + + let val = to_sql(val); + let val = without_combining(&val); + self.args.push(val.into()); + let arg_idx = self.args.len(); + let field_idx_str = format!("' || ?{arg_idx} || '"); + let other_idx_str = "%".to_string(); + + let notetype_clause = |ctx: &FieldQualifiedSearchContext| -> String { + let field_index_clause = |range: &Range| { + let f = (0..ctx.total_fields_in_note) + .filter_map(|i| { + if i as u32 == range.start { + Some(&field_idx_str) + } else if range.contains(&(i as u32)) { + None + } else { + Some(&other_idx_str) + } + }) + .join("\x1f"); + format!( + "coalesce(process_text(n.flds, {}), n.flds) like '{f}' escape '\\'", + ProcessTextFlags::NoCombining.bits() + ) + }; + + let all_field_clauses = ctx + .field_ranges_to_search + .iter() + .map(field_index_clause) + .join(" or "); + format!("(n.mid = {mid} and ({all_field_clauses}))", mid = ctx.ntid) + }; + let all_notetype_clauses = field_indicies_by_notetype + .iter() + .map(notetype_clause) + .join(" or "); + write!(self.sql, "({all_notetype_clauses})").unwrap(); + + Ok(()) + } + fn write_single_field_regexp(&mut self, field_name: &str, val: &str) -> Result<()> { let field_indicies_by_notetype = self.fields_indices_by_notetype(field_name)?; if field_indicies_by_notetype.is_empty() { @@ -1116,6 +1173,20 @@ mod test { vec!["(?i)te.*st".into()] ) ); + // field search with no-combine + assert_eq!( + s(ctx, "front:nc:frânçais"), + ( + concat!( + "(((n.mid = 1581236385344 and (coalesce(process_text(n.flds, 1), n.flds) like '' || ?1 || '\u{1f}%' escape '\\')) or ", + "(n.mid = 1581236385345 and (coalesce(process_text(n.flds, 1), n.flds) like '' || ?1 || '\u{1f}%\u{1f}%' escape '\\')) or ", + "(n.mid = 1581236385346 and (coalesce(process_text(n.flds, 1), n.flds) like '' || ?1 || '\u{1f}%' escape '\\')) or ", + "(n.mid = 1581236385347 and (coalesce(process_text(n.flds, 1), n.flds) like '' || ?1 || '\u{1f}%' escape '\\'))))" + ) + .into(), + vec!["francais".into()] + ) + ); // all field search assert_eq!( s(ctx, "*:te*st"), diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 3bbe6fd0a..0d30abc15 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -69,7 +69,12 @@ fn write_search_node(node: &SearchNode) -> String { use SearchNode::*; match node { UnqualifiedText(s) => maybe_quote(&s.replace(':', "\\:")), - SingleField { field, text, is_re } => write_single_field(field, text, *is_re), + SingleField { + field, + text, + is_re, + is_nc, + } => write_single_field(field, text, *is_re, *is_nc), AddedInDays(u) => format!("added:{u}"), EditedInDays(u) => format!("edited:{u}"), IntroducedInDays(u) => format!("introduced:{u}"), @@ -81,7 +86,7 @@ fn write_search_node(node: &SearchNode) -> String { NotetypeId(NotetypeIdType(i)) => format!("mid:{i}"), Notetype(s) => maybe_quote(&format!("note:{s}")), Rated { days, ease } => write_rated(days, ease), - Tag { tag, is_re } => write_single_field("tag", tag, *is_re), + Tag { tag, is_re, is_nc } => write_single_field("tag", tag, *is_re, *is_nc), Duplicates { notetype_id, text } => write_dupe(notetype_id, text), State(k) => write_state(k), Flag(u) => format!("flag:{u}"), @@ -116,14 +121,25 @@ fn needs_quotation(txt: &str) -> bool { } /// Also used by tag search, which has the same syntax. -fn write_single_field(field: &str, text: &str, is_re: bool) -> String { - let re = if is_re { "re:" } else { "" }; - let text = if !is_re && text.starts_with("re:") { +fn write_single_field(field: &str, text: &str, is_re: bool, is_nc: bool) -> String { + let prefix = if is_re { + "re:" + } else if is_nc { + "nc:" + } else { + "" + }; + let text = if !is_re && !is_nc && (text.starts_with("re:") || text.starts_with("ne:")) { text.replacen(':', "\\:", 1) } else { text.to_string() }; - maybe_quote(&format!("{}:{}{}", field.replace(':', "\\:"), re, &text)) + maybe_quote(&format!( + "{}:{}{}", + field.replace(':', "\\:"), + prefix, + &text + )) } fn write_template(template: &TemplateKind) -> String {