Merge branch 'ical_unknown_fields'

This commit is contained in:
daladim 2021-11-10 22:22:57 +01:00
commit 582c187e04
10 changed files with 222 additions and 108 deletions

8
Cargo.lock generated
View file

@ -1,5 +1,7 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "0.7.15" version = "0.7.15"
@ -341,10 +343,10 @@ dependencies = [
[[package]] [[package]]
name = "ical" name = "ical"
version = "0.7.0" version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/daladim/ical-rs.git?branch=ical_serde#f3a182eee5f1f6acf44fa1512839602d7105a899"
checksum = "4a9f7215ad0d77e69644570dee000c7678a47ba7441062c1b5f918adde0d73cf"
dependencies = [ dependencies = [
"serde",
"thiserror", "thiserror",
] ]

View file

@ -28,7 +28,10 @@ serde_json = "1.0"
async-trait = "0.1" async-trait = "0.1"
uuid = { version = "0.8", features = ["v4"] } uuid = { version = "0.8", features = ["v4"] }
sanitize-filename = "0.3" sanitize-filename = "0.3"
ical = "0.7" ical = { version = "0.6", features = ["serde-derive"] }
ics = "0.5" ics = "0.5"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
csscolorparser = { version = "0.5", features = ["serde"] } csscolorparser = { version = "0.5", features = ["serde"] }
[patch.crates-io]
ical = { git = "https://github.com/daladim/ical-rs.git", branch = "ical_serde" }

View file

@ -32,6 +32,10 @@ impl Event {
&self.name &self.name
} }
pub fn ical_prod_id(&self) -> &str {
unimplemented!()
}
pub fn creation_date(&self) -> Option<&DateTime<Utc>> { pub fn creation_date(&self) -> Option<&DateTime<Utc>> {
unimplemented!() unimplemented!()
} }

View file

@ -5,33 +5,38 @@ use std::error::Error;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use ics::properties::{Completed, Created, LastModified, PercentComplete, Status, Summary}; use ics::properties::{Completed, Created, LastModified, PercentComplete, Status, Summary};
use ics::{ICalendar, ToDo}; 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::item::Item;
use crate::task::CompletionStatus; 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` /// Create an iCal item from a `crate::item::Item`
pub fn build_from(item: &Item) -> Result<String, Box<dyn Error>> { pub fn build_from(item: &Item) -> Result<String, Box<dyn Error>> {
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<String, Box<dyn Error>> {
let s_last_modified = format_date_time(task.last_modified());
let mut todo = ToDo::new( let mut todo = ToDo::new(
item.uid(), task.uid(),
s_last_modified.clone(), s_last_modified.clone(),
); );
item.creation_date().map(|dt| task.creation_date().map(|dt|
todo.push(Created::new(format_date_time(dt))) todo.push(Created::new(format_date_time(dt)))
); );
todo.push(LastModified::new(s_last_modified)); todo.push(LastModified::new(s_last_modified));
todo.push(Summary::new(item.name())); todo.push(Summary::new(task.name()));
match item { match task.completion_status() {
Item::Task(t) => {
match t.completion_status() {
CompletionStatus::Uncompleted => { CompletionStatus::Uncompleted => {
todo.push(Status::needs_action()); todo.push(Status::needs_action());
}, },
@ -43,13 +48,14 @@ pub fn build_from(item: &Item) -> Result<String, Box<dyn Error>> {
todo.push(Status::completed()); todo.push(Status::completed());
} }
} }
},
_ => { // Also add fields that we have not handled
unimplemented!() 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", ical_product_id()); let mut calendar = ICalendar::new("2.0", task.ical_prod_id());
calendar.add_todo(todo); calendar.add_todo(todo);
Ok(calendar.to_string()) Ok(calendar.to_string())
@ -60,10 +66,26 @@ fn format_date_time(dt: &DateTime<Utc>) -> 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::Task; use crate::Task;
use crate::settings::{ORG_NAME, PRODUCT_NAME};
#[test] #[test]
fn test_ical_from_completed_task() { fn test_ical_from_completed_task() {

View file

@ -6,3 +6,51 @@ mod parser;
pub use parser::parse; pub use parser::parse;
mod builder; mod builder;
pub use builder::build_from; 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);
}
}
}

View file

@ -17,13 +17,17 @@ use crate::Event;
pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result<Item, Box<dyn Error>> { pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result<Item, Box<dyn Error>> {
let mut reader = ical::IcalParser::new(content.as_bytes()); let mut reader = ical::IcalParser::new(content.as_bytes());
let parsed_item = match reader.next() { 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 { 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, 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)? { let item = match assert_single_type(&parsed_item)? {
CurrentType::Event(_) => { CurrentType::Event(_) => {
Item::Event(Event::new()) Item::Event(Event::new())
@ -36,11 +40,29 @@ pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result<
let mut last_modified = None; let mut last_modified = None;
let mut completion_date = None; let mut completion_date = None;
let mut creation_date = None; let mut creation_date = None;
let mut extra_parameters = Vec::new();
for prop in &todo.properties { for prop in &todo.properties {
if prop.name == "SUMMARY" { match prop.name.as_str() {
name = prop.value.clone(); "SUMMARY" => { name = prop.value.clone() },
} "UID" => { uid = prop.value.clone() },
if prop.name == "STATUS" { "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: // Possible values:
// "NEEDS-ACTION" ;Indicates to-do needs action. // "NEEDS-ACTION" ;Indicates to-do needs action.
// "COMPLETED" ;Indicates to-do completed. // "COMPLETED" ;Indicates to-do completed.
@ -50,24 +72,10 @@ pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result<
completed = true; completed = true;
} }
} }
if prop.name == "UID" { _ => {
uid = prop.value.clone(); // 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 == "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 { 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), 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<String>) -> Option<DateTime<Utc>
} }
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> { enum CurrentType<'a> {
Event(&'a IcalEvent), Event(&'a IcalEvent),
Todo(&'a IcalTodo), Todo(&'a IcalTodo),

View file

@ -19,48 +19,27 @@ pub enum Item {
Task(crate::task::Task), 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 { impl Item {
pub fn id(&self) -> &ItemId { synthetise_common_getter!(id, &ItemId);
match self { synthetise_common_getter!(uid, &str);
Item::Event(e) => e.id(), synthetise_common_getter!(name, &str);
Item::Task(t) => t.id(), synthetise_common_getter!(creation_date, Option<&DateTime<Utc>>);
} synthetise_common_getter!(last_modified, &DateTime<Utc>);
} 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<Utc>> {
match self {
Item::Event(e) => e.creation_date(),
Item::Task(t) => t.creation_date(),
}
}
pub fn last_modified(&self) -> &DateTime<Utc> {
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) { pub fn set_sync_status(&mut self, new_status: SyncStatus) {
match self { match self {
Item::Event(e) => e.set_sync_status(new_status), Item::Event(e) => e.set_sync_status(new_status),

View file

@ -3,6 +3,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use ical::property::Property;
use crate::item::ItemId; use crate::item::ItemId;
use crate::item::SyncStatus; use crate::item::SyncStatus;
@ -51,6 +52,13 @@ pub struct Task {
/// The display name of the task /// The display name of the task
name: String, 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<Property>,
} }
@ -66,13 +74,17 @@ impl Task {
let new_completion_status = if completed { let new_completion_status = if completed {
CompletionStatus::Completed(Some(Utc::now())) CompletionStatus::Completed(Some(Utc::now()))
} else { CompletionStatus::Uncompleted }; } 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 /// Create a new Task instance, that may be synced on the server already
pub fn new_with_parameters(name: String, uid: String, id: ItemId, pub fn new_with_parameters(name: String, uid: String, id: ItemId,
completion_status: CompletionStatus, completion_status: CompletionStatus,
sync_status: SyncStatus, creation_date: Option<DateTime<Utc>>, last_modified: DateTime<Utc>) -> Self sync_status: SyncStatus, creation_date: Option<DateTime<Utc>>, last_modified: DateTime<Utc>,
ical_prod_id: String, extra_parameters: Vec<Property>,
) -> Self
{ {
Self { Self {
id, id,
@ -82,6 +94,8 @@ impl Task {
sync_status, sync_status,
creation_date, creation_date,
last_modified, last_modified,
ical_prod_id,
extra_parameters,
} }
} }
@ -89,10 +103,12 @@ impl Task {
pub fn uid(&self) -> &str { &self.uid } pub fn uid(&self) -> &str { &self.uid }
pub fn name(&self) -> &str { &self.name } pub fn name(&self) -> &str { &self.name }
pub fn completed(&self) -> bool { self.completion_status.is_completed() } 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 sync_status(&self) -> &SyncStatus { &self.sync_status }
pub fn last_modified(&self) -> &DateTime<Utc> { &self.last_modified } pub fn last_modified(&self) -> &DateTime<Utc> { &self.last_modified }
pub fn creation_date(&self) -> Option<&DateTime<Utc>> { self.creation_date.as_ref() } pub fn creation_date(&self) -> Option<&DateTime<Utc>> { self.creation_date.as_ref() }
pub fn completion_status(&self) -> &CompletionStatus { &self.completion_status } pub fn completion_status(&self) -> &CompletionStatus { &self.completion_status }
pub fn extra_parameters(&self) -> &[Property] { &self.extra_parameters }
#[cfg(any(test, feature = "integration_tests"))] #[cfg(any(test, feature = "integration_tests"))]
pub fn has_same_observable_content_as(&self, other: &Task) -> bool { pub fn has_same_observable_content_as(&self, other: &Task) -> bool {

View file

@ -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

View file

@ -380,7 +380,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
String::from("Task Q, created on the server"), String::from("Task Q, created on the server"),
id_q.to_string(), id_q, id_q.to_string(), id_q,
CompletionStatus::Uncompleted, 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{ after_sync: LocatedState::BothSynced( ItemState{
calendar: third_cal.clone(), calendar: third_cal.clone(),
@ -400,7 +400,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
String::from("Task R, created locally"), String::from("Task R, created locally"),
id_r.to_string(), id_r, id_r.to_string(), id_r,
CompletionStatus::Uncompleted, 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(), remote_changes_to_apply: Vec::new(),
after_sync: LocatedState::BothSynced( ItemState{ after_sync: LocatedState::BothSynced( ItemState{
@ -578,7 +578,8 @@ pub fn scenarii_transient_task() -> Vec<ItemScenario> {
String::from("A transient task that will be deleted before the sync"), String::from("A transient task that will be deleted before the sync"),
id_transient.to_string(), id_transient, id_transient.to_string(), id_transient,
CompletionStatus::Uncompleted, 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")), ChangeToApply::Rename(String::from("A new name")),
@ -642,6 +643,7 @@ async fn populate_test_provider(scenarii: &[ItemScenario], mock_behaviour: Arc<M
sync_status, sync_status,
Some(now), Some(now),
now, now,
"prod_id".to_string(), Vec::new(),
)); ));
match required_state { match required_state {