diff --git a/Cargo.lock b/Cargo.lock index 454f946..1642435 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3206,6 +3206,8 @@ dependencies = [ "rustical_xml", "serde", "sha2", + "strum", + "strum_macros", "thiserror 2.0.12", "tokio", "tracing", @@ -3785,6 +3787,25 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" + +[[package]] +name = "strum_macros" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index c1fcde0..f238e0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,6 +85,8 @@ sqlx = { version = "0.8", default-features = false, features = [ "migrate", "json", ] } +strum = "0.27" +strum_macros = "0.27" serde_json = { version = "1.0", features = ["raw_value"] } sqlx-sqlite = { version = "0.8", features = ["bundled"] } ical = { version = "0.11", features = ["generator", "serde"] } diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index 2e9595d..05b663f 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -28,6 +28,8 @@ rand.workspace = true uuid.workspace = true clap.workspace = true rustical_dav.workspace = true +strum.workspace = true +strum_macros.workspace = true [dev-dependencies] rstest = { workspace = true } diff --git a/crates/store/src/calendar/mod.rs b/crates/store/src/calendar/mod.rs index 3807d75..a61feee 100644 --- a/crates/store/src/calendar/mod.rs +++ b/crates/store/src/calendar/mod.rs @@ -2,6 +2,7 @@ mod calendar; mod event; mod journal; mod object; +mod rrule; mod timestamp; mod todo; diff --git a/crates/store/src/calendar/rrule.rs b/crates/store/src/calendar/rrule.rs new file mode 100644 index 0000000..7c7ae33 --- /dev/null +++ b/crates/store/src/calendar/rrule.rs @@ -0,0 +1,265 @@ +use super::CalDateTime; +use std::{num::ParseIntError, str::FromStr}; +use strum_macros::EnumString; + +#[derive(Debug, thiserror::Error)] +pub enum ParserError { + #[error("Missing RRULE FREQ")] + MissingFrequency, + #[error("Invalid RRULE part: {0}")] + InvalidPart(String), + #[error(transparent)] + StrumError(#[from] strum::ParseError), + #[error(transparent)] + ParseIntError(#[from] ParseIntError), + // A little dumb :( + #[error(transparent)] + StoreError(#[from] crate::Error), +} + +#[derive(Debug, Clone, EnumString, Default, PartialEq)] +#[strum(serialize_all = "UPPERCASE")] +pub enum RecurrenceFrequency { + Secondly, + Minutely, + Hourly, + #[default] + Daily, + Weekly, + Monthly, + Yearly, +} + +#[derive(Debug, Clone, EnumString, PartialEq)] +#[strum(serialize_all = "UPPERCASE")] +pub enum Weekday { + Mo, + Tu, + We, + Th, + Fr, + Sa, + Su, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RecurrenceLimit { + Count(usize), + Until(CalDateTime), +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct RecurrenceRule { + // Self-explanatory + pub frequency: RecurrenceFrequency, + pub limit: Option, + // Repeat every n-th time + pub interval: Option, + + pub bysecond: Option>, + pub byminute: Option>, + pub byhour: Option>, + pub byday: Option, Weekday)>>, + pub bymonthday: Option>, + pub byyearday: Option>, + pub byweekno: Option>, + pub bymonth: Option>, + pub week_start: Option, + // Selects the n-th occurence within an a recurrence rule + pub bysetpos: Option>, +} + +impl RecurrenceRule { + pub fn parse(rule: &str) -> Result { + let mut frequency = None; + let mut limit = None; + let mut interval = None; + let mut bysecond = None; + let mut byminute = None; + let mut byhour = None; + let mut byday = None; + let mut bymonthday = None; + let mut byyearday = None; + let mut byweekno = None; + let mut bymonth = None; + let mut week_start = None; + let mut bysetpos = None; + + for part in rule.split(';') { + match part + .split_once('=') + .ok_or(ParserError::InvalidPart(part.to_owned()))? + { + ("FREQ", val) => { + frequency = Some(RecurrenceFrequency::from_str(val)?); + } + ("COUNT", val) => limit = Some(RecurrenceLimit::Count(val.parse()?)), + ("UNTIL", val) => { + limit = Some(RecurrenceLimit::Until(CalDateTime::parse(val, None)?)) + } + ("INTERVAL", val) => interval = Some(val.parse()?), + ("BYSECOND", val) => { + bysecond = Some( + val.split(',') + .map(|val| val.parse()) + .collect::, _>>()?, + ); + } + ("BYMINUTE", val) => { + byminute = Some( + val.split(',') + .map(|val| val.parse()) + .collect::, _>>()?, + ); + } + ("BYHOUR", val) => { + byhour = Some( + val.split(',') + .map(|val| val.parse()) + .collect::, _>>()?, + ); + } + ("BYDAY", val) => { + byday = Some( + val.split(',') + .map(|val| { + assert!(val.len() >= 2); + let weekday = + Weekday::from_str(val.get((val.len() - 2)..).unwrap())?; + let prefix = if val.len() > 2 { + Some(val.get(..(val.len() - 2)).unwrap().parse()?) + } else { + None + }; + Ok((prefix, weekday)) + }) + .collect::, ParserError>>()?, + ); + } + ("BYMONTHDAY", val) => { + bymonthday = Some( + val.split(',') + .map(|val| val.parse()) + .collect::, _>>()?, + ); + } + ("BYYEARDAY", val) => { + byyearday = Some( + val.split(',') + .map(|val| val.parse()) + .collect::, _>>()?, + ); + } + ("BYWEEKNO", val) => { + byweekno = Some( + val.split(',') + .map(|val| val.parse()) + .collect::, _>>()?, + ); + } + ("BYMONTH", val) => { + bymonth = Some( + val.split(',') + .map(|val| val.parse()) + .collect::, _>>()?, + ); + } + ("WKST", val) => week_start = Some(Weekday::from_str(val)?), + ("BYSETPOS", val) => { + bysetpos = Some( + val.split(',') + .map(|val| val.parse()) + .collect::, _>>()?, + ); + } + (name, val) => panic!("Cannot handle {name}={val}"), + } + } + Ok(Self { + frequency: frequency.ok_or(ParserError::MissingFrequency)?, + limit, + interval, + bysecond, + byminute, + byhour, + byday, + bymonthday, + byyearday, + byweekno, + bymonth, + week_start, + bysetpos, + }) + } +} + +#[cfg(test)] +mod tests { + use crate::calendar::{ + CalDateTime, + rrule::{RecurrenceFrequency, RecurrenceLimit, Weekday}, + }; + + use super::{ParserError, RecurrenceRule}; + + #[test] + fn parse_recurrence_rule() -> Result<(), ParserError> { + assert_eq!( + RecurrenceRule::parse("FREQ=DAILY;UNTIL=20250516T133000Z;INTERVAL=3")?, + RecurrenceRule { + frequency: RecurrenceFrequency::Daily, + limit: Some(RecurrenceLimit::Until( + CalDateTime::parse("20250516T133000Z", None).unwrap() + )), + interval: Some(3), + ..Default::default() + } + ); + assert_eq!( + RecurrenceRule::parse("FREQ=WEEKLY;COUNT=4;INTERVAL=2;BYDAY=TU,TH,SU")?, + RecurrenceRule { + frequency: RecurrenceFrequency::Weekly, + limit: Some(RecurrenceLimit::Count(4)), + interval: Some(2), + byday: Some(vec![ + (None, Weekday::Tu), + (None, Weekday::Th), + (None, Weekday::Su), + ]), + ..Default::default() + } + ); + // Example: Last workday of the month + assert_eq!( + RecurrenceRule::parse("FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1")?, + RecurrenceRule { + frequency: RecurrenceFrequency::Monthly, + byday: Some(vec![ + (None, Weekday::Mo), + (None, Weekday::Tu), + (None, Weekday::We), + (None, Weekday::Th), + (None, Weekday::Fr), + ]), + bysetpos: Some(vec![-1]), + ..Default::default() + } + ); + + // Every last Sunday of March + assert_eq!( + RecurrenceRule::parse("FREQ=YEARLY;UNTIL=20370329T010000Z;BYDAY=-1SU;BYMONTH=3")?, + RecurrenceRule { + frequency: RecurrenceFrequency::Yearly, + limit: Some(RecurrenceLimit::Until( + CalDateTime::parse("20370329T010000Z", None).unwrap() + )), + byday: Some(vec![(Some(-1), Weekday::Su)]), + bymonth: Some(vec![3]), + ..Default::default() + } + ); + + Ok(()) + } +}