Enable nc: to only search in a specific field

This commit is contained in:
max 2025-09-02 03:52:49 +02:00 committed by Max Romanowski
parent 8ef208e418
commit b0dc15772f
6 changed files with 135 additions and 16 deletions

View file

@ -240,6 +240,7 @@ Thomas Rixen <thomas.rixen@student.uclouvain.be>
Siyuan Mattuwu Yan <syan4@ualberta.ca> Siyuan Mattuwu Yan <syan4@ualberta.ca>
Lee Doughty <32392044+leedoughty@users.noreply.github.com> Lee Doughty <32392044+leedoughty@users.noreply.github.com>
memchr <memchr@proton.me> memchr <memchr@proton.me>
Max Romanowski <maxr777@proton.me>
******************** ********************

View file

@ -175,6 +175,7 @@ impl SearchNode {
Self::Tag { Self::Tag {
tag: escape_anki_wildcards_for_search_node(name), tag: escape_anki_wildcards_for_search_node(name),
is_re: false, is_re: false,
is_nc: false,
} }
} }

View file

@ -57,6 +57,7 @@ pub enum SearchNode {
field: String, field: String,
text: String, text: String,
is_re: bool, is_re: bool,
is_nc: bool,
}, },
AddedInDays(u32), AddedInDays(u32),
EditedInDays(u32), EditedInDays(u32),
@ -78,6 +79,7 @@ pub enum SearchNode {
Tag { Tag {
tag: String, tag: String,
is_re: bool, is_re: bool,
is_nc: bool,
}, },
Duplicates { Duplicates {
notetype_id: NotetypeId, notetype_id: NotetypeId,
@ -374,11 +376,13 @@ fn parse_tag(s: &str) -> ParseResult<'_, SearchNode> {
SearchNode::Tag { SearchNode::Tag {
tag: unescape_quotes(re), tag: unescape_quotes(re),
is_re: true, is_re: true,
is_nc: false,
} }
} else { } else {
SearchNode::Tag { SearchNode::Tag {
tag: unescape(s)?, tag: unescape(s)?,
is_re: false, 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)?, field: unescape(key)?,
text: unescape_quotes(stripped), text: unescape_quotes(stripped),
is_re: true, 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 { } else {
SearchNode::SingleField { SearchNode::SingleField {
field: unescape(key)?, field: unescape(key)?,
text: unescape(val)?, text: unescape(val)?,
is_re: false, is_re: false,
is_nc: false,
} }
}) })
} }
@ -807,6 +820,7 @@ mod test {
field: "foo".into(), field: "foo".into(),
text: "bar baz".into(), text: "bar baz".into(),
is_re: false, is_re: false,
is_nc: false,
}) })
]))), ]))),
Or, Or,
@ -819,7 +833,18 @@ mod test {
vec![Search(SingleField { vec![Search(SingleField {
field: "foo".into(), field: "foo".into(),
text: "bar".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 { vec![Search(SingleField {
field: "field".into(), field: "field".into(),
text: "va\"lue".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""#)?,); assert_eq!(parse(r#""field:va\"lue""#)?, parse(r#"field:"va\"lue""#)?,);
@ -906,14 +932,16 @@ mod test {
parse("tag:hard")?, parse("tag:hard")?,
vec![Search(Tag { vec![Search(Tag {
tag: "hard".into(), tag: "hard".into(),
is_re: false is_re: false,
is_nc: false
})] })]
); );
assert_eq!( assert_eq!(
parse(r"tag:re:\\")?, parse(r"tag:re:\\")?,
vec![Search(Tag { vec![Search(Tag {
tag: r"\\".into(), tag: r"\\".into(),
is_re: true is_re: true,
is_nc: false
})] })]
); );
assert_eq!( assert_eq!(

View file

@ -41,6 +41,7 @@ impl TryFrom<anki_proto::search::SearchNode> for Node {
field: escape_anki_wildcards_for_search_node(&s), field: escape_anki_wildcards_for_search_node(&s),
text: "_*".to_string(), text: "_*".to_string(),
is_re: false, is_re: false,
is_nc: false,
}), }),
Filter::Rated(rated) => Node::Search(SearchNode::Rated { Filter::Rated(rated) => Node::Search(SearchNode::Rated {
days: rated.days, days: rated.days,
@ -108,6 +109,7 @@ impl TryFrom<anki_proto::search::SearchNode> for Node {
field: escape_anki_wildcards(&field.field_name), field: escape_anki_wildcards(&field.field_name),
text: escape_anki_wildcards(&field.text), text: escape_anki_wildcards(&field.text),
is_re: field.is_re, is_re: field.is_re,
is_nc: false,
}), }),
Filter::LiteralText(text) => { Filter::LiteralText(text) => {
let text = escape_anki_wildcards(&text); let text = escape_anki_wildcards(&text);

View file

@ -138,9 +138,12 @@ impl SqlWriter<'_> {
false, false,
)? )?
} }
SearchNode::SingleField { field, text, is_re } => { SearchNode::SingleField {
self.write_field(&norm(field), &self.norm_note(text), *is_re)? field,
} text,
is_re,
is_nc,
} => self.write_field(&norm(field), &self.norm_note(text), *is_re, *is_nc)?,
SearchNode::Duplicates { notetype_id, text } => { SearchNode::Duplicates { notetype_id, text } => {
self.write_dupe(*notetype_id, &self.norm_note(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::Notetype(notetype) => self.write_notetype(&norm(notetype)),
SearchNode::Rated { days, ease } => self.write_rated(">", -i64::from(*days), ease)?, 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::State(state) => self.write_state(state)?,
SearchNode::Flag(flag) => { SearchNode::Flag(flag) => {
write!(self.sql, "(c.flags & 7) == {flag}").unwrap(); write!(self.sql, "(c.flags & 7) == {flag}").unwrap();
@ -296,7 +299,7 @@ impl SqlWriter<'_> {
Ok(()) 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 { if is_re {
self.args.push(format!("(?i){tag}")); self.args.push(format!("(?i){tag}"));
write!(self.sql, "regexp_tags(?{}, n.tags)", self.args.len()).unwrap(); 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 matches!(field_name, "*" | "_*" | "*_") {
if is_re { if is_re {
self.write_all_fields_regexp(val); self.write_all_fields_regexp(val);
@ -577,6 +580,8 @@ impl SqlWriter<'_> {
Ok(()) Ok(())
} else if is_re { } else if is_re {
self.write_single_field_regexp(field_name, val) self.write_single_field_regexp(field_name, val)
} else if is_nc {
self.write_single_field_nc(field_name, val)
} else { } else {
self.write_single_field(field_name, val) self.write_single_field(field_name, val)
} }
@ -592,6 +597,58 @@ impl SqlWriter<'_> {
write!(self.sql, "regexp_fields(?{}, n.flds)", self.args.len()).unwrap(); 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<u32>| {
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<()> { 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)?; let field_indicies_by_notetype = self.fields_indices_by_notetype(field_name)?;
if field_indicies_by_notetype.is_empty() { if field_indicies_by_notetype.is_empty() {
@ -1116,6 +1173,20 @@ mod test {
vec!["(?i)te.*st".into()] 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 // all field search
assert_eq!( assert_eq!(
s(ctx, "*:te*st"), s(ctx, "*:te*st"),

View file

@ -69,7 +69,12 @@ fn write_search_node(node: &SearchNode) -> String {
use SearchNode::*; use SearchNode::*;
match node { match node {
UnqualifiedText(s) => maybe_quote(&s.replace(':', "\\:")), 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}"), AddedInDays(u) => format!("added:{u}"),
EditedInDays(u) => format!("edited:{u}"), EditedInDays(u) => format!("edited:{u}"),
IntroducedInDays(u) => format!("introduced:{u}"), IntroducedInDays(u) => format!("introduced:{u}"),
@ -81,7 +86,7 @@ fn write_search_node(node: &SearchNode) -> String {
NotetypeId(NotetypeIdType(i)) => format!("mid:{i}"), NotetypeId(NotetypeIdType(i)) => format!("mid:{i}"),
Notetype(s) => maybe_quote(&format!("note:{s}")), Notetype(s) => maybe_quote(&format!("note:{s}")),
Rated { days, ease } => write_rated(days, ease), 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), Duplicates { notetype_id, text } => write_dupe(notetype_id, text),
State(k) => write_state(k), State(k) => write_state(k),
Flag(u) => format!("flag:{u}"), 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. /// Also used by tag search, which has the same syntax.
fn write_single_field(field: &str, text: &str, is_re: bool) -> String { fn write_single_field(field: &str, text: &str, is_re: bool, is_nc: bool) -> String {
let re = if is_re { "re:" } else { "" }; let prefix = if is_re {
let text = if !is_re && text.starts_with("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) text.replacen(':', "\\:", 1)
} else { } else {
text.to_string() text.to_string()
}; };
maybe_quote(&format!("{}:{}{}", field.replace(':', "\\:"), re, &text)) maybe_quote(&format!(
"{}:{}{}",
field.replace(':', "\\:"),
prefix,
&text
))
} }
fn write_template(template: &TemplateKind) -> String { fn write_template(template: &TemplateKind) -> String {