Merge branch 'ical_unknown_fields'
This commit is contained in:
commit
582c187e04
10 changed files with 222 additions and 108 deletions
8
Cargo.lock
generated
8
Cargo.lock
generated
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -32,6 +32,10 @@ impl Event {
|
|||
&self.name
|
||||
}
|
||||
|
||||
pub fn ical_prod_id(&self) -> &str {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
pub fn creation_date(&self) -> Option<&DateTime<Utc>> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
|
|
@ -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<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(
|
||||
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<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Task;
|
||||
use crate::settings::{ORG_NAME, PRODUCT_NAME};
|
||||
|
||||
#[test]
|
||||
fn test_ical_from_completed_task() {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,13 +17,17 @@ use crate::Event;
|
|||
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 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<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> {
|
||||
Event(&'a IcalEvent),
|
||||
Todo(&'a IcalTodo),
|
||||
|
|
59
src/item.rs
59
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<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) {
|
||||
match self {
|
||||
Item::Event(e) => e.set_sync_status(new_status),
|
||||
|
|
20
src/task.rs
20
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<Property>,
|
||||
}
|
||||
|
||||
|
||||
|
@ -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<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 {
|
||||
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<Utc> { &self.last_modified }
|
||||
pub fn creation_date(&self) -> Option<&DateTime<Utc>> { 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 {
|
||||
|
|
20
tests/assets/ical_with_unknown_fields.ics
Normal file
20
tests/assets/ical_with_unknown_fields.ics
Normal 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
|
|
@ -380,7 +380,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
|
|||
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<ItemScenario> {
|
|||
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<ItemScenario> {
|
|||
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<M
|
|||
sync_status,
|
||||
Some(now),
|
||||
now,
|
||||
"prod_id".to_string(), Vec::new(),
|
||||
));
|
||||
|
||||
match required_state {
|
||||
|
|
Loading…
Add table
Reference in a new issue