diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 99185fb26..55ab6b5fc 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -6,11 +6,13 @@ use nom::branch::alt; use nom::bytes::complete::escaped; use nom::bytes::complete::is_not; use nom::bytes::complete::tag; +use nom::character::complete::alphanumeric1; use nom::character::complete::anychar; use nom::character::complete::char; use nom::character::complete::none_of; use nom::character::complete::one_of; use nom::combinator::map; +use nom::combinator::recognize; use nom::combinator::verify; use nom::error::ErrorKind as NomErrorKind; use nom::multi::many0; @@ -101,6 +103,7 @@ pub enum PropertyKind { Ease(f32), Position(u32), Rated(i32, RatingKind), + CustomDataNumber { key: String, value: f32 }, } #[derive(Debug, PartialEq, Eq, Clone)] @@ -403,6 +406,7 @@ fn parse_prop(prop_clause: &str) -> ParseResult { tag("pos"), tag("rated"), tag("resched"), + recognize(preceded(tag("cdn:"), alphanumeric1)), ))(prop_clause) .map_err(|_| { parse_failure( @@ -442,7 +446,13 @@ fn parse_prop(prop_clause: &str) -> ParseResult { "reps" => PropertyKind::Reps(parse_u32(num, prop_clause)?), "lapses" => PropertyKind::Lapses(parse_u32(num, prop_clause)?), "pos" => PropertyKind::Position(parse_u32(num, prop_clause)?), - _ => unreachable!(), + other => { + let Some(prop) = other.strip_prefix("cdn:") else { unreachable!() }; + PropertyKind::CustomDataNumber { + key: prop.into(), + value: parse_f32(num, prop_clause)?, + } + } }; Ok(SearchNode::Property { @@ -903,6 +913,16 @@ mod test { kind: PropertyKind::Ease(3.3) })] ); + assert_eq!( + parse("prop:cdn:abc<=1")?, + vec![Search(Property { + operator: "<=".into(), + kind: PropertyKind::CustomDataNumber { + key: "abc".into(), + value: 1.0 + } + })] + ); Ok(()) } @@ -1112,6 +1132,18 @@ mod test { provided: "DUE<5".into(), }, ); + assert_err_kind( + "prop:cdn=5", + InvalidPropProperty { + provided: "cdn=5".to_string(), + }, + ); + assert_err_kind( + "prop:cdn:=5", + InvalidPropProperty { + provided: "cdn:=5".to_string(), + }, + ); assert_err_kind( "prop:lapses", diff --git a/rslib/src/search/sqlwriter.rs b/rslib/src/search/sqlwriter.rs index ffbb8113e..9381bea24 100644 --- a/rslib/src/search/sqlwriter.rs +++ b/rslib/src/search/sqlwriter.rs @@ -354,6 +354,13 @@ impl SqlWriter<'_> { write!(self.sql, "factor {} {}", op, (ease * 1000.0) as u32).unwrap() } PropertyKind::Rated(days, ease) => self.write_rated(op, i64::from(*days), ease)?, + PropertyKind::CustomDataNumber { key, value } => { + write!( + self.sql, + "extract_custom_data_number(c.data, '{key}') {op} {value}" + ) + .unwrap(); + } } Ok(()) @@ -1166,6 +1173,10 @@ mod test { ) ); assert_eq!(s(ctx, "prop:rated>-5:3").0, s(ctx, "rated:5:3").0); + assert_eq!( + &s(ctx, "prop:cdn:r=1").0, + "(extract_custom_data_number(c.data, 'r') = 1)" + ); // note types by name assert_eq!( diff --git a/rslib/src/search/writer.rs b/rslib/src/search/writer.rs index 520ebd288..b43522cfa 100644 --- a/rslib/src/search/writer.rs +++ b/rslib/src/search/writer.rs @@ -171,6 +171,7 @@ fn write_property(operator: &str, kind: &PropertyKind) -> String { RatingKind::AnyAnswerButton => format!("prop:rated{}{}", operator, u), RatingKind::ManualReschedule => format!("prop:resched{}{}", operator, u), }, + CustomDataNumber { key, value } => format!("prop:cdn:{key}{operator}{value}"), } } diff --git a/rslib/src/storage/sqlite.rs b/rslib/src/storage/sqlite.rs index b07f17942..e6fcd69b1 100644 --- a/rslib/src/storage/sqlite.rs +++ b/rslib/src/storage/sqlite.rs @@ -13,6 +13,7 @@ use regex::Regex; use rusqlite::functions::FunctionFlags; use rusqlite::params; use rusqlite::Connection; +use serde_json::Value; use unicase::UniCase; use super::upgrades::SCHEMA_MAX_VERSION; @@ -24,6 +25,7 @@ use crate::error::DbErrorKind; use crate::prelude::*; use crate::scheduler::timing::local_minutes_west_for_stamp; use crate::scheduler::timing::v1_creation_date; +use crate::storage::card::data::CardData; use crate::text::without_combining; fn unicase_compare(s1: &str, s2: &str) -> Ordering { @@ -67,6 +69,7 @@ fn open_or_create_collection_db(path: &Path) -> Result { add_regexp_tags_function(&db)?; add_without_combining_function(&db)?; add_fnvhash_function(&db)?; + add_extract_custom_data_number_function(&db)?; db.create_collation("unicase", unicase_compare)?; @@ -190,6 +193,28 @@ fn add_regexp_tags_function(db: &Connection) -> rusqlite::Result<()> { ) } +/// eg. extract_custom_data_number(card.data, 'r') -> float | null +fn add_extract_custom_data_number_function(db: &Connection) -> rusqlite::Result<()> { + db.create_scalar_function( + "extract_custom_data_number", + 2, + FunctionFlags::SQLITE_DETERMINISTIC, + move |ctx| { + assert_eq!(ctx.len(), 2, "called with unexpected number of arguments"); + + let Ok(card_data) = ctx.get_raw(0).as_str() else { return Ok(None) }; + if card_data.is_empty() { + return Ok(None); + } + let Ok(key) = ctx.get_raw(1).as_str() else { return Ok(None) }; + let custom_data = &CardData::from_str(card_data).custom_data; + let Ok(value) = serde_json::from_str::(custom_data) else { return Ok(None) }; + let num = value.get(key).and_then(|v| v.as_f64()); + Ok(num) + }, + ) +} + /// Fetch schema version from database. /// Return (must_create, version) fn schema_version(db: &Connection) -> Result<(bool, u8)> {