20 Commits
Author SHA1 Message Date
daladim 96eec57951 Added a github actions file 2021-11-10 22:52:47 +01:00
daladim 0f55850b6d [cleanup] Deprecated settings.rs for config.rs 2021-11-10 22:47:33 +01:00
daladim 413b2b285e [cleanup] 2021-11-10 22:45:46 +01:00
daladim 582c187e04 Merge branch 'ical_unknown_fields' 2021-11-10 22:22:57 +01:00
daladim 9dcf7e0499 Fixed tests to support unknown iCal fields 2021-11-10 22:22:24 +01:00
daladim b0db19b586 Forked ical-rs to support serde 2021-11-10 22:16:40 +01:00
daladim 75fe00983d Added a parsing/building round-trip test 2021-11-10 22:16:40 +01:00
daladim 39867a9f15 PRODID is parsed, stored and generated 2021-11-10 22:16:40 +01:00
daladim f7acadc3e2 Unhandled lines from iCal files are stored 2021-11-10 22:16:40 +01:00
daladim 49bdb3d199 Macro for code de-duplication 2021-11-05 23:37:25 +01:00
daladim d6e93d846e [minor] Turned a if list into a match 2021-11-04 18:55:12 +01:00
daladim 40503f46d5 typo 2021-11-04 18:54:53 +01:00
daladim 7d09d72175 SyncEvent is more usable 2021-11-04 09:01:25 +01:00
daladim d8c89ec727 [doc] 2021-11-03 21:58:00 +01:00
daladim 9bd45d5a07 Merge branch 'feedback' 2021-10-13 08:19:41 +02:00
daladim b404fc68e8 Sending sync progress 2021-10-09 00:25:23 +02:00
daladim 7fb98a471b Feedback infrastructure 2021-10-09 00:23:50 +02:00
daladim 04c8b3a2ee Renamed SyncResult -> SyncProgress 2021-10-08 23:26:48 +02:00
daladim f7ec5d29be [doc] 2021-10-08 23:03:43 +02:00
daladim 38fd04c3a4 Moved SyncResult 2021-10-08 23:03:24 +02:00
18 changed files with 500 additions and 337 deletions
+24
View File
@@ -0,0 +1,24 @@
name: Rust
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build
run: cargo build --verbose
- name: Run regular tests
run: cargo test --verbose
- name: Run specific integration tests
run: cargo test --verbose --features=integration_tests
Generated
+8 -5
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",
] ]
@@ -409,6 +411,7 @@ dependencies = [
"ics", "ics",
"log", "log",
"minidom", "minidom",
"once_cell",
"reqwest", "reqwest",
"sanitize-filename", "sanitize-filename",
"serde", "serde",
@@ -547,9 +550,9 @@ dependencies = [
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.5.2" version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13bd41f508810a131401606d54ac32a467c97172d74ba7662562ebba5ad07fa0" checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56"
[[package]] [[package]]
name = "openssl" name = "openssl"
+5 -1
View File
@@ -28,7 +28,11 @@ 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"] }
once_cell = "1.8"
[patch.crates-io]
ical = { git = "https://github.com/daladim/ical-rs.git", branch = "ical_serde" }
+13 -5
View File
@@ -1,3 +1,5 @@
//! This is an example of how kitchen-fridge can be used
use std::path::Path; use std::path::Path;
use chrono::{Utc}; use chrono::{Utc};
@@ -12,16 +14,22 @@ use kitchen_fridge::cache::Cache;
use kitchen_fridge::CalDavProvider; use kitchen_fridge::CalDavProvider;
use kitchen_fridge::traits::BaseCalendar; use kitchen_fridge::traits::BaseCalendar;
use kitchen_fridge::traits::CompleteCalendar; use kitchen_fridge::traits::CompleteCalendar;
use kitchen_fridge::settings::URL;
use kitchen_fridge::settings::USERNAME;
use kitchen_fridge::settings::PASSWORD;
use kitchen_fridge::settings::EXAMPLE_CREATED_CALENDAR_URL;
use kitchen_fridge::settings::EXAMPLE_EXISTING_CALENDAR_URL;
use kitchen_fridge::utils::pause; use kitchen_fridge::utils::pause;
const CACHE_FOLDER: &str = "test_cache/provider_sync"; const CACHE_FOLDER: &str = "test_cache/provider_sync";
// TODO: change these values with yours
pub const URL: &str = "https://my.server.com/remote.php/dav/files/john";
pub const USERNAME: &str = "username";
pub const PASSWORD: &str = "secret_password";
pub const EXAMPLE_TASK_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/6121A0BE-C2E0-4F16-A3FA-658E54E7062A/74439558-CDFF-426C-92CD-ECDDACE971B0.ics";
pub const EXAMPLE_EXISTING_CALENDAR_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/a_calendar_name/";
pub const EXAMPLE_CREATED_CALENDAR_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/a_calendar_that_we_have_created/";
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
env_logger::init(); env_logger::init();
+11
View File
@@ -0,0 +1,11 @@
//! Support for compile-time configuration options
use once_cell::sync::Lazy;
/// Part of the ProdID string that describes the organization (example of a ProdID string: `-//ABC Corporation//My Product//EN`)
/// You can override it at compile-time with the `KITCHEN_FRIDGE_ICAL_ORG_NAME` environment variable, or keep the default value
pub static ORG_NAME: Lazy<String> = Lazy::new(|| option_env!("KITCHEN_FRIDGE_ICAL_ORG_NAME").unwrap_or("My organization").to_string() );
/// Part of the ProdID string that describes the product name (example of a ProdID string: `-//ABC Corporation//My Product//EN`)
/// You can override it at compile-time with the `KITCHEN_FRIDGE_ICAL_PRODUCT_NAME` environment variable, or keep the default value
pub static PRODUCT_NAME: Lazy<String> = Lazy::new(|| option_env!("KITCHEN_FRIDGE_ICAL_PRODUCT_NAME").unwrap_or("KitchenFridge").to_string() );
+4
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!()
} }
+50 -28
View File
@@ -5,51 +5,57 @@ 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) => { CompletionStatus::Uncompleted => {
match t.completion_status() { todo.push(Status::needs_action());
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!()
}, },
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); 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::config::{ORG_NAME, PRODUCT_NAME};
#[test] #[test]
fn test_ical_from_completed_task() { fn test_ical_from_completed_task() {
@@ -82,7 +104,7 @@ mod tests {
COMPLETED:{}\r\n\ COMPLETED:{}\r\n\
STATUS:COMPLETED\r\n\ STATUS:COMPLETED\r\n\
END:VTODO\r\n\ END:VTODO\r\n\
END:VCALENDAR\r\n", ORG_NAME, PRODUCT_NAME, uid, s_now, s_now, s_now, s_now); END:VCALENDAR\r\n", *ORG_NAME, *PRODUCT_NAME, uid, s_now, s_now, s_now, s_now);
assert_eq!(ical, expected_ical); assert_eq!(ical, expected_ical);
} }
@@ -102,7 +124,7 @@ mod tests {
SUMMARY:This is a task with ÜTF-8 characters\r\n\ SUMMARY:This is a task with ÜTF-8 characters\r\n\
STATUS:NEEDS-ACTION\r\n\ STATUS:NEEDS-ACTION\r\n\
END:VTODO\r\n\ END:VTODO\r\n\
END:VCALENDAR\r\n", ORG_NAME, PRODUCT_NAME, uid, s_now, s_now, s_now); END:VCALENDAR\r\n", *ORG_NAME, *PRODUCT_NAME, uid, s_now, s_now, s_now);
assert_eq!(ical, expected_ical); assert_eq!(ical, expected_ical);
} }
+48
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::config::{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);
}
}
}
+51 -33
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,38 +40,42 @@ 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" => {
// Possible values: // The property can be specified once, but is not mandatory
// "NEEDS-ACTION" ;Indicates to-do needs action. // "This property specifies the date and time that the information associated with
// "COMPLETED" ;Indicates to-do completed. // the calendar component was last revised in the calendar store."
// "IN-PROCESS" ;Indicates to-do in process of. last_modified = parse_date_time_from_property(&prop.value)
// "CANCELLED" ;Indicates to-do was cancelled. },
if prop.value.as_ref().map(|s| s.as_str()) == Some("COMPLETED") { "COMPLETED" => {
completed = true; // 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 { 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),
+19 -40
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),
+3 -2
View File
@@ -18,7 +18,8 @@
//! //!
//! ## Examples //! ## Examples
//! //!
//! See example usage in the `examples/` folder, that you can run using `cargo run --example <example-name>` //! See example usage in the `examples/` folder, that you can run using `cargo run --example <example-name>`. \
//! You can also have a look at `tasklist`, a GUI app that uses `kitchen-fridge` under the hood.
pub mod traits; pub mod traits;
@@ -38,7 +39,7 @@ pub mod cache;
pub use cache::Cache; pub use cache::Cache;
pub mod ical; pub mod ical;
pub mod settings; pub mod config;
pub mod utils; pub mod utils;
pub mod resource; pub mod resource;
+120 -88
View File
@@ -1,4 +1,6 @@
//! This modules abstracts data sources and merges them in a single virtual one //! This modules abstracts data sources and merges them in a single virtual one
//!
//! It is also responsible for syncing them together
use std::error::Error; use std::error::Error;
use std::collections::HashSet; use std::collections::HashSet;
@@ -7,39 +9,12 @@ use std::sync::{Arc, Mutex};
use crate::traits::{BaseCalendar, CalDavSource, DavCalendar}; use crate::traits::{BaseCalendar, CalDavSource, DavCalendar};
use crate::traits::CompleteCalendar; use crate::traits::CompleteCalendar;
use crate::item::SyncStatus; use crate::item::{ItemId, SyncStatus};
use crate::calendar::CalendarId; use crate::calendar::CalendarId;
/// A counter of errors that happen during a sync pub mod sync_progress;
struct SyncResult { use sync_progress::SyncProgress;
n_errors: u32, use sync_progress::{FeedbackSender, SyncEvent};
}
impl SyncResult {
pub fn new() -> Self {
Self { n_errors: 0 }
}
pub fn is_success(&self) -> bool {
self.n_errors == 0
}
pub fn error(&mut self, text: &str) {
log::error!("{}", text);
self.n_errors += 1;
}
pub fn warn(&mut self, text: &str) {
log::warn!("{}", text);
self.n_errors += 1;
}
pub fn info(&mut self, text: &str) {
log::info!("{}", text);
}
pub fn debug(&mut self, text: &str) {
log::debug!("{}", text);
}
pub fn trace(&mut self, text: &str) {
log::trace!("{}", text);
}
}
/// A data source that combines two `CalDavSource`s, which is able to sync both sources. /// A data source that combines two `CalDavSource`s, which is able to sync both sources.
/// ///
@@ -90,23 +65,38 @@ where
/// To be sure `local` accurately mirrors the `remote` source, you can run [`Provider::sync`] /// To be sure `local` accurately mirrors the `remote` source, you can run [`Provider::sync`]
pub fn remote(&self) -> &R { &self.remote } pub fn remote(&self) -> &R { &self.remote }
/// Performs a synchronisation between `local` and `remote`. /// Performs a synchronisation between `local` and `remote`, and provide feeedback to the user about the progress.
/// ///
/// This bidirectional sync applies additions/deletions made on a source to the other source. /// This bidirectional sync applies additions/deletions made on a source to the other source.
/// In case of conflicts (the same item has been modified on both ends since the last sync, `remote` always wins) /// In case of conflicts (the same item has been modified on both ends since the last sync, `remote` always wins).
/// ///
/// It returns whether the sync was totally successful (details about errors are logged using the `log::*` macros). /// It returns whether the sync was totally successful (details about errors are logged using the `log::*` macros).
/// In case errors happened, the sync might have been partially executed, and you can safely run this function again, since it has been designed to gracefully recover from errors. /// In case errors happened, the sync might have been partially executed but your data will never be correupted (either locally nor in the server).
pub async fn sync(&mut self) -> bool { /// Simply run this function again, it will re-start a sync, picking up where it failed.
let mut result = SyncResult::new(); pub async fn sync_with_feedback(&mut self, feedback_sender: FeedbackSender) -> bool {
if let Err(err) = self.run_sync(&mut result).await { let mut progress = SyncProgress::new_with_feedback_channel(feedback_sender);
result.error(&format!("Sync terminated because of an error: {}", err)); self.run_sync(&mut progress).await
}
result.is_success()
} }
async fn run_sync(&mut self, result: &mut SyncResult) -> Result<(), Box<dyn Error>> { /// Performs a synchronisation between `local` and `remote`, without giving any feedback.
result.info("Starting a sync"); ///
/// See [`Self::sync_with_feedback`]
pub async fn sync(&mut self) -> bool {
let mut progress = SyncProgress::new();
self.run_sync(&mut progress).await
}
async fn run_sync(&mut self, progress: &mut SyncProgress) -> bool {
if let Err(err) = self.run_sync_inner(progress).await {
progress.error(&format!("Sync terminated because of an error: {}", err));
}
progress.feedback(SyncEvent::Finished{ success: progress.is_success() });
progress.is_success()
}
async fn run_sync_inner(&mut self, progress: &mut SyncProgress) -> Result<(), Box<dyn Error>> {
progress.info("Starting a sync.");
progress.feedback(SyncEvent::Started);
let mut handled_calendars = HashSet::new(); let mut handled_calendars = HashSet::new();
@@ -115,14 +105,14 @@ where
for (cal_id, cal_remote) in cals_remote { for (cal_id, cal_remote) in cals_remote {
let counterpart = match self.get_or_insert_local_counterpart_calendar(&cal_id, cal_remote.clone()).await { let counterpart = match self.get_or_insert_local_counterpart_calendar(&cal_id, cal_remote.clone()).await {
Err(err) => { Err(err) => {
result.warn(&format!("Unable to get or insert local counterpart calendar for {} ({}). Skipping this time", cal_id, err)); progress.warn(&format!("Unable to get or insert local counterpart calendar for {} ({}). Skipping this time", cal_id, err));
continue; continue;
}, },
Ok(arc) => arc, Ok(arc) => arc,
}; };
if let Err(err) = Self::sync_calendar_pair(counterpart, cal_remote, result).await { if let Err(err) = Self::sync_calendar_pair(counterpart, cal_remote, progress).await {
result.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_id, err)); progress.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_id, err));
continue; continue;
} }
handled_calendars.insert(cal_id); handled_calendars.insert(cal_id);
@@ -137,19 +127,19 @@ where
let counterpart = match self.get_or_insert_remote_counterpart_calendar(&cal_id, cal_local.clone()).await { let counterpart = match self.get_or_insert_remote_counterpart_calendar(&cal_id, cal_local.clone()).await {
Err(err) => { Err(err) => {
result.warn(&format!("Unable to get or insert remote counterpart calendar for {} ({}). Skipping this time", cal_id, err)); progress.warn(&format!("Unable to get or insert remote counterpart calendar for {} ({}). Skipping this time", cal_id, err));
continue; continue;
}, },
Ok(arc) => arc, Ok(arc) => arc,
}; };
if let Err(err) = Self::sync_calendar_pair(cal_local, counterpart, result).await { if let Err(err) = Self::sync_calendar_pair(cal_local, counterpart, progress).await {
result.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_id, err)); progress.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_id, err));
continue; continue;
} }
} }
result.info("Sync ended"); progress.info("Sync ended");
Ok(()) Ok(())
} }
@@ -163,12 +153,19 @@ where
} }
async fn sync_calendar_pair(cal_local: Arc<Mutex<T>>, cal_remote: Arc<Mutex<U>>, result: &mut SyncResult) -> Result<(), Box<dyn Error>> { async fn sync_calendar_pair(cal_local: Arc<Mutex<T>>, cal_remote: Arc<Mutex<U>>, progress: &mut SyncProgress) -> Result<(), Box<dyn Error>> {
let mut cal_remote = cal_remote.lock().unwrap(); let mut cal_remote = cal_remote.lock().unwrap();
let mut cal_local = cal_local.lock().unwrap(); let mut cal_local = cal_local.lock().unwrap();
let cal_name = cal_local.name().to_string();
progress.info(&format!("Syncing calendar {}", cal_name));
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
details: "started".to_string()
});
// Step 1 - find the differences // Step 1 - find the differences
result.debug("Finding the differences to sync..."); progress.debug("Finding the differences to sync...");
let mut local_del = HashSet::new(); let mut local_del = HashSet::new();
let mut remote_del = HashSet::new(); let mut remote_del = HashSet::new();
let mut local_changes = HashSet::new(); let mut local_changes = HashSet::new();
@@ -177,51 +174,56 @@ where
let mut remote_additions = HashSet::new(); let mut remote_additions = HashSet::new();
let remote_items = cal_remote.get_item_version_tags().await?; let remote_items = cal_remote.get_item_version_tags().await?;
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
details: format!("{} remote items", remote_items.len()),
});
let mut local_items_to_handle = cal_local.get_item_ids().await?; let mut local_items_to_handle = cal_local.get_item_ids().await?;
for (id, remote_tag) in remote_items { for (id, remote_tag) in remote_items {
result.trace(&format!("***** Considering remote item {}...", id)); progress.trace(&format!("***** Considering remote item {}...", id));
match cal_local.get_item_by_id(&id).await { match cal_local.get_item_by_id(&id).await {
None => { None => {
// This was created on the remote // This was created on the remote
result.debug(&format!("* {} is a remote addition", id)); progress.debug(&format!("* {} is a remote addition", id));
remote_additions.insert(id); remote_additions.insert(id);
}, },
Some(local_item) => { Some(local_item) => {
if local_items_to_handle.remove(&id) == false { if local_items_to_handle.remove(&id) == false {
result.error(&format!("Inconsistent state: missing task {} from the local tasks", id)); progress.error(&format!("Inconsistent state: missing task {} from the local tasks", id));
} }
match local_item.sync_status() { match local_item.sync_status() {
SyncStatus::NotSynced => { SyncStatus::NotSynced => {
result.error(&format!("ID reuse between remote and local sources ({}). Ignoring this item in the sync", id)); progress.error(&format!("ID reuse between remote and local sources ({}). Ignoring this item in the sync", id));
continue; continue;
}, },
SyncStatus::Synced(local_tag) => { SyncStatus::Synced(local_tag) => {
if &remote_tag != local_tag { if &remote_tag != local_tag {
// This has been modified on the remote // This has been modified on the remote
result.debug(&format!("* {} is a remote change", id)); progress.debug(&format!("* {} is a remote change", id));
remote_changes.insert(id); remote_changes.insert(id);
} }
}, },
SyncStatus::LocallyModified(local_tag) => { SyncStatus::LocallyModified(local_tag) => {
if &remote_tag == local_tag { if &remote_tag == local_tag {
// This has been changed locally // This has been changed locally
result.debug(&format!("* {} is a local change", id)); progress.debug(&format!("* {} is a local change", id));
local_changes.insert(id); local_changes.insert(id);
} else { } else {
result.info(&format!("Conflict: task {} has been modified in both sources. Using the remote version.", id)); progress.info(&format!("Conflict: task {} has been modified in both sources. Using the remote version.", id));
result.debug(&format!("* {} is considered a remote change", id)); progress.debug(&format!("* {} is considered a remote change", id));
remote_changes.insert(id); remote_changes.insert(id);
} }
}, },
SyncStatus::LocallyDeleted(local_tag) => { SyncStatus::LocallyDeleted(local_tag) => {
if &remote_tag == local_tag { if &remote_tag == local_tag {
// This has been locally deleted // This has been locally deleted
result.debug(&format!("* {} is a local deletion", id)); progress.debug(&format!("* {} is a local deletion", id));
local_del.insert(id); local_del.insert(id);
} else { } else {
result.info(&format!("Conflict: task {} has been locally deleted and remotely modified. Reverting to the remote version.", id)); progress.info(&format!("Conflict: task {} has been locally deleted and remotely modified. Reverting to the remote version.", id));
result.debug(&format!("* {} is a considered a remote change", id)); progress.debug(&format!("* {} is a considered a remote change", id));
remote_changes.insert(id); remote_changes.insert(id);
} }
}, },
@@ -232,10 +234,10 @@ where
// Also iterate on the local tasks that are not on the remote // Also iterate on the local tasks that are not on the remote
for id in local_items_to_handle { for id in local_items_to_handle {
result.trace(&format!("##### Considering local item {}...", id)); progress.trace(&format!("##### Considering local item {}...", id));
let local_item = match cal_local.get_item_by_id(&id).await { let local_item = match cal_local.get_item_by_id(&id).await {
None => { None => {
result.error(&format!("Inconsistent state: missing task {} from the local tasks", id)); progress.error(&format!("Inconsistent state: missing task {} from the local tasks", id));
continue; continue;
}, },
Some(item) => item, Some(item) => item,
@@ -244,21 +246,21 @@ where
match local_item.sync_status() { match local_item.sync_status() {
SyncStatus::Synced(_) => { SyncStatus::Synced(_) => {
// This item has been removed from the remote // This item has been removed from the remote
result.debug(&format!("# {} is a deletion from the server", id)); progress.debug(&format!("# {} is a deletion from the server", id));
remote_del.insert(id); remote_del.insert(id);
}, },
SyncStatus::NotSynced => { SyncStatus::NotSynced => {
// This item has just been locally created // This item has just been locally created
result.debug(&format!("# {} has been locally created", id)); progress.debug(&format!("# {} has been locally created", id));
local_additions.insert(id); local_additions.insert(id);
}, },
SyncStatus::LocallyDeleted(_) => { SyncStatus::LocallyDeleted(_) => {
// This item has been deleted from both sources // This item has been deleted from both sources
result.debug(&format!("# {} has been deleted from both sources", id)); progress.debug(&format!("# {} has been deleted from both sources", id));
remote_del.insert(id); remote_del.insert(id);
}, },
SyncStatus::LocallyModified(_) => { SyncStatus::LocallyModified(_) => {
result.info(&format!("Conflict: item {} has been deleted from the server and locally modified. Deleting the local copy", id)); progress.info(&format!("Conflict: item {} has been deleted from the server and locally modified. Deleting the local copy", id));
remote_del.insert(id); remote_del.insert(id);
}, },
} }
@@ -266,44 +268,56 @@ where
// Step 2 - commit changes // Step 2 - commit changes
result.trace("Committing changes..."); progress.trace("Committing changes...");
for id_del in local_del { for id_del in local_del {
result.debug(&format!("> Pushing local deletion {} to the server", id_del)); progress.debug(&format!("> Pushing local deletion {} to the server", id_del));
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
details: Self::item_name(&cal_local, &id_del).await,
});
match cal_remote.delete_item(&id_del).await { match cal_remote.delete_item(&id_del).await {
Err(err) => { Err(err) => {
result.warn(&format!("Unable to delete remote item {}: {}", id_del, err)); progress.warn(&format!("Unable to delete remote item {}: {}", id_del, err));
}, },
Ok(()) => { Ok(()) => {
// Change the local copy from "marked to deletion" to "actually deleted" // Change the local copy from "marked to deletion" to "actually deleted"
if let Err(err) = cal_local.immediately_delete_item(&id_del).await { if let Err(err) = cal_local.immediately_delete_item(&id_del).await {
result.error(&format!("Unable to permanently delete local item {}: {}", id_del, err)); progress.error(&format!("Unable to permanently delete local item {}: {}", id_del, err));
} }
}, },
} }
} }
for id_del in remote_del { for id_del in remote_del {
result.debug(&format!("> Applying remote deletion {} locally", id_del)); progress.debug(&format!("> Applying remote deletion {} locally", id_del));
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
details: Self::item_name(&cal_local, &id_del).await,
});
if let Err(err) = cal_local.immediately_delete_item(&id_del).await { if let Err(err) = cal_local.immediately_delete_item(&id_del).await {
result.warn(&format!("Unable to delete local item {}: {}", id_del, err)); progress.warn(&format!("Unable to delete local item {}: {}", id_del, err));
} }
} }
for id_add in remote_additions { for id_add in remote_additions {
result.debug(&format!("> Applying remote addition {} locally", id_add)); progress.debug(&format!("> Applying remote addition {} locally", id_add));
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
details: Self::item_name(&cal_local, &id_add).await,
});
match cal_remote.get_item_by_id(&id_add).await { match cal_remote.get_item_by_id(&id_add).await {
Err(err) => { Err(err) => {
result.warn(&format!("Unable to get remote item {}: {}. Skipping it.", id_add, err)); progress.warn(&format!("Unable to get remote item {}: {}. Skipping it.", id_add, err));
continue; continue;
}, },
Ok(item) => match item { Ok(item) => match item {
None => { None => {
result.error(&format!("Inconsistency: new item {} has vanished from the remote end", id_add)); progress.error(&format!("Inconsistency: new item {} has vanished from the remote end", id_add));
continue; continue;
}, },
Some(new_item) => { Some(new_item) => {
if let Err(err) = cal_local.add_item(new_item.clone()).await { if let Err(err) = cal_local.add_item(new_item.clone()).await {
result.error(&format!("Not able to add item {} to local calendar: {}", id_add, err)); progress.error(&format!("Not able to add item {} to local calendar: {}", id_add, err));
} }
}, },
}, },
@@ -311,20 +325,24 @@ where
} }
for id_change in remote_changes { for id_change in remote_changes {
result.debug(&format!("> Applying remote change {} locally", id_change)); progress.debug(&format!("> Applying remote change {} locally", id_change));
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
details: Self::item_name(&cal_local, &id_change).await,
});
match cal_remote.get_item_by_id(&id_change).await { match cal_remote.get_item_by_id(&id_change).await {
Err(err) => { Err(err) => {
result.warn(&format!("Unable to get remote item {}: {}. Skipping it", id_change, err)); progress.warn(&format!("Unable to get remote item {}: {}. Skipping it", id_change, err));
continue; continue;
}, },
Ok(item) => match item { Ok(item) => match item {
None => { None => {
result.error(&format!("Inconsistency: modified item {} has vanished from the remote end", id_change)); progress.error(&format!("Inconsistency: modified item {} has vanished from the remote end", id_change));
continue; continue;
}, },
Some(item) => { Some(item) => {
if let Err(err) = cal_local.update_item(item.clone()).await { if let Err(err) = cal_local.update_item(item.clone()).await {
result.error(&format!("Unable to update item {} in local calendar: {}", id_change, err)); progress.error(&format!("Unable to update item {} in local calendar: {}", id_change, err));
} }
}, },
} }
@@ -333,15 +351,19 @@ where
for id_add in local_additions { for id_add in local_additions {
result.debug(&format!("> Pushing local addition {} to the server", id_add)); progress.debug(&format!("> Pushing local addition {} to the server", id_add));
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
details: Self::item_name(&cal_local, &id_add).await,
});
match cal_local.get_item_by_id_mut(&id_add).await { match cal_local.get_item_by_id_mut(&id_add).await {
None => { None => {
result.error(&format!("Inconsistency: created item {} has been marked for upload but is locally missing", id_add)); progress.error(&format!("Inconsistency: created item {} has been marked for upload but is locally missing", id_add));
continue; continue;
}, },
Some(item) => { Some(item) => {
match cal_remote.add_item(item.clone()).await { match cal_remote.add_item(item.clone()).await {
Err(err) => result.error(&format!("Unable to add item {} to remote calendar: {}", id_add, err)), Err(err) => progress.error(&format!("Unable to add item {} to remote calendar: {}", id_add, err)),
Ok(new_ss) => { Ok(new_ss) => {
// Update local sync status // Update local sync status
item.set_sync_status(new_ss); item.set_sync_status(new_ss);
@@ -352,15 +374,19 @@ where
} }
for id_change in local_changes { for id_change in local_changes {
result.debug(&format!("> Pushing local change {} to the server", id_change)); progress.debug(&format!("> Pushing local change {} to the server", id_change));
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
details: Self::item_name(&cal_local, &id_change).await,
});
match cal_local.get_item_by_id_mut(&id_change).await { match cal_local.get_item_by_id_mut(&id_change).await {
None => { None => {
result.error(&format!("Inconsistency: modified item {} has been marked for upload but is locally missing", id_change)); progress.error(&format!("Inconsistency: modified item {} has been marked for upload but is locally missing", id_change));
continue; continue;
}, },
Some(item) => { Some(item) => {
match cal_remote.update_item(item.clone()).await { match cal_remote.update_item(item.clone()).await {
Err(err) => result.error(&format!("Unable to update item {} in remote calendar: {}", id_change, err)), Err(err) => progress.error(&format!("Unable to update item {} in remote calendar: {}", id_change, err)),
Ok(new_ss) => { Ok(new_ss) => {
// Update local sync status // Update local sync status
item.set_sync_status(new_ss); item.set_sync_status(new_ss);
@@ -372,6 +398,12 @@ where
Ok(()) Ok(())
} }
async fn item_name(cal: &T, id: &ItemId) -> String {
cal.get_item_by_id(id).await.map(|item| item.name()).unwrap_or_default().to_string()
}
} }
+101
View File
@@ -0,0 +1,101 @@
//! Utilities to track the progression of a sync
use std::fmt::{Display, Error, Formatter};
/// An event that happens during a sync
#[derive(Clone, Debug)]
pub enum SyncEvent {
/// Sync has not started
NotStarted,
/// Sync has just started but no calendar is handled yet
Started,
/// Sync is in progress.
InProgress{ calendar: String, details: String},
/// Sync is finished
Finished{ success: bool },
}
impl Display for SyncEvent {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
match self {
SyncEvent::NotStarted => write!(f, "Not started"),
SyncEvent::Started => write!(f, "Sync has started..."),
SyncEvent::InProgress{calendar, details} => write!(f, "[{}] {}...", calendar, details),
SyncEvent::Finished{success} => match success {
true => write!(f, "Sync successfully finished"),
false => write!(f, "Sync finished with errors"),
}
}
}
}
impl Default for SyncEvent {
fn default() -> Self {
Self::NotStarted
}
}
/// See [`feedback_channel`]
pub type FeedbackSender = tokio::sync::watch::Sender<SyncEvent>;
/// See [`feedback_channel`]
pub type FeedbackReceiver = tokio::sync::watch::Receiver<SyncEvent>;
/// Create a feeback channel, that can be used to retrieve the current progress of a sync operation
pub fn feedback_channel() -> (FeedbackSender, FeedbackReceiver) {
tokio::sync::watch::channel(SyncEvent::default())
}
/// A structure that tracks the progression and the errors that happen during a sync
pub struct SyncProgress {
n_errors: u32,
feedback_channel: Option<FeedbackSender>
}
impl SyncProgress {
pub fn new() -> Self {
Self { n_errors: 0, feedback_channel: None }
}
pub fn new_with_feedback_channel(channel: FeedbackSender) -> Self {
Self { n_errors: 0, feedback_channel: Some(channel) }
}
pub fn is_success(&self) -> bool {
self.n_errors == 0
}
/// Log an error
pub fn error(&mut self, text: &str) {
log::error!("{}", text);
self.n_errors += 1;
}
/// Log a warning
pub fn warn(&mut self, text: &str) {
log::warn!("{}", text);
self.n_errors += 1;
}
/// Log an info
pub fn info(&mut self, text: &str) {
log::info!("{}", text);
}
/// Log a debug message
pub fn debug(&mut self, text: &str) {
log::debug!("{}", text);
}
/// Log a trace message
pub fn trace(&mut self, text: &str) {
log::trace!("{}", text);
}
/// Send an event as a feedback to the listener (if any).
pub fn feedback(&mut self, event: SyncEvent) {
self.feedback_channel
.as_ref()
.map(|sender| {
sender.send(event)
});
}
}
-12
View File
@@ -1,12 +0,0 @@
// TODO: change these values with yours
pub const URL: &str = "https://my.server.com/remote.php/dav/files/john";
pub const USERNAME: &str = "username";
pub const PASSWORD: &str = "secret_password";
pub const EXAMPLE_TASK_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/6121A0BE-C2E0-4F16-A3FA-658E54E7062A/74439558-CDFF-426C-92CD-ECDDACE971B0.ics";
pub const EXAMPLE_EXISTING_CALENDAR_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/a_calendar_name/";
pub const EXAMPLE_CREATED_CALENDAR_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/a_calendar_that_we_have_created/";
pub const ORG_NAME: &str = "My organisation";
pub const PRODUCT_NAME: &str = "My CalDAV client";
+18 -2
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 {
+20
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
-118
View File
@@ -1,118 +0,0 @@
//! Some tests of a CalDAV client.
//! Most of them are not really integration tests, but just development tests that should be cleaned up one day.
use reqwest::Method;
use reqwest::header::CONTENT_TYPE;
use minidom::Element;
use url::Url;
use kitchen_fridge::{calendar::SupportedComponents, client::Client};
use kitchen_fridge::traits::CalDavSource;
use kitchen_fridge::settings::URL;
use kitchen_fridge::settings::USERNAME;
use kitchen_fridge::settings::PASSWORD;
use kitchen_fridge::settings::EXAMPLE_TASK_URL;
use kitchen_fridge::settings::EXAMPLE_CREATED_CALENDAR_URL;
static EXAMPLE_TASKS_BODY_LAST_MODIFIED: &str = r#"
<C:calendar-query xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data />
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VTODO">
<C:prop-filter name="LAST-MODIFIED">
<C:time-range start="20210228T002308Z"
end="20260105T000000Z"/>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"#;
#[tokio::test]
#[ignore]
async fn show_calendars() {
let _ = env_logger::builder().is_test(true).try_init();
let client = Client::new(URL, USERNAME, PASSWORD).unwrap();
let calendars = client.get_calendars().await.unwrap();
println!("Calendars:");
kitchen_fridge::utils::print_dav_calendar_list(&calendars).await;
}
#[tokio::test]
#[ignore]
async fn create_cal() {
let _ = env_logger::builder().is_test(true).try_init();
let mut client = Client::new(URL, USERNAME, PASSWORD).unwrap();
let id: Url = kitchen_fridge::settings::EXAMPLE_CREATED_CALENDAR_URL.parse().unwrap();
let name = "a created calendar".into();
let supported_components = SupportedComponents::TODO;
client.create_calendar(id, name, supported_components, Some(csscolorparser::parse("gold").unwrap())).await.unwrap();
}
#[tokio::test]
#[ignore]
async fn profind() {
let _ = env_logger::builder().is_test(true).try_init();
let url: Url = EXAMPLE_TASK_URL.parse().unwrap();
let method = Method::from_bytes(b"PROPFIND")
.expect("cannot create PROPFIND method.");
let res = reqwest::Client::new()
.request(method, url.as_str())
.header("Depth", 0)
.header(CONTENT_TYPE, "application/xml")
.basic_auth(USERNAME, Some(PASSWORD))
//.body(body)
.send()
.await
.unwrap();
println!("{:?}", res.text().await);
}
#[tokio::test]
#[ignore]
async fn last_modified() {
let _ = env_logger::builder().is_test(true).try_init();
let url: Url = EXAMPLE_CREATED_CALENDAR_URL.parse().unwrap();
let method = Method::from_bytes(b"REPORT")
.expect("cannot create REPORT method.");
let res = reqwest::Client::new()
.request(method, url.as_str())
.header("Depth", 1)
.header(CONTENT_TYPE, "application/xml")
.basic_auth(USERNAME, Some(PASSWORD))
.body(EXAMPLE_TASKS_BODY_LAST_MODIFIED)
.send()
.await
.unwrap();
let el: Element = res.text().await.unwrap().parse().unwrap();
kitchen_fridge::utils::print_xml(&el);
}
//
//
// TODO: test w/ wrong creds
// TODO: test withou connection
//
+5 -3
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 {