Compare commits
20
Commits
0.2
...
github_actions
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96eec57951 | ||
|
|
0f55850b6d | ||
|
|
413b2b285e | ||
|
|
582c187e04 | ||
|
|
9dcf7e0499 | ||
|
|
b0db19b586 | ||
|
|
75fe00983d | ||
|
|
39867a9f15 | ||
|
|
f7acadc3e2 | ||
|
|
49bdb3d199 | ||
|
|
d6e93d846e | ||
|
|
40503f46d5 | ||
|
|
7d09d72175 | ||
|
|
d8c89ec727 | ||
|
|
9bd45d5a07 | ||
|
|
b404fc68e8 | ||
|
|
7fb98a471b | ||
|
|
04c8b3a2ee | ||
|
|
f7ec5d29be | ||
|
|
38fd04c3a4 |
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -409,6 +411,7 @@ dependencies = [
|
||||
"ics",
|
||||
"log",
|
||||
"minidom",
|
||||
"once_cell",
|
||||
"reqwest",
|
||||
"sanitize-filename",
|
||||
"serde",
|
||||
@@ -547,9 +550,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.5.2"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13bd41f508810a131401606d54ac32a467c97172d74ba7662562ebba5ad07fa0"
|
||||
checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
|
||||
+5
-1
@@ -28,7 +28,11 @@ 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"] }
|
||||
once_cell = "1.8"
|
||||
|
||||
[patch.crates-io]
|
||||
ical = { git = "https://github.com/daladim/ical-rs.git", branch = "ical_serde" }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! This is an example of how kitchen-fridge can be used
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{Utc};
|
||||
@@ -12,16 +14,22 @@ use kitchen_fridge::cache::Cache;
|
||||
use kitchen_fridge::CalDavProvider;
|
||||
use kitchen_fridge::traits::BaseCalendar;
|
||||
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;
|
||||
|
||||
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]
|
||||
async fn main() {
|
||||
env_logger::init();
|
||||
|
||||
@@ -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() );
|
||||
@@ -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!()
|
||||
}
|
||||
|
||||
+40
-18
@@ -5,33 +5,38 @@ 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() {
|
||||
match task.completion_status() {
|
||||
CompletionStatus::Uncompleted => {
|
||||
todo.push(Status::needs_action());
|
||||
},
|
||||
@@ -43,13 +48,14 @@ pub fn build_from(item: &Item) -> Result<String, Box<dyn Error>> {
|
||||
todo.push(Status::completed());
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
unimplemented!()
|
||||
},
|
||||
|
||||
// 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", ical_product_id());
|
||||
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::config::{ORG_NAME, PRODUCT_NAME};
|
||||
|
||||
#[test]
|
||||
fn test_ical_from_completed_task() {
|
||||
@@ -82,7 +104,7 @@ mod tests {
|
||||
COMPLETED:{}\r\n\
|
||||
STATUS:COMPLETED\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);
|
||||
}
|
||||
@@ -102,7 +124,7 @@ mod tests {
|
||||
SUMMARY:This is a task with ÜTF-8 characters\r\n\
|
||||
STATUS:NEEDS-ACTION\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);
|
||||
}
|
||||
|
||||
@@ -6,3 +6,51 @@ mod parser;
|
||||
pub use parser::parse;
|
||||
mod builder;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-24
@@ -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,11 +40,29 @@ 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" {
|
||||
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.
|
||||
@@ -50,24 +72,10 @@ pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result<
|
||||
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 {
|
||||
@@ -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),
|
||||
|
||||
+19
-40
@@ -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),
|
||||
|
||||
+3
-2
@@ -18,7 +18,8 @@
|
||||
//!
|
||||
//! ## 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;
|
||||
|
||||
@@ -38,7 +39,7 @@ pub mod cache;
|
||||
pub use cache::Cache;
|
||||
pub mod ical;
|
||||
|
||||
pub mod settings;
|
||||
pub mod config;
|
||||
pub mod utils;
|
||||
pub mod resource;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! 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::collections::HashSet;
|
||||
@@ -7,39 +9,12 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::traits::{BaseCalendar, CalDavSource, DavCalendar};
|
||||
use crate::traits::CompleteCalendar;
|
||||
use crate::item::SyncStatus;
|
||||
use crate::item::{ItemId, SyncStatus};
|
||||
use crate::calendar::CalendarId;
|
||||
|
||||
/// A counter of errors that happen during a sync
|
||||
struct SyncResult {
|
||||
n_errors: u32,
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
pub mod sync_progress;
|
||||
use sync_progress::SyncProgress;
|
||||
use sync_progress::{FeedbackSender, SyncEvent};
|
||||
|
||||
/// 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`]
|
||||
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.
|
||||
/// 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).
|
||||
/// 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.
|
||||
pub async fn sync(&mut self) -> bool {
|
||||
let mut result = SyncResult::new();
|
||||
if let Err(err) = self.run_sync(&mut result).await {
|
||||
result.error(&format!("Sync terminated because of an error: {}", err));
|
||||
}
|
||||
result.is_success()
|
||||
/// In case errors happened, the sync might have been partially executed but your data will never be correupted (either locally nor in the server).
|
||||
/// Simply run this function again, it will re-start a sync, picking up where it failed.
|
||||
pub async fn sync_with_feedback(&mut self, feedback_sender: FeedbackSender) -> bool {
|
||||
let mut progress = SyncProgress::new_with_feedback_channel(feedback_sender);
|
||||
self.run_sync(&mut progress).await
|
||||
}
|
||||
|
||||
async fn run_sync(&mut self, result: &mut SyncResult) -> Result<(), Box<dyn Error>> {
|
||||
result.info("Starting a sync");
|
||||
/// Performs a synchronisation between `local` and `remote`, without giving any feedback.
|
||||
///
|
||||
/// 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();
|
||||
|
||||
@@ -115,14 +105,14 @@ where
|
||||
for (cal_id, cal_remote) in cals_remote {
|
||||
let counterpart = match self.get_or_insert_local_counterpart_calendar(&cal_id, cal_remote.clone()).await {
|
||||
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;
|
||||
},
|
||||
Ok(arc) => arc,
|
||||
};
|
||||
|
||||
if let Err(err) = Self::sync_calendar_pair(counterpart, cal_remote, result).await {
|
||||
result.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_id, err));
|
||||
if let Err(err) = Self::sync_calendar_pair(counterpart, cal_remote, progress).await {
|
||||
progress.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_id, err));
|
||||
continue;
|
||||
}
|
||||
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 {
|
||||
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;
|
||||
},
|
||||
Ok(arc) => arc,
|
||||
};
|
||||
|
||||
if let Err(err) = Self::sync_calendar_pair(cal_local, counterpart, result).await {
|
||||
result.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_id, err));
|
||||
if let Err(err) = Self::sync_calendar_pair(cal_local, counterpart, progress).await {
|
||||
progress.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_id, err));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.info("Sync ended");
|
||||
progress.info("Sync ended");
|
||||
|
||||
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_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
|
||||
result.debug("Finding the differences to sync...");
|
||||
progress.debug("Finding the differences to sync...");
|
||||
let mut local_del = HashSet::new();
|
||||
let mut remote_del = HashSet::new();
|
||||
let mut local_changes = HashSet::new();
|
||||
@@ -177,51 +174,56 @@ where
|
||||
let mut remote_additions = HashSet::new();
|
||||
|
||||
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?;
|
||||
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 {
|
||||
None => {
|
||||
// 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);
|
||||
},
|
||||
Some(local_item) => {
|
||||
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() {
|
||||
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;
|
||||
},
|
||||
SyncStatus::Synced(local_tag) => {
|
||||
if &remote_tag != local_tag {
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
SyncStatus::LocallyModified(local_tag) => {
|
||||
if &remote_tag == local_tag {
|
||||
// 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);
|
||||
} else {
|
||||
result.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.info(&format!("Conflict: task {} has been modified in both sources. Using the remote version.", id));
|
||||
progress.debug(&format!("* {} is considered a remote change", id));
|
||||
remote_changes.insert(id);
|
||||
}
|
||||
},
|
||||
SyncStatus::LocallyDeleted(local_tag) => {
|
||||
if &remote_tag == local_tag {
|
||||
// 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);
|
||||
} else {
|
||||
result.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.info(&format!("Conflict: task {} has been locally deleted and remotely modified. Reverting to the remote version.", id));
|
||||
progress.debug(&format!("* {} is a considered a remote change", id));
|
||||
remote_changes.insert(id);
|
||||
}
|
||||
},
|
||||
@@ -232,10 +234,10 @@ where
|
||||
|
||||
// Also iterate on the local tasks that are not on the remote
|
||||
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 {
|
||||
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;
|
||||
},
|
||||
Some(item) => item,
|
||||
@@ -244,21 +246,21 @@ where
|
||||
match local_item.sync_status() {
|
||||
SyncStatus::Synced(_) => {
|
||||
// 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);
|
||||
},
|
||||
SyncStatus::NotSynced => {
|
||||
// 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);
|
||||
},
|
||||
SyncStatus::LocallyDeleted(_) => {
|
||||
// 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);
|
||||
},
|
||||
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);
|
||||
},
|
||||
}
|
||||
@@ -266,44 +268,56 @@ where
|
||||
|
||||
|
||||
// Step 2 - commit changes
|
||||
result.trace("Committing changes...");
|
||||
progress.trace("Committing changes...");
|
||||
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 {
|
||||
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(()) => {
|
||||
// Change the local copy from "marked to deletion" to "actually deleted"
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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;
|
||||
},
|
||||
Ok(item) => match item {
|
||||
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;
|
||||
},
|
||||
Some(new_item) => {
|
||||
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 {
|
||||
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 {
|
||||
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;
|
||||
},
|
||||
Ok(item) => match item {
|
||||
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;
|
||||
},
|
||||
Some(item) => {
|
||||
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 {
|
||||
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 {
|
||||
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;
|
||||
},
|
||||
Some(item) => {
|
||||
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) => {
|
||||
// Update local sync status
|
||||
item.set_sync_status(new_ss);
|
||||
@@ -352,15 +374,19 @@ where
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
},
|
||||
Some(item) => {
|
||||
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) => {
|
||||
// Update local sync status
|
||||
item.set_sync_status(new_ss);
|
||||
@@ -372,6 +398,12 @@ where
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user