├── .gitignore ├── tests ├── sync-reminder.rs ├── assets │ └── ical_with_unknown_fields.ics ├── sync.rs └── scenarii.rs ├── .github └── workflows │ └── rust.yml ├── src ├── config.rs ├── resource.rs ├── event.rs ├── ical │ ├── mod.rs │ ├── builder.rs │ └── parser.rs ├── calendar │ ├── mod.rs │ ├── remote_calendar.rs │ └── cached_calendar.rs ├── lib.rs ├── provider │ ├── sync_progress.rs │ └── mod.rs ├── item.rs ├── utils │ └── mod.rs ├── traits.rs ├── mock_behaviour.rs ├── task.rs ├── client.rs └── cache.rs ├── LICENSE ├── README.md ├── Cargo.toml ├── examples ├── shared.rs ├── toggle-completions.rs └── provider-sync.rs └── resources └── kitchen-fridge.svg /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /test_cache 3 | -------------------------------------------------------------------------------- /tests/sync-reminder.rs: -------------------------------------------------------------------------------- 1 | #[test] 2 | #[ignore] 3 | fn do_not_forget_to_run_tests_with_specific_features() { 4 | // This is just a reminder that there are tests that can be run only when cargo feature "integration_tests" is enabled. 5 | // See `sync.rs` 6 | // See also the CI configuration in the `.gitlab` folder 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run regular tests 22 | run: cargo test --verbose 23 | - name: Run specific integration tests 24 | run: cargo test --verbose --features=integration_tests 25 | -------------------------------------------------------------------------------- /tests/assets/ical_with_unknown_fields.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | VERSION:2.0 3 | PRODID:-//Todo Corp LTD//Awesome Product ®//EN 4 | BEGIN:VTODO 5 | UID:20f57387-e116-4702-b463-d352aeaf80d0 6 | X_FAVOURITE_PAINT_FINISH:matte 7 | DTSTAMP:20211103T214742 8 | CREATED:20211103T212345 9 | LAST-MODIFIED:20211103T214742 10 | SUMMARY:This is a task with ÜTF-8 characters 11 | STATUS:NEEDS-ACTION 12 | DUE:20211103T220000 13 | PRIORITY:6 14 | PERCENT-COMPLETE:48 15 | IMAGE;DISPLAY=BADGE;FMTTYPE=image/png;VALUE=URI:http://example.com/images/p 16 | arty.png 17 | CONFERENCE;FEATURE=PHONE;LABEL=Attendee dial-in;VALUE=URI:tel:+1-888-555-04 18 | 56,,,555123 19 | END:VTODO 20 | END:VCALENDAR 21 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | //! Support for library configuration options 2 | 3 | use std::sync::{Arc, Mutex}; 4 | use once_cell::sync::Lazy; 5 | 6 | /// Part of the ProdID string that describes the organization (example of a ProdID string: `-//ABC Corporation//My Product//EN`). 7 | /// Feel free to override it when initing this library. 8 | pub static ORG_NAME: Lazy>> = Lazy::new(|| Arc::new(Mutex::new("My organization".to_string()))); 9 | 10 | /// Part of the ProdID string that describes the product name (example of a ProdID string: `-//ABC Corporation//My Product//EN`). 11 | /// Feel free to override it when initing this library. 12 | pub static PRODUCT_NAME: Lazy>> = Lazy::new(|| Arc::new(Mutex::new("KitchenFridge".to_string()))); 13 | -------------------------------------------------------------------------------- /src/resource.rs: -------------------------------------------------------------------------------- 1 | use url::Url; 2 | 3 | /// Just a wrapper around a URL and credentials 4 | #[derive(Clone, Debug)] 5 | pub struct Resource { 6 | url: Url, 7 | username: String, 8 | password: String, 9 | } 10 | 11 | impl Resource { 12 | pub fn new(url: Url, username: String, password: String) -> Self { 13 | Self { url, username, password } 14 | } 15 | 16 | pub fn url(&self) -> &Url { &self.url } 17 | pub fn username(&self) -> &String { &self.username } 18 | pub fn password(&self) -> &String { &self.password } 19 | 20 | /// Build a new Resource by keeping the same credentials, scheme and server from `base` but changing the path part 21 | pub fn combine(&self, new_path: &str) -> Resource { 22 | let mut built = (*self).clone(); 23 | built.url.set_path(&new_path); 24 | built 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2021 kitchen-fridge contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kitchen-fridge 2 | 3 |

4 | kitchen-fridge logo 5 |

6 | 7 | kitchen-fridge is a CalDAV (iCal file transfer over WebDAV) Rust client library. 8 | 9 | CalDAV is described as "Calendaring Extensions to WebDAV" in [RFC 4791](https://datatracker.ietf.org/doc/html/rfc4791) and [RFC 7986](https://datatracker.ietf.org/doc/html/rfc7986) and the underlying iCal format is described at least in [RFC 5545](https://datatracker.ietf.org/doc/html/rfc5545). \ 10 | This library has been intensivley tested with Nextcloud servers. It should support Owncloud and iCloud as well, since they use the very same CalDAV protocol. 11 | 12 | Its [documentation](https://docs.rs/kitchen-fridge/) is available on docs.rs. 13 | 14 | 15 | CalDAV is described as "Calendaring Extensions to WebDAV" in [RFC 4791](https://datatracker.ietf.org/doc/html/rfc4791) and [RFC 7986](https://datatracker.ietf.org/doc/html/rfc7986) and the underlying iCal format is described at least in [RFC 5545](https://datatracker.ietf.org/doc/html/rfc5545). 16 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kitchen-fridge" 3 | version = "0.4.0" 4 | authors = ["daladim"] 5 | edition = "2018" 6 | description = "A CalDAV (ical file management over WebDAV) library" 7 | repository = "https://github.com/daladim/kitchen-fridge" 8 | documentation = "https://docs.rs/kitchen-fridge" 9 | license = "MIT" 10 | readme = "README.md" 11 | keywords = ["CalDAV", "client", "WebDAV", "todo", "iCloud"] 12 | categories = ["network-programming", "web-programming::http-client"] 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [features] 17 | integration_tests = ["local_calendar_mocks_remote_calendars"] 18 | local_calendar_mocks_remote_calendars = [] 19 | 20 | [dependencies] 21 | env_logger = "0.9" 22 | log = "0.4" 23 | tokio = { version = "1.2", features = ["macros", "rt", "rt-multi-thread"]} 24 | reqwest = "0.11" 25 | minidom = "0.13" 26 | url = { version = "2.2", features = ["serde"] } 27 | bitflags = "1.2" 28 | serde = { version = "1.0", features = ["derive"] } 29 | serde_json = "1.0" 30 | async-trait = "0.1" 31 | uuid = { version = "0.8", features = ["v4"] } 32 | sanitize-filename = "0.3" 33 | ical-daladim = { version = "0.8", features = ["serde-derive"] } 34 | ics = "0.5" 35 | chrono = { version = "0.4", features = ["serde"] } 36 | csscolorparser = { version = "0.5", features = ["serde"] } 37 | once_cell = "1.8" 38 | itertools = "0.10" 39 | -------------------------------------------------------------------------------- /src/event.rs: -------------------------------------------------------------------------------- 1 | //! Calendar events (iCal `VEVENT` items) 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use chrono::{DateTime, Utc}; 5 | use url::Url; 6 | 7 | use crate::item::SyncStatus; 8 | 9 | /// TODO: implement `Event` one day. 10 | /// This crate currently only supports tasks, not calendar events. 11 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 12 | pub struct Event { 13 | uid: String, 14 | name: String, 15 | sync_status: SyncStatus, 16 | } 17 | 18 | impl Event { 19 | pub fn new() -> Self { 20 | unimplemented!(); 21 | } 22 | 23 | pub fn url(&self) -> &Url { 24 | unimplemented!(); 25 | } 26 | 27 | pub fn uid(&self) -> &str { 28 | &self.uid 29 | } 30 | 31 | pub fn name(&self) -> &str { 32 | &self.name 33 | } 34 | 35 | pub fn ical_prod_id(&self) -> &str { 36 | unimplemented!() 37 | } 38 | 39 | pub fn creation_date(&self) -> Option<&DateTime> { 40 | unimplemented!() 41 | } 42 | 43 | pub fn last_modified(&self) -> &DateTime { 44 | unimplemented!() 45 | } 46 | 47 | pub fn sync_status(&self) -> &SyncStatus { 48 | &self.sync_status 49 | } 50 | pub fn set_sync_status(&mut self, new_status: SyncStatus) { 51 | self.sync_status = new_status; 52 | } 53 | 54 | #[cfg(any(test, feature = "integration_tests"))] 55 | pub fn has_same_observable_content_as(&self, _other: &Event) -> bool { 56 | unimplemented!(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/ical/mod.rs: -------------------------------------------------------------------------------- 1 | //! This module handles conversion between iCal files and internal representations 2 | //! 3 | //! It is a wrapper around different Rust third-party libraries, since I haven't find any complete library that is able to parse _and_ generate iCal files 4 | 5 | mod parser; 6 | pub use parser::parse; 7 | mod builder; 8 | pub use builder::build_from; 9 | 10 | use crate::config::{ORG_NAME, PRODUCT_NAME}; 11 | 12 | pub fn default_prod_id() -> String { 13 | format!("-//{}//{}//EN", ORG_NAME.lock().unwrap(), PRODUCT_NAME.lock().unwrap()) 14 | } 15 | 16 | 17 | 18 | #[cfg(test)] 19 | mod tests { 20 | use super::*; 21 | 22 | use std::collections::HashSet; 23 | use crate::item::SyncStatus; 24 | 25 | #[test] 26 | fn test_ical_round_trip_serde() { 27 | let ical_with_unknown_fields = std::fs::read_to_string("tests/assets/ical_with_unknown_fields.ics").unwrap(); 28 | 29 | let item_id = "http://item.id".parse().unwrap(); 30 | let sync_status = SyncStatus::NotSynced; 31 | let deserialized = parse(&ical_with_unknown_fields, item_id, sync_status).unwrap(); 32 | let serialized = build_from(&deserialized).unwrap(); 33 | assert_same_fields(&ical_with_unknown_fields, &serialized); 34 | } 35 | 36 | /// Assert the properties are present (possibly in another order) 37 | /// RFC5545 "imposes no ordering of properties within an iCalendar object." 38 | fn assert_same_fields(left: &str, right: &str) { 39 | let left_parts: HashSet<&str> = left.split("\r\n").collect(); 40 | let right_parts: HashSet<&str> = right.split("\r\n").collect(); 41 | 42 | // Let's be more explicit than assert_eq!(left_parts, right_parts); 43 | if left_parts != right_parts { 44 | println!("Only in left:"); 45 | for item in left_parts.difference(&right_parts) { 46 | println!(" * {}", item); 47 | } 48 | println!("Only in right:"); 49 | for item in right_parts.difference(&left_parts) { 50 | println!(" * {}", item); 51 | } 52 | 53 | assert_eq!(left_parts, right_parts); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /examples/shared.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use kitchen_fridge::client::Client; 4 | use kitchen_fridge::traits::CalDavSource; 5 | use kitchen_fridge::CalDavProvider; 6 | use kitchen_fridge::cache::Cache; 7 | 8 | 9 | // TODO: change these values with yours 10 | pub const URL: &str = "https://my.server.com/remote.php/dav/files/john"; 11 | pub const USERNAME: &str = "username"; 12 | pub const PASSWORD: &str = "secret_password"; 13 | 14 | pub const EXAMPLE_EXISTING_CALENDAR_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/a_calendar_name/"; 15 | pub const EXAMPLE_CREATED_CALENDAR_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/a_calendar_that_we_have_created/"; 16 | 17 | fn main() { 18 | panic!("This file is not supposed to be executed"); 19 | } 20 | 21 | 22 | /// Initializes a Provider, and run an initial sync from the server 23 | pub async fn initial_sync(cache_folder: &str) -> CalDavProvider { 24 | let cache_path = Path::new(cache_folder); 25 | 26 | let client = Client::new(URL, USERNAME, PASSWORD).unwrap(); 27 | let cache = match Cache::from_folder(&cache_path) { 28 | Ok(cache) => cache, 29 | Err(err) => { 30 | log::warn!("Invalid cache file: {}. Using a default cache", err); 31 | Cache::new(&cache_path) 32 | } 33 | }; 34 | let mut provider = CalDavProvider::new(client, cache); 35 | 36 | 37 | let cals = provider.local().get_calendars().await.unwrap(); 38 | println!("---- Local items, before sync -----"); 39 | kitchen_fridge::utils::print_calendar_list(&cals).await; 40 | 41 | println!("Starting a sync..."); 42 | println!("Depending on your RUST_LOG value, you may see more or less details about the progress."); 43 | // Note that we could use sync_with_feedback() to have better and formatted feedback 44 | if provider.sync().await == false { 45 | log::warn!("Sync did not complete, see the previous log lines for more info. You can safely start a new sync."); 46 | } 47 | provider.local().save_to_folder().unwrap(); 48 | 49 | println!("---- Local items, after sync -----"); 50 | let cals = provider.local().get_calendars().await.unwrap(); 51 | kitchen_fridge::utils::print_calendar_list(&cals).await; 52 | 53 | provider 54 | } 55 | -------------------------------------------------------------------------------- /examples/toggle-completions.rs: -------------------------------------------------------------------------------- 1 | //! This is an example of how kitchen-fridge can be used. 2 | //! This binary simply toggles all completion statuses of the tasks it finds. 3 | 4 | use std::error::Error; 5 | 6 | use chrono::Utc; 7 | 8 | use kitchen_fridge::item::Item; 9 | use kitchen_fridge::task::CompletionStatus; 10 | use kitchen_fridge::CalDavProvider; 11 | use kitchen_fridge::utils::pause; 12 | 13 | mod shared; 14 | use shared::initial_sync; 15 | use shared::{URL, USERNAME}; 16 | 17 | const CACHE_FOLDER: &str = "test_cache/toggle_completion"; 18 | 19 | #[tokio::main] 20 | async fn main() { 21 | env_logger::init(); 22 | 23 | println!("This example show how to sync a remote server with a local cache, using a Provider."); 24 | println!("Make sure you have edited the constants in the 'shared.rs' file to include correct URLs and credentials."); 25 | println!("You can also set the RUST_LOG environment variable to display more info about the sync."); 26 | println!(""); 27 | println!("This will use the following settings:"); 28 | println!(" * URL = {}", URL); 29 | println!(" * USERNAME = {}", USERNAME); 30 | pause(); 31 | 32 | let mut provider = initial_sync(CACHE_FOLDER).await; 33 | 34 | toggle_all_tasks_and_sync_again(&mut provider).await.unwrap(); 35 | } 36 | 37 | async fn toggle_all_tasks_and_sync_again(provider: &mut CalDavProvider) -> Result<(), Box> { 38 | let mut n_toggled = 0; 39 | 40 | for (_url, cal) in provider.local().get_calendars_sync()?.iter() { 41 | for (_url, item) in cal.lock().unwrap().get_items_mut_sync()?.iter_mut() { 42 | match item { 43 | Item::Task(task) => { 44 | match task.completed() { 45 | false => task.set_completion_status(CompletionStatus::Completed(Some(Utc::now()))), 46 | true => task.set_completion_status(CompletionStatus::Uncompleted), 47 | }; 48 | n_toggled += 1; 49 | } 50 | Item::Event(_) => { 51 | // Not doing anything with calendar events 52 | }, 53 | } 54 | } 55 | } 56 | 57 | println!("{} items toggled.", n_toggled); 58 | println!("Syncing..."); 59 | 60 | provider.sync().await; 61 | 62 | println!("Syncing complete."); 63 | 64 | Ok(()) 65 | } 66 | -------------------------------------------------------------------------------- /src/calendar/mod.rs: -------------------------------------------------------------------------------- 1 | //! Various objects that implement Calendar-related traits 2 | 3 | pub mod cached_calendar; 4 | pub mod remote_calendar; 5 | 6 | use std::convert::TryFrom; 7 | use std::error::Error; 8 | 9 | use serde::{Deserialize, Serialize}; 10 | 11 | use bitflags::bitflags; 12 | 13 | bitflags! { 14 | #[derive(Serialize, Deserialize)] 15 | pub struct SupportedComponents: u8 { 16 | /// An event, such as a calendar meeting 17 | const EVENT = 1; 18 | /// A to-do item, such as a reminder 19 | const TODO = 2; 20 | } 21 | } 22 | 23 | impl SupportedComponents { 24 | pub fn to_xml_string(&self) -> String { 25 | format!(r#" 26 | 27 | {} {} 28 | 29 | "#, 30 | if self.contains(Self::EVENT) { "" } else { "" }, 31 | if self.contains(Self::TODO) { "" } else { "" }, 32 | ) 33 | } 34 | } 35 | 36 | impl TryFrom for SupportedComponents { 37 | type Error = Box; 38 | 39 | /// Create an instance from an XML element 40 | fn try_from(element: minidom::Element) -> Result { 41 | if element.name() != "supported-calendar-component-set" { 42 | return Err("Element must be a ".into()); 43 | } 44 | 45 | let mut flags = Self::empty(); 46 | for child in element.children() { 47 | match child.attr("name") { 48 | None => continue, 49 | Some("VEVENT") => flags.insert(Self::EVENT), 50 | Some("VTODO") => flags.insert(Self::TODO), 51 | Some(other) => { 52 | log::warn!("Unimplemented supported component type: {:?}. Ignoring it", other); 53 | continue 54 | }, 55 | }; 56 | } 57 | 58 | Ok(flags) 59 | } 60 | } 61 | 62 | 63 | /// Flags to tell which events should be retrieved 64 | pub enum SearchFilter { 65 | /// Return all items 66 | All, 67 | /// Return only tasks 68 | Tasks, 69 | // /// Return only completed tasks 70 | // CompletedTasks, 71 | // /// Return only calendar events 72 | // Events, 73 | } 74 | 75 | impl Default for SearchFilter { 76 | fn default() -> Self { 77 | SearchFilter::All 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate provides a CalDAV client library. \ 2 | //! CalDAV is described as "Calendaring Extensions to WebDAV" in [RFC 4791](https://datatracker.ietf.org/doc/html/rfc4791) and [RFC 7986](https://datatracker.ietf.org/doc/html/rfc7986) and the underlying iCal format is described at least in [RFC 5545](https://datatracker.ietf.org/doc/html/rfc5545). \ 3 | //! This library has been intensivley tested with Nextcloud servers. It should support Owncloud and iCloud as well, since they use the very same CalDAV protocol. 4 | //! 5 | //! This initial implementation only supports TODO events. Thus it can fetch and update a CalDAV-hosted todo-list...just like [sticky notes on a kitchen fridge](https://www.google.com/search?q=kitchen+fridge+todo+list&tbm=isch) would. \ 6 | //! Supporting other items (and especially regular CalDAV calendar events) should be fairly trivial, as it should boil down to adding little logic in iCal files parsing, but any help is appreciated :-) 7 | //! 8 | //! ## Possible uses 9 | //! 10 | //! It provides a CalDAV client in the [`client`] module, that can be used as a stand-alone module. 11 | //! 12 | //! Because the connection to the server may be slow, this crate also provides a local cache for CalDAV data in the [`cache`] module. 13 | //! This way, user-frendly apps are able to quicky display cached data on startup. 14 | //! 15 | //! These two "data sources" (actual client and local cache) can be used together in a [`CalDavProvider`](CalDavProvider). \ 16 | //! A `CalDavProvider` abstracts these two sources by merging them together into one virtual source. \ 17 | //! It also handles synchronisation between the local cache and the server, and robustly recovers from any network error (so that it never corrupts the local or remote source). 18 | //! 19 | //! Note that many methods are defined in common traits (see [`crate::traits`]). 20 | //! 21 | //! ## Examples 22 | //! 23 | //! See example usage in the `examples/` folder, that you can run using `cargo run --example `. \ 24 | //! You can also have a look at [`Voilà`](https://github.com/daladim/voila-tasks), a GUI app that uses `kitchen-fridge` under the hood. 25 | //! 26 | //! ## Configuration options 27 | //! 28 | //! Have a look at the [`config`] module to see what default options can be overridden. 29 | 30 | #![doc(html_logo_url = "https://raw.githubusercontent.com/daladim/kitchen-fridge/master/resources/kitchen-fridge.svg")] 31 | 32 | pub mod traits; 33 | 34 | pub mod calendar; 35 | pub mod item; 36 | pub use item::Item; 37 | pub mod task; 38 | pub use task::Task; 39 | pub mod event; 40 | pub use event::Event; 41 | pub mod provider; 42 | pub mod mock_behaviour; 43 | 44 | pub mod client; 45 | pub use client::Client; 46 | pub mod cache; 47 | pub use cache::Cache; 48 | pub mod ical; 49 | 50 | pub mod config; 51 | pub mod utils; 52 | pub mod resource; 53 | 54 | /// Unless you want another kind of Provider to write integration tests, you'll probably want this kind of Provider. \ 55 | /// See alse the [`Provider` documentation](crate::provider::Provider) 56 | pub type CalDavProvider = provider::Provider; 57 | -------------------------------------------------------------------------------- /src/provider/sync_progress.rs: -------------------------------------------------------------------------------- 1 | //! Utilities to track the progression of a sync 2 | 3 | use std::fmt::{Display, Error, Formatter}; 4 | 5 | /// An event that happens during a sync 6 | #[derive(Clone, Debug)] 7 | pub enum SyncEvent { 8 | /// Sync has not started 9 | NotStarted, 10 | /// Sync has just started but no calendar is handled yet 11 | Started, 12 | /// Sync is in progress. 13 | InProgress{ calendar: String, items_done_already: usize, details: String}, 14 | /// Sync is finished 15 | Finished{ success: bool }, 16 | } 17 | 18 | impl Display for SyncEvent { 19 | fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { 20 | match self { 21 | SyncEvent::NotStarted => write!(f, "Not started"), 22 | SyncEvent::Started => write!(f, "Sync has started..."), 23 | SyncEvent::InProgress{calendar, items_done_already, details} => write!(f, "{} [{}/?] {}...", calendar, items_done_already, details), 24 | SyncEvent::Finished{success} => match success { 25 | true => write!(f, "Sync successfully finished"), 26 | false => write!(f, "Sync finished with errors"), 27 | } 28 | } 29 | } 30 | } 31 | 32 | impl Default for SyncEvent { 33 | fn default() -> Self { 34 | Self::NotStarted 35 | } 36 | } 37 | 38 | 39 | 40 | /// See [`feedback_channel`] 41 | pub type FeedbackSender = tokio::sync::watch::Sender; 42 | /// See [`feedback_channel`] 43 | pub type FeedbackReceiver = tokio::sync::watch::Receiver; 44 | 45 | /// Create a feeback channel, that can be used to retrieve the current progress of a sync operation 46 | pub fn feedback_channel() -> (FeedbackSender, FeedbackReceiver) { 47 | tokio::sync::watch::channel(SyncEvent::default()) 48 | } 49 | 50 | 51 | 52 | 53 | /// A structure that tracks the progression and the errors that happen during a sync 54 | pub struct SyncProgress { 55 | n_errors: u32, 56 | feedback_channel: Option, 57 | counter: usize, 58 | } 59 | impl SyncProgress { 60 | pub fn new() -> Self { 61 | Self { n_errors: 0, feedback_channel: None, counter: 0 } 62 | } 63 | pub fn new_with_feedback_channel(channel: FeedbackSender) -> Self { 64 | Self { n_errors: 0, feedback_channel: Some(channel), counter: 0 } 65 | } 66 | 67 | /// Reset the user-info counter 68 | pub fn reset_counter(&mut self) { 69 | self.counter = 0; 70 | } 71 | /// Increments the user-info counter. 72 | pub fn increment_counter(&mut self, increment: usize) { 73 | self.counter += increment; 74 | } 75 | /// Retrieves the current user-info counter. 76 | /// This counts "arbitrary things", that's provided as a convenience but it is not used internally 77 | /// (e.g. that can be used to keep track of the items handled for the current calendar) 78 | pub fn counter(&self) -> usize { 79 | self.counter 80 | } 81 | 82 | 83 | 84 | pub fn is_success(&self) -> bool { 85 | self.n_errors == 0 86 | } 87 | 88 | /// Log an error 89 | pub fn error(&mut self, text: &str) { 90 | log::error!("{}", text); 91 | self.n_errors += 1; 92 | } 93 | /// Log a warning 94 | pub fn warn(&mut self, text: &str) { 95 | log::warn!("{}", text); 96 | self.n_errors += 1; 97 | } 98 | /// Log an info 99 | pub fn info(&mut self, text: &str) { 100 | log::info!("{}", text); 101 | } 102 | /// Log a debug message 103 | pub fn debug(&mut self, text: &str) { 104 | log::debug!("{}", text); 105 | } 106 | /// Log a trace message 107 | pub fn trace(&mut self, text: &str) { 108 | log::trace!("{}", text); 109 | } 110 | /// Send an event as a feedback to the listener (if any). 111 | pub fn feedback(&mut self, event: SyncEvent) { 112 | self.feedback_channel 113 | .as_ref() 114 | .map(|sender| { 115 | sender.send(event) 116 | }); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/item.rs: -------------------------------------------------------------------------------- 1 | //! CalDAV items (todo, events, journals...) 2 | // TODO: move Event and Task to nest them in crate::items::calendar::Calendar? 3 | 4 | use serde::{Deserialize, Serialize}; 5 | use url::Url; 6 | use chrono::{DateTime, Utc}; 7 | 8 | 9 | #[derive(Clone, Debug, Serialize, Deserialize)] 10 | pub enum Item { 11 | Event(crate::event::Event), 12 | Task(crate::task::Task), 13 | } 14 | 15 | /// Returns `task.$property_name` or `event.$property_name`, depending on whether self is a Task or an Event 16 | macro_rules! synthetise_common_getter { 17 | ($property_name:ident, $return_type:ty) => { 18 | pub fn $property_name(&self) -> $return_type { 19 | match self { 20 | Item::Event(e) => e.$property_name(), 21 | Item::Task(t) => t.$property_name(), 22 | } 23 | } 24 | } 25 | } 26 | 27 | impl Item { 28 | synthetise_common_getter!(url, &Url); 29 | synthetise_common_getter!(uid, &str); 30 | synthetise_common_getter!(name, &str); 31 | synthetise_common_getter!(creation_date, Option<&DateTime>); 32 | synthetise_common_getter!(last_modified, &DateTime); 33 | synthetise_common_getter!(sync_status, &SyncStatus); 34 | synthetise_common_getter!(ical_prod_id, &str); 35 | 36 | pub fn set_sync_status(&mut self, new_status: SyncStatus) { 37 | match self { 38 | Item::Event(e) => e.set_sync_status(new_status), 39 | Item::Task(t) => t.set_sync_status(new_status), 40 | } 41 | } 42 | 43 | pub fn is_event(&self) -> bool { 44 | match &self { 45 | Item::Event(_) => true, 46 | _ => false, 47 | } 48 | } 49 | 50 | pub fn is_task(&self) -> bool { 51 | match &self { 52 | Item::Task(_) => true, 53 | _ => false, 54 | } 55 | } 56 | 57 | /// Returns a mutable reference to the inner Task 58 | /// 59 | /// # Panics 60 | /// Panics if the inner item is not a Task 61 | pub fn unwrap_task_mut(&mut self) -> &mut crate::task::Task { 62 | match self { 63 | Item::Task(t) => t, 64 | _ => panic!("Not a task"), 65 | } 66 | } 67 | 68 | /// Returns a reference to the inner Task 69 | /// 70 | /// # Panics 71 | /// Panics if the inner item is not a Task 72 | pub fn unwrap_task(&self) -> &crate::task::Task { 73 | match self { 74 | Item::Task(t) => t, 75 | _ => panic!("Not a task"), 76 | } 77 | } 78 | 79 | #[cfg(any(test, feature = "integration_tests"))] 80 | pub fn has_same_observable_content_as(&self, other: &Item) -> bool { 81 | match (self, other) { 82 | (Item::Event(s), Item::Event(o)) => s.has_same_observable_content_as(o), 83 | (Item::Task(s), Item::Task(o)) => s.has_same_observable_content_as(o), 84 | _ => false, 85 | } 86 | } 87 | } 88 | 89 | 90 | 91 | 92 | /// A VersionTag is basically a CalDAV `ctag` or `etag`. Whenever it changes, this means the data has changed. 93 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 94 | pub struct VersionTag { 95 | tag: String 96 | } 97 | 98 | impl From for VersionTag { 99 | fn from(tag: String) -> VersionTag { 100 | Self { tag } 101 | } 102 | } 103 | 104 | impl VersionTag { 105 | /// Get the inner version tag (usually a WebDAV `ctag` or `etag`) 106 | pub fn as_str(&self) -> &str { 107 | &self.tag 108 | } 109 | 110 | /// Generate a random VersionTag 111 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 112 | pub fn random() -> Self { 113 | let random = uuid::Uuid::new_v4().to_hyphenated().to_string(); 114 | Self { tag: random } 115 | } 116 | } 117 | 118 | 119 | 120 | /// Describes whether this item has been synced already, or modified since the last time it was synced 121 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 122 | pub enum SyncStatus { 123 | /// This item has ben locally created, and never synced yet 124 | NotSynced, 125 | /// At the time this item has ben synced, it has a given version tag, and has not been locally modified since then. 126 | /// Note: in integration tests, in case we are mocking a remote calendar by a local calendar, this is the only valid variant (remote calendars make no distinction between all these variants) 127 | Synced(VersionTag), 128 | /// This item has been synced when it had a given version tag, and has been locally modified since then. 129 | LocallyModified(VersionTag), 130 | /// This item has been synced when it had a given version tag, and has been locally deleted since then. 131 | LocallyDeleted(VersionTag), 132 | } 133 | impl SyncStatus { 134 | /// Generate a random SyncStatus::Synced 135 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 136 | pub fn random_synced() -> Self { 137 | Self::Synced(VersionTag::random()) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/ical/builder.rs: -------------------------------------------------------------------------------- 1 | //! A module to build ICal files 2 | 3 | use std::error::Error; 4 | 5 | use chrono::{DateTime, Utc}; 6 | use ics::properties::{Completed, Created, LastModified, PercentComplete, Status, Summary}; 7 | use ics::{ICalendar, ToDo}; 8 | use ics::components::Parameter as IcsParameter; 9 | use ics::components::Property as IcsProperty; 10 | use ical::property::Property as IcalProperty; 11 | 12 | use crate::Task; 13 | use crate::item::Item; 14 | use crate::task::CompletionStatus; 15 | 16 | 17 | /// Create an iCal item from a `crate::item::Item` 18 | pub fn build_from(item: &Item) -> Result> { 19 | match item { 20 | Item::Task(t) => build_from_task(t), 21 | _ => unimplemented!(), 22 | } 23 | } 24 | 25 | pub fn build_from_task(task: &Task) -> Result> { 26 | let s_last_modified = format_date_time(task.last_modified()); 27 | 28 | let mut todo = ToDo::new( 29 | task.uid(), 30 | s_last_modified.clone(), 31 | ); 32 | 33 | task.creation_date().map(|dt| 34 | todo.push(Created::new(format_date_time(dt))) 35 | ); 36 | todo.push(LastModified::new(s_last_modified)); 37 | todo.push(Summary::new(task.name())); 38 | 39 | match task.completion_status() { 40 | CompletionStatus::Uncompleted => { 41 | todo.push(Status::needs_action()); 42 | }, 43 | CompletionStatus::Completed(completion_date) => { 44 | todo.push(PercentComplete::new("100")); 45 | completion_date.as_ref().map(|dt| todo.push( 46 | Completed::new(format_date_time(dt)) 47 | )); 48 | todo.push(Status::completed()); 49 | } 50 | } 51 | 52 | // Also add fields that we have not handled 53 | for ical_property in task.extra_parameters() { 54 | let ics_property = ical_to_ics_property(ical_property.clone()); 55 | todo.push(ics_property); 56 | } 57 | 58 | let mut calendar = ICalendar::new("2.0", task.ical_prod_id()); 59 | calendar.add_todo(todo); 60 | 61 | Ok(calendar.to_string()) 62 | } 63 | 64 | fn format_date_time(dt: &DateTime) -> String { 65 | dt.format("%Y%m%dT%H%M%S").to_string() 66 | } 67 | 68 | 69 | fn ical_to_ics_property(prop: IcalProperty) -> IcsProperty<'static> { 70 | let mut ics_prop = match prop.value { 71 | Some(value) => IcsProperty::new(prop.name, value), 72 | None => IcsProperty::new(prop.name, ""), 73 | }; 74 | prop.params.map(|v| { 75 | for (key, vec_values) in v { 76 | let values = vec_values.join(";"); 77 | ics_prop.add(IcsParameter::new(key, values)); 78 | } 79 | }); 80 | ics_prop 81 | } 82 | 83 | 84 | #[cfg(test)] 85 | mod tests { 86 | use super::*; 87 | use crate::Task; 88 | use crate::config::{ORG_NAME, PRODUCT_NAME}; 89 | 90 | #[test] 91 | fn test_ical_from_completed_task() { 92 | let (s_now, uid, ical) = build_task(true); 93 | 94 | let expected_ical = format!("BEGIN:VCALENDAR\r\n\ 95 | VERSION:2.0\r\n\ 96 | PRODID:-//{}//{}//EN\r\n\ 97 | BEGIN:VTODO\r\n\ 98 | UID:{}\r\n\ 99 | DTSTAMP:{}\r\n\ 100 | CREATED:{}\r\n\ 101 | LAST-MODIFIED:{}\r\n\ 102 | SUMMARY:This is a task with ÜTF-8 characters\r\n\ 103 | PERCENT-COMPLETE:100\r\n\ 104 | COMPLETED:{}\r\n\ 105 | STATUS:COMPLETED\r\n\ 106 | END:VTODO\r\n\ 107 | END:VCALENDAR\r\n", ORG_NAME.lock().unwrap(), PRODUCT_NAME.lock().unwrap(), uid, s_now, s_now, s_now, s_now); 108 | 109 | assert_eq!(ical, expected_ical); 110 | } 111 | 112 | #[test] 113 | fn test_ical_from_uncompleted_task() { 114 | let (s_now, uid, ical) = build_task(false); 115 | 116 | let expected_ical = format!("BEGIN:VCALENDAR\r\n\ 117 | VERSION:2.0\r\n\ 118 | PRODID:-//{}//{}//EN\r\n\ 119 | BEGIN:VTODO\r\n\ 120 | UID:{}\r\n\ 121 | DTSTAMP:{}\r\n\ 122 | CREATED:{}\r\n\ 123 | LAST-MODIFIED:{}\r\n\ 124 | SUMMARY:This is a task with ÜTF-8 characters\r\n\ 125 | STATUS:NEEDS-ACTION\r\n\ 126 | END:VTODO\r\n\ 127 | END:VCALENDAR\r\n", ORG_NAME.lock().unwrap(), PRODUCT_NAME.lock().unwrap(), uid, s_now, s_now, s_now); 128 | 129 | assert_eq!(ical, expected_ical); 130 | } 131 | 132 | fn build_task(completed: bool) -> (String, String, String) { 133 | let cal_url = "http://my.calend.ar/id".parse().unwrap(); 134 | let now = Utc::now(); 135 | let s_now = format_date_time(&now); 136 | 137 | let task = Item::Task(Task::new( 138 | String::from("This is a task with ÜTF-8 characters"), completed, &cal_url 139 | )); 140 | 141 | let ical = build_from(&task).unwrap(); 142 | (s_now, task.uid().to_string(), ical) 143 | } 144 | 145 | #[test] 146 | #[ignore] 147 | fn test_ical_from_event() { 148 | unimplemented!(); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | //! Some utility functions 2 | 3 | use std::collections::{HashMap, HashSet}; 4 | use std::sync::{Arc, Mutex}; 5 | use std::hash::Hash; 6 | use std::io::{stdin, stdout, Read, Write}; 7 | 8 | use minidom::Element; 9 | use url::Url; 10 | 11 | use crate::traits::CompleteCalendar; 12 | use crate::traits::DavCalendar; 13 | use crate::Item; 14 | use crate::item::SyncStatus; 15 | 16 | /// Walks an XML tree and returns every element that has the given name 17 | pub fn find_elems>(root: &Element, searched_name: S) -> Vec<&Element> { 18 | let searched_name = searched_name.as_ref(); 19 | let mut elems: Vec<&Element> = Vec::new(); 20 | 21 | for el in root.children() { 22 | if el.name() == searched_name { 23 | elems.push(el); 24 | } else { 25 | let ret = find_elems(el, searched_name); 26 | elems.extend(ret); 27 | } 28 | } 29 | elems 30 | } 31 | 32 | /// Walks an XML tree until it finds an elements with the given name 33 | pub fn find_elem>(root: &Element, searched_name: S) -> Option<&Element> { 34 | let searched_name = searched_name.as_ref(); 35 | if root.name() == searched_name { 36 | return Some(root); 37 | } 38 | 39 | for el in root.children() { 40 | if el.name() == searched_name { 41 | return Some(el); 42 | } else { 43 | let ret = find_elem(el, searched_name); 44 | if ret.is_some() { 45 | return ret; 46 | } 47 | } 48 | } 49 | None 50 | } 51 | 52 | 53 | pub fn print_xml(element: &Element) { 54 | let mut writer = std::io::stdout(); 55 | 56 | let mut xml_writer = minidom::quick_xml::Writer::new_with_indent( 57 | std::io::stdout(), 58 | 0x20, 4 59 | ); 60 | let _ = element.to_writer(&mut xml_writer); 61 | let _ = writer.write(&[0x0a]); 62 | } 63 | 64 | /// A debug utility that pretty-prints calendars 65 | pub async fn print_calendar_list(cals: &HashMap>>) 66 | where 67 | C: CompleteCalendar, 68 | { 69 | for (url, cal) in cals { 70 | println!("CAL {} ({})", cal.lock().unwrap().name(), url); 71 | match cal.lock().unwrap().get_items().await { 72 | Err(_err) => continue, 73 | Ok(map) => { 74 | for (_, item) in map { 75 | print_task(item); 76 | } 77 | }, 78 | } 79 | } 80 | } 81 | 82 | /// A debug utility that pretty-prints calendars 83 | pub async fn print_dav_calendar_list(cals: &HashMap>>) 84 | where 85 | C: DavCalendar, 86 | { 87 | for (url, cal) in cals { 88 | println!("CAL {} ({})", cal.lock().unwrap().name(), url); 89 | match cal.lock().unwrap().get_item_version_tags().await { 90 | Err(_err) => continue, 91 | Ok(map) => { 92 | for (url, version_tag) in map { 93 | println!(" * {} (version {:?})", url, version_tag); 94 | } 95 | }, 96 | } 97 | } 98 | } 99 | 100 | pub fn print_task(item: &Item) { 101 | match item { 102 | Item::Task(task) => { 103 | let completion = if task.completed() { "✓" } else { " " }; 104 | let sync = match task.sync_status() { 105 | SyncStatus::NotSynced => ".", 106 | SyncStatus::Synced(_) => "=", 107 | SyncStatus::LocallyModified(_) => "~", 108 | SyncStatus::LocallyDeleted(_) => "x", 109 | }; 110 | println!(" {}{} {}\t{}", completion, sync, task.name(), task.url()); 111 | }, 112 | _ => return, 113 | } 114 | } 115 | 116 | 117 | /// Compare keys of two hashmaps for equality 118 | pub fn keys_are_the_same(left: &HashMap, right: &HashMap) -> bool 119 | where 120 | T: Hash + Eq + Clone + std::fmt::Display, 121 | { 122 | if left.len() != right.len() { 123 | log::debug!("Count of keys mismatch: {} and {}", left.len(), right.len()); 124 | return false; 125 | } 126 | 127 | let keys_l: HashSet = left.keys().cloned().collect(); 128 | let keys_r: HashSet = right.keys().cloned().collect(); 129 | let result = keys_l == keys_r; 130 | if result == false { 131 | log::debug!("Keys of a map mismatch"); 132 | for key in keys_l { 133 | log::debug!(" left: {}", key); 134 | } 135 | log::debug!("RIGHT:"); 136 | for key in keys_r { 137 | log::debug!(" right: {}", key); 138 | } 139 | } 140 | result 141 | } 142 | 143 | 144 | /// Wait for the user to press enter 145 | pub fn pause() { 146 | let mut stdout = stdout(); 147 | stdout.write_all(b"Press Enter to continue...").unwrap(); 148 | stdout.flush().unwrap(); 149 | stdin().read_exact(&mut [0]).unwrap(); 150 | } 151 | 152 | 153 | /// Generate a random URL with a given prefix 154 | pub fn random_url(parent_calendar: &Url) -> Url { 155 | let random = uuid::Uuid::new_v4().to_hyphenated().to_string(); 156 | parent_calendar.join(&random).unwrap(/* this cannot panic since we've just created a string that is a valid URL */) 157 | } 158 | -------------------------------------------------------------------------------- /examples/provider-sync.rs: -------------------------------------------------------------------------------- 1 | //! This is an example of how kitchen-fridge can be used 2 | 3 | use chrono::{Utc}; 4 | use url::Url; 5 | 6 | use kitchen_fridge::traits::CalDavSource; 7 | use kitchen_fridge::calendar::SupportedComponents; 8 | use kitchen_fridge::Item; 9 | use kitchen_fridge::Task; 10 | use kitchen_fridge::task::CompletionStatus; 11 | use kitchen_fridge::CalDavProvider; 12 | use kitchen_fridge::traits::BaseCalendar; 13 | use kitchen_fridge::traits::CompleteCalendar; 14 | use kitchen_fridge::utils::pause; 15 | 16 | mod shared; 17 | use shared::initial_sync; 18 | use shared::{URL, USERNAME, EXAMPLE_EXISTING_CALENDAR_URL, EXAMPLE_CREATED_CALENDAR_URL}; 19 | 20 | const CACHE_FOLDER: &str = "test_cache/provider_sync"; 21 | 22 | 23 | #[tokio::main] 24 | async fn main() { 25 | env_logger::init(); 26 | 27 | println!("This example show how to sync a remote server with a local cache, using a Provider."); 28 | println!("Make sure you have edited the constants in the 'shared.rs' file to include correct URLs and credentials."); 29 | println!("You can also set the RUST_LOG environment variable to display more info about the sync."); 30 | println!(""); 31 | println!("This will use the following settings:"); 32 | println!(" * URL = {}", URL); 33 | println!(" * USERNAME = {}", USERNAME); 34 | println!(" * EXAMPLE_EXISTING_CALENDAR_URL = {}", EXAMPLE_EXISTING_CALENDAR_URL); 35 | println!(" * EXAMPLE_CREATED_CALENDAR_URL = {}", EXAMPLE_CREATED_CALENDAR_URL); 36 | pause(); 37 | 38 | let mut provider = initial_sync(CACHE_FOLDER).await; 39 | 40 | add_items_and_sync_again(&mut provider).await; 41 | } 42 | 43 | async fn add_items_and_sync_again(provider: &mut CalDavProvider) { 44 | println!("\nNow, we'll add a calendar and a few tasks and run the sync again."); 45 | pause(); 46 | 47 | // Create a new calendar... 48 | let new_calendar_url: Url = EXAMPLE_CREATED_CALENDAR_URL.parse().unwrap(); 49 | let new_calendar_name = "A brave new calendar".to_string(); 50 | if let Err(_err) = provider.local_mut() 51 | .create_calendar(new_calendar_url.clone(), new_calendar_name.clone(), SupportedComponents::TODO, Some("#ff8000".parse().unwrap())) 52 | .await { 53 | println!("Unable to add calendar, maybe it exists already. We're not adding it after all."); 54 | } 55 | 56 | // ...and add a task in it 57 | let new_name = "This is a new task in a new calendar"; 58 | let new_task = Task::new(String::from(new_name), true, &new_calendar_url); 59 | provider.local().get_calendar(&new_calendar_url).await.unwrap() 60 | .lock().unwrap().add_item(Item::Task(new_task)).await.unwrap(); 61 | 62 | 63 | // Also create a task in a previously existing calendar 64 | let changed_calendar_url: Url = EXAMPLE_EXISTING_CALENDAR_URL.parse().unwrap(); 65 | let new_task_name = "This is a new task we're adding as an example, with ÜTF-8 characters"; 66 | let new_task = Task::new(String::from(new_task_name), false, &changed_calendar_url); 67 | let new_url = new_task.url().clone(); 68 | provider.local().get_calendar(&changed_calendar_url).await.unwrap() 69 | .lock().unwrap().add_item(Item::Task(new_task)).await.unwrap(); 70 | 71 | 72 | if provider.sync().await == false { 73 | log::warn!("Sync did not complete, see the previous log lines for more info. You can safely start a new sync. The new task may not have been synced."); 74 | } else { 75 | println!("Done syncing the new task '{}' and the new calendar '{}'", new_task_name, new_calendar_name); 76 | } 77 | provider.local().save_to_folder().unwrap(); 78 | 79 | complete_item_and_sync_again(provider, &changed_calendar_url, &new_url).await; 80 | } 81 | 82 | async fn complete_item_and_sync_again( 83 | provider: &mut CalDavProvider, 84 | changed_calendar_url: &Url, 85 | url_to_complete: &Url) 86 | { 87 | println!("\nNow, we'll mark this last task as completed, and run the sync again."); 88 | pause(); 89 | 90 | let completion_status = CompletionStatus::Completed(Some(Utc::now())); 91 | provider.local().get_calendar(changed_calendar_url).await.unwrap() 92 | .lock().unwrap().get_item_by_url_mut(url_to_complete).await.unwrap() 93 | .unwrap_task_mut() 94 | .set_completion_status(completion_status); 95 | 96 | if provider.sync().await == false { 97 | log::warn!("Sync did not complete, see the previous log lines for more info. You can safely start a new sync. The new task may not have been synced."); 98 | } else { 99 | println!("Done syncing the completed task"); 100 | } 101 | provider.local().save_to_folder().unwrap(); 102 | 103 | remove_items_and_sync_again(provider, changed_calendar_url, url_to_complete).await; 104 | } 105 | 106 | async fn remove_items_and_sync_again( 107 | provider: &mut CalDavProvider, 108 | changed_calendar_url: &Url, 109 | id_to_remove: &Url) 110 | { 111 | println!("\nNow, we'll delete this last task, and run the sync again."); 112 | pause(); 113 | 114 | // Remove the task we had created 115 | provider.local().get_calendar(changed_calendar_url).await.unwrap() 116 | .lock().unwrap() 117 | .mark_for_deletion(id_to_remove).await.unwrap(); 118 | 119 | if provider.sync().await == false { 120 | log::warn!("Sync did not complete, see the previous log lines for more info. You can safely start a new sync. The new task may not have been synced."); 121 | } else { 122 | println!("Done syncing the deleted task"); 123 | } 124 | provider.local().save_to_folder().unwrap(); 125 | 126 | println!("Done. You can start this example again to see the cache being restored from its current saved state") 127 | } 128 | -------------------------------------------------------------------------------- /src/traits.rs: -------------------------------------------------------------------------------- 1 | //! Traits used by multiple structs in this crate 2 | 3 | use std::error::Error; 4 | use std::collections::{HashMap, HashSet}; 5 | use std::sync::{Arc, Mutex}; 6 | 7 | use async_trait::async_trait; 8 | use csscolorparser::Color; 9 | use url::Url; 10 | 11 | use crate::item::SyncStatus; 12 | use crate::item::Item; 13 | use crate::item::VersionTag; 14 | use crate::calendar::SupportedComponents; 15 | use crate::resource::Resource; 16 | 17 | /// This trait must be implemented by data sources (either local caches or remote CalDAV clients) 18 | /// 19 | /// Note that some concrete types (e.g. [`crate::cache::Cache`]) can also provide non-async versions of these functions 20 | #[async_trait] 21 | pub trait CalDavSource { 22 | /// Returns the current calendars that this source contains 23 | /// This function may trigger an update (that can be a long process, or that can even fail, e.g. in case of a remote server) 24 | async fn get_calendars(&self) -> Result>>, Box>; 25 | /// Returns the calendar matching the URL 26 | async fn get_calendar(&self, url: &Url) -> Option>>; 27 | /// Create a calendar if it did not exist, and return it 28 | async fn create_calendar(&mut self, url: Url, name: String, supported_components: SupportedComponents, color: Option) 29 | -> Result>, Box>; 30 | 31 | // Removing a calendar is not supported yet 32 | } 33 | 34 | /// This trait contains functions that are common to all calendars 35 | /// 36 | /// Note that some concrete types (e.g. [`crate::calendar::cached_calendar::CachedCalendar`]) can also provide non-async versions of these functions 37 | #[async_trait] 38 | pub trait BaseCalendar { 39 | /// Returns the calendar name 40 | fn name(&self) -> &str; 41 | 42 | /// Returns the calendar URL 43 | fn url(&self) -> &Url; 44 | 45 | /// Returns the supported kinds of components for this calendar 46 | fn supported_components(&self) -> crate::calendar::SupportedComponents; 47 | 48 | /// Returns the user-defined color of this calendar 49 | fn color(&self) -> Option<&Color>; 50 | 51 | /// Add an item into this calendar, and return its new sync status. 52 | /// For local calendars, the sync status is not modified. 53 | /// For remote calendars, the sync status is updated by the server 54 | async fn add_item(&mut self, item: Item) -> Result>; 55 | 56 | /// Update an item that already exists in this calendar and returns its new `SyncStatus` 57 | /// This replaces a given item at a given URL 58 | async fn update_item(&mut self, item: Item) -> Result>; 59 | 60 | /// Returns whether this calDAV calendar supports to-do items 61 | fn supports_todo(&self) -> bool { 62 | self.supported_components().contains(crate::calendar::SupportedComponents::TODO) 63 | } 64 | 65 | /// Returns whether this calDAV calendar supports calendar items 66 | fn supports_events(&self) -> bool { 67 | self.supported_components().contains(crate::calendar::SupportedComponents::EVENT) 68 | } 69 | } 70 | 71 | 72 | /// Functions availabe for calendars that are backed by a CalDAV server 73 | /// 74 | /// Note that some concrete types (e.g. [`crate::calendar::cached_calendar::CachedCalendar`]) can also provide non-async versions of these functions 75 | #[async_trait] 76 | pub trait DavCalendar : BaseCalendar { 77 | /// Create a new calendar 78 | fn new(name: String, resource: Resource, supported_components: SupportedComponents, color: Option) -> Self; 79 | 80 | /// Get the URLs and the version tags of every item in this calendar 81 | async fn get_item_version_tags(&self) -> Result, Box>; 82 | 83 | /// Returns a particular item 84 | async fn get_item_by_url(&self, url: &Url) -> Result, Box>; 85 | 86 | /// Returns a set of items. 87 | /// This is usually faster than calling multiple consecutive [`DavCalendar::get_item_by_url`], since it only issues one HTTP request. 88 | async fn get_items_by_url(&self, urls: &[Url]) -> Result>, Box>; 89 | 90 | /// Delete an item 91 | async fn delete_item(&mut self, item_url: &Url) -> Result<(), Box>; 92 | 93 | /// Get the URLs of all current items in this calendar 94 | async fn get_item_urls(&self) -> Result, Box> { 95 | let items = self.get_item_version_tags().await?; 96 | Ok(items.iter() 97 | .map(|(url, _tag)| url.clone()) 98 | .collect()) 99 | } 100 | 101 | // Note: the CalDAV protocol could also enable to do this: 102 | // fn get_current_version(&self) -> CTag 103 | } 104 | 105 | 106 | /// Functions availabe for calendars we have full knowledge of 107 | /// 108 | /// Usually, these are local calendars fully backed by a local folder 109 | /// 110 | /// Note that some concrete types (e.g. [`crate::calendar::cached_calendar::CachedCalendar`]) can also provide non-async versions of these functions 111 | #[async_trait] 112 | pub trait CompleteCalendar : BaseCalendar { 113 | /// Create a new calendar 114 | fn new(name: String, url: Url, supported_components: SupportedComponents, color: Option) -> Self; 115 | 116 | /// Get the URLs of all current items in this calendar 117 | async fn get_item_urls(&self) -> Result, Box>; 118 | 119 | /// Returns all items that this calendar contains 120 | async fn get_items(&self) -> Result, Box>; 121 | 122 | /// Returns all items that this calendar contains 123 | async fn get_items_mut(&mut self) -> Result, Box>; 124 | 125 | /// Returns a particular item 126 | async fn get_item_by_url<'a>(&'a self, url: &Url) -> Option<&'a Item>; 127 | 128 | /// Returns a particular item 129 | async fn get_item_by_url_mut<'a>(&'a mut self, url: &Url) -> Option<&'a mut Item>; 130 | 131 | /// Mark an item for deletion. 132 | /// This is required so that the upcoming sync will know it should also also delete this task from the server 133 | /// (and then call [`CompleteCalendar::immediately_delete_item`] once it has been successfully deleted on the server) 134 | async fn mark_for_deletion(&mut self, item_id: &Url) -> Result<(), Box>; 135 | 136 | /// Immediately remove an item. See [`CompleteCalendar::mark_for_deletion`] 137 | async fn immediately_delete_item(&mut self, item_id: &Url) -> Result<(), Box>; 138 | } 139 | -------------------------------------------------------------------------------- /src/mock_behaviour.rs: -------------------------------------------------------------------------------- 1 | //! This module provides ways to tweak mocked calendars, so that they can return errors on some tests 2 | #![cfg(feature = "local_calendar_mocks_remote_calendars")] 3 | 4 | use std::error::Error; 5 | 6 | /// This stores some behaviour tweaks, that describe how a mocked instance will behave during a given test 7 | /// 8 | /// So that a functions fails _n_ times after _m_ initial successes, set `(m, n)` for the suited parameter 9 | #[derive(Default, Clone, Debug)] 10 | pub struct MockBehaviour { 11 | /// If this is true, every action will be allowed 12 | pub is_suspended: bool, 13 | 14 | // From the CalDavSource trait 15 | pub get_calendars_behaviour: (u32, u32), 16 | //pub get_calendar_behaviour: (u32, u32), 17 | pub create_calendar_behaviour: (u32, u32), 18 | 19 | // From the BaseCalendar trait 20 | pub add_item_behaviour: (u32, u32), 21 | pub update_item_behaviour: (u32, u32), 22 | 23 | // From the DavCalendar trait 24 | pub get_item_version_tags_behaviour: (u32, u32), 25 | pub get_item_by_url_behaviour: (u32, u32), 26 | pub delete_item_behaviour: (u32, u32), 27 | } 28 | 29 | impl MockBehaviour { 30 | pub fn new() -> Self { 31 | Self::default() 32 | } 33 | 34 | /// All items will fail at once, for `n_fails` times 35 | pub fn fail_now(n_fails: u32) -> Self { 36 | Self { 37 | is_suspended: false, 38 | get_calendars_behaviour: (0, n_fails), 39 | //get_calendar_behaviour: (0, n_fails), 40 | create_calendar_behaviour: (0, n_fails), 41 | add_item_behaviour: (0, n_fails), 42 | update_item_behaviour: (0, n_fails), 43 | get_item_version_tags_behaviour: (0, n_fails), 44 | get_item_by_url_behaviour: (0, n_fails), 45 | delete_item_behaviour: (0, n_fails), 46 | } 47 | } 48 | 49 | /// Suspend this mock behaviour until you call `resume` 50 | pub fn suspend(&mut self) { 51 | self.is_suspended = true; 52 | } 53 | /// Make this behaviour active again 54 | pub fn resume(&mut self) { 55 | self.is_suspended = false; 56 | } 57 | 58 | pub fn copy_from(&mut self, other: &Self) { 59 | self.get_calendars_behaviour = other.get_calendars_behaviour; 60 | self.create_calendar_behaviour = other.create_calendar_behaviour; 61 | } 62 | 63 | pub fn can_get_calendars(&mut self) -> Result<(), Box> { 64 | if self.is_suspended { return Ok(()) } 65 | decrement(&mut self.get_calendars_behaviour, "get_calendars") 66 | } 67 | // pub fn can_get_calendar(&mut self) -> Result<(), Box> { 68 | // if self.is_suspended { return Ok(()) } 69 | // decrement(&mut self.get_calendar_behaviour, "get_calendar") 70 | // } 71 | pub fn can_create_calendar(&mut self) -> Result<(), Box> { 72 | if self.is_suspended { return Ok(()) } 73 | decrement(&mut self.create_calendar_behaviour, "create_calendar") 74 | } 75 | pub fn can_add_item(&mut self) -> Result<(), Box> { 76 | if self.is_suspended { return Ok(()) } 77 | decrement(&mut self.add_item_behaviour, "add_item") 78 | } 79 | pub fn can_update_item(&mut self) -> Result<(), Box> { 80 | if self.is_suspended { return Ok(()) } 81 | decrement(&mut self.update_item_behaviour, "update_item") 82 | } 83 | pub fn can_get_item_version_tags(&mut self) -> Result<(), Box> { 84 | if self.is_suspended { return Ok(()) } 85 | decrement(&mut self.get_item_version_tags_behaviour, "get_item_version_tags") 86 | } 87 | pub fn can_get_item_by_url(&mut self) -> Result<(), Box> { 88 | if self.is_suspended { return Ok(()) } 89 | decrement(&mut self.get_item_by_url_behaviour, "get_item_by_url") 90 | } 91 | pub fn can_delete_item(&mut self) -> Result<(), Box> { 92 | if self.is_suspended { return Ok(()) } 93 | decrement(&mut self.delete_item_behaviour, "delete_item") 94 | } 95 | } 96 | 97 | 98 | /// Return Ok(()) in case the value is `(1+, _)` or `(_, 0)`, or return Err and decrement otherwise 99 | fn decrement(value: &mut (u32, u32), descr: &str) -> Result<(), Box> { 100 | let remaining_successes = value.0; 101 | let remaining_failures = value.1; 102 | 103 | if remaining_successes > 0 { 104 | value.0 = value.0 - 1; 105 | log::debug!("Mock behaviour: allowing a {} ({:?})", descr, value); 106 | Ok(()) 107 | } else { 108 | if remaining_failures > 0 { 109 | value.1 = value.1 - 1; 110 | log::debug!("Mock behaviour: failing a {} ({:?})", descr, value); 111 | Err(format!("Mocked behaviour requires this {} to fail this time. ({:?})", descr, value).into()) 112 | } else { 113 | log::debug!("Mock behaviour: allowing a {} ({:?})", descr, value); 114 | Ok(()) 115 | } 116 | } 117 | } 118 | 119 | #[cfg(test)] 120 | mod test { 121 | use super::*; 122 | 123 | #[test] 124 | fn test_mock_behaviour() { 125 | let mut ok = MockBehaviour::new(); 126 | assert!(ok.can_get_calendars().is_ok()); 127 | assert!(ok.can_get_calendars().is_ok()); 128 | assert!(ok.can_get_calendars().is_ok()); 129 | assert!(ok.can_get_calendars().is_ok()); 130 | assert!(ok.can_get_calendars().is_ok()); 131 | assert!(ok.can_get_calendars().is_ok()); 132 | assert!(ok.can_get_calendars().is_ok()); 133 | 134 | let mut now = MockBehaviour::fail_now(2); 135 | assert!(now.can_get_calendars().is_err()); 136 | assert!(now.can_create_calendar().is_err()); 137 | assert!(now.can_create_calendar().is_err()); 138 | assert!(now.can_get_calendars().is_err()); 139 | assert!(now.can_get_calendars().is_ok()); 140 | assert!(now.can_get_calendars().is_ok()); 141 | assert!(now.can_create_calendar().is_ok()); 142 | 143 | let mut custom = MockBehaviour{ 144 | get_calendars_behaviour: (0,1), 145 | create_calendar_behaviour: (1,3), 146 | ..MockBehaviour::default() 147 | }; 148 | assert!(custom.can_get_calendars().is_err()); 149 | assert!(custom.can_get_calendars().is_ok()); 150 | assert!(custom.can_get_calendars().is_ok()); 151 | assert!(custom.can_get_calendars().is_ok()); 152 | assert!(custom.can_get_calendars().is_ok()); 153 | assert!(custom.can_get_calendars().is_ok()); 154 | assert!(custom.can_get_calendars().is_ok()); 155 | assert!(custom.can_create_calendar().is_ok()); 156 | assert!(custom.can_create_calendar().is_err()); 157 | assert!(custom.can_create_calendar().is_err()); 158 | assert!(custom.can_create_calendar().is_err()); 159 | assert!(custom.can_create_calendar().is_ok()); 160 | assert!(custom.can_create_calendar().is_ok()); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/task.rs: -------------------------------------------------------------------------------- 1 | //! To-do tasks (iCal `VTODO` item) 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use uuid::Uuid; 5 | use chrono::{DateTime, Utc}; 6 | use ical::property::Property; 7 | use url::Url; 8 | 9 | use crate::item::SyncStatus; 10 | use crate::utils::random_url; 11 | 12 | /// RFC5545 defines the completion as several optional fields, yet some combinations make no sense. 13 | /// This enum provides an API that forbids such impossible combinations. 14 | /// 15 | /// * `COMPLETED` is an optional timestamp that tells whether this task is completed 16 | /// * `STATUS` is an optional field, that can be set to `NEEDS-ACTION`, `COMPLETED`, or others. 17 | /// Even though having a `COMPLETED` date but a `STATUS:NEEDS-ACTION` is theorically possible, it obviously makes no sense. This API ensures this cannot happen 18 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 19 | pub enum CompletionStatus { 20 | Completed(Option>), 21 | Uncompleted, 22 | } 23 | impl CompletionStatus { 24 | pub fn is_completed(&self) -> bool { 25 | match self { 26 | CompletionStatus::Completed(_) => true, 27 | _ => false, 28 | } 29 | } 30 | } 31 | 32 | /// A to-do task 33 | #[derive(Clone, Debug, Serialize, Deserialize)] 34 | pub struct Task { 35 | /// The task URL 36 | url: Url, 37 | 38 | /// Persistent, globally unique identifier for the calendar component 39 | /// The [RFC](https://tools.ietf.org/html/rfc5545#page-117) recommends concatenating a timestamp with the server's domain name. 40 | /// UUID are even better so we'll generate them, but we have to support tasks from the server, that may have any arbitrary strings here. 41 | uid: String, 42 | 43 | /// The sync status of this item 44 | sync_status: SyncStatus, 45 | /// The time this item was created. 46 | /// This is not required by RFC5545. This will be populated in tasks created by this crate, but can be None for tasks coming from a server 47 | creation_date: Option>, 48 | /// The last time this item was modified 49 | last_modified: DateTime, 50 | /// The completion status of this task 51 | completion_status: CompletionStatus, 52 | 53 | /// The display name of the task 54 | name: String, 55 | 56 | 57 | /// The PRODID, as defined in iCal files 58 | ical_prod_id: String, 59 | 60 | /// Extra parameters that have not been parsed from the iCal file (because they're not supported (yet) by this crate). 61 | /// They are needed to serialize this item into an equivalent iCal file 62 | extra_parameters: Vec, 63 | } 64 | 65 | 66 | impl Task { 67 | /// Create a brand new Task that is not on a server yet. 68 | /// This will pick a new (random) task ID. 69 | pub fn new(name: String, completed: bool, parent_calendar_url: &Url) -> Self { 70 | let new_url = random_url(parent_calendar_url); 71 | let new_sync_status = SyncStatus::NotSynced; 72 | let new_uid = Uuid::new_v4().to_hyphenated().to_string(); 73 | let new_creation_date = Some(Utc::now()); 74 | let new_last_modified = Utc::now(); 75 | let new_completion_status = if completed { 76 | CompletionStatus::Completed(Some(Utc::now())) 77 | } else { CompletionStatus::Uncompleted }; 78 | let ical_prod_id = crate::ical::default_prod_id(); 79 | let extra_parameters = Vec::new(); 80 | Self::new_with_parameters(name, new_uid, new_url, new_completion_status, new_sync_status, new_creation_date, new_last_modified, ical_prod_id, extra_parameters) 81 | } 82 | 83 | /// Create a new Task instance, that may be synced on the server already 84 | pub fn new_with_parameters(name: String, uid: String, new_url: Url, 85 | completion_status: CompletionStatus, 86 | sync_status: SyncStatus, creation_date: Option>, last_modified: DateTime, 87 | ical_prod_id: String, extra_parameters: Vec, 88 | ) -> Self 89 | { 90 | Self { 91 | url: new_url, 92 | uid, 93 | name, 94 | completion_status, 95 | sync_status, 96 | creation_date, 97 | last_modified, 98 | ical_prod_id, 99 | extra_parameters, 100 | } 101 | } 102 | 103 | pub fn url(&self) -> &Url { &self.url } 104 | pub fn uid(&self) -> &str { &self.uid } 105 | pub fn name(&self) -> &str { &self.name } 106 | pub fn completed(&self) -> bool { self.completion_status.is_completed() } 107 | pub fn ical_prod_id(&self) -> &str { &self.ical_prod_id } 108 | pub fn sync_status(&self) -> &SyncStatus { &self.sync_status } 109 | pub fn last_modified(&self) -> &DateTime { &self.last_modified } 110 | pub fn creation_date(&self) -> Option<&DateTime> { self.creation_date.as_ref() } 111 | pub fn completion_status(&self) -> &CompletionStatus { &self.completion_status } 112 | pub fn extra_parameters(&self) -> &[Property] { &self.extra_parameters } 113 | 114 | #[cfg(any(test, feature = "integration_tests"))] 115 | pub fn has_same_observable_content_as(&self, other: &Task) -> bool { 116 | self.url == other.url 117 | && self.uid == other.uid 118 | && self.name == other.name 119 | // sync status must be the same variant, but we ignore its embedded version tag 120 | && std::mem::discriminant(&self.sync_status) == std::mem::discriminant(&other.sync_status) 121 | // completion status must be the same variant, but we ignore its embedded completion date (they are not totally mocked in integration tests) 122 | && std::mem::discriminant(&self.completion_status) == std::mem::discriminant(&other.completion_status) 123 | // last modified dates are ignored (they are not totally mocked in integration tests) 124 | } 125 | 126 | pub fn set_sync_status(&mut self, new_status: SyncStatus) { 127 | self.sync_status = new_status; 128 | } 129 | 130 | fn update_sync_status(&mut self) { 131 | match &self.sync_status { 132 | SyncStatus::NotSynced => return, 133 | SyncStatus::LocallyModified(_) => return, 134 | SyncStatus::Synced(prev_vt) => { 135 | self.sync_status = SyncStatus::LocallyModified(prev_vt.clone()); 136 | } 137 | SyncStatus::LocallyDeleted(_) => { 138 | log::warn!("Trying to update an item that has previously been deleted. These changes will probably be ignored at next sync."); 139 | return; 140 | }, 141 | } 142 | } 143 | 144 | fn update_last_modified(&mut self) { 145 | self.last_modified = Utc::now(); 146 | } 147 | 148 | 149 | /// Rename a task. 150 | /// This updates its "last modified" field 151 | pub fn set_name(&mut self, new_name: String) { 152 | self.update_sync_status(); 153 | self.update_last_modified(); 154 | self.name = new_name; 155 | } 156 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 157 | /// Rename a task, but forces a "master" SyncStatus, just like CalDAV servers are always "masters" 158 | pub fn mock_remote_calendar_set_name(&mut self, new_name: String) { 159 | self.sync_status = SyncStatus::random_synced(); 160 | self.update_last_modified(); 161 | self.name = new_name; 162 | } 163 | 164 | /// Set the completion status 165 | pub fn set_completion_status(&mut self, new_completion_status: CompletionStatus) { 166 | self.update_sync_status(); 167 | self.update_last_modified(); 168 | self.completion_status = new_completion_status; 169 | } 170 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 171 | /// Set the completion status, but forces a "master" SyncStatus, just like CalDAV servers are always "masters" 172 | pub fn mock_remote_calendar_set_completion_status(&mut self, new_completion_status: CompletionStatus) { 173 | self.sync_status = SyncStatus::random_synced(); 174 | self.completion_status = new_completion_status; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/calendar/remote_calendar.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::error::Error; 3 | use std::sync::Mutex; 4 | 5 | use async_trait::async_trait; 6 | use reqwest::{header::CONTENT_TYPE, header::CONTENT_LENGTH}; 7 | use csscolorparser::Color; 8 | use url::Url; 9 | 10 | use crate::traits::BaseCalendar; 11 | use crate::traits::DavCalendar; 12 | use crate::calendar::SupportedComponents; 13 | use crate::item::Item; 14 | use crate::item::VersionTag; 15 | use crate::item::SyncStatus; 16 | use crate::resource::Resource; 17 | use crate::utils::find_elem; 18 | 19 | static TASKS_BODY: &str = r#" 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | "#; 31 | 32 | static MULTIGET_BODY_PREFIX: &str = r#" 33 | 34 | 35 | 36 | 37 | "#; 38 | static MULTIGET_BODY_SUFFIX: &str = r#" 39 | 40 | "#; 41 | 42 | 43 | 44 | /// A CalDAV calendar created by a [`Client`](crate::client::Client). 45 | #[derive(Debug)] 46 | pub struct RemoteCalendar { 47 | name: String, 48 | resource: Resource, 49 | supported_components: SupportedComponents, 50 | color: Option, 51 | 52 | cached_version_tags: Mutex>>, 53 | } 54 | 55 | #[async_trait] 56 | impl BaseCalendar for RemoteCalendar { 57 | fn name(&self) -> &str { &self.name } 58 | fn url(&self) -> &Url { &self.resource.url() } 59 | fn supported_components(&self) -> crate::calendar::SupportedComponents { 60 | self.supported_components 61 | } 62 | fn color(&self) -> Option<&Color> { 63 | self.color.as_ref() 64 | } 65 | 66 | async fn add_item(&mut self, item: Item) -> Result> { 67 | let ical_text = crate::ical::build_from(&item)?; 68 | 69 | let response = reqwest::Client::new() 70 | .put(item.url().clone()) 71 | .header("If-None-Match", "*") 72 | .header(CONTENT_TYPE, "text/calendar") 73 | .header(CONTENT_LENGTH, ical_text.len()) 74 | .basic_auth(self.resource.username(), Some(self.resource.password())) 75 | .body(ical_text) 76 | .send() 77 | .await?; 78 | 79 | if response.status().is_success() == false { 80 | return Err(format!("Unexpected HTTP status code {:?}", response.status()).into()); 81 | } 82 | 83 | let reply_hdrs = response.headers(); 84 | match reply_hdrs.get("ETag") { 85 | None => Err(format!("No ETag in these response headers: {:?} (request was {:?})", reply_hdrs, item.url()).into()), 86 | Some(etag) => { 87 | let vtag_str = etag.to_str()?; 88 | let vtag = VersionTag::from(String::from(vtag_str)); 89 | Ok(SyncStatus::Synced(vtag)) 90 | } 91 | } 92 | } 93 | 94 | async fn update_item(&mut self, item: Item) -> Result> { 95 | let old_etag = match item.sync_status() { 96 | SyncStatus::NotSynced => return Err("Cannot update an item that has not been synced already".into()), 97 | SyncStatus::Synced(_) => return Err("Cannot update an item that has not changed".into()), 98 | SyncStatus::LocallyModified(etag) => etag, 99 | SyncStatus::LocallyDeleted(etag) => etag, 100 | }; 101 | let ical_text = crate::ical::build_from(&item)?; 102 | 103 | let request = reqwest::Client::new() 104 | .put(item.url().clone()) 105 | .header("If-Match", old_etag.as_str()) 106 | .header(CONTENT_TYPE, "text/calendar") 107 | .header(CONTENT_LENGTH, ical_text.len()) 108 | .basic_auth(self.resource.username(), Some(self.resource.password())) 109 | .body(ical_text) 110 | .send() 111 | .await?; 112 | 113 | if request.status().is_success() == false { 114 | return Err(format!("Unexpected HTTP status code {:?}", request.status()).into()); 115 | } 116 | 117 | let reply_hdrs = request.headers(); 118 | match reply_hdrs.get("ETag") { 119 | None => Err(format!("No ETag in these response headers: {:?} (request was {:?})", reply_hdrs, item.url()).into()), 120 | Some(etag) => { 121 | let vtag_str = etag.to_str()?; 122 | let vtag = VersionTag::from(String::from(vtag_str)); 123 | Ok(SyncStatus::Synced(vtag)) 124 | } 125 | } 126 | } 127 | } 128 | 129 | #[async_trait] 130 | impl DavCalendar for RemoteCalendar { 131 | fn new(name: String, resource: Resource, supported_components: SupportedComponents, color: Option) -> Self { 132 | Self { 133 | name, resource, supported_components, color, 134 | cached_version_tags: Mutex::new(None), 135 | } 136 | } 137 | 138 | 139 | async fn get_item_version_tags(&self) -> Result, Box> { 140 | if let Some(map) = &*self.cached_version_tags.lock().unwrap() { 141 | log::debug!("Version tags are already cached."); 142 | return Ok(map.clone()); 143 | }; 144 | 145 | let responses = crate::client::sub_request_and_extract_elems(&self.resource, "REPORT", TASKS_BODY.to_string(), "response").await?; 146 | 147 | let mut items = HashMap::new(); 148 | for response in responses { 149 | let item_url = crate::utils::find_elem(&response, "href") 150 | .map(|elem| self.resource.combine(&elem.text())); 151 | let item_url = match item_url { 152 | None => { 153 | log::warn!("Unable to extract HREF"); 154 | continue; 155 | }, 156 | Some(resource) => { 157 | resource.url().clone() 158 | }, 159 | }; 160 | 161 | let version_tag = match crate::utils::find_elem(&response, "getetag") { 162 | None => { 163 | log::warn!("Unable to extract ETAG for item {}, ignoring it", item_url); 164 | continue; 165 | }, 166 | Some(etag) => { 167 | VersionTag::from(etag.text()) 168 | } 169 | }; 170 | 171 | items.insert(item_url.clone(), version_tag); 172 | } 173 | 174 | // Note: the mutex cannot be locked during this whole async function, but it can safely be re-entrant (this will just waste an unnecessary request) 175 | *self.cached_version_tags.lock().unwrap() = Some(items.clone()); 176 | Ok(items) 177 | } 178 | 179 | async fn get_item_by_url(&self, url: &Url) -> Result, Box> { 180 | let res = reqwest::Client::new() 181 | .get(url.clone()) 182 | .header(CONTENT_TYPE, "text/calendar") 183 | .basic_auth(self.resource.username(), Some(self.resource.password())) 184 | .send() 185 | .await?; 186 | 187 | if res.status().is_success() == false { 188 | return Err(format!("Unexpected HTTP status code {:?}", res.status()).into()); 189 | } 190 | 191 | let text = res.text().await?; 192 | 193 | // This is supposed to be cached 194 | let version_tags = self.get_item_version_tags().await?; 195 | let vt = match version_tags.get(url) { 196 | None => return Err(format!("Inconsistent data: {} has no version tag", url).into()), 197 | Some(vt) => vt, 198 | }; 199 | 200 | let item = crate::ical::parse(&text, url.clone(), SyncStatus::Synced(vt.clone()))?; 201 | Ok(Some(item)) 202 | } 203 | 204 | async fn get_items_by_url(&self, urls: &[Url]) -> Result>, Box> { 205 | // Build the request body 206 | let mut hrefs = String::new(); 207 | for url in urls { 208 | hrefs.push_str(&format!(" {}\n", url.path())); 209 | } 210 | let body = format!("{}{}{}", MULTIGET_BODY_PREFIX, hrefs, MULTIGET_BODY_SUFFIX); 211 | 212 | // Send the request 213 | let xml_replies = crate::client::sub_request_and_extract_elems(&self.resource, "REPORT", body, "response").await?; 214 | 215 | // This is supposed to be cached 216 | let version_tags = self.get_item_version_tags().await?; 217 | 218 | // Parse the results 219 | let mut results = Vec::new(); 220 | for xml_reply in xml_replies { 221 | let href = find_elem(&xml_reply, "href").ok_or("Missing HREF")?.text(); 222 | let mut url = self.resource.url().clone(); 223 | url.set_path(&href); 224 | let ical_data = find_elem(&xml_reply, "calendar-data").ok_or("Missing calendar-data")?.text(); 225 | 226 | let vt = match version_tags.get(&url) { 227 | None => return Err(format!("Inconsistent data: {} has no version tag", url).into()), 228 | Some(vt) => vt, 229 | }; 230 | 231 | let item = crate::ical::parse(&ical_data, url.clone(), SyncStatus::Synced(vt.clone()))?; 232 | results.push(Some(item)); 233 | } 234 | 235 | Ok(results) 236 | } 237 | 238 | async fn delete_item(&mut self, item_url: &Url) -> Result<(), Box> { 239 | let del_response = reqwest::Client::new() 240 | .delete(item_url.clone()) 241 | .basic_auth(self.resource.username(), Some(self.resource.password())) 242 | .send() 243 | .await?; 244 | 245 | if del_response.status().is_success() == false { 246 | return Err(format!("Unexpected HTTP status code {:?}", del_response.status()).into()); 247 | } 248 | 249 | Ok(()) 250 | } 251 | } 252 | 253 | -------------------------------------------------------------------------------- /src/client.rs: -------------------------------------------------------------------------------- 1 | //! This module provides a client to connect to a CalDAV server 2 | 3 | use std::error::Error; 4 | use std::convert::TryFrom; 5 | use std::collections::HashMap; 6 | use std::sync::{Arc, Mutex}; 7 | 8 | use async_trait::async_trait; 9 | use reqwest::{Method, StatusCode}; 10 | use reqwest::header::CONTENT_TYPE; 11 | use minidom::Element; 12 | use url::Url; 13 | use csscolorparser::Color; 14 | 15 | use crate::resource::Resource; 16 | use crate::utils::{find_elem, find_elems}; 17 | use crate::calendar::remote_calendar::RemoteCalendar; 18 | use crate::calendar::SupportedComponents; 19 | use crate::traits::CalDavSource; 20 | use crate::traits::BaseCalendar; 21 | use crate::traits::DavCalendar; 22 | 23 | 24 | static DAVCLIENT_BODY: &str = r#" 25 | 26 | 27 | 28 | 29 | 30 | "#; 31 | 32 | static HOMESET_BODY: &str = r#" 33 | 34 | 35 | 36 | 37 | 38 | 39 | "#; 40 | 41 | static CAL_BODY: &str = r#" 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | "#; 51 | 52 | 53 | 54 | pub(crate) async fn sub_request(resource: &Resource, method: &str, body: String, depth: u32) -> Result> { 55 | let method = method.parse() 56 | .expect("invalid method name"); 57 | 58 | let res = reqwest::Client::new() 59 | .request(method, resource.url().clone()) 60 | .header("Depth", depth) 61 | .header(CONTENT_TYPE, "application/xml") 62 | .basic_auth(resource.username(), Some(resource.password())) 63 | .body(body) 64 | .send() 65 | .await?; 66 | 67 | if res.status().is_success() == false { 68 | return Err(format!("Unexpected HTTP status code {:?}", res.status()).into()); 69 | } 70 | 71 | let text = res.text().await?; 72 | Ok(text) 73 | } 74 | 75 | pub(crate) async fn sub_request_and_extract_elem(resource: &Resource, body: String, items: &[&str]) -> Result> { 76 | let text = sub_request(resource, "PROPFIND", body, 0).await?; 77 | 78 | let mut current_element: &Element = &text.parse()?; 79 | for item in items { 80 | current_element = match find_elem(¤t_element, item) { 81 | Some(elem) => elem, 82 | None => return Err(format!("missing element {}", item).into()), 83 | } 84 | } 85 | Ok(current_element.text()) 86 | } 87 | 88 | pub(crate) async fn sub_request_and_extract_elems(resource: &Resource, method: &str, body: String, item: &str) -> Result, Box> { 89 | let text = sub_request(resource, method, body, 1).await?; 90 | 91 | let element: &Element = &text.parse()?; 92 | Ok(find_elems(&element, item) 93 | .iter() 94 | .map(|elem| (*elem).clone()) 95 | .collect() 96 | ) 97 | } 98 | 99 | 100 | /// A CalDAV data source that fetches its data from a CalDAV server 101 | #[derive(Debug)] 102 | pub struct Client { 103 | resource: Resource, 104 | 105 | /// The interior mutable part of a Client. 106 | /// This data may be retrieved once and then cached 107 | cached_replies: Mutex, 108 | } 109 | 110 | 111 | #[derive(Debug, Default)] 112 | struct CachedReplies { 113 | principal: Option, 114 | calendar_home_set: Option, 115 | calendars: Option>>>, 116 | } 117 | 118 | impl Client { 119 | /// Create a client. This does not start a connection 120 | pub fn new, T: ToString, U: ToString>(url: S, username: T, password: U) -> Result> { 121 | let url = Url::parse(url.as_ref())?; 122 | 123 | Ok(Self{ 124 | resource: Resource::new(url, username.to_string(), password.to_string()), 125 | cached_replies: Mutex::new(CachedReplies::default()), 126 | }) 127 | } 128 | 129 | /// Return the Principal URL, or fetch it from server if not known yet 130 | async fn get_principal(&self) -> Result> { 131 | if let Some(p) = &self.cached_replies.lock().unwrap().principal { 132 | return Ok(p.clone()); 133 | } 134 | 135 | let href = sub_request_and_extract_elem(&self.resource, DAVCLIENT_BODY.into(), &["current-user-principal", "href"]).await?; 136 | let principal_url = self.resource.combine(&href); 137 | self.cached_replies.lock().unwrap().principal = Some(principal_url.clone()); 138 | log::debug!("Principal URL is {}", href); 139 | 140 | return Ok(principal_url); 141 | } 142 | 143 | /// Return the Homeset URL, or fetch it from server if not known yet 144 | async fn get_cal_home_set(&self) -> Result> { 145 | if let Some(h) = &self.cached_replies.lock().unwrap().calendar_home_set { 146 | return Ok(h.clone()); 147 | } 148 | let principal_url = self.get_principal().await?; 149 | 150 | let href = sub_request_and_extract_elem(&principal_url, HOMESET_BODY.into(), &["calendar-home-set", "href"]).await?; 151 | let chs_url = self.resource.combine(&href); 152 | self.cached_replies.lock().unwrap().calendar_home_set = Some(chs_url.clone()); 153 | log::debug!("Calendar home set URL is {:?}", href); 154 | 155 | Ok(chs_url) 156 | } 157 | 158 | async fn populate_calendars(&self) -> Result<(), Box> { 159 | let cal_home_set = self.get_cal_home_set().await?; 160 | 161 | let reps = sub_request_and_extract_elems(&cal_home_set, "PROPFIND", CAL_BODY.to_string(), "response").await?; 162 | let mut calendars = HashMap::new(); 163 | for rep in reps { 164 | let display_name = find_elem(&rep, "displayname").map(|e| e.text()).unwrap_or("".to_string()); 165 | log::debug!("Considering calendar {}", display_name); 166 | 167 | // We filter out non-calendar items 168 | let resource_types = match find_elem(&rep, "resourcetype") { 169 | None => continue, 170 | Some(rt) => rt, 171 | }; 172 | let mut found_calendar_type = false; 173 | for resource_type in resource_types.children() { 174 | if resource_type.name() == "calendar" { 175 | found_calendar_type = true; 176 | break; 177 | } 178 | } 179 | if found_calendar_type == false { 180 | continue; 181 | } 182 | 183 | // We filter out the root calendar collection, that has an empty supported-calendar-component-set 184 | let el_supported_comps = match find_elem(&rep, "supported-calendar-component-set") { 185 | None => continue, 186 | Some(comps) => comps, 187 | }; 188 | if el_supported_comps.children().count() == 0 { 189 | continue; 190 | } 191 | 192 | let calendar_href = match find_elem(&rep, "href") { 193 | None => { 194 | log::warn!("Calendar {} has no URL! Ignoring it.", display_name); 195 | continue; 196 | }, 197 | Some(h) => h.text(), 198 | }; 199 | 200 | let this_calendar_url = self.resource.combine(&calendar_href); 201 | 202 | let supported_components = match crate::calendar::SupportedComponents::try_from(el_supported_comps.clone()) { 203 | Err(err) => { 204 | log::warn!("Calendar {} has invalid supported components ({})! Ignoring it.", display_name, err); 205 | continue; 206 | }, 207 | Ok(sc) => sc, 208 | }; 209 | 210 | let this_calendar_color = find_elem(&rep, "calendar-color") 211 | .and_then(|col| { 212 | col.texts().next() 213 | .and_then(|t| csscolorparser::parse(t).ok()) 214 | }); 215 | 216 | let this_calendar = RemoteCalendar::new(display_name, this_calendar_url, supported_components, this_calendar_color); 217 | log::info!("Found calendar {}", this_calendar.name()); 218 | calendars.insert(this_calendar.url().clone(), Arc::new(Mutex::new(this_calendar))); 219 | } 220 | 221 | let mut replies = self.cached_replies.lock().unwrap(); 222 | replies.calendars = Some(calendars); 223 | Ok(()) 224 | } 225 | 226 | } 227 | 228 | #[async_trait] 229 | impl CalDavSource for Client { 230 | async fn get_calendars(&self) -> Result>>, Box> { 231 | self.populate_calendars().await?; 232 | 233 | match &self.cached_replies.lock().unwrap().calendars { 234 | Some(cals) => { 235 | return Ok(cals.clone()) 236 | }, 237 | None => return Err("No calendars available".into()) 238 | }; 239 | } 240 | 241 | async fn get_calendar(&self, url: &Url) -> Option>> { 242 | if let Err(err) = self.populate_calendars().await { 243 | log::warn!("Unable to fetch calendars: {}", err); 244 | return None; 245 | } 246 | 247 | self.cached_replies.lock().unwrap() 248 | .calendars 249 | .as_ref() 250 | .and_then(|cals| cals.get(url)) 251 | .map(|cal| cal.clone()) 252 | } 253 | 254 | async fn create_calendar(&mut self, url: Url, name: String, supported_components: SupportedComponents, color: Option) -> Result>, Box> { 255 | self.populate_calendars().await?; 256 | 257 | match self.cached_replies.lock().unwrap().calendars.as_ref() { 258 | None => return Err("No calendars have been fetched".into()), 259 | Some(cals) => { 260 | if cals.contains_key(&url) { 261 | return Err("This calendar already exists".into()); 262 | } 263 | }, 264 | } 265 | 266 | let creation_body = calendar_body(name, supported_components, color); 267 | 268 | let response = reqwest::Client::new() 269 | .request(Method::from_bytes(b"MKCALENDAR").unwrap(), url.clone()) 270 | .header(CONTENT_TYPE, "application/xml") 271 | .basic_auth(self.resource.username(), Some(self.resource.password())) 272 | .body(creation_body) 273 | .send() 274 | .await?; 275 | 276 | let status = response.status(); 277 | if status != StatusCode::CREATED { 278 | return Err(format!("Unexpected HTTP status code. Expected CREATED, got {}", status.as_u16()).into()); 279 | } 280 | 281 | self.get_calendar(&url).await.ok_or(format!("Unable to insert calendar {:?}", url).into()) 282 | } 283 | } 284 | 285 | fn calendar_body(name: String, supported_components: SupportedComponents, color: Option) -> String { 286 | let color_property = match color { 287 | None => "".to_string(), 288 | Some(color) => format!("{}FF", color.to_hex_string().to_ascii_uppercase()), 289 | }; 290 | 291 | // This is taken from https://tools.ietf.org/html/rfc4791#page-24 292 | format!(r#" 293 | 294 | 295 | 296 | {} 297 | {} 298 | {} 299 | 300 | 301 | 302 | "#, 303 | name, 304 | color_property, 305 | supported_components.to_xml_string(), 306 | ) 307 | } 308 | -------------------------------------------------------------------------------- /src/ical/parser.rs: -------------------------------------------------------------------------------- 1 | //! A module to parse ICal files 2 | 3 | use std::error::Error; 4 | 5 | use ical::parser::ical::component::{IcalCalendar, IcalEvent, IcalTodo}; 6 | use chrono::{DateTime, TimeZone, Utc}; 7 | use url::Url; 8 | 9 | use crate::Item; 10 | use crate::item::SyncStatus; 11 | use crate::Task; 12 | use crate::task::CompletionStatus; 13 | use crate::Event; 14 | 15 | 16 | /// Parse an iCal file into the internal representation [`crate::Item`] 17 | pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result> { 18 | let mut reader = ical::IcalParser::new(content.as_bytes()); 19 | let parsed_item = match reader.next() { 20 | None => return Err(format!("Invalid iCal data to parse for item {}", item_url).into()), 21 | Some(item) => match item { 22 | Err(err) => return Err(format!("Unable to parse iCal data for item {}: {}", item_url, err).into()), 23 | Ok(item) => item, 24 | } 25 | }; 26 | 27 | let ical_prod_id = extract_ical_prod_id(&parsed_item) 28 | .map(|s| s.to_string()) 29 | .unwrap_or_else(|| super::default_prod_id()); 30 | 31 | let item = match assert_single_type(&parsed_item)? { 32 | CurrentType::Event(_) => { 33 | Item::Event(Event::new()) 34 | }, 35 | 36 | CurrentType::Todo(todo) => { 37 | let mut name = None; 38 | let mut uid = None; 39 | let mut completed = false; 40 | let mut last_modified = None; 41 | let mut completion_date = None; 42 | let mut creation_date = None; 43 | let mut extra_parameters = Vec::new(); 44 | 45 | for prop in &todo.properties { 46 | match prop.name.as_str() { 47 | "SUMMARY" => { name = prop.value.clone() }, 48 | "UID" => { uid = prop.value.clone() }, 49 | "DTSTAMP" => { 50 | // The property can be specified once, but is not mandatory 51 | // "This property specifies the date and time that the information associated with 52 | // the calendar component was last revised in the calendar store." 53 | // "In the case of an iCalendar object that doesn't specify a "METHOD" 54 | // property [e.g.: VTODO and VEVENT], this property is equivalent to the "LAST-MODIFIED" property". 55 | last_modified = parse_date_time_from_property(&prop.value); 56 | }, 57 | "LAST-MODIFIED" => { 58 | // The property can be specified once, but is not mandatory 59 | // "This property specifies the date and time that the information associated with 60 | // the calendar component was last revised in the calendar store." 61 | // In practise, for VEVENT and VTODO, this is generally the same value as DTSTAMP. 62 | last_modified = parse_date_time_from_property(&prop.value); 63 | } 64 | "COMPLETED" => { 65 | // The property can be specified once, but is not mandatory 66 | // "This property defines the date and time that a to-do was 67 | // actually completed." 68 | completion_date = parse_date_time_from_property(&prop.value) 69 | }, 70 | "CREATED" => { 71 | // The property can be specified once, but is not mandatory 72 | creation_date = parse_date_time_from_property(&prop.value) 73 | }, 74 | "STATUS" => { 75 | // Possible values: 76 | // "NEEDS-ACTION" ;Indicates to-do needs action. 77 | // "COMPLETED" ;Indicates to-do completed. 78 | // "IN-PROCESS" ;Indicates to-do in process of. 79 | // "CANCELLED" ;Indicates to-do was cancelled. 80 | if prop.value.as_ref().map(|s| s.as_str()) == Some("COMPLETED") { 81 | completed = true; 82 | } 83 | } 84 | _ => { 85 | // This field is not supported. Let's store it anyway, so that we are able to re-create an identical iCal file 86 | extra_parameters.push(prop.clone()); 87 | } 88 | } 89 | } 90 | let name = match name { 91 | Some(name) => name, 92 | None => return Err(format!("Missing name for item {}", item_url).into()), 93 | }; 94 | let uid = match uid { 95 | Some(uid) => uid, 96 | None => return Err(format!("Missing UID for item {}", item_url).into()), 97 | }; 98 | let last_modified = match last_modified { 99 | Some(dt) => dt, 100 | None => return Err(format!("Missing DTSTAMP for item {}, but this is required by RFC5545", item_url).into()), 101 | }; 102 | let completion_status = match completed { 103 | false => { 104 | if completion_date.is_some() { 105 | log::warn!("Task {:?} has an inconsistent content: its STATUS is not completed, yet it has a COMPLETED timestamp at {:?}", uid, completion_date); 106 | } 107 | CompletionStatus::Uncompleted 108 | }, 109 | true => CompletionStatus::Completed(completion_date), 110 | }; 111 | 112 | Item::Task(Task::new_with_parameters(name, uid, item_url, completion_status, sync_status, creation_date, last_modified, ical_prod_id, extra_parameters)) 113 | }, 114 | }; 115 | 116 | 117 | // What to do with multiple items? 118 | if reader.next().map(|r| r.is_ok()) == Some(true) { 119 | return Err("Parsing multiple items are not supported".into()); 120 | } 121 | 122 | Ok(item) 123 | } 124 | 125 | fn parse_date_time(dt: &str) -> Result, chrono::format::ParseError> { 126 | Utc.datetime_from_str(dt, "%Y%m%dT%H%M%SZ") 127 | .or_else(|_err| Utc.datetime_from_str(dt, "%Y%m%dT%H%M%S") ) 128 | } 129 | 130 | fn parse_date_time_from_property(value: &Option) -> Option> { 131 | value.as_ref() 132 | .and_then(|s| { 133 | parse_date_time(s) 134 | .map_err(|err| { 135 | log::warn!("Invalid timestamp: {}", s); 136 | err 137 | }) 138 | .ok() 139 | }) 140 | } 141 | 142 | 143 | fn extract_ical_prod_id(item: &IcalCalendar) -> Option<&str> { 144 | for prop in &item.properties { 145 | if &prop.name == "PRODID" { 146 | return prop.value.as_ref().map(|s| s.as_str()) 147 | } 148 | } 149 | None 150 | } 151 | 152 | 153 | enum CurrentType<'a> { 154 | Event(&'a IcalEvent), 155 | Todo(&'a IcalTodo), 156 | } 157 | 158 | fn assert_single_type<'a>(item: &'a IcalCalendar) -> Result, Box> { 159 | let n_events = item.events.len(); 160 | let n_todos = item.todos.len(); 161 | let n_journals = item.journals.len(); 162 | 163 | if n_events == 1 { 164 | if n_todos != 0 || n_journals != 0 { 165 | return Err("Only a single TODO or a single EVENT is supported".into()); 166 | } else { 167 | return Ok(CurrentType::Event(&item.events[0])); 168 | } 169 | } 170 | 171 | if n_todos == 1 { 172 | if n_events != 0 || n_journals != 0 { 173 | return Err("Only a single TODO or a single EVENT is supported".into()); 174 | } else { 175 | return Ok(CurrentType::Todo(&item.todos[0])); 176 | } 177 | } 178 | 179 | return Err("Only a single TODO or a single EVENT is supported".into()); 180 | } 181 | 182 | 183 | #[cfg(test)] 184 | mod test { 185 | const EXAMPLE_ICAL: &str = r#"BEGIN:VCALENDAR 186 | VERSION:2.0 187 | PRODID:-//Nextcloud Tasks v0.13.6 188 | BEGIN:VTODO 189 | UID:0633de27-8c32-42be-bcb8-63bc879c6185@some-domain.com 190 | CREATED:20210321T001600 191 | LAST-MODIFIED:20210321T001600 192 | DTSTAMP:20210321T001600 193 | SUMMARY:Do not forget to do this 194 | END:VTODO 195 | END:VCALENDAR 196 | "#; 197 | 198 | const EXAMPLE_ICAL_COMPLETED: &str = r#"BEGIN:VCALENDAR 199 | VERSION:2.0 200 | PRODID:-//Nextcloud Tasks v0.13.6 201 | BEGIN:VTODO 202 | UID:19960401T080045Z-4000F192713-0052@example.com 203 | CREATED:20210321T001600 204 | LAST-MODIFIED:20210402T081557 205 | DTSTAMP:20210402T081557 206 | SUMMARY:Clean up your room or Mom will be angry 207 | PERCENT-COMPLETE:100 208 | COMPLETED:20210402T081557 209 | STATUS:COMPLETED 210 | END:VTODO 211 | END:VCALENDAR 212 | "#; 213 | 214 | const EXAMPLE_ICAL_COMPLETED_WITHOUT_A_COMPLETION_DATE: &str = r#"BEGIN:VCALENDAR 215 | VERSION:2.0 216 | PRODID:-//Nextcloud Tasks v0.13.6 217 | BEGIN:VTODO 218 | UID:19960401T080045Z-4000F192713-0052@example.com 219 | CREATED:20210321T001600 220 | LAST-MODIFIED:20210402T081557 221 | DTSTAMP:20210402T081557 222 | SUMMARY:Clean up your room or Mom will be angry 223 | STATUS:COMPLETED 224 | END:VTODO 225 | END:VCALENDAR 226 | "#; 227 | 228 | const EXAMPLE_MULTIPLE_ICAL: &str = r#"BEGIN:VCALENDAR 229 | VERSION:2.0 230 | PRODID:-//Nextcloud Tasks v0.13.6 231 | BEGIN:VTODO 232 | UID:0633de27-8c32-42be-bcb8-63bc879c6185 233 | CREATED:20210321T001600 234 | LAST-MODIFIED:20210321T001600 235 | DTSTAMP:20210321T001600 236 | SUMMARY:Call Mom 237 | END:VTODO 238 | END:VCALENDAR 239 | BEGIN:VCALENDAR 240 | BEGIN:VTODO 241 | UID:0633de27-8c32-42be-bcb8-63bc879c6185 242 | CREATED:20210321T001600 243 | LAST-MODIFIED:20210321T001600 244 | DTSTAMP:20210321T001600 245 | SUMMARY:Buy a gift for Mom 246 | END:VTODO 247 | END:VCALENDAR 248 | "#; 249 | 250 | use super::*; 251 | use crate::item::VersionTag; 252 | 253 | #[test] 254 | fn test_ical_parsing() { 255 | let version_tag = VersionTag::from(String::from("test-tag")); 256 | let sync_status = SyncStatus::Synced(version_tag); 257 | let item_url: Url = "http://some.id/for/testing".parse().unwrap(); 258 | 259 | let item = parse(EXAMPLE_ICAL, item_url.clone(), sync_status.clone()).unwrap(); 260 | let task = item.unwrap_task(); 261 | 262 | assert_eq!(task.name(), "Do not forget to do this"); 263 | assert_eq!(task.url(), &item_url); 264 | assert_eq!(task.uid(), "0633de27-8c32-42be-bcb8-63bc879c6185@some-domain.com"); 265 | assert_eq!(task.completed(), false); 266 | assert_eq!(task.completion_status(), &CompletionStatus::Uncompleted); 267 | assert_eq!(task.sync_status(), &sync_status); 268 | assert_eq!(task.last_modified(), &Utc.ymd(2021, 03, 21).and_hms(0, 16, 0)); 269 | } 270 | 271 | #[test] 272 | fn test_completed_ical_parsing() { 273 | let version_tag = VersionTag::from(String::from("test-tag")); 274 | let sync_status = SyncStatus::Synced(version_tag); 275 | let item_url: Url = "http://some.id/for/testing".parse().unwrap(); 276 | 277 | let item = parse(EXAMPLE_ICAL_COMPLETED, item_url.clone(), sync_status.clone()).unwrap(); 278 | let task = item.unwrap_task(); 279 | 280 | assert_eq!(task.completed(), true); 281 | assert_eq!(task.completion_status(), &CompletionStatus::Completed(Some(Utc.ymd(2021, 04, 02).and_hms(8, 15, 57)))); 282 | } 283 | 284 | #[test] 285 | fn test_completed_without_date_ical_parsing() { 286 | let version_tag = VersionTag::from(String::from("test-tag")); 287 | let sync_status = SyncStatus::Synced(version_tag); 288 | let item_url: Url = "http://some.id/for/testing".parse().unwrap(); 289 | 290 | let item = parse(EXAMPLE_ICAL_COMPLETED_WITHOUT_A_COMPLETION_DATE, item_url.clone(), sync_status.clone()).unwrap(); 291 | let task = item.unwrap_task(); 292 | 293 | assert_eq!(task.completed(), true); 294 | assert_eq!(task.completion_status(), &CompletionStatus::Completed(None)); 295 | } 296 | 297 | #[test] 298 | fn test_multiple_items_in_ical() { 299 | let version_tag = VersionTag::from(String::from("test-tag")); 300 | let sync_status = SyncStatus::Synced(version_tag); 301 | let item_url: Url = "http://some.id/for/testing".parse().unwrap(); 302 | 303 | let item = parse(EXAMPLE_MULTIPLE_ICAL, item_url.clone(), sync_status.clone()); 304 | assert!(item.is_err()); 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/cache.rs: -------------------------------------------------------------------------------- 1 | //! This module provides a local cache for CalDAV data 2 | 3 | use std::path::PathBuf; 4 | use std::path::Path; 5 | use std::error::Error; 6 | use std::collections::HashMap; 7 | use std::sync::{Arc, Mutex}; 8 | use std::ffi::OsStr; 9 | 10 | use serde::{Deserialize, Serialize}; 11 | use async_trait::async_trait; 12 | use csscolorparser::Color; 13 | use url::Url; 14 | 15 | use crate::traits::CalDavSource; 16 | use crate::traits::BaseCalendar; 17 | use crate::traits::CompleteCalendar; 18 | use crate::calendar::cached_calendar::CachedCalendar; 19 | use crate::calendar::SupportedComponents; 20 | 21 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 22 | use crate::mock_behaviour::MockBehaviour; 23 | 24 | const MAIN_FILE: &str = "data.json"; 25 | 26 | /// A CalDAV source that stores its items in a local folder. 27 | /// 28 | /// It automatically updates the content of the folder when dropped (see its `Drop` implementation), but you can also manually call [`Cache::save_to_folder`] 29 | /// 30 | /// Most of its functionality is provided by the `CalDavSource` async trait it implements. 31 | /// However, since these functions do not _need_ to be actually async, non-async versions of them are also provided for better convenience. See [`Cache::get_calendar_sync`] for example 32 | #[derive(Debug)] 33 | pub struct Cache { 34 | backing_folder: PathBuf, 35 | data: CachedData, 36 | 37 | /// In tests, we may add forced errors to this object 38 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 39 | mock_behaviour: Option>>, 40 | } 41 | 42 | #[derive(Default, Debug, Serialize, Deserialize)] 43 | struct CachedData { 44 | #[serde(skip)] 45 | calendars: HashMap>>, 46 | } 47 | 48 | impl Cache { 49 | /// Activate the "mocking remote source" features (i.e. tell its children calendars that they are mocked remote calendars) 50 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 51 | pub fn set_mock_behaviour(&mut self, mock_behaviour: Option>>) { 52 | self.mock_behaviour = mock_behaviour; 53 | } 54 | 55 | 56 | /// Get the path to the cache folder 57 | pub fn cache_folder() -> PathBuf { 58 | return PathBuf::from(String::from("~/.config/my-tasks/cache/")) 59 | } 60 | 61 | /// Initialize a cache from the content of a valid backing folder if it exists. 62 | /// Returns an error otherwise 63 | pub fn from_folder(folder: &Path) -> Result> { 64 | // Load shared data... 65 | let main_file = folder.join(MAIN_FILE); 66 | let mut data: CachedData = match std::fs::File::open(&main_file) { 67 | Err(err) => { 68 | return Err(format!("Unable to open file {:?}: {}", main_file, err).into()); 69 | }, 70 | Ok(file) => serde_json::from_reader(file)?, 71 | }; 72 | 73 | // ...and every calendar 74 | for entry in std::fs::read_dir(folder)? { 75 | match entry { 76 | Err(err) => { 77 | log::error!("Unable to read dir: {:?}", err); 78 | continue; 79 | }, 80 | Ok(entry) => { 81 | let cal_path = entry.path(); 82 | log::debug!("Considering {:?}", cal_path); 83 | if cal_path.extension() == Some(OsStr::new("cal")) { 84 | match Self::load_calendar(&cal_path) { 85 | Err(err) => { 86 | log::error!("Unable to load calendar {:?} from cache: {:?}", cal_path, err); 87 | continue; 88 | }, 89 | Ok(cal) => 90 | data.calendars.insert(cal.url().clone(), Arc::new(Mutex::new(cal))), 91 | }; 92 | } 93 | }, 94 | } 95 | } 96 | 97 | Ok(Self{ 98 | backing_folder: PathBuf::from(folder), 99 | data, 100 | 101 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 102 | mock_behaviour: None, 103 | }) 104 | } 105 | 106 | fn load_calendar(path: &Path) -> Result> { 107 | let file = std::fs::File::open(&path)?; 108 | Ok(serde_json::from_reader(file)?) 109 | } 110 | 111 | /// Initialize a cache with the default contents 112 | pub fn new(folder_path: &Path) -> Self { 113 | Self{ 114 | backing_folder: PathBuf::from(folder_path), 115 | data: CachedData::default(), 116 | 117 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 118 | mock_behaviour: None, 119 | } 120 | } 121 | 122 | /// Store the current Cache to its backing folder 123 | /// 124 | /// Note that this is automatically called when `self` is `drop`ped 125 | pub fn save_to_folder(&self) -> Result<(), std::io::Error> { 126 | let folder = &self.backing_folder; 127 | std::fs::create_dir_all(folder)?; 128 | 129 | // Save the general data 130 | let main_file_path = folder.join(MAIN_FILE); 131 | let file = std::fs::File::create(&main_file_path)?; 132 | serde_json::to_writer(file, &self.data)?; 133 | 134 | // Save each calendar 135 | for (cal_url, cal_mutex) in &self.data.calendars { 136 | let file_name = sanitize_filename::sanitize(cal_url.as_str()) + ".cal"; 137 | let cal_file = folder.join(file_name); 138 | let file = std::fs::File::create(&cal_file)?; 139 | let cal = cal_mutex.lock().unwrap(); 140 | serde_json::to_writer(file, &*cal)?; 141 | } 142 | 143 | Ok(()) 144 | } 145 | 146 | 147 | /// Compares two Caches to check they have the same current content 148 | /// 149 | /// This is not a complete equality test: some attributes (sync status...) may differ. This should mostly be used in tests 150 | #[cfg(any(test, feature = "integration_tests"))] 151 | pub async fn has_same_observable_content_as(&self, other: &Self) -> Result> { 152 | let calendars_l = self.get_calendars().await?; 153 | let calendars_r = other.get_calendars().await?; 154 | 155 | if crate::utils::keys_are_the_same(&calendars_l, &calendars_r) == false { 156 | log::debug!("Different keys for calendars"); 157 | return Ok(false); 158 | } 159 | 160 | for (calendar_url, cal_l) in calendars_l { 161 | log::debug!("Comparing calendars {}", calendar_url); 162 | let cal_l = cal_l.lock().unwrap(); 163 | let cal_r = match calendars_r.get(&calendar_url) { 164 | Some(c) => c.lock().unwrap(), 165 | None => return Err("should not happen, we've just tested keys are the same".into()), 166 | }; 167 | 168 | // TODO: check calendars have the same names/ID/whatever 169 | if cal_l.has_same_observable_content_as(&cal_r).await? == false { 170 | log::debug!("Different calendars"); 171 | return Ok(false) 172 | } 173 | 174 | } 175 | Ok(true) 176 | } 177 | } 178 | 179 | impl Drop for Cache { 180 | fn drop(&mut self) { 181 | if let Err(err) = self.save_to_folder() { 182 | log::error!("Unable to automatically save the cache when it's no longer required: {}", err); 183 | } 184 | } 185 | } 186 | 187 | impl Cache { 188 | /// The non-async version of [`crate::traits::CalDavSource::get_calendars`] 189 | pub fn get_calendars_sync(&self) -> Result>>, Box> { 190 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 191 | self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_get_calendars())?; 192 | 193 | Ok(self.data.calendars.iter() 194 | .map(|(url, cal)| (url.clone(), cal.clone())) 195 | .collect() 196 | ) 197 | } 198 | 199 | /// The non-async version of [`crate::traits::CalDavSource::get_calendar`] 200 | pub fn get_calendar_sync(&self, url: &Url) -> Option>> { 201 | self.data.calendars.get(url).map(|arc| arc.clone()) 202 | } 203 | } 204 | 205 | #[async_trait] 206 | impl CalDavSource for Cache { 207 | async fn get_calendars(&self) -> Result>>, Box> { 208 | self.get_calendars_sync() 209 | } 210 | 211 | async fn get_calendar(&self, url: &Url) -> Option>> { 212 | self.get_calendar_sync(url) 213 | } 214 | 215 | async fn create_calendar(&mut self, url: Url, name: String, supported_components: SupportedComponents, color: Option) -> Result>, Box> { 216 | log::debug!("Inserting local calendar {}", url); 217 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 218 | self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_create_calendar())?; 219 | 220 | let new_calendar = CachedCalendar::new(name, url.clone(), supported_components, color); 221 | let arc = Arc::new(Mutex::new(new_calendar)); 222 | 223 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 224 | if let Some(behaviour) = &self.mock_behaviour { 225 | arc.lock().unwrap().set_mock_behaviour(Some(Arc::clone(behaviour))); 226 | }; 227 | 228 | match self.data.calendars.insert(url, arc.clone()) { 229 | Some(_) => Err("Attempt to insert calendar failed: there is alredy such a calendar.".into()), 230 | None => Ok(arc), 231 | } 232 | } 233 | } 234 | 235 | #[cfg(test)] 236 | mod tests { 237 | use super::*; 238 | 239 | use url::Url; 240 | use crate::calendar::SupportedComponents; 241 | use crate::item::Item; 242 | use crate::task::Task; 243 | 244 | async fn populate_cache(cache_path: &Path) -> Cache { 245 | let mut cache = Cache::new(&cache_path); 246 | 247 | let _shopping_list = cache.create_calendar( 248 | Url::parse("https://caldav.com/shopping").unwrap(), 249 | "My shopping list".to_string(), 250 | SupportedComponents::TODO, 251 | Some(csscolorparser::parse("lime").unwrap()), 252 | ).await.unwrap(); 253 | 254 | let bucket_list = cache.create_calendar( 255 | Url::parse("https://caldav.com/bucket-list").unwrap(), 256 | "My bucket list".to_string(), 257 | SupportedComponents::TODO, 258 | Some(csscolorparser::parse("#ff8000").unwrap()), 259 | ).await.unwrap(); 260 | 261 | { 262 | let mut bucket_list = bucket_list.lock().unwrap(); 263 | let cal_url = bucket_list.url().clone(); 264 | bucket_list.add_item(Item::Task(Task::new( 265 | String::from("Attend a concert of JS Bach"), false, &cal_url 266 | ))).await.unwrap(); 267 | 268 | bucket_list.add_item(Item::Task(Task::new( 269 | String::from("Climb the Lighthouse of Alexandria"), true, &cal_url 270 | ))).await.unwrap(); 271 | } 272 | 273 | cache 274 | } 275 | 276 | #[tokio::test] 277 | async fn cache_serde() { 278 | let _ = env_logger::builder().is_test(true).try_init(); 279 | let cache_path = PathBuf::from(String::from("test_cache/serde_test")); 280 | let cache = populate_cache(&cache_path).await; 281 | 282 | cache.save_to_folder().unwrap(); 283 | 284 | let retrieved_cache = Cache::from_folder(&cache_path).unwrap(); 285 | assert_eq!(cache.backing_folder, retrieved_cache.backing_folder); 286 | let test = cache.has_same_observable_content_as(&retrieved_cache).await; 287 | println!("Equal? {:?}", test); 288 | assert_eq!(test.unwrap(), true); 289 | } 290 | 291 | #[tokio::test] 292 | async fn cache_sanity_checks() { 293 | let _ = env_logger::builder().is_test(true).try_init(); 294 | let cache_path = PathBuf::from(String::from("test_cache/sanity_tests")); 295 | let mut cache = populate_cache(&cache_path).await; 296 | 297 | // We should not be able to add a second calendar with the same URL 298 | let second_addition_same_calendar = cache.create_calendar( 299 | Url::parse("https://caldav.com/shopping").unwrap(), 300 | "My shopping list".to_string(), 301 | SupportedComponents::TODO, 302 | None, 303 | ).await; 304 | assert!(second_addition_same_calendar.is_err()); 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /tests/sync.rs: -------------------------------------------------------------------------------- 1 | mod scenarii; 2 | 3 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 4 | use std::sync::{Arc, Mutex}; 5 | 6 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 7 | use kitchen_fridge::mock_behaviour::MockBehaviour; 8 | 9 | 10 | 11 | /// A test that simulates a regular synchronisation between a local cache and a server. 12 | /// Note that this uses a second cache to "mock" a server. 13 | struct TestFlavour { 14 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 15 | scenarii: Vec, 16 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 17 | mock_behaviour: Arc>, 18 | } 19 | 20 | #[cfg(not(feature = "local_calendar_mocks_remote_calendars"))] 21 | impl TestFlavour { 22 | pub fn normal() -> Self { Self{} } 23 | pub fn first_sync_to_local() -> Self { Self{} } 24 | pub fn first_sync_to_server() -> Self { Self{} } 25 | pub fn transient_task() -> Self { Self{} } 26 | pub fn normal_with_errors1() -> Self { Self{} } 27 | pub fn normal_with_errors2() -> Self { Self{} } 28 | pub fn normal_with_errors3() -> Self { Self{} } 29 | pub fn normal_with_errors4() -> Self { Self{} } 30 | pub fn normal_with_errors5() -> Self { Self{} } 31 | pub fn normal_with_errors6() -> Self { Self{} } 32 | pub fn normal_with_errors7() -> Self { Self{} } 33 | pub fn normal_with_errors8() -> Self { Self{} } 34 | pub fn normal_with_errors9() -> Self { Self{} } 35 | pub fn normal_with_errors10() -> Self { Self{} } 36 | pub fn normal_with_errors11() -> Self { Self{} } 37 | pub fn normal_with_errors12() -> Self { Self{} } 38 | 39 | pub async fn run(&self, _max_attempts: u32) { 40 | panic!("WARNING: This test required the \"integration_tests\" Cargo feature"); 41 | } 42 | } 43 | 44 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 45 | impl TestFlavour { 46 | pub fn normal() -> Self { 47 | Self { 48 | scenarii: scenarii::scenarii_basic(), 49 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour::new())), 50 | } 51 | } 52 | 53 | pub fn first_sync_to_local() -> Self { 54 | Self { 55 | scenarii: scenarii::scenarii_first_sync_to_local(), 56 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour::new())), 57 | } 58 | } 59 | 60 | pub fn first_sync_to_server() -> Self { 61 | Self { 62 | scenarii: scenarii::scenarii_first_sync_to_server(), 63 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour::new())), 64 | } 65 | } 66 | 67 | pub fn transient_task() -> Self { 68 | Self { 69 | scenarii: scenarii::scenarii_transient_task(), 70 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour::new())), 71 | } 72 | } 73 | 74 | pub fn normal_with_errors1() -> Self { 75 | Self { 76 | scenarii: scenarii::scenarii_basic(), 77 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour::fail_now(10))), 78 | } 79 | } 80 | 81 | pub fn normal_with_errors2() -> Self { 82 | Self { 83 | scenarii: scenarii::scenarii_basic(), 84 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour{ 85 | get_calendars_behaviour: (0,1), 86 | create_calendar_behaviour: (2,2), 87 | ..MockBehaviour::default() 88 | })), 89 | } 90 | } 91 | 92 | pub fn normal_with_errors3() -> Self { 93 | Self { 94 | scenarii: scenarii::scenarii_first_sync_to_server(), 95 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour{ 96 | get_calendars_behaviour: (1,6), 97 | create_calendar_behaviour: (0,1), 98 | ..MockBehaviour::default() 99 | })), 100 | } 101 | } 102 | 103 | pub fn normal_with_errors4() -> Self { 104 | Self { 105 | scenarii: scenarii::scenarii_first_sync_to_server(), 106 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour{ 107 | add_item_behaviour: (1,3), 108 | ..MockBehaviour::default() 109 | })), 110 | } 111 | } 112 | 113 | pub fn normal_with_errors5() -> Self { 114 | Self { 115 | scenarii: scenarii::scenarii_basic(), 116 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour{ 117 | get_item_version_tags_behaviour: (0,1), 118 | ..MockBehaviour::default() 119 | })), 120 | } 121 | } 122 | 123 | pub fn normal_with_errors6() -> Self { 124 | Self { 125 | scenarii: scenarii::scenarii_basic(), 126 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour{ 127 | get_item_by_url_behaviour: (3,2), 128 | ..MockBehaviour::default() 129 | })), 130 | } 131 | } 132 | 133 | pub fn normal_with_errors7() -> Self { 134 | Self { 135 | scenarii: scenarii::scenarii_basic(), 136 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour{ 137 | delete_item_behaviour: (0,2), 138 | ..MockBehaviour::default() 139 | })), 140 | } 141 | } 142 | 143 | pub fn normal_with_errors8() -> Self { 144 | Self { 145 | scenarii: scenarii::scenarii_basic(), 146 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour{ 147 | add_item_behaviour: (2,3), 148 | get_item_by_url_behaviour: (1,12), 149 | ..MockBehaviour::default() 150 | })), 151 | } 152 | } 153 | 154 | pub fn normal_with_errors9() -> Self { 155 | Self { 156 | scenarii: scenarii::scenarii_basic(), 157 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour{ 158 | get_calendars_behaviour: (0,8), 159 | delete_item_behaviour: (1,1), 160 | ..MockBehaviour::default() 161 | })), 162 | } 163 | } 164 | 165 | pub fn normal_with_errors10() -> Self { 166 | Self { 167 | scenarii: scenarii::scenarii_first_sync_to_server(), 168 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour{ 169 | get_calendars_behaviour: (0,8), 170 | delete_item_behaviour: (1,1), 171 | create_calendar_behaviour: (1,4), 172 | get_item_version_tags_behaviour: (3,1), 173 | ..MockBehaviour::default() 174 | })), 175 | } 176 | } 177 | 178 | pub fn normal_with_errors11() -> Self { 179 | Self { 180 | scenarii: scenarii::scenarii_basic(), 181 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour{ 182 | get_calendars_behaviour: (0,8), 183 | delete_item_behaviour: (1,1), 184 | create_calendar_behaviour: (1,4), 185 | get_item_version_tags_behaviour: (3,1), 186 | get_item_by_url_behaviour: (0,41), 187 | ..MockBehaviour::default() 188 | })), 189 | } 190 | } 191 | 192 | pub fn normal_with_errors12() -> Self { 193 | Self { 194 | scenarii: scenarii::scenarii_basic(), 195 | mock_behaviour: Arc::new(Mutex::new(MockBehaviour{ 196 | update_item_behaviour: (0,3), 197 | ..MockBehaviour::default() 198 | })), 199 | } 200 | } 201 | 202 | 203 | pub async fn run(&self, max_attempts: u32) { 204 | self.mock_behaviour.lock().unwrap().suspend(); 205 | 206 | let mut provider = scenarii::populate_test_provider_before_sync(&self.scenarii, Arc::clone(&self.mock_behaviour)).await; 207 | print_provider(&provider, "before sync").await; 208 | 209 | self.mock_behaviour.lock().unwrap().resume(); 210 | for attempt in 0..max_attempts { 211 | println!("\nSyncing...\n"); 212 | if provider.sync().await == true { 213 | println!("Sync complete after {} attempts (multiple attempts are due to forced errors in mocked behaviour)", attempt+1); 214 | break 215 | } 216 | } 217 | self.mock_behaviour.lock().unwrap().suspend(); 218 | 219 | print_provider(&provider, "after sync").await; 220 | 221 | // Check the contents of both sources are the same after sync 222 | assert!(provider.remote().has_same_observable_content_as(provider.local()).await.unwrap()); 223 | 224 | // But also explicitely check that every item is expected 225 | let expected_provider = scenarii::populate_test_provider_after_sync(&self.scenarii, Arc::clone(&self.mock_behaviour)).await; 226 | 227 | assert!(provider.local() .has_same_observable_content_as(expected_provider.local() ).await.unwrap()); 228 | assert!(provider.remote().has_same_observable_content_as(expected_provider.remote()).await.unwrap()); 229 | 230 | // Perform a second sync, even if no change has happened, just to check 231 | println!("Syncing again"); 232 | provider.sync().await; 233 | assert!(provider.local() .has_same_observable_content_as(expected_provider.local() ).await.unwrap()); 234 | assert!(provider.remote().has_same_observable_content_as(expected_provider.remote()).await.unwrap()); 235 | } 236 | } 237 | 238 | 239 | 240 | async fn run_flavour(flavour: TestFlavour, max_attempts: u32) { 241 | let _ = env_logger::builder().is_test(true).try_init(); 242 | flavour.run(max_attempts).await; 243 | } 244 | 245 | #[tokio::test] 246 | #[cfg_attr(not(feature="integration_tests"), ignore)] 247 | async fn test_regular_sync() { 248 | run_flavour(TestFlavour::normal(), 1).await; 249 | } 250 | 251 | #[tokio::test] 252 | #[cfg_attr(not(feature="integration_tests"), ignore)] 253 | async fn test_sync_empty_initial_local() { 254 | run_flavour(TestFlavour::first_sync_to_local(), 1).await; 255 | } 256 | 257 | #[tokio::test] 258 | #[cfg_attr(not(feature="integration_tests"), ignore)] 259 | async fn test_sync_empty_initial_server() { 260 | run_flavour(TestFlavour::first_sync_to_server(), 1).await; 261 | } 262 | 263 | #[tokio::test] 264 | #[cfg_attr(not(feature="integration_tests"), ignore)] 265 | async fn test_sync_transient_task() { 266 | run_flavour(TestFlavour::transient_task(), 1).await; 267 | } 268 | 269 | #[tokio::test] 270 | #[cfg_attr(not(feature="integration_tests"), ignore)] 271 | async fn test_errors_in_regular_sync1() { 272 | run_flavour(TestFlavour::normal_with_errors1(), 100).await; 273 | } 274 | 275 | #[tokio::test] 276 | #[cfg_attr(not(feature="integration_tests"), ignore)] 277 | async fn test_errors_in_regular_sync2() { 278 | run_flavour(TestFlavour::normal_with_errors2(), 100).await; 279 | } 280 | 281 | #[tokio::test] 282 | #[cfg_attr(not(feature="integration_tests"), ignore)] 283 | async fn test_errors_in_regular_sync3() { 284 | run_flavour(TestFlavour::normal_with_errors3(), 100).await; 285 | } 286 | 287 | #[tokio::test] 288 | #[cfg_attr(not(feature="integration_tests"), ignore)] 289 | async fn test_errors_in_regular_sync4() { 290 | run_flavour(TestFlavour::normal_with_errors4(), 100).await; 291 | } 292 | 293 | #[tokio::test] 294 | #[cfg_attr(not(feature="integration_tests"), ignore)] 295 | async fn test_errors_in_regular_sync5() { 296 | run_flavour(TestFlavour::normal_with_errors5(), 100).await; 297 | } 298 | 299 | #[tokio::test] 300 | #[cfg_attr(not(feature="integration_tests"), ignore)] 301 | async fn test_errors_in_regular_sync6() { 302 | run_flavour(TestFlavour::normal_with_errors6(), 100).await; 303 | } 304 | 305 | #[tokio::test] 306 | #[cfg_attr(not(feature="integration_tests"), ignore)] 307 | async fn test_errors_in_regular_sync7() { 308 | run_flavour(TestFlavour::normal_with_errors7(), 100).await; 309 | } 310 | 311 | #[tokio::test] 312 | #[cfg_attr(not(feature="integration_tests"), ignore)] 313 | async fn test_errors_in_regular_sync8() { 314 | run_flavour(TestFlavour::normal_with_errors8(), 100).await; 315 | } 316 | 317 | #[tokio::test] 318 | #[cfg_attr(not(feature="integration_tests"), ignore)] 319 | async fn test_errors_in_regular_sync9() { 320 | run_flavour(TestFlavour::normal_with_errors9(), 100).await; 321 | } 322 | 323 | #[tokio::test] 324 | #[cfg_attr(not(feature="integration_tests"), ignore)] 325 | async fn test_errors_in_regular_sync10() { 326 | run_flavour(TestFlavour::normal_with_errors10(), 100).await; 327 | } 328 | 329 | #[tokio::test] 330 | #[cfg_attr(not(feature="integration_tests"), ignore)] 331 | async fn test_errors_in_regular_sync11() { 332 | run_flavour(TestFlavour::normal_with_errors11(), 100).await; 333 | } 334 | 335 | #[tokio::test] 336 | #[cfg_attr(not(feature="integration_tests"), ignore)] 337 | async fn test_errors_in_regular_sync12() { 338 | run_flavour(TestFlavour::normal_with_errors12(), 100).await; 339 | } 340 | 341 | #[cfg(feature = "integration_tests")] 342 | use kitchen_fridge::{traits::CalDavSource, 343 | provider::Provider, 344 | cache::Cache, 345 | calendar::cached_calendar::CachedCalendar, 346 | }; 347 | 348 | /// Print the contents of the provider. This is usually used for debugging 349 | #[allow(dead_code)] 350 | #[cfg(feature = "integration_tests")] 351 | async fn print_provider(provider: &Provider, title: &str) { 352 | let cals_server = provider.remote().get_calendars().await.unwrap(); 353 | println!("----Server, {}-------", title); 354 | kitchen_fridge::utils::print_calendar_list(&cals_server).await; 355 | let cals_local = provider.local().get_calendars().await.unwrap(); 356 | println!("-----Local, {}-------", title); 357 | kitchen_fridge::utils::print_calendar_list(&cals_local).await; 358 | } 359 | -------------------------------------------------------------------------------- /src/calendar/cached_calendar.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | use std::error::Error; 3 | 4 | use serde::{Deserialize, Serialize}; 5 | use async_trait::async_trait; 6 | use csscolorparser::Color; 7 | use url::Url; 8 | 9 | use crate::item::SyncStatus; 10 | use crate::traits::{BaseCalendar, CompleteCalendar}; 11 | use crate::calendar::SupportedComponents; 12 | use crate::Item; 13 | 14 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 15 | use std::sync::{Arc, Mutex}; 16 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 17 | use crate::mock_behaviour::MockBehaviour; 18 | 19 | 20 | /// A calendar used by the [`cache`](crate::cache) module 21 | /// 22 | /// Most of its functionality is provided by the async traits it implements. 23 | /// However, since these functions do not _need_ to be actually async, non-async versions of them are also provided for better convenience. See [`CachedCalendar::add_item_sync`] for example 24 | #[derive(Clone, Debug, Serialize, Deserialize)] 25 | pub struct CachedCalendar { 26 | name: String, 27 | url: Url, 28 | supported_components: SupportedComponents, 29 | color: Option, 30 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 31 | #[serde(skip)] 32 | mock_behaviour: Option>>, 33 | 34 | items: HashMap, 35 | } 36 | 37 | impl CachedCalendar { 38 | /// Activate the "mocking remote calendar" feature (i.e. ignore sync statuses, since this is what an actual CalDAV sever would do) 39 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 40 | pub fn set_mock_behaviour(&mut self, mock_behaviour: Option>>) { 41 | self.mock_behaviour = mock_behaviour; 42 | } 43 | 44 | 45 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 46 | fn add_item_maybe_mocked(&mut self, item: Item) -> Result> { 47 | if self.mock_behaviour.is_some() { 48 | self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_add_item())?; 49 | self.add_or_update_item_force_synced(item) 50 | } else { 51 | self.regular_add_or_update_item(item) 52 | } 53 | } 54 | 55 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 56 | fn update_item_maybe_mocked(&mut self, item: Item) -> Result> { 57 | if self.mock_behaviour.is_some() { 58 | self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_update_item())?; 59 | self.add_or_update_item_force_synced(item) 60 | } else { 61 | self.regular_add_or_update_item(item) 62 | } 63 | } 64 | 65 | /// Add or update an item 66 | fn regular_add_or_update_item(&mut self, item: Item) -> Result> { 67 | let ss_clone = item.sync_status().clone(); 68 | log::debug!("Adding or updating an item with {:?}", ss_clone); 69 | self.items.insert(item.url().clone(), item); 70 | Ok(ss_clone) 71 | } 72 | 73 | /// Add or update an item, but force a "synced" SyncStatus. This is the normal behaviour that would happen on a server 74 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 75 | fn add_or_update_item_force_synced(&mut self, mut item: Item) -> Result> { 76 | log::debug!("Adding or updating an item, but forces a synced SyncStatus"); 77 | match item.sync_status() { 78 | SyncStatus::Synced(_) => (), 79 | _ => item.set_sync_status(SyncStatus::random_synced()), 80 | }; 81 | let ss_clone = item.sync_status().clone(); 82 | self.items.insert(item.url().clone(), item); 83 | Ok(ss_clone) 84 | } 85 | 86 | /// Some kind of equality check 87 | #[cfg(any(test, feature = "integration_tests"))] 88 | pub async fn has_same_observable_content_as(&self, other: &CachedCalendar) -> Result> { 89 | if self.name != other.name 90 | || self.url != other.url 91 | || self.supported_components != other.supported_components 92 | || self.color != other.color 93 | { 94 | log::debug!("Calendar properties mismatch"); 95 | return Ok(false); 96 | } 97 | 98 | 99 | let items_l = self.get_items().await?; 100 | let items_r = other.get_items().await?; 101 | 102 | if crate::utils::keys_are_the_same(&items_l, &items_r) == false { 103 | log::debug!("Different keys for items"); 104 | return Ok(false); 105 | } 106 | for (url_l, item_l) in items_l { 107 | let item_r = match items_r.get(&url_l) { 108 | Some(c) => c, 109 | None => return Err("should not happen, we've just tested keys are the same".into()), 110 | }; 111 | if item_l.has_same_observable_content_as(&item_r) == false { 112 | log::debug!("Different items for URL {}:", url_l); 113 | log::debug!("{:#?}", item_l); 114 | log::debug!("{:#?}", item_r); 115 | return Ok(false); 116 | } 117 | } 118 | 119 | Ok(true) 120 | } 121 | 122 | /// The non-async version of [`Self::get_item_urls`] 123 | pub fn get_item_urls_sync(&self) -> Result, Box> { 124 | Ok(self.items.iter() 125 | .map(|(url, _)| url.clone()) 126 | .collect() 127 | ) 128 | } 129 | 130 | /// The non-async version of [`Self::get_items`] 131 | pub fn get_items_sync(&self) -> Result, Box> { 132 | Ok(self.items.iter() 133 | .map(|(url, item)| (url.clone(), item)) 134 | .collect() 135 | ) 136 | } 137 | 138 | /// The non-async version of [`Self::get_items_mut`] 139 | pub fn get_items_mut_sync(&mut self) -> Result, Box> { 140 | Ok(self.items.iter_mut() 141 | .map(|(url, item)| (url.clone(), item)) 142 | .collect() 143 | ) 144 | } 145 | 146 | /// The non-async version of [`Self::get_item_by_url`] 147 | pub fn get_item_by_url_sync<'a>(&'a self, url: &Url) -> Option<&'a Item> { 148 | self.items.get(url) 149 | } 150 | 151 | /// The non-async version of [`Self::get_item_by_url_mut`] 152 | pub fn get_item_by_url_mut_sync<'a>(&'a mut self, url: &Url) -> Option<&'a mut Item> { 153 | self.items.get_mut(url) 154 | } 155 | 156 | /// The non-async version of [`Self::add_item`] 157 | pub fn add_item_sync(&mut self, item: Item) -> Result> { 158 | if self.items.contains_key(item.url()) { 159 | return Err(format!("Item {:?} cannot be added, it exists already", item.url()).into()); 160 | } 161 | #[cfg(not(feature = "local_calendar_mocks_remote_calendars"))] 162 | return self.regular_add_or_update_item(item); 163 | 164 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 165 | return self.add_item_maybe_mocked(item); 166 | } 167 | 168 | /// The non-async version of [`Self::update_item`] 169 | pub fn update_item_sync(&mut self, item: Item) -> Result> { 170 | if self.items.contains_key(item.url()) == false { 171 | return Err(format!("Item {:?} cannot be updated, it does not already exist", item.url()).into()); 172 | } 173 | #[cfg(not(feature = "local_calendar_mocks_remote_calendars"))] 174 | return self.regular_add_or_update_item(item); 175 | 176 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 177 | return self.update_item_maybe_mocked(item); 178 | } 179 | 180 | /// The non-async version of [`Self::mark_for_deletion`] 181 | pub fn mark_for_deletion_sync(&mut self, item_url: &Url) -> Result<(), Box> { 182 | match self.items.get_mut(item_url) { 183 | None => Err("no item for this key".into()), 184 | Some(item) => { 185 | match item.sync_status() { 186 | SyncStatus::Synced(prev_ss) => { 187 | let prev_ss = prev_ss.clone(); 188 | item.set_sync_status( SyncStatus::LocallyDeleted(prev_ss)); 189 | }, 190 | SyncStatus::LocallyModified(prev_ss) => { 191 | let prev_ss = prev_ss.clone(); 192 | item.set_sync_status( SyncStatus::LocallyDeleted(prev_ss)); 193 | }, 194 | SyncStatus::LocallyDeleted(prev_ss) => { 195 | let prev_ss = prev_ss.clone(); 196 | item.set_sync_status( SyncStatus::LocallyDeleted(prev_ss)); 197 | }, 198 | SyncStatus::NotSynced => { 199 | // This was never synced to the server, we can safely delete it as soon as now 200 | self.items.remove(item_url); 201 | }, 202 | }; 203 | Ok(()) 204 | } 205 | } 206 | } 207 | 208 | /// The non-async version of [`Self::immediately_delete_item`] 209 | pub fn immediately_delete_item_sync(&mut self, item_url: &Url) -> Result<(), Box> { 210 | match self.items.remove(item_url) { 211 | None => Err(format!("Item {} is absent from this calendar", item_url).into()), 212 | Some(_) => Ok(()) 213 | } 214 | } 215 | 216 | } 217 | 218 | 219 | #[async_trait] 220 | impl BaseCalendar for CachedCalendar { 221 | fn name(&self) -> &str { 222 | &self.name 223 | } 224 | 225 | fn url(&self) -> &Url { 226 | &self.url 227 | } 228 | 229 | fn supported_components(&self) -> SupportedComponents { 230 | self.supported_components 231 | } 232 | 233 | fn color(&self) -> Option<&Color> { 234 | self.color.as_ref() 235 | } 236 | 237 | async fn add_item(&mut self, item: Item) -> Result> { 238 | self.add_item_sync(item) 239 | } 240 | 241 | async fn update_item(&mut self, item: Item) -> Result> { 242 | self.update_item_sync(item) 243 | } 244 | } 245 | 246 | #[async_trait] 247 | impl CompleteCalendar for CachedCalendar { 248 | fn new(name: String, url: Url, supported_components: SupportedComponents, color: Option) -> Self { 249 | Self { 250 | name, url, supported_components, color, 251 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 252 | mock_behaviour: None, 253 | items: HashMap::new(), 254 | } 255 | } 256 | 257 | async fn get_item_urls(&self) -> Result, Box> { 258 | self.get_item_urls_sync() 259 | } 260 | 261 | async fn get_items(&self) -> Result, Box> { 262 | self.get_items_sync() 263 | } 264 | 265 | async fn get_items_mut(&mut self) -> Result, Box> { 266 | self.get_items_mut_sync() 267 | } 268 | 269 | async fn get_item_by_url<'a>(&'a self, url: &Url) -> Option<&'a Item> { 270 | self.get_item_by_url_sync(url) 271 | } 272 | 273 | async fn get_item_by_url_mut<'a>(&'a mut self, url: &Url) -> Option<&'a mut Item> { 274 | self.get_item_by_url_mut_sync(url) 275 | } 276 | 277 | async fn mark_for_deletion(&mut self, item_url: &Url) -> Result<(), Box> { 278 | self.mark_for_deletion_sync(item_url) 279 | } 280 | 281 | async fn immediately_delete_item(&mut self, item_url: &Url) -> Result<(), Box> { 282 | self.immediately_delete_item_sync(item_url) 283 | } 284 | } 285 | 286 | 287 | 288 | // This class can be used to mock a remote calendar for integration tests 289 | 290 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 291 | use crate::{item::VersionTag, 292 | traits::DavCalendar, 293 | resource::Resource}; 294 | 295 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 296 | #[async_trait] 297 | impl DavCalendar for CachedCalendar { 298 | fn new(name: String, resource: Resource, supported_components: SupportedComponents, color: Option) -> Self { 299 | crate::traits::CompleteCalendar::new(name, resource.url().clone(), supported_components, color) 300 | } 301 | 302 | async fn get_item_version_tags(&self) -> Result, Box> { 303 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 304 | self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_get_item_version_tags())?; 305 | 306 | use crate::item::SyncStatus; 307 | 308 | let mut result = HashMap::new(); 309 | 310 | for (url, item) in self.items.iter() { 311 | let vt = match item.sync_status() { 312 | SyncStatus::Synced(vt) => vt.clone(), 313 | _ => { 314 | panic!("Mock calendars must contain only SyncStatus::Synced. Got {:?}", item); 315 | } 316 | }; 317 | result.insert(url.clone(), vt); 318 | } 319 | 320 | Ok(result) 321 | } 322 | 323 | async fn get_item_by_url(&self, url: &Url) -> Result, Box> { 324 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 325 | self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_get_item_by_url())?; 326 | 327 | Ok(self.items.get(url).cloned()) 328 | } 329 | 330 | async fn get_items_by_url(&self, urls: &[Url]) -> Result>, Box> { 331 | let mut v = Vec::new(); 332 | for url in urls { 333 | v.push(DavCalendar::get_item_by_url(self, url).await?); 334 | } 335 | Ok(v) 336 | } 337 | 338 | async fn delete_item(&mut self, item_url: &Url) -> Result<(), Box> { 339 | #[cfg(feature = "local_calendar_mocks_remote_calendars")] 340 | self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_delete_item())?; 341 | 342 | self.immediately_delete_item(item_url).await 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /resources/kitchen-fridge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 22 | 25 | 29 | 33 | 34 | 43 | 44 | 66 | 68 | 69 | 71 | image/svg+xml 72 | 74 | 75 | 76 | 77 | 78 | 91 | 97 | 105 | 112 | 113 | 119 | 127 | 135 | 136 | 142 | 148 | 157 | 163 | 171 | 179 | 187 | 195 | 203 | 211 | 217 | 226 | 244 | 250 | 256 | 264 | 270 | 276 | 281 | 286 | 291 | 296 | 301 | 306 | 311 | 316 | 323 | 330 | 337 | 344 | 349 | 354 | 360 | 366 | 367 | 373 | 380 | 387 | 388 | 389 | -------------------------------------------------------------------------------- /src/provider/mod.rs: -------------------------------------------------------------------------------- 1 | //! This modules abstracts data sources and merges them in a single virtual one 2 | //! 3 | //! It is also responsible for syncing them together 4 | 5 | use std::error::Error; 6 | use std::collections::HashSet; 7 | use std::marker::PhantomData; 8 | use std::sync::{Arc, Mutex}; 9 | use std::fmt::{Display, Formatter}; 10 | 11 | use url::Url; 12 | use itertools::Itertools; 13 | 14 | use crate::traits::{BaseCalendar, CalDavSource, DavCalendar}; 15 | use crate::traits::CompleteCalendar; 16 | use crate::item::SyncStatus; 17 | 18 | pub mod sync_progress; 19 | use sync_progress::SyncProgress; 20 | use sync_progress::{FeedbackSender, SyncEvent}; 21 | 22 | /// How many items will be batched in a single HTTP request when downloading from the server 23 | #[cfg(not(test))] 24 | const DOWNLOAD_BATCH_SIZE: usize = 30; 25 | /// How many items will be batched in a single HTTP request when downloading from the server 26 | #[cfg(test)] 27 | const DOWNLOAD_BATCH_SIZE: usize = 3; 28 | 29 | // I am too lazy to actually make `fetch_and_apply` generic over an async closure. 30 | // Let's work around by passing an enum, so that `fetch_and_apply` will know what to do 31 | enum BatchDownloadType { 32 | RemoteAdditions, 33 | RemoteChanges, 34 | } 35 | 36 | impl Display for BatchDownloadType { 37 | fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { 38 | match self { 39 | Self::RemoteAdditions => write!(f, "remote additions"), 40 | Self::RemoteChanges => write!(f, "remote changes"), 41 | } 42 | } 43 | } 44 | 45 | 46 | /// A data source that combines two `CalDavSource`s, which is able to sync both sources. 47 | /// 48 | /// Usually, you will only need to use a provider between a server and a local cache, that is to say a [`CalDavProvider`](crate::CalDavProvider), i.e. a `Provider`. \ 49 | /// However, providers can be used for integration tests, where the remote source is mocked by a `Cache`. 50 | #[derive(Debug)] 51 | pub struct Provider 52 | where 53 | L: CalDavSource, 54 | T: CompleteCalendar + Sync + Send, 55 | R: CalDavSource, 56 | U: DavCalendar + Sync + Send, 57 | { 58 | /// The remote source (usually a server) 59 | remote: R, 60 | /// The local cache 61 | local: L, 62 | 63 | phantom_t: PhantomData, 64 | phantom_u: PhantomData, 65 | } 66 | 67 | impl Provider 68 | where 69 | L: CalDavSource, 70 | T: CompleteCalendar + Sync + Send, 71 | R: CalDavSource, 72 | U: DavCalendar + Sync + Send, 73 | { 74 | /// Create a provider. 75 | /// 76 | /// `remote` is usually a [`Client`](crate::client::Client), `local` is usually a [`Cache`](crate::cache::Cache). 77 | /// However, both can be interchangeable. The only difference is that `remote` always wins in case of a sync conflict 78 | pub fn new(remote: R, local: L) -> Self { 79 | Self { remote, local, 80 | phantom_t: PhantomData, phantom_u: PhantomData, 81 | } 82 | } 83 | 84 | /// Returns the data source described as `local` 85 | pub fn local(&self) -> &L { &self.local } 86 | /// Returns the data source described as `local` 87 | pub fn local_mut(&mut self) -> &mut L { &mut self.local } 88 | /// Returns the data source described as `remote`. 89 | /// 90 | /// Apart from tests, there are very few (if any) reasons to access `remote` directly. 91 | /// Usually, you should rather use the `local` source, which (usually) is a much faster local cache. 92 | /// To be sure `local` accurately mirrors the `remote` source, you can run [`Provider::sync`] 93 | pub fn remote(&self) -> &R { &self.remote } 94 | 95 | /// Performs a synchronisation between `local` and `remote`, and provide feeedback to the user about the progress. 96 | /// 97 | /// This bidirectional sync applies additions/deletions made on a source to the other source. 98 | /// In case of conflicts (the same item has been modified on both ends since the last sync, `remote` always wins). 99 | /// 100 | /// It returns whether the sync was totally successful (details about errors are logged using the `log::*` macros). 101 | /// In case errors happened, the sync might have been partially executed but your data will never be correupted (either locally nor in the server). 102 | /// Simply run this function again, it will re-start a sync, picking up where it failed. 103 | pub async fn sync_with_feedback(&mut self, feedback_sender: FeedbackSender) -> bool { 104 | let mut progress = SyncProgress::new_with_feedback_channel(feedback_sender); 105 | self.run_sync(&mut progress).await 106 | } 107 | 108 | /// Performs a synchronisation between `local` and `remote`, without giving any feedback. 109 | /// 110 | /// See [`Self::sync_with_feedback`] 111 | pub async fn sync(&mut self) -> bool { 112 | let mut progress = SyncProgress::new(); 113 | self.run_sync(&mut progress).await 114 | } 115 | 116 | async fn run_sync(&mut self, progress: &mut SyncProgress) -> bool { 117 | if let Err(err) = self.run_sync_inner(progress).await { 118 | progress.error(&format!("Sync terminated because of an error: {}", err)); 119 | } 120 | progress.feedback(SyncEvent::Finished{ success: progress.is_success() }); 121 | progress.is_success() 122 | } 123 | 124 | async fn run_sync_inner(&mut self, progress: &mut SyncProgress) -> Result<(), Box> { 125 | progress.info("Starting a sync."); 126 | progress.feedback(SyncEvent::Started); 127 | 128 | let mut handled_calendars = HashSet::new(); 129 | 130 | // Sync every remote calendar 131 | let cals_remote = self.remote.get_calendars().await?; 132 | for (cal_url, cal_remote) in cals_remote { 133 | let counterpart = match self.get_or_insert_local_counterpart_calendar(&cal_url, cal_remote.clone()).await { 134 | Err(err) => { 135 | progress.warn(&format!("Unable to get or insert local counterpart calendar for {} ({}). Skipping this time", cal_url, err)); 136 | continue; 137 | }, 138 | Ok(arc) => arc, 139 | }; 140 | 141 | if let Err(err) = Self::sync_calendar_pair(counterpart, cal_remote, progress).await { 142 | progress.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_url, err)); 143 | continue; 144 | } 145 | handled_calendars.insert(cal_url); 146 | } 147 | 148 | // Sync every local calendar that would not be in the remote yet 149 | let cals_local = self.local.get_calendars().await?; 150 | for (cal_url, cal_local) in cals_local { 151 | if handled_calendars.contains(&cal_url) { 152 | continue; 153 | } 154 | 155 | let counterpart = match self.get_or_insert_remote_counterpart_calendar(&cal_url, cal_local.clone()).await { 156 | Err(err) => { 157 | progress.warn(&format!("Unable to get or insert remote counterpart calendar for {} ({}). Skipping this time", cal_url, err)); 158 | continue; 159 | }, 160 | Ok(arc) => arc, 161 | }; 162 | 163 | if let Err(err) = Self::sync_calendar_pair(cal_local, counterpart, progress).await { 164 | progress.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_url, err)); 165 | continue; 166 | } 167 | } 168 | 169 | progress.info("Sync ended"); 170 | 171 | Ok(()) 172 | } 173 | 174 | 175 | async fn get_or_insert_local_counterpart_calendar(&mut self, cal_url: &Url, needle: Arc>) -> Result>, Box> { 176 | get_or_insert_counterpart_calendar("local", &mut self.local, cal_url, needle).await 177 | } 178 | async fn get_or_insert_remote_counterpart_calendar(&mut self, cal_url: &Url, needle: Arc>) -> Result>, Box> { 179 | get_or_insert_counterpart_calendar("remote", &mut self.remote, cal_url, needle).await 180 | } 181 | 182 | 183 | async fn sync_calendar_pair(cal_local: Arc>, cal_remote: Arc>, progress: &mut SyncProgress) -> Result<(), Box> { 184 | let mut cal_remote = cal_remote.lock().unwrap(); 185 | let mut cal_local = cal_local.lock().unwrap(); 186 | let cal_name = cal_local.name().to_string(); 187 | 188 | progress.info(&format!("Syncing calendar {}", cal_name)); 189 | progress.reset_counter(); 190 | progress.feedback(SyncEvent::InProgress{ 191 | calendar: cal_name.clone(), 192 | items_done_already: 0, 193 | details: "started".to_string() 194 | }); 195 | 196 | // Step 1 - find the differences 197 | progress.debug("Finding the differences to sync..."); 198 | let mut local_del = HashSet::new(); 199 | let mut remote_del = HashSet::new(); 200 | let mut local_changes = HashSet::new(); 201 | let mut remote_changes = HashSet::new(); 202 | let mut local_additions = HashSet::new(); 203 | let mut remote_additions = HashSet::new(); 204 | 205 | let remote_items = cal_remote.get_item_version_tags().await?; 206 | progress.feedback(SyncEvent::InProgress{ 207 | calendar: cal_name.clone(), 208 | items_done_already: 0, 209 | details: format!("{} remote items", remote_items.len()), 210 | }); 211 | 212 | let mut local_items_to_handle = cal_local.get_item_urls().await?; 213 | for (url, remote_tag) in remote_items { 214 | progress.trace(&format!("***** Considering remote item {}...", url)); 215 | match cal_local.get_item_by_url(&url).await { 216 | None => { 217 | // This was created on the remote 218 | progress.debug(&format!("* {} is a remote addition", url)); 219 | remote_additions.insert(url); 220 | }, 221 | Some(local_item) => { 222 | if local_items_to_handle.remove(&url) == false { 223 | progress.error(&format!("Inconsistent state: missing task {} from the local tasks", url)); 224 | } 225 | 226 | match local_item.sync_status() { 227 | SyncStatus::NotSynced => { 228 | progress.error(&format!("URL reuse between remote and local sources ({}). Ignoring this item in the sync", url)); 229 | continue; 230 | }, 231 | SyncStatus::Synced(local_tag) => { 232 | if &remote_tag != local_tag { 233 | // This has been modified on the remote 234 | progress.debug(&format!("* {} is a remote change", url)); 235 | remote_changes.insert(url); 236 | } 237 | }, 238 | SyncStatus::LocallyModified(local_tag) => { 239 | if &remote_tag == local_tag { 240 | // This has been changed locally 241 | progress.debug(&format!("* {} is a local change", url)); 242 | local_changes.insert(url); 243 | } else { 244 | progress.info(&format!("Conflict: task {} has been modified in both sources. Using the remote version.", url)); 245 | progress.debug(&format!("* {} is considered a remote change", url)); 246 | remote_changes.insert(url); 247 | } 248 | }, 249 | SyncStatus::LocallyDeleted(local_tag) => { 250 | if &remote_tag == local_tag { 251 | // This has been locally deleted 252 | progress.debug(&format!("* {} is a local deletion", url)); 253 | local_del.insert(url); 254 | } else { 255 | progress.info(&format!("Conflict: task {} has been locally deleted and remotely modified. Reverting to the remote version.", url)); 256 | progress.debug(&format!("* {} is a considered a remote change", url)); 257 | remote_changes.insert(url); 258 | } 259 | }, 260 | } 261 | } 262 | } 263 | } 264 | 265 | // Also iterate on the local tasks that are not on the remote 266 | for url in local_items_to_handle { 267 | progress.trace(&format!("##### Considering local item {}...", url)); 268 | let local_item = match cal_local.get_item_by_url(&url).await { 269 | None => { 270 | progress.error(&format!("Inconsistent state: missing task {} from the local tasks", url)); 271 | continue; 272 | }, 273 | Some(item) => item, 274 | }; 275 | 276 | match local_item.sync_status() { 277 | SyncStatus::Synced(_) => { 278 | // This item has been removed from the remote 279 | progress.debug(&format!("# {} is a deletion from the server", url)); 280 | remote_del.insert(url); 281 | }, 282 | SyncStatus::NotSynced => { 283 | // This item has just been locally created 284 | progress.debug(&format!("# {} has been locally created", url)); 285 | local_additions.insert(url); 286 | }, 287 | SyncStatus::LocallyDeleted(_) => { 288 | // This item has been deleted from both sources 289 | progress.debug(&format!("# {} has been deleted from both sources", url)); 290 | remote_del.insert(url); 291 | }, 292 | SyncStatus::LocallyModified(_) => { 293 | progress.info(&format!("Conflict: item {} has been deleted from the server and locally modified. Deleting the local copy", url)); 294 | remote_del.insert(url); 295 | }, 296 | } 297 | } 298 | 299 | 300 | // Step 2 - commit changes 301 | progress.trace("Committing changes..."); 302 | for url_del in local_del { 303 | progress.debug(&format!("> Pushing local deletion {} to the server", url_del)); 304 | progress.increment_counter(1); 305 | progress.feedback(SyncEvent::InProgress{ 306 | calendar: cal_name.clone(), 307 | items_done_already: progress.counter(), 308 | details: Self::item_name(&cal_local, &url_del).await, 309 | }); 310 | 311 | match cal_remote.delete_item(&url_del).await { 312 | Err(err) => { 313 | progress.warn(&format!("Unable to delete remote item {}: {}", url_del, err)); 314 | }, 315 | Ok(()) => { 316 | // Change the local copy from "marked to deletion" to "actually deleted" 317 | if let Err(err) = cal_local.immediately_delete_item(&url_del).await { 318 | progress.error(&format!("Unable to permanently delete local item {}: {}", url_del, err)); 319 | } 320 | }, 321 | } 322 | } 323 | 324 | for url_del in remote_del { 325 | progress.debug(&format!("> Applying remote deletion {} locally", url_del)); 326 | progress.increment_counter(1); 327 | progress.feedback(SyncEvent::InProgress{ 328 | calendar: cal_name.clone(), 329 | items_done_already: progress.counter(), 330 | details: Self::item_name(&cal_local, &url_del).await, 331 | }); 332 | if let Err(err) = cal_local.immediately_delete_item(&url_del).await { 333 | progress.warn(&format!("Unable to delete local item {}: {}", url_del, err)); 334 | } 335 | } 336 | 337 | Self::apply_remote_additions( 338 | remote_additions, 339 | &mut *cal_local, 340 | &mut *cal_remote, 341 | progress, 342 | &cal_name 343 | ).await; 344 | 345 | Self::apply_remote_changes( 346 | remote_changes, 347 | &mut *cal_local, 348 | &mut *cal_remote, 349 | progress, 350 | &cal_name 351 | ).await; 352 | 353 | 354 | for url_add in local_additions { 355 | progress.debug(&format!("> Pushing local addition {} to the server", url_add)); 356 | progress.increment_counter(1); 357 | progress.feedback(SyncEvent::InProgress{ 358 | calendar: cal_name.clone(), 359 | items_done_already: progress.counter(), 360 | details: Self::item_name(&cal_local, &url_add).await, 361 | }); 362 | match cal_local.get_item_by_url_mut(&url_add).await { 363 | None => { 364 | progress.error(&format!("Inconsistency: created item {} has been marked for upload but is locally missing", url_add)); 365 | continue; 366 | }, 367 | Some(item) => { 368 | match cal_remote.add_item(item.clone()).await { 369 | Err(err) => progress.error(&format!("Unable to add item {} to remote calendar: {}", url_add, err)), 370 | Ok(new_ss) => { 371 | // Update local sync status 372 | item.set_sync_status(new_ss); 373 | }, 374 | } 375 | }, 376 | }; 377 | } 378 | 379 | for url_change in local_changes { 380 | progress.debug(&format!("> Pushing local change {} to the server", url_change)); 381 | progress.increment_counter(1); 382 | progress.feedback(SyncEvent::InProgress{ 383 | calendar: cal_name.clone(), 384 | items_done_already: progress.counter(), 385 | details: Self::item_name(&cal_local, &url_change).await, 386 | }); 387 | match cal_local.get_item_by_url_mut(&url_change).await { 388 | None => { 389 | progress.error(&format!("Inconsistency: modified item {} has been marked for upload but is locally missing", url_change)); 390 | continue; 391 | }, 392 | Some(item) => { 393 | match cal_remote.update_item(item.clone()).await { 394 | Err(err) => progress.error(&format!("Unable to update item {} in remote calendar: {}", url_change, err)), 395 | Ok(new_ss) => { 396 | // Update local sync status 397 | item.set_sync_status(new_ss); 398 | }, 399 | }; 400 | } 401 | }; 402 | } 403 | 404 | Ok(()) 405 | } 406 | 407 | 408 | async fn item_name(cal: &T, url: &Url) -> String { 409 | cal.get_item_by_url(url).await.map(|item| item.name()).unwrap_or_default().to_string() 410 | } 411 | 412 | async fn apply_remote_additions( 413 | mut remote_additions: HashSet, 414 | cal_local: &mut T, 415 | cal_remote: &mut U, 416 | progress: &mut SyncProgress, 417 | cal_name: &str 418 | ) { 419 | for batch in remote_additions.drain().chunks(DOWNLOAD_BATCH_SIZE).into_iter() { 420 | Self::fetch_batch_and_apply(BatchDownloadType::RemoteAdditions, batch, cal_local, cal_remote, progress, cal_name).await; 421 | } 422 | } 423 | 424 | async fn apply_remote_changes( 425 | mut remote_changes: HashSet, 426 | cal_local: &mut T, 427 | cal_remote: &mut U, 428 | progress: &mut SyncProgress, 429 | cal_name: &str 430 | ) { 431 | for batch in remote_changes.drain().chunks(DOWNLOAD_BATCH_SIZE).into_iter() { 432 | Self::fetch_batch_and_apply(BatchDownloadType::RemoteChanges, batch, cal_local, cal_remote, progress, cal_name).await; 433 | } 434 | } 435 | 436 | async fn fetch_batch_and_apply>( 437 | batch_type: BatchDownloadType, 438 | remote_additions: I, 439 | cal_local: &mut T, 440 | cal_remote: &mut U, 441 | progress: &mut SyncProgress, 442 | cal_name: &str 443 | ) { 444 | progress.debug(&format!("> Applying a batch of {} locally", batch_type) /* too bad Chunks does not implement ExactSizeIterator, that could provide useful debug info. See https://github.com/rust-itertools/itertools/issues/171 */); 445 | 446 | let list_of_additions: Vec = remote_additions.map(|url| url.clone()).collect(); 447 | match cal_remote.get_items_by_url(&list_of_additions).await { 448 | Err(err) => { 449 | progress.warn(&format!("Unable to get the batch of {} {:?}: {}. Skipping them.", batch_type, list_of_additions, err)); 450 | }, 451 | Ok(items) => { 452 | for item in items { 453 | match item { 454 | None => { 455 | progress.error(&format!("Inconsistency: an item from the batch has vanished from the remote end")); 456 | continue; 457 | }, 458 | Some(new_item) => { 459 | let local_update_result = match batch_type { 460 | BatchDownloadType::RemoteAdditions => cal_local.add_item(new_item.clone()).await, 461 | BatchDownloadType::RemoteChanges => cal_local.update_item(new_item.clone()).await, 462 | }; 463 | if let Err(err) = local_update_result { 464 | progress.error(&format!("Not able to add item {} to local calendar: {}", new_item.url(), err)); 465 | } 466 | }, 467 | } 468 | } 469 | 470 | // Notifying every item at the same time would not make sense. Let's notify only one of them 471 | let one_item_name = match list_of_additions.get(0) { 472 | Some(url) => Self::item_name(&cal_local, &url).await, 473 | None => String::from(""), 474 | }; 475 | progress.increment_counter(list_of_additions.len()); 476 | progress.feedback(SyncEvent::InProgress{ 477 | calendar: cal_name.to_string(), 478 | items_done_already: progress.counter(), 479 | details: one_item_name, 480 | }); 481 | }, 482 | } 483 | } 484 | } 485 | 486 | 487 | async fn get_or_insert_counterpart_calendar(haystack_descr: &str, haystack: &mut H, cal_url: &Url, needle: Arc>) 488 | -> Result>, Box> 489 | where 490 | H: CalDavSource, 491 | I: BaseCalendar, 492 | N: BaseCalendar, 493 | { 494 | loop { 495 | if let Some(cal) = haystack.get_calendar(&cal_url).await { 496 | break Ok(cal); 497 | } 498 | 499 | // This calendar does not exist locally yet, let's add it 500 | log::debug!("Adding a {} calendar {}", haystack_descr, cal_url); 501 | let src = needle.lock().unwrap(); 502 | let name = src.name().to_string(); 503 | let supported_comps = src.supported_components(); 504 | let color = src.color(); 505 | if let Err(err) = haystack.create_calendar( 506 | cal_url.clone(), 507 | name, 508 | supported_comps, 509 | color.cloned(), 510 | ).await{ 511 | return Err(err); 512 | } 513 | } 514 | } 515 | 516 | -------------------------------------------------------------------------------- /tests/scenarii.rs: -------------------------------------------------------------------------------- 1 | //! Multiple scenarios that are performed to test sync operations correctly work 2 | //! 3 | //! This module creates test data. 4 | //! To do so, "scenarii" are defined. A scenario contains an inital state before sync, changes made either on the local or remote side, then the expected final state that should be present in both sources after sync. 5 | //! 6 | //! This module builds actual CalDAV sources (actually [`crate::cache::Cache`]s, that can also mock what would be [`crate::client::Client`]s in a real program) and [`crate::provider::Provider]`s that contain this data 7 | //! 8 | //! This module can also check the sources after a sync contain the actual data we expect 9 | #![cfg(feature = "local_calendar_mocks_remote_calendars")] 10 | 11 | use std::path::PathBuf; 12 | use std::sync::{Arc, Mutex}; 13 | use std::error::Error; 14 | use url::Url; 15 | 16 | use chrono::Utc; 17 | 18 | use kitchen_fridge::calendar::SupportedComponents; 19 | use kitchen_fridge::traits::CalDavSource; 20 | use kitchen_fridge::traits::BaseCalendar; 21 | use kitchen_fridge::traits::CompleteCalendar; 22 | use kitchen_fridge::traits::DavCalendar; 23 | use kitchen_fridge::cache::Cache; 24 | use kitchen_fridge::Item; 25 | use kitchen_fridge::item::SyncStatus; 26 | use kitchen_fridge::Task; 27 | use kitchen_fridge::task::CompletionStatus; 28 | use kitchen_fridge::calendar::cached_calendar::CachedCalendar; 29 | use kitchen_fridge::provider::Provider; 30 | use kitchen_fridge::mock_behaviour::MockBehaviour; 31 | use kitchen_fridge::utils::random_url; 32 | 33 | pub enum LocatedState { 34 | /// Item does not exist yet or does not exist anymore 35 | None, 36 | /// Item is only in the local source 37 | Local(ItemState), 38 | /// Item is only in the remote source 39 | Remote(ItemState), 40 | /// Item is synced at both locations, 41 | BothSynced(ItemState), 42 | } 43 | 44 | pub struct ItemState { 45 | // TODO: if/when this crate supports Events as well, we could add such events here 46 | /// The calendar it is in 47 | calendar: Url, 48 | /// Its name 49 | name: String, 50 | /// Its completion status 51 | completed: bool, 52 | } 53 | 54 | pub enum ChangeToApply { 55 | Rename(String), 56 | SetCompletion(bool), 57 | Create(Url, Item), 58 | /// "remove" means "mark for deletion" in the local calendar, or "immediately delete" on the remote calendar 59 | Remove, 60 | // ChangeCalendar(Url) is useless, as long as changing a calendar is implemented as "delete in one calendar and re-create it in another one" 61 | } 62 | 63 | 64 | pub struct ItemScenario { 65 | url: Url, 66 | initial_state: LocatedState, 67 | local_changes_to_apply: Vec, 68 | remote_changes_to_apply: Vec, 69 | after_sync: LocatedState, 70 | } 71 | 72 | /// Generate the scenarii required for the following test: 73 | /// * At the last sync: both sources had A, B, C, D, E, F, G, H, I, J, K, L, M✓, N✓, O✓, P✓ at last sync 74 | /// A-F are in a calendar, G-M are in a second one, and in a third calendar from N on 75 | /// 76 | /// * Before the newer sync, this will be the content of the sources: 77 | /// * cache: A, B, D', E, F'', G , H✓, I✓, J✓, M, N✓, O, P' , R 78 | /// * server: A, C, D, E', F', G✓, H , I', K✓, M✓, N , O, P✓, Q 79 | /// 80 | /// Hence, here is the expected result after the sync: 81 | /// * both: A, D', E', F', G✓, H✓, I', K✓, M, N, O, P', Q, R 82 | /// 83 | /// Notes: 84 | /// * X': name has been modified since the last sync 85 | /// * X'/X'': name conflict 86 | /// * X✓: task has been marked as completed 87 | pub fn scenarii_basic() -> Vec { 88 | let mut tasks = Vec::new(); 89 | 90 | let first_cal = Url::from("https://some.calend.ar/calendar-1/".parse().unwrap()); 91 | let second_cal = Url::from("https://some.calend.ar/calendar-2/".parse().unwrap()); 92 | let third_cal = Url::from("https://some.calend.ar/calendar-3/".parse().unwrap()); 93 | 94 | tasks.push( 95 | ItemScenario { 96 | url: random_url(&first_cal), 97 | initial_state: LocatedState::BothSynced( ItemState{ 98 | calendar: first_cal.clone(), 99 | name: String::from("Task A"), 100 | completed: false, 101 | }), 102 | local_changes_to_apply: Vec::new(), 103 | remote_changes_to_apply: Vec::new(), 104 | after_sync: LocatedState::BothSynced( ItemState{ 105 | calendar: first_cal.clone(), 106 | name: String::from("Task A"), 107 | completed: false, 108 | }), 109 | } 110 | ); 111 | 112 | tasks.push( 113 | ItemScenario { 114 | url: random_url(&first_cal), 115 | initial_state: LocatedState::BothSynced( ItemState{ 116 | calendar: first_cal.clone(), 117 | name: String::from("Task B"), 118 | completed: false, 119 | }), 120 | local_changes_to_apply: Vec::new(), 121 | remote_changes_to_apply: vec![ChangeToApply::Remove], 122 | after_sync: LocatedState::None, 123 | } 124 | ); 125 | 126 | tasks.push( 127 | ItemScenario { 128 | url: random_url(&first_cal), 129 | initial_state: LocatedState::BothSynced( ItemState{ 130 | calendar: first_cal.clone(), 131 | name: String::from("Task C"), 132 | completed: false, 133 | }), 134 | local_changes_to_apply: vec![ChangeToApply::Remove], 135 | remote_changes_to_apply: Vec::new(), 136 | after_sync: LocatedState::None, 137 | } 138 | ); 139 | 140 | tasks.push( 141 | ItemScenario { 142 | url: random_url(&first_cal), 143 | initial_state: LocatedState::BothSynced( ItemState{ 144 | calendar: first_cal.clone(), 145 | name: String::from("Task D"), 146 | completed: false, 147 | }), 148 | local_changes_to_apply: vec![ChangeToApply::Rename(String::from("Task D, locally renamed"))], 149 | remote_changes_to_apply: Vec::new(), 150 | after_sync: LocatedState::BothSynced( ItemState{ 151 | calendar: first_cal.clone(), 152 | name: String::from("Task D, locally renamed"), 153 | completed: false, 154 | }), 155 | } 156 | ); 157 | 158 | tasks.push( 159 | ItemScenario { 160 | url: random_url(&first_cal), 161 | initial_state: LocatedState::BothSynced( ItemState{ 162 | calendar: first_cal.clone(), 163 | name: String::from("Task E"), 164 | completed: false, 165 | }), 166 | local_changes_to_apply: Vec::new(), 167 | remote_changes_to_apply: vec![ChangeToApply::Rename(String::from("Task E, remotely renamed"))], 168 | after_sync: LocatedState::BothSynced( ItemState{ 169 | calendar: first_cal.clone(), 170 | name: String::from("Task E, remotely renamed"), 171 | completed: false, 172 | }), 173 | } 174 | ); 175 | 176 | tasks.push( 177 | ItemScenario { 178 | url: random_url(&first_cal), 179 | initial_state: LocatedState::BothSynced( ItemState{ 180 | calendar: first_cal.clone(), 181 | name: String::from("Task F"), 182 | completed: false, 183 | }), 184 | local_changes_to_apply: vec![ChangeToApply::Rename(String::from("Task F, locally renamed"))], 185 | remote_changes_to_apply: vec![ChangeToApply::Rename(String::from("Task F, remotely renamed"))], 186 | // Conflict: the server wins 187 | after_sync: LocatedState::BothSynced( ItemState{ 188 | calendar: first_cal.clone(), 189 | name: String::from("Task F, remotely renamed"), 190 | completed: false, 191 | }), 192 | } 193 | ); 194 | 195 | tasks.push( 196 | ItemScenario { 197 | url: random_url(&second_cal), 198 | initial_state: LocatedState::BothSynced( ItemState{ 199 | calendar: second_cal.clone(), 200 | name: String::from("Task G"), 201 | completed: false, 202 | }), 203 | local_changes_to_apply: Vec::new(), 204 | remote_changes_to_apply: vec![ChangeToApply::SetCompletion(true)], 205 | after_sync: LocatedState::BothSynced( ItemState{ 206 | calendar: second_cal.clone(), 207 | name: String::from("Task G"), 208 | completed: true, 209 | }), 210 | } 211 | ); 212 | 213 | tasks.push( 214 | ItemScenario { 215 | url: random_url(&second_cal), 216 | initial_state: LocatedState::BothSynced( ItemState{ 217 | calendar: second_cal.clone(), 218 | name: String::from("Task H"), 219 | completed: false, 220 | }), 221 | local_changes_to_apply: vec![ChangeToApply::SetCompletion(true)], 222 | remote_changes_to_apply: Vec::new(), 223 | after_sync: LocatedState::BothSynced( ItemState{ 224 | calendar: second_cal.clone(), 225 | name: String::from("Task H"), 226 | completed: true, 227 | }), 228 | } 229 | ); 230 | 231 | tasks.push( 232 | ItemScenario { 233 | url: random_url(&second_cal), 234 | initial_state: LocatedState::BothSynced( ItemState{ 235 | calendar: second_cal.clone(), 236 | name: String::from("Task I"), 237 | completed: false, 238 | }), 239 | local_changes_to_apply: vec![ChangeToApply::SetCompletion(true)], 240 | remote_changes_to_apply: vec![ChangeToApply::Rename(String::from("Task I, remotely renamed"))], 241 | // Conflict, the server wins 242 | after_sync: LocatedState::BothSynced( ItemState{ 243 | calendar: second_cal.clone(), 244 | name: String::from("Task I, remotely renamed"), 245 | completed: false, 246 | }), 247 | } 248 | ); 249 | 250 | tasks.push( 251 | ItemScenario { 252 | url: random_url(&second_cal), 253 | initial_state: LocatedState::BothSynced( ItemState{ 254 | calendar: second_cal.clone(), 255 | name: String::from("Task J"), 256 | completed: false, 257 | }), 258 | local_changes_to_apply: vec![ChangeToApply::SetCompletion(true)], 259 | remote_changes_to_apply: vec![ChangeToApply::Remove], 260 | after_sync: LocatedState::None, 261 | } 262 | ); 263 | 264 | tasks.push( 265 | ItemScenario { 266 | url: random_url(&second_cal), 267 | initial_state: LocatedState::BothSynced( ItemState{ 268 | calendar: second_cal.clone(), 269 | name: String::from("Task K"), 270 | completed: false, 271 | }), 272 | local_changes_to_apply: vec![ChangeToApply::Remove], 273 | remote_changes_to_apply: vec![ChangeToApply::SetCompletion(true)], 274 | after_sync: LocatedState::BothSynced( ItemState{ 275 | calendar: second_cal.clone(), 276 | name: String::from("Task K"), 277 | completed: true, 278 | }), 279 | } 280 | ); 281 | 282 | tasks.push( 283 | ItemScenario { 284 | url: random_url(&second_cal), 285 | initial_state: LocatedState::BothSynced( ItemState{ 286 | calendar: second_cal.clone(), 287 | name: String::from("Task L"), 288 | completed: false, 289 | }), 290 | local_changes_to_apply: vec![ChangeToApply::Remove], 291 | remote_changes_to_apply: vec![ChangeToApply::Remove], 292 | after_sync: LocatedState::None, 293 | } 294 | ); 295 | 296 | tasks.push( 297 | ItemScenario { 298 | url: random_url(&second_cal), 299 | initial_state: LocatedState::BothSynced( ItemState{ 300 | calendar: second_cal.clone(), 301 | name: String::from("Task M"), 302 | completed: true, 303 | }), 304 | local_changes_to_apply: vec![ChangeToApply::SetCompletion(false)], 305 | remote_changes_to_apply: Vec::new(), 306 | after_sync: LocatedState::BothSynced( ItemState{ 307 | calendar: second_cal.clone(), 308 | name: String::from("Task M"), 309 | completed: false, 310 | }), 311 | } 312 | ); 313 | 314 | tasks.push( 315 | ItemScenario { 316 | url: random_url(&third_cal), 317 | initial_state: LocatedState::BothSynced( ItemState{ 318 | calendar: third_cal.clone(), 319 | name: String::from("Task N"), 320 | completed: true, 321 | }), 322 | local_changes_to_apply: Vec::new(), 323 | remote_changes_to_apply: vec![ChangeToApply::SetCompletion(false)], 324 | after_sync: LocatedState::BothSynced( ItemState{ 325 | calendar: third_cal.clone(), 326 | name: String::from("Task N"), 327 | completed: false, 328 | }), 329 | } 330 | ); 331 | 332 | tasks.push( 333 | ItemScenario { 334 | url: random_url(&third_cal), 335 | initial_state: LocatedState::BothSynced( ItemState{ 336 | calendar: third_cal.clone(), 337 | name: String::from("Task O"), 338 | completed: true, 339 | }), 340 | local_changes_to_apply: vec![ChangeToApply::SetCompletion(false)], 341 | remote_changes_to_apply: vec![ChangeToApply::SetCompletion(false)], 342 | after_sync: LocatedState::BothSynced( ItemState{ 343 | calendar: third_cal.clone(), 344 | name: String::from("Task O"), 345 | completed: false, 346 | }), 347 | } 348 | ); 349 | 350 | let url_p = random_url(&third_cal); 351 | tasks.push( 352 | ItemScenario { 353 | url: url_p.clone(), 354 | initial_state: LocatedState::BothSynced( ItemState{ 355 | calendar: third_cal.clone(), 356 | name: String::from("Task P"), 357 | completed: true, 358 | }), 359 | local_changes_to_apply: vec![ 360 | ChangeToApply::Rename(String::from("Task P, locally renamed and un-completed")), 361 | ChangeToApply::SetCompletion(false), 362 | ], 363 | remote_changes_to_apply: Vec::new(), 364 | after_sync: LocatedState::BothSynced( ItemState{ 365 | calendar: third_cal.clone(), 366 | name: String::from("Task P, locally renamed and un-completed"), 367 | completed: false, 368 | }), 369 | } 370 | ); 371 | 372 | let url_q = random_url(&third_cal); 373 | tasks.push( 374 | ItemScenario { 375 | url: url_q.clone(), 376 | initial_state: LocatedState::None, 377 | local_changes_to_apply: Vec::new(), 378 | remote_changes_to_apply: vec![ChangeToApply::Create(third_cal.clone(), Item::Task( 379 | Task::new_with_parameters( 380 | String::from("Task Q, created on the server"), 381 | url_q.to_string(), url_q, 382 | CompletionStatus::Uncompleted, 383 | SyncStatus::random_synced(), Some(Utc::now()), Utc::now(), "prod_id".to_string(), Vec::new() ) 384 | ))], 385 | after_sync: LocatedState::BothSynced( ItemState{ 386 | calendar: third_cal.clone(), 387 | name: String::from("Task Q, created on the server"), 388 | completed: false, 389 | }), 390 | } 391 | ); 392 | 393 | let url_r = random_url(&third_cal); 394 | tasks.push( 395 | ItemScenario { 396 | url: url_r.clone(), 397 | initial_state: LocatedState::None, 398 | local_changes_to_apply: vec![ChangeToApply::Create(third_cal.clone(), Item::Task( 399 | Task::new_with_parameters( 400 | String::from("Task R, created locally"), 401 | url_r.to_string(), url_r, 402 | CompletionStatus::Uncompleted, 403 | SyncStatus::NotSynced, Some(Utc::now()), Utc::now(), "prod_id".to_string(), Vec::new() ) 404 | ))], 405 | remote_changes_to_apply: Vec::new(), 406 | after_sync: LocatedState::BothSynced( ItemState{ 407 | calendar: third_cal.clone(), 408 | name: String::from("Task R, created locally"), 409 | completed: false, 410 | }), 411 | } 412 | ); 413 | 414 | tasks 415 | } 416 | 417 | /// This scenario basically checks a first sync to an empty local cache 418 | pub fn scenarii_first_sync_to_local() -> Vec { 419 | let mut tasks = Vec::new(); 420 | 421 | let cal1 = Url::from("https://some.calend.ar/first/".parse().unwrap()); 422 | let cal2 = Url::from("https://some.calend.ar/second/".parse().unwrap()); 423 | 424 | tasks.push( 425 | ItemScenario { 426 | url: random_url(&cal1), 427 | initial_state: LocatedState::Remote( ItemState{ 428 | calendar: cal1.clone(), 429 | name: String::from("Task A1"), 430 | completed: false, 431 | }), 432 | local_changes_to_apply: Vec::new(), 433 | remote_changes_to_apply: Vec::new(), 434 | after_sync: LocatedState::BothSynced( ItemState{ 435 | calendar: cal1.clone(), 436 | name: String::from("Task A1"), 437 | completed: false, 438 | }), 439 | } 440 | ); 441 | 442 | tasks.push( 443 | ItemScenario { 444 | url: random_url(&cal2), 445 | initial_state: LocatedState::Remote( ItemState{ 446 | calendar: cal2.clone(), 447 | name: String::from("Task A2"), 448 | completed: false, 449 | }), 450 | local_changes_to_apply: Vec::new(), 451 | remote_changes_to_apply: Vec::new(), 452 | after_sync: LocatedState::BothSynced( ItemState{ 453 | calendar: cal2.clone(), 454 | name: String::from("Task A2"), 455 | completed: false, 456 | }), 457 | } 458 | ); 459 | 460 | tasks.push( 461 | ItemScenario { 462 | url: random_url(&cal1), 463 | initial_state: LocatedState::Remote( ItemState{ 464 | calendar: cal1.clone(), 465 | name: String::from("Task B1"), 466 | completed: false, 467 | }), 468 | local_changes_to_apply: Vec::new(), 469 | remote_changes_to_apply: Vec::new(), 470 | after_sync: LocatedState::BothSynced( ItemState{ 471 | calendar: cal1.clone(), 472 | name: String::from("Task B1"), 473 | completed: false, 474 | }), 475 | } 476 | ); 477 | 478 | tasks 479 | } 480 | 481 | /// This scenario basically checks a first sync to an empty server 482 | pub fn scenarii_first_sync_to_server() -> Vec { 483 | let mut tasks = Vec::new(); 484 | 485 | let cal3 = Url::from("https://some.calend.ar/third/".parse().unwrap()); 486 | let cal4 = Url::from("https://some.calend.ar/fourth/".parse().unwrap()); 487 | 488 | tasks.push( 489 | ItemScenario { 490 | url: random_url(&cal3), 491 | initial_state: LocatedState::Local( ItemState{ 492 | calendar: cal3.clone(), 493 | name: String::from("Task A3"), 494 | completed: false, 495 | }), 496 | local_changes_to_apply: Vec::new(), 497 | remote_changes_to_apply: Vec::new(), 498 | after_sync: LocatedState::BothSynced( ItemState{ 499 | calendar: cal3.clone(), 500 | name: String::from("Task A3"), 501 | completed: false, 502 | }), 503 | } 504 | ); 505 | 506 | tasks.push( 507 | ItemScenario { 508 | url: random_url(&cal4), 509 | initial_state: LocatedState::Local( ItemState{ 510 | calendar: cal4.clone(), 511 | name: String::from("Task A4"), 512 | completed: false, 513 | }), 514 | local_changes_to_apply: Vec::new(), 515 | remote_changes_to_apply: Vec::new(), 516 | after_sync: LocatedState::BothSynced( ItemState{ 517 | calendar: cal4.clone(), 518 | name: String::from("Task A4"), 519 | completed: false, 520 | }), 521 | } 522 | ); 523 | 524 | tasks.push( 525 | ItemScenario { 526 | url: random_url(&cal3), 527 | initial_state: LocatedState::Local( ItemState{ 528 | calendar: cal3.clone(), 529 | name: String::from("Task B3"), 530 | completed: false, 531 | }), 532 | local_changes_to_apply: Vec::new(), 533 | remote_changes_to_apply: Vec::new(), 534 | after_sync: LocatedState::BothSynced( ItemState{ 535 | calendar: cal3.clone(), 536 | name: String::from("Task B3"), 537 | completed: false, 538 | }), 539 | } 540 | ); 541 | 542 | tasks 543 | } 544 | 545 | 546 | /// This scenario tests a task added and deleted before a sync happens 547 | pub fn scenarii_transient_task() -> Vec { 548 | let mut tasks = Vec::new(); 549 | 550 | let cal = Url::from("https://some.calend.ar/transient/".parse().unwrap()); 551 | 552 | tasks.push( 553 | ItemScenario { 554 | url: random_url(&cal), 555 | initial_state: LocatedState::Local( ItemState{ 556 | calendar: cal.clone(), 557 | name: String::from("A task, so that the calendar actually exists"), 558 | completed: false, 559 | }), 560 | local_changes_to_apply: Vec::new(), 561 | remote_changes_to_apply: Vec::new(), 562 | after_sync: LocatedState::BothSynced( ItemState{ 563 | calendar: cal.clone(), 564 | name: String::from("A task, so that the calendar actually exists"), 565 | completed: false, 566 | }), 567 | } 568 | ); 569 | 570 | let url_transient = random_url(&cal); 571 | tasks.push( 572 | ItemScenario { 573 | url: url_transient.clone(), 574 | initial_state: LocatedState::None, 575 | local_changes_to_apply: vec![ 576 | ChangeToApply::Create(cal, Item::Task( 577 | Task::new_with_parameters( 578 | String::from("A transient task that will be deleted before the sync"), 579 | url_transient.to_string(), url_transient, 580 | CompletionStatus::Uncompleted, 581 | SyncStatus::NotSynced, Some(Utc::now()), Utc::now(), 582 | "prod_id".to_string(), Vec::new() ) 583 | )), 584 | 585 | ChangeToApply::Rename(String::from("A new name")), 586 | ChangeToApply::SetCompletion(true), 587 | ChangeToApply::Remove, 588 | ], 589 | remote_changes_to_apply: Vec::new(), 590 | after_sync: LocatedState::None, 591 | } 592 | ); 593 | 594 | tasks 595 | } 596 | 597 | 598 | /// Build a `Provider` that contains the data (defined in the given scenarii) before sync 599 | pub async fn populate_test_provider_before_sync(scenarii: &[ItemScenario], mock_behaviour: Arc>) -> Provider { 600 | let mut provider = populate_test_provider(scenarii, mock_behaviour, false).await; 601 | apply_changes_on_provider(&mut provider, scenarii).await; 602 | provider 603 | } 604 | 605 | /// Build a `Provider` that contains the data (defined in the given scenarii) after sync 606 | pub async fn populate_test_provider_after_sync(scenarii: &[ItemScenario], mock_behaviour: Arc>) -> Provider { 607 | populate_test_provider(scenarii, mock_behaviour, true).await 608 | } 609 | 610 | async fn populate_test_provider(scenarii: &[ItemScenario], mock_behaviour: Arc>, populate_for_final_state: bool) -> Provider { 611 | let mut local = Cache::new(&PathBuf::from(String::from("test_cache/local/"))); 612 | let mut remote = Cache::new(&PathBuf::from(String::from("test_cache/remote/"))); 613 | remote.set_mock_behaviour(Some(mock_behaviour)); 614 | 615 | // Create the initial state, as if we synced both sources in a given state 616 | for item in scenarii { 617 | let required_state = if populate_for_final_state { &item.after_sync } else { &item.initial_state }; 618 | let (state, sync_status) = match required_state { 619 | LocatedState::None => continue, 620 | LocatedState::Local(s) => { 621 | assert!(populate_for_final_state == false, "You are not supposed to expect an item in this state after sync"); 622 | (s, SyncStatus::NotSynced) 623 | }, 624 | LocatedState::Remote(s) => { 625 | assert!(populate_for_final_state == false, "You are not supposed to expect an item in this state after sync"); 626 | (s, SyncStatus::random_synced()) 627 | } 628 | LocatedState::BothSynced(s) => (s, SyncStatus::random_synced()), 629 | }; 630 | 631 | let now = Utc::now(); 632 | let completion_status = match state.completed { 633 | false => CompletionStatus::Uncompleted, 634 | true => CompletionStatus::Completed(Some(now)), 635 | }; 636 | 637 | let new_item = Item::Task( 638 | Task::new_with_parameters( 639 | state.name.clone(), 640 | item.url.to_string(), 641 | item.url.clone(), 642 | completion_status, 643 | sync_status, 644 | Some(now), 645 | now, 646 | "prod_id".to_string(), Vec::new(), 647 | )); 648 | 649 | match required_state { 650 | LocatedState::None => panic!("Should not happen, we've continued already"), 651 | LocatedState::Local(s) => { 652 | get_or_insert_calendar(&mut local, &s.calendar).await.unwrap().lock().unwrap().add_item(new_item).await.unwrap(); 653 | }, 654 | LocatedState::Remote(s) => { 655 | get_or_insert_calendar(&mut remote, &s.calendar).await.unwrap().lock().unwrap().add_item(new_item).await.unwrap(); 656 | }, 657 | LocatedState::BothSynced(s) => { 658 | get_or_insert_calendar(&mut local, &s.calendar).await.unwrap().lock().unwrap().add_item(new_item.clone()).await.unwrap(); 659 | get_or_insert_calendar(&mut remote, &s.calendar).await.unwrap().lock().unwrap().add_item(new_item).await.unwrap(); 660 | }, 661 | } 662 | } 663 | Provider::new(remote, local) 664 | } 665 | 666 | /// Apply `local_changes_to_apply` and `remote_changes_to_apply` to a provider that contains data before sync 667 | async fn apply_changes_on_provider(provider: &mut Provider, scenarii: &[ItemScenario]) { 668 | // Apply changes to each item 669 | for item in scenarii { 670 | let initial_calendar_url = match &item.initial_state { 671 | LocatedState::None => None, 672 | LocatedState::Local(state) => Some(state.calendar.clone()), 673 | LocatedState::Remote(state) => Some(state.calendar.clone()), 674 | LocatedState::BothSynced(state) => Some(state.calendar.clone()), 675 | }; 676 | 677 | let mut calendar_url = initial_calendar_url.clone(); 678 | for local_change in &item.local_changes_to_apply { 679 | calendar_url = Some(apply_change(provider.local(), calendar_url, &item.url, local_change, false).await); 680 | } 681 | 682 | let mut calendar_url = initial_calendar_url; 683 | for remote_change in &item.remote_changes_to_apply { 684 | calendar_url = Some(apply_change(provider.remote(), calendar_url, &item.url, remote_change, true).await); 685 | } 686 | } 687 | } 688 | 689 | async fn get_or_insert_calendar(source: &mut Cache, url: &Url) 690 | -> Result>, Box> 691 | { 692 | match source.get_calendar(url).await { 693 | Some(cal) => Ok(cal), 694 | None => { 695 | let new_name = format!("Test calendar for URL {}", url); 696 | let supported_components = SupportedComponents::TODO; 697 | let color = csscolorparser::parse("#ff8000").unwrap(); // TODO: we should rather have specific colors, depending on the calendars 698 | 699 | source.create_calendar( 700 | url.clone(), 701 | new_name.to_string(), 702 | supported_components, 703 | Some(color), 704 | ).await 705 | } 706 | } 707 | } 708 | 709 | /// Apply a single change on a given source, and returns the calendar URL that was modified 710 | async fn apply_change(source: &S, calendar_url: Option, item_url: &Url, change: &ChangeToApply, is_remote: bool) -> Url 711 | where 712 | S: CalDavSource, 713 | C: CompleteCalendar + DavCalendar, // in this test, we're using a calendar that mocks both kinds 714 | { 715 | match calendar_url { 716 | Some(cal) => { 717 | apply_changes_on_an_existing_item(source, &cal, item_url, change, is_remote).await; 718 | cal 719 | }, 720 | None => { 721 | create_test_item(source, change).await 722 | }, 723 | } 724 | } 725 | 726 | async fn apply_changes_on_an_existing_item(source: &S, calendar_url: &Url, item_url: &Url, change: &ChangeToApply, is_remote: bool) 727 | where 728 | S: CalDavSource, 729 | C: CompleteCalendar + DavCalendar, // in this test, we're using a calendar that mocks both kinds 730 | { 731 | let cal = source.get_calendar(calendar_url).await.unwrap(); 732 | let mut cal = cal.lock().unwrap(); 733 | let task = cal.get_item_by_url_mut(item_url).await.unwrap().unwrap_task_mut(); 734 | 735 | match change { 736 | ChangeToApply::Rename(new_name) => { 737 | if is_remote { 738 | task.mock_remote_calendar_set_name(new_name.clone()); 739 | } else { 740 | task.set_name(new_name.clone()); 741 | } 742 | }, 743 | ChangeToApply::SetCompletion(new_status) => { 744 | let completion_status = match new_status { 745 | false => CompletionStatus::Uncompleted, 746 | true => CompletionStatus::Completed(Some(Utc::now())), 747 | }; 748 | if is_remote { 749 | task.mock_remote_calendar_set_completion_status(completion_status); 750 | } else { 751 | task.set_completion_status(completion_status); 752 | } 753 | }, 754 | ChangeToApply::Remove => { 755 | match is_remote { 756 | false => cal.mark_for_deletion(item_url).await.unwrap(), 757 | true => cal.delete_item(item_url).await.unwrap(), 758 | }; 759 | }, 760 | ChangeToApply::Create(_calendar_url, _item) => { 761 | panic!("This function only handles already existing items"); 762 | }, 763 | } 764 | } 765 | 766 | /// Create an item, and returns the URL of the calendar it was inserted in 767 | async fn create_test_item(source: &S, change: &ChangeToApply) -> Url 768 | where 769 | S: CalDavSource, 770 | C: CompleteCalendar + DavCalendar, // in this test, we're using a calendar that mocks both kinds 771 | { 772 | match change { 773 | ChangeToApply::Rename(_) | 774 | ChangeToApply::SetCompletion(_) | 775 | ChangeToApply::Remove => { 776 | panic!("This function only creates items that do not exist yet"); 777 | } 778 | ChangeToApply::Create(calendar_url, item) => { 779 | let cal = source.get_calendar(calendar_url).await.unwrap(); 780 | cal.lock().unwrap().add_item(item.clone()).await.unwrap(); 781 | calendar_url.clone() 782 | }, 783 | } 784 | } 785 | --------------------------------------------------------------------------------