add a new aero-ical module
This commit is contained in:
parent
ff823a10f0
commit
52f870633c
9 changed files with 500 additions and 182 deletions
16
Cargo.lock
generated
16
Cargo.lock
generated
|
@ -72,12 +72,24 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aero-ical"
|
||||||
|
version = "0.3.0"
|
||||||
|
dependencies = [
|
||||||
|
"aero-dav",
|
||||||
|
"chrono",
|
||||||
|
"icalendar",
|
||||||
|
"nom 7.1.3",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aero-proto"
|
name = "aero-proto"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aero-collections",
|
"aero-collections",
|
||||||
"aero-dav",
|
"aero-dav",
|
||||||
|
"aero-ical",
|
||||||
"aero-sasl",
|
"aero-sasl",
|
||||||
"aero-user",
|
"aero-user",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
@ -1110,9 +1122,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chrono"
|
name = "chrono"
|
||||||
version = "0.4.35"
|
version = "0.4.38"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8eaf5903dcbc0a39312feb77df2ff4c76387d591b9fc7b04a238dcf8bb62639a"
|
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android-tzdata",
|
"android-tzdata",
|
||||||
"iana-time-zone",
|
"iana-time-zone",
|
||||||
|
|
|
@ -19,6 +19,7 @@ aero-user = { version = "0.3.0", path = "aero-user" }
|
||||||
aero-bayou = { version = "0.3.0", path = "aero-bayou" }
|
aero-bayou = { version = "0.3.0", path = "aero-bayou" }
|
||||||
aero-sasl = { version = "0.3.0", path = "aero-sasl" }
|
aero-sasl = { version = "0.3.0", path = "aero-sasl" }
|
||||||
aero-dav = { version = "0.3.0", path = "aero-dav" }
|
aero-dav = { version = "0.3.0", path = "aero-dav" }
|
||||||
|
aero-ical = { version = "0.3.0", path = "aero-ical" }
|
||||||
aero-collections = { version = "0.3.0", path = "aero-collections" }
|
aero-collections = { version = "0.3.0", path = "aero-collections" }
|
||||||
aero-proto = { version = "0.3.0", path = "aero-proto" }
|
aero-proto = { version = "0.3.0", path = "aero-proto" }
|
||||||
aerogramme = { version = "0.3.0", path = "aerogramme" }
|
aerogramme = { version = "0.3.0", path = "aerogramme" }
|
||||||
|
|
15
aero-ical/Cargo.toml
Normal file
15
aero-ical/Cargo.toml
Normal file
|
@ -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
|
8
aero-ical/src/lib.rs
Normal file
8
aero-ical/src/lib.rs
Normal file
|
@ -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 query;
|
138
aero-ical/src/parser.rs
Normal file
138
aero-ical/src/parser.rs
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
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 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
280
aero-ical/src/query.rs
Normal file
280
aero-ical/src/query.rs
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
use aero_dav::caltypes as cal;
|
||||||
|
use crate::parser as parser;
|
||||||
|
|
||||||
|
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_comp = components
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.name.as_str() == filter.name.as_str());
|
||||||
|
|
||||||
|
// Filter according to rules
|
||||||
|
match (maybe_comp, &filter.additional_rules) {
|
||||||
|
(Some(_), None) => true,
|
||||||
|
(None, Some(cal::CompFilterRules::IsNotDefined)) => true,
|
||||||
|
(None, None) => false,
|
||||||
|
(Some(_), Some(cal::CompFilterRules::IsNotDefined)) => false,
|
||||||
|
(None, Some(cal::CompFilterRules::Matches(_))) => false,
|
||||||
|
(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) {
|
||||||
|
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 single_prop = props
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.name.as_str() == single_filter.name.0.as_str());
|
||||||
|
match (&single_filter.additional_rules, single_prop) {
|
||||||
|
(None, Some(_)) | (Some(cal::PropFilterRules::IsNotDefined), None) => true,
|
||||||
|
(None, None)
|
||||||
|
| (Some(cal::PropFilterRules::IsNotDefined), Some(_))
|
||||||
|
| (Some(cal::PropFilterRules::Match(_)), None) => false,
|
||||||
|
(Some(cal::PropFilterRules::Match(pattern)), Some(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 maybe_param = prop.params.iter().find(|candidate| {
|
||||||
|
candidate.key.as_str() == single_param_filter.name.as_str()
|
||||||
|
});
|
||||||
|
|
||||||
|
match (maybe_param, &single_param_filter.additional_rules) {
|
||||||
|
(Some(_), None) => true,
|
||||||
|
(None, None) => false,
|
||||||
|
(Some(_), Some(cal::ParamFilterMatch::IsNotDefined)) => false,
|
||||||
|
(None, Some(cal::ParamFilterMatch::IsNotDefined)) => true,
|
||||||
|
(None, Some(cal::ParamFilterMatch::Match(_))) => false,
|
||||||
|
(Some(param), Some(cal::ParamFilterMatch::Match(txt_match))) => {
|
||||||
|
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() {
|
||||||
|
return prop_date(properties, "TRIGGER");
|
||||||
|
}
|
||||||
|
|
||||||
|
// B.2 Otherwise it's a timedelta relative to a parent field.
|
||||||
|
// C.1 Parse the timedelta value, returns early if invalid
|
||||||
|
|
||||||
|
// C.2 Get the parent reference absolute datetime, returns early if invalid
|
||||||
|
let maybe_related_field = 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");
|
||||||
|
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!()
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
// (start <= trigger-time) AND (end > trigger-time)
|
||||||
|
false
|
||||||
|
},
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ license = "EUPL-1.2"
|
||||||
description = "Binding between Aerogramme's internal components and well-known protocols"
|
description = "Binding between Aerogramme's internal components and well-known protocols"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
aero-ical.workspace = true
|
||||||
aero-sasl.workspace = true
|
aero-sasl.workspace = true
|
||||||
aero-dav.workspace = true
|
aero-dav.workspace = true
|
||||||
aero-user.workspace = true
|
aero-user.workspace = true
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use futures::stream::{StreamExt, TryStreamExt};
|
use futures::stream::{StreamExt, TryStreamExt};
|
||||||
use http_body_util::combinators::{BoxBody, UnsyncBoxBody};
|
use http_body_util::combinators::UnsyncBoxBody;
|
||||||
use http_body_util::BodyStream;
|
use http_body_util::BodyStream;
|
||||||
use http_body_util::StreamBody;
|
use http_body_util::StreamBody;
|
||||||
use hyper::body::Frame;
|
use hyper::body::Frame;
|
||||||
|
@ -11,10 +11,11 @@ use aero_collections::user::User;
|
||||||
use aero_dav::caltypes as cal;
|
use aero_dav::caltypes as cal;
|
||||||
use aero_dav::realization::All;
|
use aero_dav::realization::All;
|
||||||
use aero_dav::types as dav;
|
use aero_dav::types as dav;
|
||||||
|
use aero_ical::query::is_component_match;
|
||||||
|
|
||||||
use crate::dav::codec;
|
use crate::dav::codec;
|
||||||
use crate::dav::codec::{depth, deserialize, serialize, text_body};
|
use crate::dav::codec::{depth, deserialize, serialize, text_body};
|
||||||
use crate::dav::node::{DavNode, PutPolicy};
|
use crate::dav::node::DavNode;
|
||||||
use crate::dav::resource::RootNode;
|
use crate::dav::resource::RootNode;
|
||||||
|
|
||||||
pub(super) type ArcUser = std::sync::Arc<User>;
|
pub(super) type ArcUser = std::sync::Arc<User>;
|
||||||
|
@ -373,185 +374,9 @@ fn apply_filter<'a>(
|
||||||
tracing::debug!(filter=?root_filter, "calendar-query filter");
|
tracing::debug!(filter=?root_filter, "calendar-query filter");
|
||||||
|
|
||||||
// Adjust return value according to filter
|
// Adjust return value according to filter
|
||||||
match is_component_match(&[fake_vcal_component], root_filter) {
|
match is_component_match(&fake_vcal_component, &[fake_vcal_component.clone()], root_filter) {
|
||||||
true => Some(Ok(single_node)),
|
true => Some(Ok(single_node)),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ical_parse_date(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
|
|
||||||
};
|
|
||||||
|
|
||||||
NaiveDateTime::parse_from_str(dt, tmpl)
|
|
||||||
.ok()
|
|
||||||
.map(|v| v.and_utc())
|
|
||||||
}
|
|
||||||
|
|
||||||
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(ical_parse_date)
|
|
||||||
.flatten()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_properties_match(props: &[icalendar::parser::Property], filters: &[cal::PropFilter]) -> bool {
|
|
||||||
filters.iter().all(|single_filter| {
|
|
||||||
// Find the property
|
|
||||||
let single_prop = props
|
|
||||||
.iter()
|
|
||||||
.find(|candidate| candidate.name.as_str() == single_filter.name.0.as_str());
|
|
||||||
match (&single_filter.additional_rules, single_prop) {
|
|
||||||
(None, Some(_)) | (Some(cal::PropFilterRules::IsNotDefined), None) => true,
|
|
||||||
(None, None)
|
|
||||||
| (Some(cal::PropFilterRules::IsNotDefined), Some(_))
|
|
||||||
| (Some(cal::PropFilterRules::Match(_)), None) => false,
|
|
||||||
(Some(cal::PropFilterRules::Match(pattern)), Some(prop)) => {
|
|
||||||
// check value
|
|
||||||
match &pattern.time_or_text {
|
|
||||||
Some(cal::TimeOrText::Time(time_range)) => {
|
|
||||||
let maybe_parsed_date = ical_parse_date(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 maybe_param = prop.params.iter().find(|candidate| {
|
|
||||||
candidate.key.as_str() == single_param_filter.name.as_str()
|
|
||||||
});
|
|
||||||
|
|
||||||
match (maybe_param, &single_param_filter.additional_rules) {
|
|
||||||
(Some(_), None) => true,
|
|
||||||
(None, None) => false,
|
|
||||||
(Some(_), Some(cal::ParamFilterMatch::IsNotDefined)) => false,
|
|
||||||
(None, Some(cal::ParamFilterMatch::IsNotDefined)) => true,
|
|
||||||
(None, Some(cal::ParamFilterMatch::Match(_))) => false,
|
|
||||||
(Some(param), Some(cal::ParamFilterMatch::Match(txt_match))) => {
|
|
||||||
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 is_in_time_range(
|
|
||||||
properties: &[icalendar::parser::Property],
|
|
||||||
time_range: &cal::TimeRange,
|
|
||||||
) -> bool {
|
|
||||||
//@FIXME too naive: https://datatracker.ietf.org/doc/html/rfc4791#section-9.9
|
|
||||||
|
|
||||||
let (dtstart, dtend) = match (
|
|
||||||
prop_date(properties, "DTSTART"),
|
|
||||||
prop_date(properties, "DTEND"),
|
|
||||||
) {
|
|
||||||
(Some(start), None) => (start, start),
|
|
||||||
(None, Some(end)) => (end, end),
|
|
||||||
(Some(start), Some(end)) => (start, end),
|
|
||||||
_ => {
|
|
||||||
tracing::warn!("unable to extract DTSTART and DTEND from VEVENT");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
tracing::trace!(event_start=?dtstart, event_end=?dtend, filter=?time_range, "apply filter on VEVENT");
|
|
||||||
match time_range {
|
|
||||||
cal::TimeRange::OnlyStart(after) => &dtend >= after,
|
|
||||||
cal::TimeRange::OnlyEnd(before) => &dtstart <= before,
|
|
||||||
cal::TimeRange::FullRange(after, before) => &dtend >= after && &dtstart <= before,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
use chrono::NaiveDateTime;
|
|
||||||
fn is_component_match(
|
|
||||||
components: &[icalendar::parser::Component],
|
|
||||||
filter: &cal::CompFilter,
|
|
||||||
) -> bool {
|
|
||||||
// Find the component among the list
|
|
||||||
let maybe_comp = components
|
|
||||||
.iter()
|
|
||||||
.find(|candidate| candidate.name.as_str() == filter.name.as_str());
|
|
||||||
|
|
||||||
// Filter according to rules
|
|
||||||
match (maybe_comp, &filter.additional_rules) {
|
|
||||||
(Some(_), None) => true,
|
|
||||||
(None, Some(cal::CompFilterRules::IsNotDefined)) => true,
|
|
||||||
(None, None) => false,
|
|
||||||
(Some(_), Some(cal::CompFilterRules::IsNotDefined)) => false,
|
|
||||||
(None, Some(cal::CompFilterRules::Matches(_))) => false,
|
|
||||||
(Some(component), Some(cal::CompFilterRules::Matches(matcher))) => {
|
|
||||||
// check time range
|
|
||||||
if let Some(time_range) = &matcher.time_range {
|
|
||||||
if !is_in_time_range(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.components.as_ref(), &inner_filter)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -684,6 +684,7 @@ fn rfc4791_webdav_caldav() {
|
||||||
)
|
)
|
||||||
.send()?;
|
.send()?;
|
||||||
//@FIXME not yet supported. returns DAV: 1 ; expects DAV: 1 calendar-access
|
//@FIXME not yet supported. returns DAV: 1 ; expects DAV: 1 calendar-access
|
||||||
|
// Not used by any client I know, so not implementing it now.
|
||||||
|
|
||||||
// --- REPORT calendar-query ---
|
// --- REPORT calendar-query ---
|
||||||
//@FIXME missing support for calendar-data...
|
//@FIXME missing support for calendar-data...
|
||||||
|
@ -754,6 +755,43 @@ fn rfc4791_webdav_caldav() {
|
||||||
assert_eq!(multistatus.responses.len(), 1);
|
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#"<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<C:calendar-query xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<D:prop xmlns:D="DAV:">
|
||||||
|
<D:getetag/>
|
||||||
|
<C:calendar-data/>
|
||||||
|
</D:prop>
|
||||||
|
<C:filter>
|
||||||
|
<C:comp-filter name="VCALENDAR">
|
||||||
|
<C:comp-filter name="VTODO">
|
||||||
|
<C:comp-filter name="VALARM">
|
||||||
|
<C:time-range start="20060106T100000Z" end="20060107T100000Z"/>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:filter>
|
||||||
|
</C:calendar-query>"#;
|
||||||
|
let resp = http
|
||||||
|
.request(
|
||||||
|
reqwest::Method::from_bytes(b"REPORT")?,
|
||||||
|
"http://localhost:8087/alice/calendar/Personal/",
|
||||||
|
)
|
||||||
|
.body(cal_query)
|
||||||
|
.send()?;
|
||||||
|
assert_eq!(resp.status(), 207);
|
||||||
|
let multistatus = dav_deserialize::<dav::Multistatus<All>>(&resp.text()?);
|
||||||
|
//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 ---
|
// --- REPORT calendar-multiget ---
|
||||||
let cal_query = r#"<?xml version="1.0" encoding="utf-8" ?>
|
let cal_query = r#"<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
|
Loading…
Reference in a new issue