diff --git a/Cargo.lock b/Cargo.lock index ea4129b..49f3333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,7 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 3 + [[package]] name = "aho-corasick" version = "0.7.15" @@ -341,10 +343,10 @@ dependencies = [ [[package]] name = "ical" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9f7215ad0d77e69644570dee000c7678a47ba7441062c1b5f918adde0d73cf" +version = "0.6.0" +source = "git+https://github.com/daladim/ical-rs.git?branch=ical_serde#f3a182eee5f1f6acf44fa1512839602d7105a899" dependencies = [ + "serde", "thiserror", ] diff --git a/Cargo.toml b/Cargo.toml index 48e93a0..c8b3fa4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,10 @@ serde_json = "1.0" async-trait = "0.1" uuid = { version = "0.8", features = ["v4"] } sanitize-filename = "0.3" -ical = "0.7" +ical = { version = "0.6", features = ["serde-derive"] } ics = "0.5" chrono = { version = "0.4", features = ["serde"] } csscolorparser = { version = "0.5", features = ["serde"] } + +[patch.crates-io] +ical = { git = "https://github.com/daladim/ical-rs.git", branch = "ical_serde" } diff --git a/src/event.rs b/src/event.rs index 621091d..c2149ca 100644 --- a/src/event.rs +++ b/src/event.rs @@ -32,6 +32,10 @@ impl Event { &self.name } + pub fn ical_prod_id(&self) -> &str { + unimplemented!() + } + pub fn creation_date(&self) -> Option<&DateTime> { unimplemented!() } diff --git a/src/ical/builder.rs b/src/ical/builder.rs index 11ca71d..0b3b99c 100644 --- a/src/ical/builder.rs +++ b/src/ical/builder.rs @@ -5,51 +5,57 @@ use std::error::Error; use chrono::{DateTime, Utc}; use ics::properties::{Completed, Created, LastModified, PercentComplete, Status, Summary}; use ics::{ICalendar, ToDo}; +use ics::components::Parameter as IcsParameter; +use ics::components::Property as IcsProperty; +use ical::property::Property as IcalProperty; +use crate::Task; use crate::item::Item; use crate::task::CompletionStatus; -use crate::settings::{ORG_NAME, PRODUCT_NAME}; -fn ical_product_id() -> String { - format!("-//{}//{}//EN", ORG_NAME, PRODUCT_NAME) -} /// Create an iCal item from a `crate::item::Item` pub fn build_from(item: &Item) -> Result> { - let s_last_modified = format_date_time(item.last_modified()); + match item { + Item::Task(t) => build_from_task(t), + _ => unimplemented!(), + } +} + +pub fn build_from_task(task: &Task) -> Result> { + let s_last_modified = format_date_time(task.last_modified()); let mut todo = ToDo::new( - item.uid(), + task.uid(), s_last_modified.clone(), ); - item.creation_date().map(|dt| + task.creation_date().map(|dt| todo.push(Created::new(format_date_time(dt))) ); todo.push(LastModified::new(s_last_modified)); - todo.push(Summary::new(item.name())); + todo.push(Summary::new(task.name())); - match item { - Item::Task(t) => { - match t.completion_status() { - CompletionStatus::Uncompleted => { - todo.push(Status::needs_action()); - }, - CompletionStatus::Completed(completion_date) => { - todo.push(PercentComplete::new("100")); - completion_date.as_ref().map(|dt| todo.push( - Completed::new(format_date_time(dt)) - )); - todo.push(Status::completed()); - } - } - }, - _ => { - unimplemented!() + match task.completion_status() { + CompletionStatus::Uncompleted => { + todo.push(Status::needs_action()); }, + CompletionStatus::Completed(completion_date) => { + todo.push(PercentComplete::new("100")); + completion_date.as_ref().map(|dt| todo.push( + Completed::new(format_date_time(dt)) + )); + todo.push(Status::completed()); + } } - let mut calendar = ICalendar::new("2.0", ical_product_id()); + // Also add fields that we have not handled + for ical_property in task.extra_parameters() { + let ics_property = ical_to_ics_property(ical_property.clone()); + todo.push(ics_property); + } + + let mut calendar = ICalendar::new("2.0", task.ical_prod_id()); calendar.add_todo(todo); Ok(calendar.to_string()) @@ -60,10 +66,26 @@ fn format_date_time(dt: &DateTime) -> String { } +fn ical_to_ics_property(prop: IcalProperty) -> IcsProperty<'static> { + let mut ics_prop = match prop.value { + Some(value) => IcsProperty::new(prop.name, value), + None => IcsProperty::new(prop.name, ""), + }; + prop.params.map(|v| { + for (key, vec_values) in v { + let values = vec_values.join(";"); + ics_prop.add(IcsParameter::new(key, values)); + } + }); + ics_prop +} + + #[cfg(test)] mod tests { use super::*; use crate::Task; + use crate::settings::{ORG_NAME, PRODUCT_NAME}; #[test] fn test_ical_from_completed_task() { diff --git a/src/ical/mod.rs b/src/ical/mod.rs index b8a520e..2f5cecc 100644 --- a/src/ical/mod.rs +++ b/src/ical/mod.rs @@ -6,3 +6,51 @@ mod parser; pub use parser::parse; mod builder; pub use builder::build_from; + +use crate::settings::{ORG_NAME, PRODUCT_NAME}; + +pub fn default_prod_id() -> String { + format!("-//{}//{}//EN", ORG_NAME, PRODUCT_NAME) +} + + + +#[cfg(test)] +mod tests { + use super::*; + + use std::collections::HashSet; + use crate::item::SyncStatus; + + #[test] + fn test_ical_round_trip_serde() { + let ical_with_unknown_fields = std::fs::read_to_string("tests/assets/ical_with_unknown_fields.ics").unwrap(); + + let item_id = "http://item.id".parse().unwrap(); + let sync_status = SyncStatus::NotSynced; + let deserialized = parse(&ical_with_unknown_fields, item_id, sync_status).unwrap(); + let serialized = build_from(&deserialized).unwrap(); + assert_same_fields(&ical_with_unknown_fields, &serialized); + } + + /// Assert the properties are present (possibly in another order) + /// RFC5545 "imposes no ordering of properties within an iCalendar object." + fn assert_same_fields(left: &str, right: &str) { + let left_parts: HashSet<&str> = left.split("\r\n").collect(); + let right_parts: HashSet<&str> = right.split("\r\n").collect(); + + // Let's be more explicit than assert_eq!(left_parts, right_parts); + if left_parts != right_parts { + println!("Only in left:"); + for item in left_parts.difference(&right_parts) { + println!(" * {}", item); + } + println!("Only in right:"); + for item in right_parts.difference(&left_parts) { + println!(" * {}", item); + } + + assert_eq!(left_parts, right_parts); + } + } +} diff --git a/src/ical/parser.rs b/src/ical/parser.rs index 93be645..8be5da5 100644 --- a/src/ical/parser.rs +++ b/src/ical/parser.rs @@ -17,13 +17,17 @@ use crate::Event; pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result> { let mut reader = ical::IcalParser::new(content.as_bytes()); let parsed_item = match reader.next() { - None => return Err(format!("Invalid uCal data to parse for item {}", item_id).into()), + None => return Err(format!("Invalid iCal data to parse for item {}", item_id).into()), Some(item) => match item { - Err(err) => return Err(format!("Unable to parse uCal data for item {}: {}", item_id, err).into()), + Err(err) => return Err(format!("Unable to parse iCal data for item {}: {}", item_id, err).into()), Ok(item) => item, } }; + let ical_prod_id = extract_ical_prod_id(&parsed_item) + .map(|s| s.to_string()) + .unwrap_or_else(|| super::default_prod_id()); + let item = match assert_single_type(&parsed_item)? { CurrentType::Event(_) => { Item::Event(Event::new()) @@ -36,38 +40,42 @@ pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result< let mut last_modified = None; let mut completion_date = None; let mut creation_date = None; + let mut extra_parameters = Vec::new(); + for prop in &todo.properties { - if prop.name == "SUMMARY" { - name = prop.value.clone(); - } - if prop.name == "STATUS" { - // Possible values: - // "NEEDS-ACTION" ;Indicates to-do needs action. - // "COMPLETED" ;Indicates to-do completed. - // "IN-PROCESS" ;Indicates to-do in process of. - // "CANCELLED" ;Indicates to-do was cancelled. - if prop.value.as_ref().map(|s| s.as_str()) == Some("COMPLETED") { - completed = true; + match prop.name.as_str() { + "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 + // the calendar component was last revised in the calendar store." + last_modified = parse_date_time_from_property(&prop.value) + }, + "COMPLETED" => { + // The property can be specified once, but is not mandatory + // "This property defines the date and time that a to-do was + // actually completed." + completion_date = 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) + }, + "STATUS" => { + // Possible values: + // "NEEDS-ACTION" ;Indicates to-do needs action. + // "COMPLETED" ;Indicates to-do completed. + // "IN-PROCESS" ;Indicates to-do in process of. + // "CANCELLED" ;Indicates to-do was cancelled. + if prop.value.as_ref().map(|s| s.as_str()) == Some("COMPLETED") { + completed = true; + } + } + _ => { + // 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()); } - } - if prop.name == "UID" { - uid = prop.value.clone(); - } - if prop.name == "DTSTAMP" { - // The property can be specified once, but is not mandatory - // "This property specifies the date and time that the information associated with - // the calendar component was last revised in the calendar store." - last_modified = parse_date_time_from_property(&prop.value) - } - if prop.name == "COMPLETED" { - // The property can be specified once, but is not mandatory - // "This property defines the date and time that a to-do was - // actually completed." - completion_date = parse_date_time_from_property(&prop.value) - } - if prop.name == "CREATED" { - // The property can be specified once, but is not mandatory - creation_date = parse_date_time_from_property(&prop.value) } } let name = match name { @@ -92,7 +100,7 @@ pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result< true => CompletionStatus::Completed(completion_date), }; - Item::Task(Task::new_with_parameters(name, uid, item_id, completion_status, sync_status, creation_date, last_modified)) + Item::Task(Task::new_with_parameters(name, uid, item_id, completion_status, sync_status, creation_date, last_modified, ical_prod_id, extra_parameters)) }, }; @@ -122,6 +130,16 @@ fn parse_date_time_from_property(value: &Option) -> Option } +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()) + } + } + None +} + + enum CurrentType<'a> { Event(&'a IcalEvent), Todo(&'a IcalTodo), diff --git a/src/item.rs b/src/item.rs index e5ee4f6..c2aaa53 100644 --- a/src/item.rs +++ b/src/item.rs @@ -19,48 +19,27 @@ pub enum Item { Task(crate::task::Task), } +/// Returns `task.$property_name` or `event.$property_name`, depending on whether self is a Task or an Event +macro_rules! synthetise_common_getter { + ($property_name:ident, $return_type:ty) => { + pub fn $property_name(&self) -> $return_type { + match self { + Item::Event(e) => e.$property_name(), + Item::Task(t) => t.$property_name(), + } + } + } +} + impl Item { - pub fn id(&self) -> &ItemId { - match self { - Item::Event(e) => e.id(), - Item::Task(t) => t.id(), - } - } + synthetise_common_getter!(id, &ItemId); + synthetise_common_getter!(uid, &str); + synthetise_common_getter!(name, &str); + synthetise_common_getter!(creation_date, Option<&DateTime>); + synthetise_common_getter!(last_modified, &DateTime); + synthetise_common_getter!(sync_status, &SyncStatus); + synthetise_common_getter!(ical_prod_id, &str); - pub fn uid(&self) -> &str { - match self { - Item::Event(e) => e.uid(), - Item::Task(t) => t.uid(), - } - } - - pub fn name(&self) -> &str { - match self { - Item::Event(e) => e.name(), - Item::Task(t) => t.name(), - } - } - - pub fn creation_date(&self) -> Option<&DateTime> { - match self { - Item::Event(e) => e.creation_date(), - Item::Task(t) => t.creation_date(), - } - } - - pub fn last_modified(&self) -> &DateTime { - match self { - Item::Event(e) => e.last_modified(), - Item::Task(t) => t.last_modified(), - } - } - - pub fn sync_status(&self) -> &SyncStatus { - match self { - Item::Event(e) => e.sync_status(), - Item::Task(t) => t.sync_status(), - } - } pub fn set_sync_status(&mut self, new_status: SyncStatus) { match self { Item::Event(e) => e.set_sync_status(new_status), diff --git a/src/task.rs b/src/task.rs index 00f7e31..91e180a 100644 --- a/src/task.rs +++ b/src/task.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use chrono::{DateTime, Utc}; +use ical::property::Property; use crate::item::ItemId; use crate::item::SyncStatus; @@ -51,6 +52,13 @@ pub struct Task { /// The display name of the task name: String, + + /// The PRODID, as defined in iCal files + ical_prod_id: String, + + /// Extra parameters that have not been parsed from the iCal file (because they're not supported (yet) by this crate). + /// They are needed to serialize this item into an equivalent iCal file + extra_parameters: Vec, } @@ -66,13 +74,17 @@ impl Task { let new_completion_status = if completed { CompletionStatus::Completed(Some(Utc::now())) } else { CompletionStatus::Uncompleted }; - Self::new_with_parameters(name, new_uid, new_item_id, new_completion_status, new_sync_status, new_creation_date, new_last_modified) + let ical_prod_id = crate::ical::default_prod_id(); + let extra_parameters = Vec::new(); + Self::new_with_parameters(name, new_uid, new_item_id, new_completion_status, new_sync_status, new_creation_date, new_last_modified, ical_prod_id, extra_parameters) } /// Create a new Task instance, that may be synced on the server already pub fn new_with_parameters(name: String, uid: String, id: ItemId, completion_status: CompletionStatus, - sync_status: SyncStatus, creation_date: Option>, last_modified: DateTime) -> Self + sync_status: SyncStatus, creation_date: Option>, last_modified: DateTime, + ical_prod_id: String, extra_parameters: Vec, + ) -> Self { Self { id, @@ -82,6 +94,8 @@ impl Task { sync_status, creation_date, last_modified, + ical_prod_id, + extra_parameters, } } @@ -89,10 +103,12 @@ impl Task { pub fn uid(&self) -> &str { &self.uid } pub fn name(&self) -> &str { &self.name } pub fn completed(&self) -> bool { self.completion_status.is_completed() } + pub fn ical_prod_id(&self) -> &str { &self.ical_prod_id } pub fn sync_status(&self) -> &SyncStatus { &self.sync_status } pub fn last_modified(&self) -> &DateTime { &self.last_modified } pub fn creation_date(&self) -> Option<&DateTime> { self.creation_date.as_ref() } pub fn completion_status(&self) -> &CompletionStatus { &self.completion_status } + pub fn extra_parameters(&self) -> &[Property] { &self.extra_parameters } #[cfg(any(test, feature = "integration_tests"))] pub fn has_same_observable_content_as(&self, other: &Task) -> bool { diff --git a/tests/assets/ical_with_unknown_fields.ics b/tests/assets/ical_with_unknown_fields.ics new file mode 100644 index 0000000..c1df431 --- /dev/null +++ b/tests/assets/ical_with_unknown_fields.ics @@ -0,0 +1,20 @@ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Todo Corp LTD//Awesome Product ®//EN +BEGIN:VTODO +UID:20f57387-e116-4702-b463-d352aeaf80d0 +X_FAVOURITE_PAINT_FINISH:matte +DTSTAMP:20211103T214742 +CREATED:20211103T212345 +LAST-MODIFIED:20211103T214742 +SUMMARY:This is a task with ÜTF-8 characters +STATUS:NEEDS-ACTION +DUE:20211103T220000 +PRIORITY:6 +PERCENT-COMPLETE:48 +IMAGE;DISPLAY=BADGE;FMTTYPE=image/png;VALUE=URI:http://example.com/images/p + arty.png +CONFERENCE;FEATURE=PHONE;LABEL=Attendee dial-in;VALUE=URI:tel:+1-888-555-04 + 56,,,555123 +END:VTODO +END:VCALENDAR diff --git a/tests/scenarii.rs b/tests/scenarii.rs index 6c9cacd..5888997 100644 --- a/tests/scenarii.rs +++ b/tests/scenarii.rs @@ -380,7 +380,7 @@ pub fn scenarii_basic() -> Vec { String::from("Task Q, created on the server"), id_q.to_string(), id_q, CompletionStatus::Uncompleted, - SyncStatus::random_synced(), Some(Utc::now()), Utc::now() ) + SyncStatus::random_synced(), Some(Utc::now()), Utc::now(), "prod_id".to_string(), Vec::new() ) ))], after_sync: LocatedState::BothSynced( ItemState{ calendar: third_cal.clone(), @@ -400,7 +400,7 @@ pub fn scenarii_basic() -> Vec { String::from("Task R, created locally"), id_r.to_string(), id_r, CompletionStatus::Uncompleted, - SyncStatus::NotSynced, Some(Utc::now()), Utc::now() ) + SyncStatus::NotSynced, Some(Utc::now()), Utc::now(), "prod_id".to_string(), Vec::new() ) ))], remote_changes_to_apply: Vec::new(), after_sync: LocatedState::BothSynced( ItemState{ @@ -578,7 +578,8 @@ pub fn scenarii_transient_task() -> Vec { String::from("A transient task that will be deleted before the sync"), id_transient.to_string(), id_transient, CompletionStatus::Uncompleted, - SyncStatus::NotSynced, Some(Utc::now()), Utc::now() ) + SyncStatus::NotSynced, Some(Utc::now()), Utc::now(), + "prod_id".to_string(), Vec::new() ) )), ChangeToApply::Rename(String::from("A new name")), @@ -642,6 +643,7 @@ async fn populate_test_provider(scenarii: &[ItemScenario], mock_behaviour: Arc