From 6b9720844aaa86ad25a77c0821dcdbc772937065 Mon Sep 17 00:00:00 2001 From: Quentin Dufour Date: Sun, 26 May 2024 10:33:04 +0200 Subject: better support for time-range --- aero-dav/src/caldecoder.rs | 8 +-- aero-dav/src/calencoder.rs | 12 ++-- aero-ical/src/lib.rs | 1 - aero-ical/src/parser.rs | 100 +++++++++++++++++---------------- aero-ical/src/query.rs | 104 ++++++++++++++++++++++++++--------- aero-proto/src/dav/controller.rs | 6 +- aerogramme/tests/behavior.rs | 51 +++++++++-------- aerogramme/tests/common/constants.rs | 17 ++++++ 8 files changed, 187 insertions(+), 112 deletions(-) diff --git a/aero-dav/src/caldecoder.rs b/aero-dav/src/caldecoder.rs index 7de5e2a..b6a843f 100644 --- a/aero-dav/src/caldecoder.rs +++ b/aero-dav/src/caldecoder.rs @@ -917,15 +917,11 @@ impl QRead for TimeRange { xml.open(CAL_URN, "time-range").await?; let start = match xml.prev_attr("start") { - Some(r) => { - Some(NaiveDateTime::parse_from_str(r.as_str(), UTC_DATETIME_FMT)?.and_utc()) - } + Some(r) => Some(NaiveDateTime::parse_from_str(r.as_str(), UTC_DATETIME_FMT)?.and_utc()), _ => None, }; let end = match xml.prev_attr("end") { - Some(r) => { - Some(NaiveDateTime::parse_from_str(r.as_str(), UTC_DATETIME_FMT)?.and_utc()) - } + Some(r) => Some(NaiveDateTime::parse_from_str(r.as_str(), UTC_DATETIME_FMT)?.and_utc()), _ => None, }; diff --git a/aero-dav/src/calencoder.rs b/aero-dav/src/calencoder.rs index d5d4305..723d95d 100644 --- a/aero-dav/src/calencoder.rs +++ b/aero-dav/src/calencoder.rs @@ -739,19 +739,15 @@ impl QWrite for TimeRange { "start", format!("{}", start.format(UTC_DATETIME_FMT)).as_str(), )), - Self::OnlyEnd(end) => empty.push_attribute(( - "end", - format!("{}", end.format(UTC_DATETIME_FMT)).as_str(), - )), + Self::OnlyEnd(end) => { + empty.push_attribute(("end", format!("{}", end.format(UTC_DATETIME_FMT)).as_str())) + } Self::FullRange(start, end) => { empty.push_attribute(( "start", format!("{}", start.format(UTC_DATETIME_FMT)).as_str(), )); - empty.push_attribute(( - "end", - format!("{}", end.format(UTC_DATETIME_FMT)).as_str(), - )); + empty.push_attribute(("end", format!("{}", end.format(UTC_DATETIME_FMT)).as_str())); } } xml.q.write_event_async(Event::Empty(empty)).await diff --git a/aero-ical/src/lib.rs b/aero-ical/src/lib.rs index 808c885..f6b4ad4 100644 --- a/aero-ical/src/lib.rs +++ b/aero-ical/src/lib.rs @@ -3,6 +3,5 @@ /// 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 query; diff --git a/aero-ical/src/parser.rs b/aero-ical/src/parser.rs index 4354737..ca271a5 100644 --- a/aero-ical/src/parser.rs +++ b/aero-ical/src/parser.rs @@ -1,11 +1,11 @@ use chrono::TimeDelta; -use nom::IResult; use nom::branch::alt; use nom::bytes::complete::{tag, tag_no_case}; -use nom::combinator::{value, opt, map, map_opt}; -use nom::sequence::{pair, tuple}; 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; @@ -19,10 +19,13 @@ pub fn date_time(dt: &str) -> Option> { 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"); + tracing::warn!( + raw_time = dt, + "floating datetime is not properly supported yet" + ); cal::FLOATING_DATETIME_FMT - }, - None => return None + } + None => return None, }; chrono::NaiveDateTime::parse_from_str(dt, tmpl) @@ -43,46 +46,58 @@ pub fn date_time(dt: &str) -> Option> { /// 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + map_opt(pair(nomchar::i64, tag_no_case("S")), |(i, _)| { + TimeDelta::try_seconds(i) + })(text) } #[cfg(test)] @@ -95,8 +110,11 @@ mod tests { 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()); + time_delta, + TimeDelta::try_days(15).unwrap() + + TimeDelta::try_hours(5).unwrap() + + TimeDelta::try_seconds(20).unwrap() + ); } #[test] @@ -104,35 +122,25 @@ mod tests { // 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() - ); + assert_eq!(time_delta, TimeDelta::try_weeks(7).unwrap()); } #[test] - fn rfc4791_example1() { + 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() - ); + 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 + // 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() - ); + assert_eq!(time_delta, TimeDelta::try_days(-2).unwrap()); } } diff --git a/aero-ical/src/query.rs b/aero-ical/src/query.rs index 5d857bb..440441f 100644 --- a/aero-ical/src/query.rs +++ b/aero-ical/src/query.rs @@ -1,5 +1,5 @@ +use crate::parser; use aero_dav::caltypes as cal; -use crate::parser as parser; pub fn is_component_match( parent: &icalendar::parser::Component, @@ -7,6 +7,7 @@ pub fn is_component_match( filter: &cal::CompFilter, ) -> bool { // Find the component among the list + //@FIXME do not handle correctly multiple entities (eg. 3 VEVENT) let maybe_comp = components .iter() .find(|candidate| candidate.name.as_str() == filter.name.as_str()); @@ -21,7 +22,12 @@ pub fn is_component_match( (Some(component), Some(cal::CompFilterRules::Matches(matcher))) => { // 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) { + if !is_in_time_range( + &filter.name, + parent, + component.properties.as_ref(), + time_range, + ) { return false; } } @@ -77,7 +83,7 @@ fn is_properties_match(props: &[icalendar::parser::Property], filters: &[cal::Pr // 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 maybe_parsed_date = parser::date_time(prop.val.as_str()); let parsed_date = match maybe_parsed_date { None => return false, @@ -146,8 +152,8 @@ fn is_properties_match(props: &[icalendar::parser::Property], filters: &[cal::Pr } fn resolve_trigger( - parent: &icalendar::parser::Component, - properties: &[icalendar::parser::Property] + parent: &icalendar::parser::Component, + properties: &[icalendar::parser::Property], ) -> Option> { // A. Do we have a TRIGGER property? If not, returns early let maybe_trigger_prop = properties @@ -160,34 +166,56 @@ fn resolve_trigger( }; // B.1 Is it an absolute datetime? If so, returns early - let maybe_absolute = trigger_prop.params.iter() + let maybe_absolute = trigger_prop + .params + .iter() .find(|param| param.key.as_str() == "VALUE") - .map(|param| param.val.as_ref()).flatten() + .map(|param| param.val.as_ref()) + .flatten() .map(|v| v.as_str() == "DATE-TIME"); if maybe_absolute.is_some() { - return prop_date(properties, "TRIGGER"); + 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_related_field = trigger_prop + let maybe_bound = trigger_prop .params .iter() .find(|param| param.key.as_str() == "RELATED") .map(|param| param.val.as_ref()) .flatten(); - let related_field = maybe_related_field.map(|v| v.as_str()).unwrap_or("DTSTART"); + + // 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 - - todo!() + let final_date = parent_date + time_delta; + tracing::trace!(trigger=?final_date, "resolved relative trigger"); + Some(final_date) } fn is_in_time_range( @@ -209,10 +237,12 @@ fn is_in_time_range( cal::Component::VEvent => { let dtstart = match prop_date(properties, "DTSTART") { Some(v) => v, - _ => return false, + _ => return false, }; let maybe_dtend = prop_date(properties, "DTEND"); - let maybe_duration = prop_parse::(properties, "DURATION").map(|d| chrono::TimeDelta::new(std::cmp::max(d, 0), 0)).flatten(); + let maybe_duration = prop_parse::(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) { @@ -223,23 +253,35 @@ fn is_in_time_range( // | 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::(properties, "DURATION").map(|d| chrono::TimeDelta::new(d, 0)).flatten(); + let maybe_duration = prop_parse::(properties, "DURATION") + .map(|d| chrono::TimeDelta::new(d, 0)) + .flatten(); - match (maybe_dtstart, maybe_duration, maybe_due, maybe_completed, maybe_created) { + 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), + (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), + (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) | @@ -247,15 +289,20 @@ fn is_in_time_range( // | 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), + (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, + (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 { @@ -264,17 +311,20 @@ fn is_in_time_range( // | 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); - // (start <= trigger-time) AND (end > trigger-time) - false - }, + match maybe_trigger { + // (start <= trigger-time) AND (end > trigger-time) + Some(trigger_time) => *start <= trigger_time && *end > trigger_time, + _ => false, + } + } _ => false, } } diff --git a/aero-proto/src/dav/controller.rs b/aero-proto/src/dav/controller.rs index 873f768..abf6a97 100644 --- a/aero-proto/src/dav/controller.rs +++ b/aero-proto/src/dav/controller.rs @@ -374,7 +374,11 @@ fn apply_filter<'a>( tracing::debug!(filter=?root_filter, "calendar-query filter"); // Adjust return value according to filter - match is_component_match(&fake_vcal_component, &[fake_vcal_component.clone()], root_filter) { + match is_component_match( + &fake_vcal_component, + &[fake_vcal_component.clone()], + root_filter, + ) { true => Some(Ok(single_node)), _ => None, } diff --git a/aerogramme/tests/behavior.rs b/aerogramme/tests/behavior.rs index 0e6dab6..d6c73e3 100644 --- a/aerogramme/tests/behavior.rs +++ b/aerogramme/tests/behavior.rs @@ -590,6 +590,13 @@ fn rfc4791_webdav_caldav() { .send()?; let _obj5_etag = resp.headers().get("etag").expect("etag must be set"); assert_eq!(resp.status(), 201); + let resp = http + .put("http://localhost:8087/alice/calendar/Personal/rfc6.ics") + .header("If-None-Match", "*") + .body(ICAL_RFC6) + .send()?; + let _obj6_etag = resp.headers().get("etag").expect("etag must be set"); + assert_eq!(resp.status(), 201); // A generic function to check a query result let check_cal = @@ -611,25 +618,17 @@ fn rfc4791_webdav_caldav() { .iter() .find(|p| p.status.0.as_u16() == 200) .expect("some propstats must be 200"); - let etag = obj_success - .prop - .0 - .iter() - .find_map(|p| match p { - dav::AnyProperty::Value(dav::Property::GetEtag(x)) => Some(x.as_str()), - _ => None, - }); + let etag = obj_success.prop.0.iter().find_map(|p| match p { + dav::AnyProperty::Value(dav::Property::GetEtag(x)) => Some(x.as_str()), + _ => None, + }); assert_eq!(etag, ref_etag); - let calendar_data = obj_success - .prop - .0 - .iter() - .find_map(|p| match p { - dav::AnyProperty::Value(dav::Property::Extension( - realization::Property::Cal(cal::Property::CalendarData(x)), - )) => Some(x.payload.as_bytes()), - _ => None, - }); + let calendar_data = obj_success.prop.0.iter().find_map(|p| match p { + dav::AnyProperty::Value(dav::Property::Extension( + realization::Property::Cal(cal::Property::CalendarData(x)), + )) => Some(x.payload.as_bytes()), + _ => None, + }); assert_eq!(calendar_data, ref_ical); }; @@ -753,7 +752,14 @@ fn rfc4791_webdav_caldav() { assert_eq!(resp.status(), 207); let multistatus = dav_deserialize::>(&resp.text()?); assert_eq!(multistatus.responses.len(), 1); - check_cal(&multistatus, ("/alice/calendar/Personal/rfc2.ics", Some(obj2_etag.to_str().expect("etag header convertible to str")), None)); + check_cal( + &multistatus, + ( + "/alice/calendar/Personal/rfc2.ics", + Some(obj2_etag.to_str().expect("etag header convertible to str")), + None, + ), + ); // 7.8.5. Example: Retrieval of To-Dos by Alarm Time Range let cal_query = r#" @@ -766,7 +772,7 @@ fn rfc4791_webdav_caldav() { - + @@ -781,18 +787,17 @@ fn rfc4791_webdav_caldav() { .send()?; assert_eq!(resp.status(), 207); let multistatus = dav_deserialize::>(&resp.text()?); - //assert_eq!(multistatus.responses.len(), 1); + assert_eq!(multistatus.responses.len(), 1); // 7.8.6. Example: Retrieval of Event by UID // @TODO // 7.8.7. Example: Retrieval of Events by PARTSTAT // @TODO - + // 7.8.9. Example: Retrieval of All Pending To-Dos // @TODO - // --- REPORT calendar-multiget --- let cal_query = r#" diff --git a/aerogramme/tests/common/constants.rs b/aerogramme/tests/common/constants.rs index 8874876..91ee159 100644 --- a/aerogramme/tests/common/constants.rs +++ b/aerogramme/tests/common/constants.rs @@ -158,3 +158,20 @@ UID:E10BA47467C5C69BB74E8725@example.com END:VTODO END:VCALENDAR "#; + +pub static ICAL_RFC6: &[u8] = br#"BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VTODO +DTSTART:20060205T235335Z +DUE;VALUE=DATE:20060104 +STATUS:NEEDS-ACTION +SUMMARY:Task #1 +UID:DDDEEB7915FA61233B861457@example.com +BEGIN:VALARM +ACTION:AUDIO +TRIGGER;RELATED=START:-PT10M +END:VALARM +END:VTODO +END:VCALENDAR +"#; -- cgit v1.2.3