|
|
|
|
@ -2,26 +2,33 @@
|
|
|
|
|
|
|
|
|
|
use std::error::Error; |
|
|
|
|
|
|
|
|
|
use chrono::{Date, DateTime, NaiveDate, NaiveTime, TimeZone, Utc}; |
|
|
|
|
use ical::parser::ical::component::{IcalCalendar, IcalEvent, IcalTodo}; |
|
|
|
|
use chrono::{DateTime, TimeZone, Utc}; |
|
|
|
|
use url::Url; |
|
|
|
|
|
|
|
|
|
use crate::Item; |
|
|
|
|
use crate::item::SyncStatus; |
|
|
|
|
use crate::Task; |
|
|
|
|
use crate::task::CompletionStatus; |
|
|
|
|
use crate::Event; |
|
|
|
|
|
|
|
|
|
use crate::Item; |
|
|
|
|
use crate::Task; |
|
|
|
|
|
|
|
|
|
/// Parse an iCal file into the internal representation [`crate::Item`]
|
|
|
|
|
pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<Item, Box<dyn Error>> { |
|
|
|
|
pub fn parse( |
|
|
|
|
content: &str, |
|
|
|
|
item_url: Url, |
|
|
|
|
sync_status: SyncStatus, |
|
|
|
|
) -> Result<Item, Box<dyn Error>> { |
|
|
|
|
let mut reader = ical::IcalParser::new(content.as_bytes()); |
|
|
|
|
let parsed_item = match reader.next() { |
|
|
|
|
None => return Err(format!("Invalid iCal data to parse for item {}", item_url).into()), |
|
|
|
|
Some(item) => match item { |
|
|
|
|
Err(err) => return Err(format!("Unable to parse iCal data for item {}: {}", item_url, err).into()), |
|
|
|
|
Err(err) => { |
|
|
|
|
return Err( |
|
|
|
|
format!("Unable to parse iCal data for item {}: {}", item_url, err).into(), |
|
|
|
|
) |
|
|
|
|
} |
|
|
|
|
Ok(item) => item, |
|
|
|
|
} |
|
|
|
|
}, |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
let ical_prod_id = extract_ical_prod_id(&parsed_item) |
|
|
|
|
@ -29,9 +36,93 @@ pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<It
|
|
|
|
|
.unwrap_or_else(|| super::default_prod_id()); |
|
|
|
|
|
|
|
|
|
let item = match assert_single_type(&parsed_item)? { |
|
|
|
|
CurrentType::Event(_) => { |
|
|
|
|
Item::Event(Event::new()) |
|
|
|
|
}, |
|
|
|
|
CurrentType::Event(event) => { |
|
|
|
|
let mut name = None; |
|
|
|
|
let mut uid = None; |
|
|
|
|
let mut last_modified = None; |
|
|
|
|
let mut creation_date = None; |
|
|
|
|
let mut start_date = None; |
|
|
|
|
let mut end_date = None; |
|
|
|
|
let mut extra_parameters = Vec::new(); |
|
|
|
|
|
|
|
|
|
for prop in &event.properties { |
|
|
|
|
match prop.name.as_str() { |
|
|
|
|
"SUMMARY" => name = prop.value.clone(), |
|
|
|
|
"UID" => uid = prop.value.clone(), |
|
|
|
|
"DTSTART" => { |
|
|
|
|
start_date = parse_date_time_from_property(&prop.value); |
|
|
|
|
} |
|
|
|
|
"DTEND" => { |
|
|
|
|
end_date = parse_date_time_from_property(&prop.value); |
|
|
|
|
} |
|
|
|
|
"DTSTAMP" => { |
|
|
|
|
last_modified = parse_date_time_from_property(&prop.value); |
|
|
|
|
} |
|
|
|
|
"LAST-MODIFIED" => { |
|
|
|
|
last_modified = parse_date_time_from_property(&prop.value); |
|
|
|
|
} |
|
|
|
|
"CREATED" => { |
|
|
|
|
// The property can be specified once, but is not mandatory
|
|
|
|
|
creation_date = parse_date_time_from_property(&prop.value) |
|
|
|
|
} |
|
|
|
|
_ => { |
|
|
|
|
// This field is not supported. Let's store it anyway, so that we are able to re-create an identical iCal file
|
|
|
|
|
extra_parameters.push(prop.clone()); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
let name = match name { |
|
|
|
|
Some(name) => name, |
|
|
|
|
None => return Err(format!("Missing name for item {}", item_url).into()), |
|
|
|
|
}; |
|
|
|
|
let uid = match uid { |
|
|
|
|
Some(uid) => uid, |
|
|
|
|
None => return Err(format!("Missing UID for item {}", item_url).into()), |
|
|
|
|
}; |
|
|
|
|
let last_modified = match last_modified { |
|
|
|
|
Some(dt) => dt, |
|
|
|
|
None => { |
|
|
|
|
return Err(format!( |
|
|
|
|
"Missing DTSTAMP for item {}, but this is required by RFC5545", |
|
|
|
|
item_url |
|
|
|
|
) |
|
|
|
|
.into()) |
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
let start_date = match start_date { |
|
|
|
|
Some(dt) => dt, |
|
|
|
|
None => { |
|
|
|
|
return Err(format!( |
|
|
|
|
"Missing DTSTART for item {}, but this is required by RFC5545", |
|
|
|
|
item_url |
|
|
|
|
) |
|
|
|
|
.into()) |
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
let end_date = match end_date { |
|
|
|
|
Some(dt) => dt, |
|
|
|
|
None => { |
|
|
|
|
return Err(format!( |
|
|
|
|
"Missing DTEND for item {}, but this is required by RFC5545", |
|
|
|
|
item_url |
|
|
|
|
) |
|
|
|
|
.into()) |
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
Item::Event(Event::new_with_parameters( |
|
|
|
|
name, |
|
|
|
|
uid, |
|
|
|
|
item_url, |
|
|
|
|
sync_status, |
|
|
|
|
creation_date, |
|
|
|
|
last_modified, |
|
|
|
|
start_date, |
|
|
|
|
end_date, |
|
|
|
|
ical_prod_id, |
|
|
|
|
extra_parameters, |
|
|
|
|
)) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
CurrentType::Todo(todo) => { |
|
|
|
|
let mut name = None; |
|
|
|
|
@ -39,13 +130,14 @@ pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<It
|
|
|
|
|
let mut completed = false; |
|
|
|
|
let mut last_modified = None; |
|
|
|
|
let mut completion_date = None; |
|
|
|
|
let mut completion_percent = None; |
|
|
|
|
let mut creation_date = None; |
|
|
|
|
let mut extra_parameters = Vec::new(); |
|
|
|
|
|
|
|
|
|
for prop in &todo.properties { |
|
|
|
|
match prop.name.as_str() { |
|
|
|
|
"SUMMARY" => { name = prop.value.clone() }, |
|
|
|
|
"UID" => { uid = prop.value.clone() }, |
|
|
|
|
"SUMMARY" => name = prop.value.clone(), |
|
|
|
|
"UID" => uid = prop.value.clone(), |
|
|
|
|
"DTSTAMP" => { |
|
|
|
|
// The property can be specified once, but is not mandatory
|
|
|
|
|
// "This property specifies the date and time that the information associated with
|
|
|
|
|
@ -53,7 +145,7 @@ pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<It
|
|
|
|
|
// "In the case of an iCalendar object that doesn't specify a "METHOD"
|
|
|
|
|
// property [e.g.: VTODO and VEVENT], this property is equivalent to the "LAST-MODIFIED" property".
|
|
|
|
|
last_modified = parse_date_time_from_property(&prop.value); |
|
|
|
|
}, |
|
|
|
|
} |
|
|
|
|
"LAST-MODIFIED" => { |
|
|
|
|
// The property can be specified once, but is not mandatory
|
|
|
|
|
// "This property specifies the date and time that the information associated with
|
|
|
|
|
@ -66,11 +158,16 @@ pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<It
|
|
|
|
|
// "This property defines the date and time that a to-do was
|
|
|
|
|
// actually completed."
|
|
|
|
|
completion_date = parse_date_time_from_property(&prop.value) |
|
|
|
|
}, |
|
|
|
|
} |
|
|
|
|
"PERCENT-COMPLETE" => { |
|
|
|
|
if let Some(value) = &prop.value { |
|
|
|
|
completion_percent = value.parse().ok() |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
"CREATED" => { |
|
|
|
|
// The property can be specified once, but is not mandatory
|
|
|
|
|
creation_date = parse_date_time_from_property(&prop.value) |
|
|
|
|
}, |
|
|
|
|
} |
|
|
|
|
"STATUS" => { |
|
|
|
|
// Possible values:
|
|
|
|
|
// "NEEDS-ACTION" ;Indicates to-do needs action.
|
|
|
|
|
@ -97,7 +194,13 @@ pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<It
|
|
|
|
|
}; |
|
|
|
|
let last_modified = match last_modified { |
|
|
|
|
Some(dt) => dt, |
|
|
|
|
None => return Err(format!("Missing DTSTAMP for item {}, but this is required by RFC5545", item_url).into()), |
|
|
|
|
None => { |
|
|
|
|
return Err(format!( |
|
|
|
|
"Missing DTSTAMP for item {}, but this is required by RFC5545", |
|
|
|
|
item_url |
|
|
|
|
) |
|
|
|
|
.into()) |
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
let completion_status = match completed { |
|
|
|
|
false => { |
|
|
|
|
@ -105,15 +208,29 @@ pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<It
|
|
|
|
|
log::warn!("Task {:?} has an inconsistent content: its STATUS is not completed, yet it has a COMPLETED timestamp at {:?}", uid, completion_date); |
|
|
|
|
} |
|
|
|
|
CompletionStatus::Uncompleted |
|
|
|
|
}, |
|
|
|
|
} |
|
|
|
|
true => CompletionStatus::Completed(completion_date), |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
Item::Task(Task::new_with_parameters(name, uid, item_url, completion_status, sync_status, creation_date, last_modified, ical_prod_id, extra_parameters)) |
|
|
|
|
}, |
|
|
|
|
let completion_percent = completion_percent.unwrap_or(match completed { |
|
|
|
|
false => 0f32, |
|
|
|
|
true => 100f32, |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
Item::Task(Task::new_with_parameters( |
|
|
|
|
name, |
|
|
|
|
uid, |
|
|
|
|
item_url, |
|
|
|
|
completion_status, |
|
|
|
|
completion_percent, |
|
|
|
|
sync_status, |
|
|
|
|
creation_date, |
|
|
|
|
last_modified, |
|
|
|
|
ical_prod_id, |
|
|
|
|
extra_parameters, |
|
|
|
|
)) |
|
|
|
|
} |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// What to do with multiple items?
|
|
|
|
|
if reader.next().map(|r| r.is_ok()) == Some(true) { |
|
|
|
|
return Err("Parsing multiple items are not supported".into()); |
|
|
|
|
@ -122,34 +239,38 @@ pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<It
|
|
|
|
|
Ok(item) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fn parse_date(dt: &str) -> Result<DateTime<Utc>, chrono::format::ParseError> { |
|
|
|
|
NaiveDate::parse_from_str("20220413", "%Y%m%d") |
|
|
|
|
.map(|dt| dt.and_time(NaiveTime::from_hms(0, 0, 0))) |
|
|
|
|
.map(|dt| DateTime::from_utc(dt, Utc)) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fn parse_date_time(dt: &str) -> Result<DateTime<Utc>, chrono::format::ParseError> { |
|
|
|
|
Utc.datetime_from_str(dt, "%Y%m%dT%H%M%SZ") |
|
|
|
|
.or_else(|_err| Utc.datetime_from_str(dt, "%Y%m%dT%H%M%S") ) |
|
|
|
|
Utc.datetime_from_str(dt, "%Y%m%dT%H%M%SZ") |
|
|
|
|
.or_else(|_err| Utc.datetime_from_str(dt, "%Y%m%dT%H%M%S")) |
|
|
|
|
.or_else(|_err| parse_date(dt)) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
fn parse_date_time_from_property(value: &Option<String>) -> Option<DateTime<Utc>> { |
|
|
|
|
value.as_ref() |
|
|
|
|
.and_then(|s| { |
|
|
|
|
parse_date_time(s) |
|
|
|
|
value.as_ref().and_then(|s| { |
|
|
|
|
parse_date_time(s) |
|
|
|
|
.map_err(|err| { |
|
|
|
|
log::warn!("Invalid timestamp: {}", s); |
|
|
|
|
log::warn!("Invalid timestamp: '{}'", s); |
|
|
|
|
err |
|
|
|
|
}) |
|
|
|
|
.ok() |
|
|
|
|
}) |
|
|
|
|
}) |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
fn extract_ical_prod_id(item: &IcalCalendar) -> Option<&str> { |
|
|
|
|
for prop in &item.properties { |
|
|
|
|
if &prop.name == "PRODID" { |
|
|
|
|
return prop.value.as_ref().map(|s| s.as_str()) |
|
|
|
|
return prop.value.as_ref().map(|s| s.as_str()); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
None |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
enum CurrentType<'a> { |
|
|
|
|
Event(&'a IcalEvent), |
|
|
|
|
Todo(&'a IcalTodo), |
|
|
|
|
@ -179,7 +300,6 @@ fn assert_single_type<'a>(item: &'a IcalCalendar) -> Result<CurrentType<'a>, Box
|
|
|
|
|
return Err("Only a single TODO or a single EVENT is supported".into()); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
#[cfg(test)] |
|
|
|
|
mod test { |
|
|
|
|
const EXAMPLE_ICAL: &str = r#"BEGIN:VCALENDAR |
|
|
|
|
@ -195,7 +315,7 @@ END:VTODO
|
|
|
|
|
END:VCALENDAR |
|
|
|
|
"#; |
|
|
|
|
|
|
|
|
|
const EXAMPLE_ICAL_COMPLETED: &str = r#"BEGIN:VCALENDAR |
|
|
|
|
const EXAMPLE_ICAL_COMPLETED: &str = r#"BEGIN:VCALENDAR |
|
|
|
|
VERSION:2.0 |
|
|
|
|
PRODID:-//Nextcloud Tasks v0.13.6
|
|
|
|
|
BEGIN:VTODO |
|
|
|
|
@ -211,7 +331,7 @@ END:VTODO
|
|
|
|
|
END:VCALENDAR |
|
|
|
|
"#; |
|
|
|
|
|
|
|
|
|
const EXAMPLE_ICAL_COMPLETED_WITHOUT_A_COMPLETION_DATE: &str = r#"BEGIN:VCALENDAR |
|
|
|
|
const EXAMPLE_ICAL_COMPLETED_WITHOUT_A_COMPLETION_DATE: &str = r#"BEGIN:VCALENDAR |
|
|
|
|
VERSION:2.0 |
|
|
|
|
PRODID:-//Nextcloud Tasks v0.13.6
|
|
|
|
|
BEGIN:VTODO |
|
|
|
|
@ -261,11 +381,17 @@ END:VCALENDAR
|
|
|
|
|
|
|
|
|
|
assert_eq!(task.name(), "Do not forget to do this"); |
|
|
|
|
assert_eq!(task.url(), &item_url); |
|
|
|
|
assert_eq!(task.uid(), "0633de27-8c32-42be-bcb8-63bc879c6185@some-domain.com"); |
|
|
|
|
assert_eq!( |
|
|
|
|
task.uid(), |
|
|
|
|
"0633de27-8c32-42be-bcb8-63bc879c6185@some-domain.com" |
|
|
|
|
); |
|
|
|
|
assert_eq!(task.completed(), false); |
|
|
|
|
assert_eq!(task.completion_status(), &CompletionStatus::Uncompleted); |
|
|
|
|
assert_eq!(task.sync_status(), &sync_status); |
|
|
|
|
assert_eq!(task.last_modified(), &Utc.ymd(2021, 03, 21).and_hms(0, 16, 0)); |
|
|
|
|
assert_eq!( |
|
|
|
|
task.last_modified(), |
|
|
|
|
&Utc.ymd(2021, 03, 21).and_hms(0, 16, 0) |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
#[test] |
|
|
|
|
@ -274,11 +400,19 @@ END:VCALENDAR
|
|
|
|
|
let sync_status = SyncStatus::Synced(version_tag); |
|
|
|
|
let item_url: Url = "http://some.id/for/testing".parse().unwrap(); |
|
|
|
|
|
|
|
|
|
let item = parse(EXAMPLE_ICAL_COMPLETED, item_url.clone(), sync_status.clone()).unwrap(); |
|
|
|
|
let item = parse( |
|
|
|
|
EXAMPLE_ICAL_COMPLETED, |
|
|
|
|
item_url.clone(), |
|
|
|
|
sync_status.clone(), |
|
|
|
|
) |
|
|
|
|
.unwrap(); |
|
|
|
|
let task = item.unwrap_task(); |
|
|
|
|
|
|
|
|
|
assert_eq!(task.completed(), true); |
|
|
|
|
assert_eq!(task.completion_status(), &CompletionStatus::Completed(Some(Utc.ymd(2021, 04, 02).and_hms(8, 15, 57)))); |
|
|
|
|
assert_eq!( |
|
|
|
|
task.completion_status(), |
|
|
|
|
&CompletionStatus::Completed(Some(Utc.ymd(2021, 04, 02).and_hms(8, 15, 57))) |
|
|
|
|
); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
#[test] |
|
|
|
|
@ -287,7 +421,12 @@ END:VCALENDAR
|
|
|
|
|
let sync_status = SyncStatus::Synced(version_tag); |
|
|
|
|
let item_url: Url = "http://some.id/for/testing".parse().unwrap(); |
|
|
|
|
|
|
|
|
|
let item = parse(EXAMPLE_ICAL_COMPLETED_WITHOUT_A_COMPLETION_DATE, item_url.clone(), sync_status.clone()).unwrap(); |
|
|
|
|
let item = parse( |
|
|
|
|
EXAMPLE_ICAL_COMPLETED_WITHOUT_A_COMPLETION_DATE, |
|
|
|
|
item_url.clone(), |
|
|
|
|
sync_status.clone(), |
|
|
|
|
) |
|
|
|
|
.unwrap(); |
|
|
|
|
let task = item.unwrap_task(); |
|
|
|
|
|
|
|
|
|
assert_eq!(task.completed(), true); |
|
|
|
|
|