diff options
author | Quentin Dufour <quentin@deuxfleurs.fr> | 2024-05-29 10:14:51 +0200 |
---|---|---|
committer | Quentin Dufour <quentin@deuxfleurs.fr> | 2024-05-29 10:14:51 +0200 |
commit | b9ce5886033677f6c65a4b873e17574fdb8df31d (patch) | |
tree | 9ed1d721361027d7d6fef0ecad65d7e1b74a7ddb /aero-ical | |
parent | 0dcf69f180f5a7b71b6ad2ac67e4cdd81e5154f1 (diff) | |
parent | 5954de6efbb040b8b47daf0c7663a60f3db1da6e (diff) | |
download | aerogramme-b9ce5886033677f6c65a4b873e17574fdb8df31d.tar.gz aerogramme-b9ce5886033677f6c65a4b873e17574fdb8df31d.zip |
Merge branch 'caldav'
Diffstat (limited to 'aero-ical')
-rw-r--r-- | aero-ical/Cargo.toml | 15 | ||||
-rw-r--r-- | aero-ical/src/lib.rs | 8 | ||||
-rw-r--r-- | aero-ical/src/parser.rs | 146 | ||||
-rw-r--r-- | aero-ical/src/prune.rs | 55 | ||||
-rw-r--r-- | aero-ical/src/query.rs | 338 |
5 files changed, 562 insertions, 0 deletions
diff --git a/aero-ical/Cargo.toml b/aero-ical/Cargo.toml new file mode 100644 index 0000000..6cfe882 --- /dev/null +++ b/aero-ical/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "aero-ical" +version = "0.3.0" +authors = ["Alex Auvolat <alex@adnab.me>", "Quentin Dufour <quentin@dufour.io>"] +edition = "2021" +license = "EUPL-1.2" +description = "An iCalendar parser" + +[dependencies] +aero-dav.workspace = true + +icalendar.workspace = true +nom.workspace = true +chrono.workspace = true +tracing.workspace = true diff --git a/aero-ical/src/lib.rs b/aero-ical/src/lib.rs new file mode 100644 index 0000000..3f6f633 --- /dev/null +++ b/aero-ical/src/lib.rs @@ -0,0 +1,8 @@ +/// The iCalendar module is not yet properly rewritten +/// Instead we heavily rely on the icalendar library +/// However, for many reason, it's not satisfying: +/// the goal will be to rewrite it in the end so it better +/// integrates into Aerogramme +pub mod parser; +pub mod prune; +pub mod query; diff --git a/aero-ical/src/parser.rs b/aero-ical/src/parser.rs new file mode 100644 index 0000000..ca271a5 --- /dev/null +++ b/aero-ical/src/parser.rs @@ -0,0 +1,146 @@ +use chrono::TimeDelta; + +use nom::branch::alt; +use nom::bytes::complete::{tag, tag_no_case}; +use nom::character::complete as nomchar; +use nom::combinator::{map, map_opt, opt, value}; +use nom::sequence::{pair, tuple}; +use nom::IResult; + +use aero_dav::caltypes as cal; + +//@FIXME too simple, we have 4 cases in practices: +// - floating datetime +// - floating datetime with a tzid as param so convertible to tz datetime +// - utc datetime +// - floating(?) date (without time) +pub fn date_time(dt: &str) -> Option<chrono::DateTime<chrono::Utc>> { + tracing::trace!(raw_time = dt, "VEVENT raw time"); + let tmpl = match dt.chars().last() { + Some('Z') => cal::UTC_DATETIME_FMT, + Some(_) => { + tracing::warn!( + raw_time = dt, + "floating datetime is not properly supported yet" + ); + cal::FLOATING_DATETIME_FMT + } + None => return None, + }; + + chrono::NaiveDateTime::parse_from_str(dt, tmpl) + .ok() + .map(|v| v.and_utc()) +} + +/// RFC3389 Duration Value +/// +/// ```abnf +/// dur-value = (["+"] / "-") "P" (dur-date / dur-time / dur-week) +/// dur-date = dur-day [dur-time] +/// dur-time = "T" (dur-hour / dur-minute / dur-second) +/// dur-week = 1*DIGIT "W" +/// dur-hour = 1*DIGIT "H" [dur-minute] +/// dur-minute = 1*DIGIT "M" [dur-second] +/// dur-second = 1*DIGIT "S" +/// dur-day = 1*DIGIT "D" +/// ``` +pub fn dur_value(text: &str) -> IResult<&str, TimeDelta> { + map_opt( + tuple(( + dur_sign, + tag_no_case("P"), + alt((dur_date, dur_time, dur_week)), + )), + |(sign, _, delta)| delta.checked_mul(sign), + )(text) +} + +fn dur_sign(text: &str) -> IResult<&str, i32> { + map(opt(alt((value(1, tag("+")), value(-1, tag("-"))))), |x| { + x.unwrap_or(1) + })(text) +} +fn dur_date(text: &str) -> IResult<&str, TimeDelta> { + map(pair(dur_day, opt(dur_time)), |(day, time)| { + day + time.unwrap_or(TimeDelta::zero()) + })(text) +} +fn dur_time(text: &str) -> IResult<&str, TimeDelta> { + map( + pair(tag_no_case("T"), alt((dur_hour, dur_minute, dur_second))), + |(_, x)| x, + )(text) +} +fn dur_week(text: &str) -> IResult<&str, TimeDelta> { + map_opt(pair(nomchar::i64, tag_no_case("W")), |(i, _)| { + TimeDelta::try_weeks(i) + })(text) +} +fn dur_day(text: &str) -> IResult<&str, TimeDelta> { + map_opt(pair(nomchar::i64, tag_no_case("D")), |(i, _)| { + TimeDelta::try_days(i) + })(text) +} +fn dur_hour(text: &str) -> IResult<&str, TimeDelta> { + map_opt( + tuple((nomchar::i64, tag_no_case("H"), opt(dur_minute))), + |(i, _, mm)| TimeDelta::try_hours(i).map(|hours| hours + mm.unwrap_or(TimeDelta::zero())), + )(text) +} +fn dur_minute(text: &str) -> IResult<&str, TimeDelta> { + map_opt( + tuple((nomchar::i64, tag_no_case("M"), opt(dur_second))), + |(i, _, ms)| TimeDelta::try_minutes(i).map(|min| min + ms.unwrap_or(TimeDelta::zero())), + )(text) +} +fn dur_second(text: &str) -> IResult<&str, TimeDelta> { + map_opt(pair(nomchar::i64, tag_no_case("S")), |(i, _)| { + TimeDelta::try_seconds(i) + })(text) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rfc5545_example1() { + // A duration of 15 days, 5 hours, and 20 seconds would be: + let to_parse = "P15DT5H0M20S"; + let (_, time_delta) = dur_value(to_parse).unwrap(); + assert_eq!( + time_delta, + TimeDelta::try_days(15).unwrap() + + TimeDelta::try_hours(5).unwrap() + + TimeDelta::try_seconds(20).unwrap() + ); + } + + #[test] + fn rfc5545_example2() { + // A duration of 7 weeks would be: + let to_parse = "P7W"; + let (_, time_delta) = dur_value(to_parse).unwrap(); + assert_eq!(time_delta, TimeDelta::try_weeks(7).unwrap()); + } + + #[test] + fn rfc4791_example1() { + // 10 minutes before + let to_parse = "-PT10M"; + + let (_, time_delta) = dur_value(to_parse).unwrap(); + assert_eq!(time_delta, TimeDelta::try_minutes(-10).unwrap()); + } + + #[test] + fn ical_org_example1() { + // The following example is for a "VALARM" calendar component that specifies an email alarm + // that will trigger 2 days before the scheduled due DATE-TIME of a to-do with which it is associated. + let to_parse = "-P2D"; + + let (_, time_delta) = dur_value(to_parse).unwrap(); + assert_eq!(time_delta, TimeDelta::try_days(-2).unwrap()); + } +} diff --git a/aero-ical/src/prune.rs b/aero-ical/src/prune.rs new file mode 100644 index 0000000..3eb50ca --- /dev/null +++ b/aero-ical/src/prune.rs @@ -0,0 +1,55 @@ +use aero_dav::caltypes as cal; +use icalendar::parser::{Component, Property}; + +pub fn component<'a>(src: &'a Component<'a>, prune: &cal::Comp) -> Option<Component<'a>> { + if src.name.as_str() != prune.name.as_str() { + return None; + } + + let name = src.name.clone(); + + let properties = match &prune.prop_kind { + Some(cal::PropKind::AllProp) | None => src.properties.clone(), + Some(cal::PropKind::Prop(l)) => src + .properties + .iter() + .filter_map(|prop| { + let sel_filt = match l + .iter() + .find(|filt| filt.name.0.as_str() == prop.name.as_str()) + { + Some(v) => v, + None => return None, + }; + + match sel_filt.novalue { + None | Some(false) => Some(prop.clone()), + Some(true) => Some(Property { + name: prop.name.clone(), + params: prop.params.clone(), + val: "".into(), + }), + } + }) + .collect::<Vec<_>>(), + }; + + let components = match &prune.comp_kind { + Some(cal::CompKind::AllComp) | None => src.components.clone(), + Some(cal::CompKind::Comp(many_inner_prune)) => src + .components + .iter() + .filter_map(|src_component| { + many_inner_prune + .iter() + .find_map(|inner_prune| component(src_component, inner_prune)) + }) + .collect::<Vec<_>>(), + }; + + Some(Component { + name, + properties, + components, + }) +} diff --git a/aero-ical/src/query.rs b/aero-ical/src/query.rs new file mode 100644 index 0000000..d69a919 --- /dev/null +++ b/aero-ical/src/query.rs @@ -0,0 +1,338 @@ +use crate::parser; +use aero_dav::caltypes as cal; + +pub fn is_component_match( + parent: &icalendar::parser::Component, + components: &[icalendar::parser::Component], + filter: &cal::CompFilter, +) -> bool { + // Find the component among the list + let maybe_comps = components + .iter() + .filter(|candidate| candidate.name.as_str() == filter.name.as_str()) + .collect::<Vec<_>>(); + + // Filter according to rules + match (&maybe_comps[..], &filter.additional_rules) { + ([_, ..], None) => true, + ([], Some(cal::CompFilterRules::IsNotDefined)) => true, + ([], None) => false, + ([_, ..], Some(cal::CompFilterRules::IsNotDefined)) => false, + (comps, Some(cal::CompFilterRules::Matches(matcher))) => comps.iter().any(|component| { + // check time range + if let Some(time_range) = &matcher.time_range { + if !is_in_time_range( + &filter.name, + parent, + component.properties.as_ref(), + time_range, + ) { + return false; + } + } + + // check properties + if !is_properties_match(component.properties.as_ref(), matcher.prop_filter.as_ref()) { + return false; + } + + // check inner components + matcher.comp_filter.iter().all(|inner_filter| { + is_component_match(component, component.components.as_ref(), &inner_filter) + }) + }), + } +} + +fn prop_date( + properties: &[icalendar::parser::Property], + name: &str, +) -> Option<chrono::DateTime<chrono::Utc>> { + properties + .iter() + .find(|candidate| candidate.name.as_str() == name) + .map(|p| p.val.as_str()) + .map(parser::date_time) + .flatten() +} + +fn prop_parse<T: std::str::FromStr>( + properties: &[icalendar::parser::Property], + name: &str, +) -> Option<T> { + properties + .iter() + .find(|candidate| candidate.name.as_str() == name) + .map(|p| p.val.as_str().parse::<T>().ok()) + .flatten() +} + +fn is_properties_match(props: &[icalendar::parser::Property], filters: &[cal::PropFilter]) -> bool { + filters.iter().all(|single_filter| { + // Find the property + let candidate_props = props + .iter() + .filter(|candidate| candidate.name.as_str() == single_filter.name.0.as_str()) + .collect::<Vec<_>>(); + + match (&single_filter.additional_rules, &candidate_props[..]) { + (None, [_, ..]) | (Some(cal::PropFilterRules::IsNotDefined), []) => true, + (None, []) | (Some(cal::PropFilterRules::IsNotDefined), [_, ..]) => false, + (Some(cal::PropFilterRules::Match(pattern)), multi_props) => { + multi_props.iter().any(|prop| { + // check value + match &pattern.time_or_text { + Some(cal::TimeOrText::Time(time_range)) => { + let maybe_parsed_date = parser::date_time(prop.val.as_str()); + + let parsed_date = match maybe_parsed_date { + None => return false, + Some(v) => v, + }; + + // see if entry is in range + let is_in_range = match time_range { + cal::TimeRange::OnlyStart(after) => &parsed_date >= after, + cal::TimeRange::OnlyEnd(before) => &parsed_date <= before, + cal::TimeRange::FullRange(after, before) => { + &parsed_date >= after && &parsed_date <= before + } + }; + if !is_in_range { + return false; + } + + // if you are here, this subcondition is valid + } + Some(cal::TimeOrText::Text(txt_match)) => { + //@FIXME ignoring collation + let is_match = match txt_match.negate_condition { + None | Some(false) => { + prop.val.as_str().contains(txt_match.text.as_str()) + } + Some(true) => !prop.val.as_str().contains(txt_match.text.as_str()), + }; + if !is_match { + return false; + } + } + None => (), // if not filter on value is set, continue + }; + + // check parameters + pattern.param_filter.iter().all(|single_param_filter| { + let multi_param = prop + .params + .iter() + .filter(|candidate| { + candidate.key.as_str() == single_param_filter.name.as_str() + }) + .collect::<Vec<_>>(); + + match (&multi_param[..], &single_param_filter.additional_rules) { + ([.., _], None) => true, + ([], None) => false, + ([.., _], Some(cal::ParamFilterMatch::IsNotDefined)) => false, + ([], Some(cal::ParamFilterMatch::IsNotDefined)) => true, + (many_params, Some(cal::ParamFilterMatch::Match(txt_match))) => { + many_params.iter().any(|param| { + let param_val = match ¶m.val { + Some(v) => v, + None => return false, + }; + + match txt_match.negate_condition { + None | Some(false) => { + param_val.as_str().contains(txt_match.text.as_str()) + } + Some(true) => { + !param_val.as_str().contains(txt_match.text.as_str()) + } + } + }) + } + } + }) + }) + } + } + }) +} + +fn resolve_trigger( + parent: &icalendar::parser::Component, + properties: &[icalendar::parser::Property], +) -> Option<chrono::DateTime<chrono::Utc>> { + // A. Do we have a TRIGGER property? If not, returns early + let maybe_trigger_prop = properties + .iter() + .find(|candidate| candidate.name.as_str() == "TRIGGER"); + + let trigger_prop = match maybe_trigger_prop { + None => return None, + Some(v) => v, + }; + + // B.1 Is it an absolute datetime? If so, returns early + let maybe_absolute = trigger_prop + .params + .iter() + .find(|param| param.key.as_str() == "VALUE") + .map(|param| param.val.as_ref()) + .flatten() + .map(|v| v.as_str() == "DATE-TIME"); + + if maybe_absolute.is_some() { + let final_date = prop_date(properties, "TRIGGER"); + tracing::trace!(trigger=?final_date, "resolved absolute trigger"); + return final_date; + } + + // B.2 Otherwise it's a timedelta relative to a parent field. + // C.1 Parse the timedelta value, returns early if invalid + let (_, time_delta) = parser::dur_value(trigger_prop.val.as_str()).ok()?; + + // C.2 Get the parent reference absolute datetime, returns early if invalid + let maybe_bound = trigger_prop + .params + .iter() + .find(|param| param.key.as_str() == "RELATED") + .map(|param| param.val.as_ref()) + .flatten(); + + // If the trigger is set relative to START, then the "DTSTART" property MUST be present in the associated + // "VEVENT" or "VTODO" calendar component. + // + // If an alarm is specified for an event with the trigger set relative to the END, + // then the "DTEND" property or the "DTSTART" and "DURATION " properties MUST be present + // in the associated "VEVENT" calendar component. + // + // If the alarm is specified for a to-do with a trigger set relative to the END, + // then either the "DUE" property or the "DTSTART" and "DURATION " properties + // MUST be present in the associated "VTODO" calendar component. + let related_field = match maybe_bound.as_ref().map(|v| v.as_str()) { + Some("START") => "DTSTART", + Some("END") => "DTEND", //@FIXME must add support for DUE, DTSTART, and DURATION + _ => "DTSTART", // by default use DTSTART + }; + let parent_date = match prop_date(parent.properties.as_ref(), related_field) { + Some(v) => v, + _ => return None, + }; + + // C.3 Compute the final date from the base date + timedelta + let final_date = parent_date + time_delta; + tracing::trace!(trigger=?final_date, "resolved relative trigger"); + Some(final_date) +} + +fn is_in_time_range( + component: &cal::Component, + parent: &icalendar::parser::Component, + properties: &[icalendar::parser::Property], + time_range: &cal::TimeRange, +) -> bool { + //@FIXME timezones are not properly handled currently (everything is UTC) + //@FIXME does not support repeat + //ref: https://datatracker.ietf.org/doc/html/rfc4791#section-9.9 + let (start, end) = match time_range { + cal::TimeRange::OnlyStart(start) => (start, &chrono::DateTime::<chrono::Utc>::MAX_UTC), + cal::TimeRange::OnlyEnd(end) => (&chrono::DateTime::<chrono::Utc>::MIN_UTC, end), + cal::TimeRange::FullRange(start, end) => (start, end), + }; + + match component { + cal::Component::VEvent => { + let dtstart = match prop_date(properties, "DTSTART") { + Some(v) => v, + _ => return false, + }; + let maybe_dtend = prop_date(properties, "DTEND"); + let maybe_duration = prop_parse::<i64>(properties, "DURATION") + .map(|d| chrono::TimeDelta::new(std::cmp::max(d, 0), 0)) + .flatten(); + + //@FIXME missing "date" management (only support "datetime") + match (&maybe_dtend, &maybe_duration) { + // | Y | N | N | * | (start < DTEND AND end > DTSTART) | + (Some(dtend), _) => start < dtend && end > &dtstart, + // | N | Y | Y | * | (start < DTSTART+DURATION AND end > DTSTART) | + (_, Some(duration)) => *start <= dtstart + *duration && end > &dtstart, + // | N | N | N | Y | (start <= DTSTART AND end > DTSTART) | + _ => start <= &dtstart && end > &dtstart, + } + } + cal::Component::VTodo => { + let maybe_dtstart = prop_date(properties, "DTSTART"); + let maybe_due = prop_date(properties, "DUE"); + let maybe_completed = prop_date(properties, "COMPLETED"); + let maybe_created = prop_date(properties, "CREATED"); + let maybe_duration = prop_parse::<i64>(properties, "DURATION") + .map(|d| chrono::TimeDelta::new(d, 0)) + .flatten(); + + match ( + maybe_dtstart, + maybe_duration, + maybe_due, + maybe_completed, + maybe_created, + ) { + // | Y | Y | N | * | * | (start <= DTSTART+DURATION) AND | + // | | | | | | ((end > DTSTART) OR | + // | | | | | | (end >= DTSTART+DURATION)) | + (Some(dtstart), Some(duration), None, _, _) => { + *start <= dtstart + duration && (*end > dtstart || *end >= dtstart + duration) + } + // | Y | N | Y | * | * | ((start < DUE) OR (start <= DTSTART)) | + // | | | | | | AND | + // | | | | | | ((end > DTSTART) OR (end >= DUE)) | + (Some(dtstart), None, Some(due), _, _) => { + (*start < due || *start <= dtstart) && (*end > dtstart || *end >= due) + } + // | Y | N | N | * | * | (start <= DTSTART) AND (end > DTSTART) | + (Some(dtstart), None, None, _, _) => *start <= dtstart && *end > dtstart, + // | N | N | Y | * | * | (start < DUE) AND (end >= DUE) | + (None, None, Some(due), _, _) => *start < due && *end >= due, + // | N | N | N | Y | Y | ((start <= CREATED) OR (start <= COMPLETED))| + // | | | | | | AND | + // | | | | | | ((end >= CREATED) OR (end >= COMPLETED))| + (None, None, None, Some(completed), Some(created)) => { + (*start <= created || *start <= completed) + && (*end >= created || *end >= completed) + } + // | N | N | N | Y | N | (start <= COMPLETED) AND (end >= COMPLETED) | + (None, None, None, Some(completed), None) => { + *start <= completed && *end >= completed + } + // | N | N | N | N | Y | (end > CREATED) | + (None, None, None, None, Some(created)) => *end > created, + // | N | N | N | N | N | TRUE | + _ => true, + } + } + cal::Component::VJournal => { + let maybe_dtstart = prop_date(properties, "DTSTART"); + match maybe_dtstart { + // | Y | Y | (start <= DTSTART) AND (end > DTSTART) | + Some(dtstart) => *start <= dtstart && *end > dtstart, + // | N | * | FALSE | + None => false, + } + } + cal::Component::VFreeBusy => { + //@FIXME freebusy is not supported yet + false + } + cal::Component::VAlarm => { + //@FIXME does not support REPEAT + let maybe_trigger = resolve_trigger(parent, properties); + match maybe_trigger { + // (start <= trigger-time) AND (end > trigger-time) + Some(trigger_time) => *start <= trigger_time && *end > trigger_time, + _ => false, + } + } + _ => false, + } +} |