diff --git a/Cargo.lock b/Cargo.lock index 5effa31..ced8132 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "serde", + "time", + "winapi", +] + [[package]] name = "core-foundation" version = "0.9.1" @@ -437,6 +451,7 @@ version = "0.1.0" dependencies = [ "async-trait", "bitflags", + "chrono", "env_logger", "ical", "ics", @@ -478,6 +493,25 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.13.0" @@ -886,6 +920,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "tinyvec" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index e5bc36d..3753516 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,4 @@ uuid = { version = "0.8", features = ["v4"] } sanitize-filename = "0.3" ical = "0.7" ics = "0.5" +chrono = { version = "0.4", features = ["serde"] } diff --git a/src/event.rs b/src/event.rs index 262292a..fbb0d26 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,6 +1,7 @@ //! Calendar events use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; use crate::item::ItemId; use crate::item::SyncStatus; @@ -31,6 +32,10 @@ impl Event { &self.name } + pub fn last_modified(&self) -> &DateTime { + unimplemented!() + } + pub fn sync_status(&self) -> &SyncStatus { &self.sync_status } diff --git a/src/ical/builder.rs b/src/ical/builder.rs index 275dafa..9671aed 100644 --- a/src/ical/builder.rs +++ b/src/ical/builder.rs @@ -2,7 +2,8 @@ use std::error::Error; -use ics::properties::{Comment, Status, Summary}; +use chrono::{DateTime, Utc}; +use ics::properties::{LastModified, Status, Summary}; use ics::{ICalendar, ToDo}; use crate::item::Item; @@ -14,9 +15,14 @@ fn ical_product_id() -> String { /// Create an iCal item from a `crate::item::Item` pub fn build_from(item: &Item) -> Result> { - let mut todo = ToDo::new(item.uid(), "20181021T190000"); - todo.push(Summary::new("Take pictures of squirrels (with ÜTF-8 chars)")); - todo.push(Comment::new("That's really something I'd like to do one day")); + let s_last_modified = format_date_time(item.last_modified()); + + let mut todo = ToDo::new( + item.uid(), + s_last_modified.clone(), + ); + todo.push(LastModified::new(s_last_modified)); + todo.push(Summary::new(item.name())); match item { Item::Task(t) => { @@ -34,6 +40,9 @@ pub fn build_from(item: &Item) -> Result> { Ok(calendar.to_string()) } +fn format_date_time(dt: &DateTime) -> String { + dt.format("%Y%m%dT%H%M%S").to_string() +} #[cfg(test)] @@ -44,20 +53,21 @@ mod tests { #[test] fn test_ical_from_task() { let cal_id = "http://my.calend.ar/id".parse().unwrap(); + let now = format_date_time(&Utc::now()); + let task = Item::Task(Task::new( - String::from("This is a task"), true, &cal_id + String::from("This is a task with ÜTF-8 characters"), true, &cal_id )); let expected_ical = format!("BEGIN:VCALENDAR\r\n\ VERSION:2.0\r\n\ PRODID:-//{}//{}//EN\r\n\ BEGIN:VTODO\r\n\ UID:{}\r\n\ - DTSTAMP:20181021T190000\r\n\ - SUMMARY:Take pictures of squirrels (with ÜTF-8 chars)\r\n\ - COMMENT:That's really something I'd like to do one day\r\n\ + DTSTAMP:{}\r\n\ + SUMMARY:This is a task with ÜTF-8 characters\r\n\ STATUS:COMPLETED\r\n\ END:VTODO\r\n\ - END:VCALENDAR\r\n", ORG_NAME, PRODUCT_NAME, task.uid()); + END:VCALENDAR\r\n", ORG_NAME, PRODUCT_NAME, task.uid(), now); let ical = build_from(&task); assert_eq!(ical.unwrap(), expected_ical); diff --git a/src/ical/parser.rs b/src/ical/parser.rs index 0bb907e..92eebad 100644 --- a/src/ical/parser.rs +++ b/src/ical/parser.rs @@ -3,6 +3,7 @@ use std::error::Error; use ical::parser::ical::component::{IcalCalendar, IcalEvent, IcalTodo}; +use chrono::{DateTime, TimeZone, Utc}; use crate::Item; use crate::item::SyncStatus; @@ -31,6 +32,7 @@ pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result< let mut name = None; let mut uid = None; let mut completed = false; + let mut last_modified = None; for prop in &todo.properties { if prop.name == "SUMMARY" { name = prop.value.clone(); @@ -48,6 +50,19 @@ pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result< if prop.name == "UID" { uid = prop.value.clone(); } + if prop.name == "DTSTAMP" { + // this property specifies the date and time that the information associated with + // the calendar component was last revised in the calendar store. + last_modified = prop.value.as_ref() + .and_then(|s| { + parse_date_time(s) + .map_err(|err| { + log::warn!("Invalid DTSTAMP: {}", s); + err + }) + .ok() + }) + } } let name = match name { Some(name) => name, @@ -57,8 +72,12 @@ pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result< Some(uid) => uid, None => return Err(format!("Missing UID for item {}", item_id).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_id).into()), + }; - Item::Task(Task::new_with_parameters(name, completed, uid, item_id, sync_status)) + Item::Task(Task::new_with_parameters(name, completed, uid, item_id, sync_status, last_modified)) }, }; @@ -71,6 +90,12 @@ pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result< Ok(item) } +fn parse_date_time(dt: &str) -> Result, chrono::format::ParseError> { + Utc.datetime_from_str(dt, "%Y%m%dT%H%M%S") +} + + + enum CurrentType<'a> { Event(&'a IcalEvent), Todo(&'a IcalTodo), @@ -171,6 +196,7 @@ END:VCALENDAR assert_eq!(task.uid(), "0633de27-8c32-42be-bcb8-63bc879c6185@some-domain.com"); assert_eq!(task.completed(), false); assert_eq!(task.sync_status(), &sync_status); + assert_eq!(task.last_modified(), &Utc.ymd(2021, 03, 21).and_hms(0, 16, 0)); } #[test] diff --git a/src/item.rs b/src/item.rs index 76d59c8..fd37569 100644 --- a/src/item.rs +++ b/src/item.rs @@ -5,6 +5,7 @@ use std::str::FromStr; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use url::Url; +use chrono::{DateTime, Utc}; use crate::resource::Resource; use crate::calendar::CalendarId; @@ -39,6 +40,13 @@ impl Item { } } + 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(), diff --git a/src/task.rs b/src/task.rs index 1588aa5..8dac21e 100644 --- a/src/task.rs +++ b/src/task.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; +use chrono::{DateTime, Utc}; use crate::item::ItemId; use crate::item::SyncStatus; @@ -17,6 +18,8 @@ pub struct Task { /// The sync status of this item sync_status: SyncStatus, + /// The last time this item was modified + last_modified: DateTime, /// The display name of the task name: String, @@ -31,17 +34,19 @@ impl Task { let new_item_id = ItemId::random(parent_calendar_id); let new_sync_status = SyncStatus::NotSynced; let new_uid = Uuid::new_v4().to_hyphenated().to_string(); - Self::new_with_parameters(name, completed, new_uid, new_item_id, new_sync_status) + let new_last_modified = Utc::now(); + Self::new_with_parameters(name, completed, new_uid, new_item_id, new_sync_status, new_last_modified) } /// Create a new Task instance, that may be synced already - pub fn new_with_parameters(name: String, completed: bool, uid: String, id: ItemId, sync_status: SyncStatus) -> Self { + pub fn new_with_parameters(name: String, completed: bool, uid: String, id: ItemId, sync_status: SyncStatus, last_modified: DateTime) -> Self { Self { id, uid, name, sync_status, completed, + last_modified, } } @@ -50,6 +55,7 @@ impl Task { pub fn name(&self) -> &str { &self.name } pub fn completed(&self) -> bool { self.completed } pub fn sync_status(&self) -> &SyncStatus { &self.sync_status } + pub fn last_modified(&self) -> &DateTime { &self.last_modified } pub fn has_same_observable_content_as(&self, other: &Task) -> bool { self.id == other.id @@ -77,10 +83,16 @@ impl Task { } } + fn update_last_modified(&mut self) { + self.last_modified = Utc::now(); + } + + /// Rename a task. /// This updates its "last modified" field pub fn set_name(&mut self, new_name: String) { self.update_sync_status(); + self.update_last_modified(); self.name = new_name; } #[cfg(feature = "local_calendar_mocks_remote_calendars")] @@ -93,6 +105,7 @@ impl Task { /// Set the completion status pub fn set_completed(&mut self, new_value: bool) { self.update_sync_status(); + self.update_last_modified(); self.completed = new_value; } #[cfg(feature = "local_calendar_mocks_remote_calendars")]