Compare commits

..

No commits in common. 'master' and 'github_actions' have entirely different histories.

  1. 513
      Cargo.lock
  2. 14
      Cargo.toml
  3. 12
      README.md
  4. 97
      examples/provider-sync.rs
  5. 54
      examples/shared.rs
  6. 65
      examples/toggle-completions.rs
  7. 388
      resources/kitchen-fridge.svg
  8. 51
      src/cache.rs
  9. 131
      src/calendar/cached_calendar.rs
  10. 11
      src/calendar/mod.rs
  11. 83
      src/calendar/remote_calendar.rs
  12. 46
      src/client.rs
  13. 15
      src/config.rs
  14. 10
      src/event.rs
  15. 8
      src/ical/builder.rs
  16. 2
      src/ical/mod.rs
  17. 48
      src/ical/parser.rs
  18. 74
      src/item.rs
  19. 16
      src/lib.rs
  20. 8
      src/mock_behaviour.rs
  21. 331
      src/provider/mod.rs
  22. 27
      src/provider/sync_progress.rs
  23. 24
      src/task.rs
  24. 64
      src/traits.rs
  25. 29
      src/utils/mod.rs
  26. 150
      tests/scenarii.rs
  27. 1
      tests/sync-reminder.rs
  28. 6
      tests/sync.rs

513
Cargo.lock generated

File diff suppressed because it is too large Load Diff

14
Cargo.toml

@ -1,14 +1,12 @@
[package] [package]
name = "kitchen-fridge" name = "kitchen-fridge"
version = "0.4.0" version = "0.1.0"
authors = ["daladim"] authors = ["daladim"]
edition = "2018" edition = "2018"
description = "A CalDAV (ical file management over WebDAV) library" description = "A CalDAV (ical file management over WebDAV) library"
repository = "https://github.com/daladim/kitchen-fridge" repository = "https://github.com/daladim/kitchen-fridge"
documentation = "https://docs.rs/kitchen-fridge"
license = "MIT" license = "MIT"
readme = "README.md" keywords = ["CalDAV", "client", "WebDAV", "todo", "RFC4791"]
keywords = ["CalDAV", "client", "WebDAV", "todo", "iCloud"]
categories = ["network-programming", "web-programming::http-client"] categories = ["network-programming", "web-programming::http-client"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -18,7 +16,7 @@ integration_tests = ["local_calendar_mocks_remote_calendars"]
local_calendar_mocks_remote_calendars = [] local_calendar_mocks_remote_calendars = []
[dependencies] [dependencies]
env_logger = "0.9" env_logger = "0.8"
log = "0.4" log = "0.4"
tokio = { version = "1.2", features = ["macros", "rt", "rt-multi-thread"]} tokio = { version = "1.2", features = ["macros", "rt", "rt-multi-thread"]}
reqwest = "0.11" reqwest = "0.11"
@ -30,9 +28,11 @@ serde_json = "1.0"
async-trait = "0.1" async-trait = "0.1"
uuid = { version = "0.8", features = ["v4"] } uuid = { version = "0.8", features = ["v4"] }
sanitize-filename = "0.3" sanitize-filename = "0.3"
ical-daladim = { version = "0.8", features = ["serde-derive"] } ical = { version = "0.6", features = ["serde-derive"] }
ics = "0.5" ics = "0.5"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
csscolorparser = { version = "0.5", features = ["serde"] } csscolorparser = { version = "0.5", features = ["serde"] }
once_cell = "1.8" once_cell = "1.8"
itertools = "0.10"
[patch.crates-io]
ical = { git = "https://github.com/daladim/ical-rs.git", branch = "ical_serde" }

12
README.md

@ -1,15 +1,5 @@
# kitchen-fridge # kitchen-fridge
<p align="center"> kitchen-fridge is a CalDAV (iCal file transfer over WebDAV) Rust library.
<img src="resources/kitchen-fridge.svg" alt="kitchen-fridge logo" width="250"/>
</p>
kitchen-fridge is a CalDAV (iCal file transfer over WebDAV) Rust client library.
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). \
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.
Its [documentation](https://docs.rs/kitchen-fridge/) is available on docs.rs. Its [documentation](https://docs.rs/kitchen-fridge/) is available on docs.rs.
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).

97
examples/provider-sync.rs

@ -1,71 +1,100 @@
//! This is an example of how kitchen-fridge can be used //! This is an example of how kitchen-fridge can be used
use std::path::Path;
use chrono::{Utc}; use chrono::{Utc};
use url::Url;
use kitchen_fridge::traits::CalDavSource; use kitchen_fridge::{client::Client, traits::CalDavSource};
use kitchen_fridge::calendar::SupportedComponents; use kitchen_fridge::calendar::{CalendarId, SupportedComponents};
use kitchen_fridge::Item; use kitchen_fridge::Item;
use kitchen_fridge::Task; use kitchen_fridge::Task;
use kitchen_fridge::task::CompletionStatus; use kitchen_fridge::task::CompletionStatus;
use kitchen_fridge::item::ItemId;
use kitchen_fridge::cache::Cache;
use kitchen_fridge::CalDavProvider; use kitchen_fridge::CalDavProvider;
use kitchen_fridge::traits::BaseCalendar; use kitchen_fridge::traits::BaseCalendar;
use kitchen_fridge::traits::CompleteCalendar; use kitchen_fridge::traits::CompleteCalendar;
use kitchen_fridge::utils::pause; use kitchen_fridge::utils::pause;
mod shared;
use shared::initial_sync;
use shared::{URL, USERNAME, EXAMPLE_EXISTING_CALENDAR_URL, EXAMPLE_CREATED_CALENDAR_URL};
const CACHE_FOLDER: &str = "test_cache/provider_sync"; const CACHE_FOLDER: &str = "test_cache/provider_sync";
// TODO: change these values with yours
pub const URL: &str = "https://my.server.com/remote.php/dav/files/john";
pub const USERNAME: &str = "username";
pub const PASSWORD: &str = "secret_password";
pub const EXAMPLE_TASK_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/6121A0BE-C2E0-4F16-A3FA-658E54E7062A/74439558-CDFF-426C-92CD-ECDDACE971B0.ics";
pub const EXAMPLE_EXISTING_CALENDAR_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/a_calendar_name/";
pub const EXAMPLE_CREATED_CALENDAR_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/a_calendar_that_we_have_created/";
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
env_logger::init(); env_logger::init();
println!("This example show how to sync a remote server with a local cache, using a Provider."); println!("This examples show how to sync a remote server with a local cache, using a Provider.");
println!("Make sure you have edited the constants in the 'shared.rs' file to include correct URLs and credentials."); println!("Make sure you have edited your settings.rs to include correct URLs and credentials.");
println!("You can also set the RUST_LOG environment variable to display more info about the sync."); println!("You can also set the RUST_LOG environment variable to display more info about the sync.");
println!("");
println!("This will use the following settings:");
println!(" * URL = {}", URL);
println!(" * USERNAME = {}", USERNAME);
println!(" * EXAMPLE_EXISTING_CALENDAR_URL = {}", EXAMPLE_EXISTING_CALENDAR_URL);
println!(" * EXAMPLE_CREATED_CALENDAR_URL = {}", EXAMPLE_CREATED_CALENDAR_URL);
pause(); pause();
let mut provider = initial_sync(CACHE_FOLDER).await; let cache_path = Path::new(CACHE_FOLDER);
let client = Client::new(URL, USERNAME, PASSWORD).unwrap();
let cache = match Cache::from_folder(&cache_path) {
Ok(cache) => cache,
Err(err) => {
log::warn!("Invalid cache file: {}. Using a default cache", err);
Cache::new(&cache_path)
}
};
let mut provider = CalDavProvider::new(client, cache);
let cals = provider.local().get_calendars().await.unwrap();
println!("---- Local items, before sync -----");
kitchen_fridge::utils::print_calendar_list(&cals).await;
println!("Starting a sync...");
if provider.sync().await == false {
log::warn!("Sync did not complete, see the previous log lines for more info. You can safely start a new sync.");
}
provider.local().save_to_folder().unwrap();
println!("---- Local items, after sync -----");
let cals = provider.local().get_calendars().await.unwrap();
kitchen_fridge::utils::print_calendar_list(&cals).await;
add_items_and_sync_again(&mut provider).await; add_items_and_sync_again(&mut provider).await;
} }
async fn add_items_and_sync_again(provider: &mut CalDavProvider) { async fn add_items_and_sync_again(provider: &mut CalDavProvider)
{
println!("\nNow, we'll add a calendar and a few tasks and run the sync again."); println!("\nNow, we'll add a calendar and a few tasks and run the sync again.");
pause(); pause();
// Create a new calendar... // Create a new calendar...
let new_calendar_url: Url = EXAMPLE_CREATED_CALENDAR_URL.parse().unwrap(); let new_calendar_id: CalendarId = EXAMPLE_CREATED_CALENDAR_URL.parse().unwrap();
let new_calendar_name = "A brave new calendar".to_string(); let new_calendar_name = "A brave new calendar".to_string();
if let Err(_err) = provider.local_mut() if let Err(_err) = provider.local_mut()
.create_calendar(new_calendar_url.clone(), new_calendar_name.clone(), SupportedComponents::TODO, Some("#ff8000".parse().unwrap())) .create_calendar(new_calendar_id.clone(), new_calendar_name.clone(), SupportedComponents::TODO, None)
.await { .await {
println!("Unable to add calendar, maybe it exists already. We're not adding it after all."); println!("Unable to add calendar, maybe it exists already. We're not adding it after all.");
} }
// ...and add a task in it // ...and add a task in it
let new_name = "This is a new task in a new calendar"; let new_name = "This is a new task in a new calendar";
let new_task = Task::new(String::from(new_name), true, &new_calendar_url); let new_task = Task::new(String::from(new_name), true, &new_calendar_id);
provider.local().get_calendar(&new_calendar_url).await.unwrap() provider.local().get_calendar(&new_calendar_id).await.unwrap()
.lock().unwrap().add_item(Item::Task(new_task)).await.unwrap(); .lock().unwrap().add_item(Item::Task(new_task)).await.unwrap();
// Also create a task in a previously existing calendar // Also create a task in a previously existing calendar
let changed_calendar_url: Url = EXAMPLE_EXISTING_CALENDAR_URL.parse().unwrap(); let changed_calendar_id: CalendarId = EXAMPLE_EXISTING_CALENDAR_URL.parse().unwrap();
let new_task_name = "This is a new task we're adding as an example, with ÜTF-8 characters"; let new_task_name = "This is a new task we're adding as an example, with ÜTF-8 characters";
let new_task = Task::new(String::from(new_task_name), false, &changed_calendar_url); let new_task = Task::new(String::from(new_task_name), false, &changed_calendar_id);
let new_url = new_task.url().clone(); let new_id = new_task.id().clone();
provider.local().get_calendar(&changed_calendar_url).await.unwrap() provider.local().get_calendar(&changed_calendar_id).await.unwrap()
.lock().unwrap().add_item(Item::Task(new_task)).await.unwrap(); .lock().unwrap().add_item(Item::Task(new_task)).await.unwrap();
@ -76,20 +105,20 @@ async fn add_items_and_sync_again(provider: &mut CalDavProvider) {
} }
provider.local().save_to_folder().unwrap(); provider.local().save_to_folder().unwrap();
complete_item_and_sync_again(provider, &changed_calendar_url, &new_url).await; complete_item_and_sync_again(provider, &changed_calendar_id, &new_id).await;
} }
async fn complete_item_and_sync_again( async fn complete_item_and_sync_again(
provider: &mut CalDavProvider, provider: &mut CalDavProvider,
changed_calendar_url: &Url, changed_calendar_id: &CalendarId,
url_to_complete: &Url) id_to_complete: &ItemId)
{ {
println!("\nNow, we'll mark this last task as completed, and run the sync again."); println!("\nNow, we'll mark this last task as completed, and run the sync again.");
pause(); pause();
let completion_status = CompletionStatus::Completed(Some(Utc::now())); let completion_status = CompletionStatus::Completed(Some(Utc::now()));
provider.local().get_calendar(changed_calendar_url).await.unwrap() provider.local().get_calendar(changed_calendar_id).await.unwrap()
.lock().unwrap().get_item_by_url_mut(url_to_complete).await.unwrap() .lock().unwrap().get_item_by_id_mut(id_to_complete).await.unwrap()
.unwrap_task_mut() .unwrap_task_mut()
.set_completion_status(completion_status); .set_completion_status(completion_status);
@ -100,19 +129,19 @@ async fn complete_item_and_sync_again(
} }
provider.local().save_to_folder().unwrap(); provider.local().save_to_folder().unwrap();
remove_items_and_sync_again(provider, changed_calendar_url, url_to_complete).await; remove_items_and_sync_again(provider, changed_calendar_id, id_to_complete).await;
} }
async fn remove_items_and_sync_again( async fn remove_items_and_sync_again(
provider: &mut CalDavProvider, provider: &mut CalDavProvider,
changed_calendar_url: &Url, changed_calendar_id: &CalendarId,
id_to_remove: &Url) id_to_remove: &ItemId)
{ {
println!("\nNow, we'll delete this last task, and run the sync again."); println!("\nNow, we'll delete this last task, and run the sync again.");
pause(); pause();
// Remove the task we had created // Remove the task we had created
provider.local().get_calendar(changed_calendar_url).await.unwrap() provider.local().get_calendar(changed_calendar_id).await.unwrap()
.lock().unwrap() .lock().unwrap()
.mark_for_deletion(id_to_remove).await.unwrap(); .mark_for_deletion(id_to_remove).await.unwrap();

54
examples/shared.rs

@ -1,54 +0,0 @@
use std::path::Path;
use kitchen_fridge::client::Client;
use kitchen_fridge::traits::CalDavSource;
use kitchen_fridge::CalDavProvider;
use kitchen_fridge::cache::Cache;
// TODO: change these values with yours
pub const URL: &str = "https://my.server.com/remote.php/dav/files/john";
pub const USERNAME: &str = "username";
pub const PASSWORD: &str = "secret_password";
pub const EXAMPLE_EXISTING_CALENDAR_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/a_calendar_name/";
pub const EXAMPLE_CREATED_CALENDAR_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/a_calendar_that_we_have_created/";
fn main() {
panic!("This file is not supposed to be executed");
}
/// Initializes a Provider, and run an initial sync from the server
pub async fn initial_sync(cache_folder: &str) -> CalDavProvider {
let cache_path = Path::new(cache_folder);
let client = Client::new(URL, USERNAME, PASSWORD).unwrap();
let cache = match Cache::from_folder(&cache_path) {
Ok(cache) => cache,
Err(err) => {
log::warn!("Invalid cache file: {}. Using a default cache", err);
Cache::new(&cache_path)
}
};
let mut provider = CalDavProvider::new(client, cache);
let cals = provider.local().get_calendars().await.unwrap();
println!("---- Local items, before sync -----");
kitchen_fridge::utils::print_calendar_list(&cals).await;
println!("Starting a sync...");
println!("Depending on your RUST_LOG value, you may see more or less details about the progress.");
// Note that we could use sync_with_feedback() to have better and formatted feedback
if provider.sync().await == false {
log::warn!("Sync did not complete, see the previous log lines for more info. You can safely start a new sync.");
}
provider.local().save_to_folder().unwrap();
println!("---- Local items, after sync -----");
let cals = provider.local().get_calendars().await.unwrap();
kitchen_fridge::utils::print_calendar_list(&cals).await;
provider
}

65
examples/toggle-completions.rs

@ -1,65 +0,0 @@
//! This is an example of how kitchen-fridge can be used.
//! This binary simply toggles all completion statuses of the tasks it finds.
use std::error::Error;
use chrono::Utc;
use kitchen_fridge::item::Item;
use kitchen_fridge::task::CompletionStatus;
use kitchen_fridge::CalDavProvider;
use kitchen_fridge::utils::pause;
mod shared;
use shared::initial_sync;
use shared::{URL, USERNAME};
const CACHE_FOLDER: &str = "test_cache/toggle_completion";
#[tokio::main]
async fn main() {
env_logger::init();
println!("This example show how to sync a remote server with a local cache, using a Provider.");
println!("Make sure you have edited the constants in the 'shared.rs' file to include correct URLs and credentials.");
println!("You can also set the RUST_LOG environment variable to display more info about the sync.");
println!("");
println!("This will use the following settings:");
println!(" * URL = {}", URL);
println!(" * USERNAME = {}", USERNAME);
pause();
let mut provider = initial_sync(CACHE_FOLDER).await;
toggle_all_tasks_and_sync_again(&mut provider).await.unwrap();
}
async fn toggle_all_tasks_and_sync_again(provider: &mut CalDavProvider) -> Result<(), Box<dyn Error>> {
let mut n_toggled = 0;
for (_url, cal) in provider.local().get_calendars_sync()?.iter() {
for (_url, item) in cal.lock().unwrap().get_items_mut_sync()?.iter_mut() {
match item {
Item::Task(task) => {
match task.completed() {
false => task.set_completion_status(CompletionStatus::Completed(Some(Utc::now()))),
true => task.set_completion_status(CompletionStatus::Uncompleted),
};
n_toggled += 1;
}
Item::Event(_) => {
// Not doing anything with calendar events
},
}
}
}
println!("{} items toggled.", n_toggled);
println!("Syncing...");
provider.sync().await;
println!("Syncing complete.");
Ok(())
}

388
resources/kitchen-fridge.svg

@ -1,388 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="212.79292mm"
height="212.79292mm"
viewBox="0 0 212.79291 212.79293"
version="1.1"
id="svg8"
sodipodi:docname="kitchen-fridge.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<defs
id="defs2">
<linearGradient
inkscape:collect="always"
id="linearGradient850-3">
<stop
style="stop-color:#555555;stop-opacity:1"
offset="0"
id="stop846" />
<stop
style="stop-color:#ffffff;stop-opacity:1"
offset="1"
id="stop848" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient850-3"
id="linearGradient852"
x1="-32.728302"
y1="114.98944"
x2="159.82718"
y2="235.52802"
gradientUnits="userSpaceOnUse" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.7"
inkscape:cx="732.34868"
inkscape:cy="461.7807"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="2510"
inkscape:window-height="1376"
inkscape:window-x="1970"
inkscape:window-y="27"
inkscape:window-maximized="1"
fit-margin-top="0.1"
fit-margin-left="42.4"
fit-margin-right="42.4"
fit-margin-bottom="0.1" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer5"
inkscape:label="Fond"
style="display:none">
<rect
style="fill:#102335;fill-opacity:1;stroke:#b40f00;stroke-width:0.26499999;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect867"
width="213.55655"
height="212.42261"
x="0"
y="0.37030393" />
</g>
<g
inkscape:label="Fridge"
inkscape:groupmode="layer"
id="layer1"
style="display:inline"
transform="translate(9.3800012,-27.370898)">
<rect
style="fill:url(#linearGradient852);fill-opacity:1;stroke:#000000;stroke-width:2.38100004;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect815"
width="125.61668"
height="206.86662"
x="34.210499"
y="28.661392"
ry="20.312485" />
<rect
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:1.14329159;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect858"
width="125.61105"
height="5.1245685"
x="34.210815"
y="154.2338" />
</g>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="Handles"
style="display:inline"
transform="translate(9.3800012,-27.370898)">
<rect
style="display:inline;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect829"
width="1.0690781"
height="66.282845"
x="147.5274"
y="80.2659"
ry="0.53453904" />
<rect
style="display:inline;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.16825604;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect829-3"
width="1.4010528"
height="36.142323"
x="147.30231"
y="165.60844"
ry="0.29147035" />
</g>
<g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="Notes"
style="display:inline"
transform="translate(9.3800012,-27.370898)">
<path
style="fill:#000000;fill-opacity:0.27699531;stroke:none;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 72.822459,64.133289 17.55153,-1.762358 1.28192,19.265199 -17.80831,1.703646 z"
id="rect850-6-3"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<rect
style="fill:#baf0ff;fill-opacity:1;stroke:none;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect850-6"
width="17.639786"
height="18.441595"
x="66.050667"
y="71.087967"
ry="0"
transform="rotate(-5.7338792)" />
<path
style="fill:#000000;fill-opacity:0.37089203;stroke:none;stroke-width:1.43235683;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 57.052763,52.364374 16.446352,-0.717802 0.746667,19.117834 -17.908228,0.01483 z"
id="rect850-5"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<rect
style="fill:#fffaba;fill-opacity:1;stroke:none;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect850"
width="17.639786"
height="18.441595"
x="55.859333"
y="51.646572"
ry="0" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1050"
width="11.091683"
height="0.6681723"
x="69.741707"
y="74.331406"
transform="rotate(-5.3501722)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1050-6"
width="11.091683"
height="0.6681723"
x="69.741707"
y="76.97068"
transform="rotate(-5.3501722)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1050-2"
width="11.091683"
height="0.6681723"
x="69.741707"
y="79.609978"
transform="rotate(-5.3501722)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1050-9"
width="11.091683"
height="0.6681723"
x="69.741707"
y="82.249275"
transform="rotate(-5.3501722)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1050-1"
width="11.091683"
height="0.6681723"
x="69.741707"
y="84.88855"
transform="rotate(-5.3501722)" />
<path
style="fill:#000000;fill-opacity:0.37089203;stroke:none;stroke-width:1.43235695;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 112.99958,47.239269 16.44636,-0.717802 0.74666,19.117834 -17.90823,0.01483 z"
id="rect850-5-2"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<rect
style="fill:#ffbaf2;fill-opacity:1;stroke:none;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect850-6-7"
width="17.639786"
height="18.441595"
x="111.91032"
y="46.230576"
ry="0"
transform="rotate(0.12870348)" />
<path
sodipodi:type="star"
style="display:inline;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path1106"
sodipodi:sides="5"
sodipodi:cx="116.59633"
sodipodi:cy="51.980659"
sodipodi:r1="5.158371"
sodipodi:r2="2.5791855"
sodipodi:arg1="0.93247652"
sodipodi:arg2="1.560795"
inkscape:flatsided="false"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 119.66993,56.123337 -3.04781,-1.563621 -3.01592,1.624268 0.54527,-3.38182 -2.47674,-2.366385 3.3848,-0.526458 1.48521,-3.086775 1.54665,3.056451 3.39465,0.458653 -2.42891,2.415449 z"
inkscape:transform-center-x="0.0074469719"
inkscape:transform-center-y="-0.21178579"
transform="matrix(0.46706944,0,0,0.44362881,61.135486,28.319186)" />
<circle
style="display:inline;fill:#000000;fill-opacity:0.28169017;stroke:none;stroke-width:0.94067413;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path1113-7"
cx="100.86058"
cy="96.979729"
r="1.4098805" />
<path
style="display:inline;fill:#000000;fill-opacity:0.4600939;stroke:none;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 99.418533,101.28282 25.586307,-0.99219 -0.90343,31.4674 -25.675065,0.0391 z"
id="rect1109-3"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccc" />
<rect
style="display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect1109"
width="26.058779"
height="35.947754"
x="99.023354"
y="94.944237"
ry="0" />
<circle
style="display:inline;fill:#000000;fill-opacity:0.28169017;stroke:none;stroke-width:0.94067413;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path1113-7-0"
cx="124.49057"
cy="95.336449"
r="1.4098805" />
<circle
style="display:inline;fill:#20b73e;fill-opacity:1;stroke:none;stroke-width:0.94067413;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path1113-9"
cx="124.63935"
cy="95.253876"
r="1.4098805" />
<path
style="display:inline;fill:none;stroke:#b6b6b6;stroke-width:0.26499999;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 104.55766,109.85454 2.45685,12.70945"
id="path1326"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#b6b6b6;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 102.2898,115.38244 11.29205,-4.48847"
id="path1359"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#b6b6b6;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 103.8962,120.01265 10.06362,-3.77976"
id="path1361"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#b6b6b6;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 108.52641,109.85454 3.07106,9.82738"
id="path1363"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#b40f00;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 110.27455,111.2247 1.79539,-3.07106"
id="path1367"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#b40f00;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 109.6131,109.19308 2.50409,1.5119"
id="path1369"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#b40f00;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 102.80952,113.6343 1.03944,-2.45685"
id="path1371"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#b40f00;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 101.95908,112.16964 2.4096,1.18118"
id="path1373"
inkscape:connector-curvature="0" />
<ellipse
style="fill:none;fill-opacity:0.4600939;stroke:#4500a3;stroke-width:0.26499999;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path1375"
cx="108.47916"
cy="115.9494"
rx="1.0866816"
ry="1.2284226" />
<ellipse
style="fill:none;fill-opacity:0.4600939;stroke:#4500a3;stroke-width:0.26499999;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path1377"
cx="106.77827"
cy="111.53181"
rx="1.1811756"
ry="1.0158111" />
<ellipse
style="fill:none;fill-opacity:0.4600939;stroke:#4500a3;stroke-width:0.26499999;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path1381"
cx="104.67578"
cy="122.39862"
rx="1.3937871"
ry="1.2047991" />
<ellipse
style="fill:none;fill-opacity:0.4600939;stroke:#4500a3;stroke-width:0.26499999;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path1383"
cx="109.32961"
cy="120.88672"
rx="1.2284226"
ry="1.4410343" />
<path
style="fill:none;stroke:#b40f00;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 113.01488,120.6741 0.0472,-3.07105"
id="path1385"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#b40f00;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 111.59747,117.79204 2.55134,1.98437"
id="path1387"
inkscape:connector-curvature="0" />
<circle
style="display:inline;fill:#000000;fill-opacity:0.28169017;stroke:none;stroke-width:0.94067413;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path1113-7-0-6"
cx="100.85116"
cy="96.796585"
r="1.4098805" />
<circle
style="display:inline;fill:#20b73e;fill-opacity:1;stroke:none;stroke-width:0.94067413;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="path1113-9-2"
cx="100.99994"
cy="96.714012"
r="1.4098805" />
</g>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Footstands"
style="display:inline"
transform="translate(9.3800012,-27.370898)">
<rect
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect4623"
width="7.7508163"
height="2.6726952"
x="54.030792"
y="236.70012" />
<rect
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:1.38199997;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
id="rect4623-6"
width="7.7508163"
height="2.6726952"
x="133.54349"
y="236.70012" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 15 KiB

51
src/cache.rs

@ -10,12 +10,12 @@ use std::ffi::OsStr;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use async_trait::async_trait; use async_trait::async_trait;
use csscolorparser::Color; use csscolorparser::Color;
use url::Url;
use crate::traits::CalDavSource; use crate::traits::CalDavSource;
use crate::traits::BaseCalendar; use crate::traits::BaseCalendar;
use crate::traits::CompleteCalendar; use crate::traits::CompleteCalendar;
use crate::calendar::cached_calendar::CachedCalendar; use crate::calendar::cached_calendar::CachedCalendar;
use crate::calendar::CalendarId;
use crate::calendar::SupportedComponents; use crate::calendar::SupportedComponents;
#[cfg(feature = "local_calendar_mocks_remote_calendars")] #[cfg(feature = "local_calendar_mocks_remote_calendars")]
@ -27,8 +27,7 @@ const MAIN_FILE: &str = "data.json";
/// ///
/// It automatically updates the content of the folder when dropped (see its `Drop` implementation), but you can also manually call [`Cache::save_to_folder`] /// It automatically updates the content of the folder when dropped (see its `Drop` implementation), but you can also manually call [`Cache::save_to_folder`]
/// ///
/// Most of its functionality is provided by the `CalDavSource` async trait it implements. /// Most of its methods are part of the `CalDavSource` trait implementation
/// 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
#[derive(Debug)] #[derive(Debug)]
pub struct Cache { pub struct Cache {
backing_folder: PathBuf, backing_folder: PathBuf,
@ -42,7 +41,7 @@ pub struct Cache {
#[derive(Default, Debug, Serialize, Deserialize)] #[derive(Default, Debug, Serialize, Deserialize)]
struct CachedData { struct CachedData {
#[serde(skip)] #[serde(skip)]
calendars: HashMap<Url, Arc<Mutex<CachedCalendar>>>, calendars: HashMap<CalendarId, Arc<Mutex<CachedCalendar>>>,
} }
impl Cache { impl Cache {
@ -87,7 +86,7 @@ impl Cache {
continue; continue;
}, },
Ok(cal) => Ok(cal) =>
data.calendars.insert(cal.url().clone(), Arc::new(Mutex::new(cal))), data.calendars.insert(cal.id().clone(), Arc::new(Mutex::new(cal))),
}; };
} }
}, },
@ -132,8 +131,8 @@ impl Cache {
serde_json::to_writer(file, &self.data)?; serde_json::to_writer(file, &self.data)?;
// Save each calendar // Save each calendar
for (cal_url, cal_mutex) in &self.data.calendars { for (cal_id, cal_mutex) in &self.data.calendars {
let file_name = sanitize_filename::sanitize(cal_url.as_str()) + ".cal"; let file_name = sanitize_filename::sanitize(cal_id.as_str()) + ".cal";
let cal_file = folder.join(file_name); let cal_file = folder.join(file_name);
let file = std::fs::File::create(&cal_file)?; let file = std::fs::File::create(&cal_file)?;
let cal = cal_mutex.lock().unwrap(); let cal = cal_mutex.lock().unwrap();
@ -157,10 +156,10 @@ impl Cache {
return Ok(false); return Ok(false);
} }
for (calendar_url, cal_l) in calendars_l { for (calendar_id, cal_l) in calendars_l {
log::debug!("Comparing calendars {}", calendar_url); log::debug!("Comparing calendars {}", calendar_id);
let cal_l = cal_l.lock().unwrap(); let cal_l = cal_l.lock().unwrap();
let cal_r = match calendars_r.get(&calendar_url) { let cal_r = match calendars_r.get(&calendar_id) {
Some(c) => c.lock().unwrap(), Some(c) => c.lock().unwrap(),
None => return Err("should not happen, we've just tested keys are the same".into()), None => return Err("should not happen, we've just tested keys are the same".into()),
}; };
@ -185,39 +184,37 @@ impl Drop for Cache {
} }
impl Cache { impl Cache {
/// The non-async version of [`crate::traits::CalDavSource::get_calendars`] pub fn get_calendars_sync(&self) -> Result<HashMap<CalendarId, Arc<Mutex<CachedCalendar>>>, Box<dyn Error>> {
pub fn get_calendars_sync(&self) -> Result<HashMap<Url, Arc<Mutex<CachedCalendar>>>, Box<dyn Error>> {
#[cfg(feature = "local_calendar_mocks_remote_calendars")] #[cfg(feature = "local_calendar_mocks_remote_calendars")]
self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_get_calendars())?; self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_get_calendars())?;
Ok(self.data.calendars.iter() Ok(self.data.calendars.iter()
.map(|(url, cal)| (url.clone(), cal.clone())) .map(|(id, cal)| (id.clone(), cal.clone()))
.collect() .collect()
) )
} }
/// The non-async version of [`crate::traits::CalDavSource::get_calendar`] pub fn get_calendar_sync(&self, id: &CalendarId) -> Option<Arc<Mutex<CachedCalendar>>> {
pub fn get_calendar_sync(&self, url: &Url) -> Option<Arc<Mutex<CachedCalendar>>> { self.data.calendars.get(id).map(|arc| arc.clone())
self.data.calendars.get(url).map(|arc| arc.clone())
} }
} }
#[async_trait] #[async_trait]
impl CalDavSource<CachedCalendar> for Cache { impl CalDavSource<CachedCalendar> for Cache {
async fn get_calendars(&self) -> Result<HashMap<Url, Arc<Mutex<CachedCalendar>>>, Box<dyn Error>> { async fn get_calendars(&self) -> Result<HashMap<CalendarId, Arc<Mutex<CachedCalendar>>>, Box<dyn Error>> {
self.get_calendars_sync() self.get_calendars_sync()
} }
async fn get_calendar(&self, url: &Url) -> Option<Arc<Mutex<CachedCalendar>>> { async fn get_calendar(&self, id: &CalendarId) -> Option<Arc<Mutex<CachedCalendar>>> {
self.get_calendar_sync(url) self.get_calendar_sync(id)
} }
async fn create_calendar(&mut self, url: Url, name: String, supported_components: SupportedComponents, color: Option<Color>) -> Result<Arc<Mutex<CachedCalendar>>, Box<dyn Error>> { async fn create_calendar(&mut self, id: CalendarId, name: String, supported_components: SupportedComponents, color: Option<Color>) -> Result<Arc<Mutex<CachedCalendar>>, Box<dyn Error>> {
log::debug!("Inserting local calendar {}", url); log::debug!("Inserting local calendar {}", id);
#[cfg(feature = "local_calendar_mocks_remote_calendars")] #[cfg(feature = "local_calendar_mocks_remote_calendars")]
self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_create_calendar())?; self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_create_calendar())?;
let new_calendar = CachedCalendar::new(name, url.clone(), supported_components, color); let new_calendar = CachedCalendar::new(name, id.clone(), supported_components, color);
let arc = Arc::new(Mutex::new(new_calendar)); let arc = Arc::new(Mutex::new(new_calendar));
#[cfg(feature = "local_calendar_mocks_remote_calendars")] #[cfg(feature = "local_calendar_mocks_remote_calendars")]
@ -225,7 +222,7 @@ impl CalDavSource<CachedCalendar> for Cache {
arc.lock().unwrap().set_mock_behaviour(Some(Arc::clone(behaviour))); arc.lock().unwrap().set_mock_behaviour(Some(Arc::clone(behaviour)));
}; };
match self.data.calendars.insert(url, arc.clone()) { match self.data.calendars.insert(id, arc.clone()) {
Some(_) => Err("Attempt to insert calendar failed: there is alredy such a calendar.".into()), Some(_) => Err("Attempt to insert calendar failed: there is alredy such a calendar.".into()),
None => Ok(arc), None => Ok(arc),
} }
@ -260,13 +257,13 @@ mod tests {
{ {
let mut bucket_list = bucket_list.lock().unwrap(); let mut bucket_list = bucket_list.lock().unwrap();
let cal_url = bucket_list.url().clone(); let cal_id = bucket_list.id().clone();
bucket_list.add_item(Item::Task(Task::new( bucket_list.add_item(Item::Task(Task::new(
String::from("Attend a concert of JS Bach"), false, &cal_url String::from("Attend a concert of JS Bach"), false, &cal_id
))).await.unwrap(); ))).await.unwrap();
bucket_list.add_item(Item::Task(Task::new( bucket_list.add_item(Item::Task(Task::new(
String::from("Climb the Lighthouse of Alexandria"), true, &cal_url String::from("Climb the Lighthouse of Alexandria"), true, &cal_id
))).await.unwrap(); ))).await.unwrap();
} }
@ -294,7 +291,7 @@ mod tests {
let cache_path = PathBuf::from(String::from("test_cache/sanity_tests")); let cache_path = PathBuf::from(String::from("test_cache/sanity_tests"));
let mut cache = populate_cache(&cache_path).await; let mut cache = populate_cache(&cache_path).await;
// We should not be able to add a second calendar with the same URL // We should not be able to add a second calendar with the same id
let second_addition_same_calendar = cache.create_calendar( let second_addition_same_calendar = cache.create_calendar(
Url::parse("https://caldav.com/shopping").unwrap(), Url::parse("https://caldav.com/shopping").unwrap(),
"My shopping list".to_string(), "My shopping list".to_string(),

131
src/calendar/cached_calendar.rs

@ -4,12 +4,12 @@ use std::error::Error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use async_trait::async_trait; use async_trait::async_trait;
use csscolorparser::Color; use csscolorparser::Color;
use url::Url;
use crate::item::SyncStatus; use crate::item::SyncStatus;
use crate::traits::{BaseCalendar, CompleteCalendar}; use crate::traits::{BaseCalendar, CompleteCalendar};
use crate::calendar::SupportedComponents; use crate::calendar::{CalendarId, SupportedComponents};
use crate::Item; use crate::Item;
use crate::item::ItemId;
#[cfg(feature = "local_calendar_mocks_remote_calendars")] #[cfg(feature = "local_calendar_mocks_remote_calendars")]
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -19,19 +19,18 @@ use crate::mock_behaviour::MockBehaviour;
/// A calendar used by the [`cache`](crate::cache) module /// A calendar used by the [`cache`](crate::cache) module
/// ///
/// Most of its functionality is provided by the async traits it implements. /// Most of its methods are part of traits implementations
/// 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
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CachedCalendar { pub struct CachedCalendar {
name: String, name: String,
url: Url, id: CalendarId,
supported_components: SupportedComponents, supported_components: SupportedComponents,
color: Option<Color>, color: Option<Color>,
#[cfg(feature = "local_calendar_mocks_remote_calendars")] #[cfg(feature = "local_calendar_mocks_remote_calendars")]
#[serde(skip)] #[serde(skip)]
mock_behaviour: Option<Arc<Mutex<MockBehaviour>>>, mock_behaviour: Option<Arc<Mutex<MockBehaviour>>>,
items: HashMap<Url, Item>, items: HashMap<ItemId, Item>,
} }
impl CachedCalendar { impl CachedCalendar {
@ -66,7 +65,7 @@ impl CachedCalendar {
fn regular_add_or_update_item(&mut self, item: Item) -> Result<SyncStatus, Box<dyn Error>> { fn regular_add_or_update_item(&mut self, item: Item) -> Result<SyncStatus, Box<dyn Error>> {
let ss_clone = item.sync_status().clone(); let ss_clone = item.sync_status().clone();
log::debug!("Adding or updating an item with {:?}", ss_clone); log::debug!("Adding or updating an item with {:?}", ss_clone);
self.items.insert(item.url().clone(), item); self.items.insert(item.id().clone(), item);
Ok(ss_clone) Ok(ss_clone)
} }
@ -79,7 +78,7 @@ impl CachedCalendar {
_ => item.set_sync_status(SyncStatus::random_synced()), _ => item.set_sync_status(SyncStatus::random_synced()),
}; };
let ss_clone = item.sync_status().clone(); let ss_clone = item.sync_status().clone();
self.items.insert(item.url().clone(), item); self.items.insert(item.id().clone(), item);
Ok(ss_clone) Ok(ss_clone)
} }
@ -87,7 +86,7 @@ impl CachedCalendar {
#[cfg(any(test, feature = "integration_tests"))] #[cfg(any(test, feature = "integration_tests"))]
pub async fn has_same_observable_content_as(&self, other: &CachedCalendar) -> Result<bool, Box<dyn Error>> { pub async fn has_same_observable_content_as(&self, other: &CachedCalendar) -> Result<bool, Box<dyn Error>> {
if self.name != other.name if self.name != other.name
|| self.url != other.url || self.id != other.id
|| self.supported_components != other.supported_components || self.supported_components != other.supported_components
|| self.color != other.color || self.color != other.color
{ {
@ -103,13 +102,13 @@ impl CachedCalendar {
log::debug!("Different keys for items"); log::debug!("Different keys for items");
return Ok(false); return Ok(false);
} }
for (url_l, item_l) in items_l { for (id_l, item_l) in items_l {
let item_r = match items_r.get(&url_l) { let item_r = match items_r.get(&id_l) {
Some(c) => c, Some(c) => c,
None => return Err("should not happen, we've just tested keys are the same".into()), None => return Err("should not happen, we've just tested keys are the same".into()),
}; };
if item_l.has_same_observable_content_as(&item_r) == false { if item_l.has_same_observable_content_as(&item_r) == false {
log::debug!("Different items for URL {}:", url_l); log::debug!("Different items for id {}:", id_l);
log::debug!("{:#?}", item_l); log::debug!("{:#?}", item_l);
log::debug!("{:#?}", item_r); log::debug!("{:#?}", item_r);
return Ok(false); return Ok(false);
@ -119,44 +118,36 @@ impl CachedCalendar {
Ok(true) Ok(true)
} }
/// The non-async version of [`Self::get_item_urls`] /// The non-async version of [`Self::get_item_ids`]
pub fn get_item_urls_sync(&self) -> Result<HashSet<Url>, Box<dyn Error>> { pub fn get_item_ids_sync(&self) -> Result<HashSet<ItemId>, Box<dyn Error>> {
Ok(self.items.iter() Ok(self.items.iter()
.map(|(url, _)| url.clone()) .map(|(id, _)| id.clone())
.collect() .collect()
) )
} }
/// The non-async version of [`Self::get_items`] /// The non-async version of [`Self::get_items`]
pub fn get_items_sync(&self) -> Result<HashMap<Url, &Item>, Box<dyn Error>> { pub fn get_items_sync(&self) -> Result<HashMap<ItemId, &Item>, Box<dyn Error>> {
Ok(self.items.iter() Ok(self.items.iter()
.map(|(url, item)| (url.clone(), item)) .map(|(id, item)| (id.clone(), item))
.collect() .collect()
) )
} }
/// The non-async version of [`Self::get_items_mut`] /// The non-async version of [`Self::get_item_by_id`]
pub fn get_items_mut_sync(&mut self) -> Result<HashMap<Url, &mut Item>, Box<dyn Error>> { pub fn get_item_by_id_sync<'a>(&'a self, id: &ItemId) -> Option<&'a Item> {
Ok(self.items.iter_mut() self.items.get(id)
.map(|(url, item)| (url.clone(), item))
.collect()
)
} }
/// The non-async version of [`Self::get_item_by_url`] /// The non-async version of [`Self::get_item_by_id_mut`]
pub fn get_item_by_url_sync<'a>(&'a self, url: &Url) -> Option<&'a Item> { pub fn get_item_by_id_mut_sync<'a>(&'a mut self, id: &ItemId) -> Option<&'a mut Item> {
self.items.get(url) self.items.get_mut(id)
}
/// The non-async version of [`Self::get_item_by_url_mut`]
pub fn get_item_by_url_mut_sync<'a>(&'a mut self, url: &Url) -> Option<&'a mut Item> {
self.items.get_mut(url)
} }
/// The non-async version of [`Self::add_item`] /// The non-async version of [`Self::add_item`]
pub fn add_item_sync(&mut self, item: Item) -> Result<SyncStatus, Box<dyn Error>> { pub fn add_item_sync(&mut self, item: Item) -> Result<SyncStatus, Box<dyn Error>> {
if self.items.contains_key(item.url()) { if self.items.contains_key(item.id()) {
return Err(format!("Item {:?} cannot be added, it exists already", item.url()).into()); return Err(format!("Item {:?} cannot be added, it exists already", item.id()).into());
} }
#[cfg(not(feature = "local_calendar_mocks_remote_calendars"))] #[cfg(not(feature = "local_calendar_mocks_remote_calendars"))]
return self.regular_add_or_update_item(item); return self.regular_add_or_update_item(item);
@ -167,8 +158,8 @@ impl CachedCalendar {
/// The non-async version of [`Self::update_item`] /// The non-async version of [`Self::update_item`]
pub fn update_item_sync(&mut self, item: Item) -> Result<SyncStatus, Box<dyn Error>> { pub fn update_item_sync(&mut self, item: Item) -> Result<SyncStatus, Box<dyn Error>> {
if self.items.contains_key(item.url()) == false { if self.items.contains_key(item.id()) == false {
return Err(format!("Item {:?} cannot be updated, it does not already exist", item.url()).into()); return Err(format!("Item {:?} cannot be updated, it does not already exist", item.id()).into());
} }
#[cfg(not(feature = "local_calendar_mocks_remote_calendars"))] #[cfg(not(feature = "local_calendar_mocks_remote_calendars"))]
return self.regular_add_or_update_item(item); return self.regular_add_or_update_item(item);
@ -178,8 +169,8 @@ impl CachedCalendar {
} }
/// The non-async version of [`Self::mark_for_deletion`] /// The non-async version of [`Self::mark_for_deletion`]
pub fn mark_for_deletion_sync(&mut self, item_url: &Url) -> Result<(), Box<dyn Error>> { pub fn mark_for_deletion_sync(&mut self, item_id: &ItemId) -> Result<(), Box<dyn Error>> {
match self.items.get_mut(item_url) { match self.items.get_mut(item_id) {
None => Err("no item for this key".into()), None => Err("no item for this key".into()),
Some(item) => { Some(item) => {
match item.sync_status() { match item.sync_status() {
@ -197,7 +188,7 @@ impl CachedCalendar {
}, },
SyncStatus::NotSynced => { SyncStatus::NotSynced => {
// This was never synced to the server, we can safely delete it as soon as now // This was never synced to the server, we can safely delete it as soon as now
self.items.remove(item_url); self.items.remove(item_id);
}, },
}; };
Ok(()) Ok(())
@ -206,9 +197,9 @@ impl CachedCalendar {
} }
/// The non-async version of [`Self::immediately_delete_item`] /// The non-async version of [`Self::immediately_delete_item`]
pub fn immediately_delete_item_sync(&mut self, item_url: &Url) -> Result<(), Box<dyn Error>> { pub fn immediately_delete_item_sync(&mut self, item_id: &ItemId) -> Result<(), Box<dyn Error>> {
match self.items.remove(item_url) { match self.items.remove(item_id) {
None => Err(format!("Item {} is absent from this calendar", item_url).into()), None => Err(format!("Item {} is absent from this calendar", item_id).into()),
Some(_) => Ok(()) Some(_) => Ok(())
} }
} }
@ -222,8 +213,8 @@ impl BaseCalendar for CachedCalendar {
&self.name &self.name
} }
fn url(&self) -> &Url { fn id(&self) -> &CalendarId {
&self.url &self.id
} }
fn supported_components(&self) -> SupportedComponents { fn supported_components(&self) -> SupportedComponents {
@ -245,41 +236,37 @@ impl BaseCalendar for CachedCalendar {
#[async_trait] #[async_trait]
impl CompleteCalendar for CachedCalendar { impl CompleteCalendar for CachedCalendar {
fn new(name: String, url: Url, supported_components: SupportedComponents, color: Option<Color>) -> Self { fn new(name: String, id: CalendarId, supported_components: SupportedComponents, color: Option<Color>) -> Self {
Self { Self {
name, url, supported_components, color, name, id, supported_components, color,
#[cfg(feature = "local_calendar_mocks_remote_calendars")] #[cfg(feature = "local_calendar_mocks_remote_calendars")]
mock_behaviour: None, mock_behaviour: None,
items: HashMap::new(), items: HashMap::new(),
} }
} }
async fn get_item_urls(&self) -> Result<HashSet<Url>, Box<dyn Error>> { async fn get_item_ids(&self) -> Result<HashSet<ItemId>, Box<dyn Error>> {
self.get_item_urls_sync() self.get_item_ids_sync()
} }
async fn get_items(&self) -> Result<HashMap<Url, &Item>, Box<dyn Error>> { async fn get_items(&self) -> Result<HashMap<ItemId, &Item>, Box<dyn Error>> {
self.get_items_sync() self.get_items_sync()
} }
async fn get_items_mut(&mut self) -> Result<HashMap<Url, &mut Item>, Box<dyn Error>> { async fn get_item_by_id<'a>(&'a self, id: &ItemId) -> Option<&'a Item> {
self.get_items_mut_sync() self.get_item_by_id_sync(id)
} }
async fn get_item_by_url<'a>(&'a self, url: &Url) -> Option<&'a Item> { async fn get_item_by_id_mut<'a>(&'a mut self, id: &ItemId) -> Option<&'a mut Item> {
self.get_item_by_url_sync(url) self.get_item_by_id_mut_sync(id)
} }
async fn get_item_by_url_mut<'a>(&'a mut self, url: &Url) -> Option<&'a mut Item> { async fn mark_for_deletion(&mut self, item_id: &ItemId) -> Result<(), Box<dyn Error>> {
self.get_item_by_url_mut_sync(url) self.mark_for_deletion_sync(item_id)
} }
async fn mark_for_deletion(&mut self, item_url: &Url) -> Result<(), Box<dyn Error>> { async fn immediately_delete_item(&mut self, item_id: &ItemId) -> Result<(), Box<dyn Error>> {
self.mark_for_deletion_sync(item_url) self.immediately_delete_item_sync(item_id)
}
async fn immediately_delete_item(&mut self, item_url: &Url) -> Result<(), Box<dyn Error>> {
self.immediately_delete_item_sync(item_url)
} }
} }
@ -299,7 +286,7 @@ impl DavCalendar for CachedCalendar {
crate::traits::CompleteCalendar::new(name, resource.url().clone(), supported_components, color) crate::traits::CompleteCalendar::new(name, resource.url().clone(), supported_components, color)
} }
async fn get_item_version_tags(&self) -> Result<HashMap<Url, VersionTag>, Box<dyn Error>> { async fn get_item_version_tags(&self) -> Result<HashMap<ItemId, VersionTag>, Box<dyn Error>> {
#[cfg(feature = "local_calendar_mocks_remote_calendars")] #[cfg(feature = "local_calendar_mocks_remote_calendars")]
self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_get_item_version_tags())?; self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_get_item_version_tags())?;
@ -307,38 +294,30 @@ impl DavCalendar for CachedCalendar {
let mut result = HashMap::new(); let mut result = HashMap::new();
for (url, item) in self.items.iter() { for (id, item) in self.items.iter() {
let vt = match item.sync_status() { let vt = match item.sync_status() {
SyncStatus::Synced(vt) => vt.clone(), SyncStatus::Synced(vt) => vt.clone(),
_ => { _ => {
panic!("Mock calendars must contain only SyncStatus::Synced. Got {:?}", item); panic!("Mock calendars must contain only SyncStatus::Synced. Got {:?}", item);
} }
}; };
result.insert(url.clone(), vt); result.insert(id.clone(), vt);
} }
Ok(result) Ok(result)
} }
async fn get_item_by_url(&self, url: &Url) -> Result<Option<Item>, Box<dyn Error>> { async fn get_item_by_id(&self, id: &ItemId) -> Result<Option<Item>, Box<dyn Error>> {
#[cfg(feature = "local_calendar_mocks_remote_calendars")] #[cfg(feature = "local_calendar_mocks_remote_calendars")]
self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_get_item_by_url())?; self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_get_item_by_id())?;
Ok(self.items.get(url).cloned())
}
async fn get_items_by_url(&self, urls: &[Url]) -> Result<Vec<Option<Item>>, Box<dyn Error>> { Ok(self.items.get(id).cloned())
let mut v = Vec::new();
for url in urls {
v.push(DavCalendar::get_item_by_url(self, url).await?);
}
Ok(v)
} }
async fn delete_item(&mut self, item_url: &Url) -> Result<(), Box<dyn Error>> { async fn delete_item(&mut self, item_id: &ItemId) -> Result<(), Box<dyn Error>> {
#[cfg(feature = "local_calendar_mocks_remote_calendars")] #[cfg(feature = "local_calendar_mocks_remote_calendars")]
self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_delete_item())?; self.mock_behaviour.as_ref().map_or(Ok(()), |b| b.lock().unwrap().can_delete_item())?;
self.immediately_delete_item(item_url).await self.immediately_delete_item(item_id).await
} }
} }

11
src/calendar/mod.rs

@ -23,12 +23,12 @@ bitflags! {
impl SupportedComponents { impl SupportedComponents {
pub fn to_xml_string(&self) -> String { pub fn to_xml_string(&self) -> String {
format!(r#" format!(r#"
<B:supported-calendar-component-set> <C:supported-calendar-component-set>
{} {} {} {}
</B:supported-calendar-component-set> </C:supported-calendar-component-set>
"#, "#,
if self.contains(Self::EVENT) { "<B:comp name=\"VEVENT\"/>" } else { "" }, if self.contains(Self::EVENT) { "<C:comp name=\"VEVENT\"/>" } else { "" },
if self.contains(Self::TODO) { "<B:comp name=\"VTODO\"/>" } else { "" }, if self.contains(Self::TODO) { "<C:comp name=\"VTODO\"/>" } else { "" },
) )
} }
} }
@ -77,3 +77,6 @@ impl Default for SearchFilter {
SearchFilter::All SearchFilter::All
} }
} }
pub type CalendarId = url::Url;

83
src/calendar/remote_calendar.rs

@ -5,16 +5,16 @@ use std::sync::Mutex;
use async_trait::async_trait; use async_trait::async_trait;
use reqwest::{header::CONTENT_TYPE, header::CONTENT_LENGTH}; use reqwest::{header::CONTENT_TYPE, header::CONTENT_LENGTH};
use csscolorparser::Color; use csscolorparser::Color;
use url::Url;
use crate::traits::BaseCalendar; use crate::traits::BaseCalendar;
use crate::traits::DavCalendar; use crate::traits::DavCalendar;
use crate::calendar::SupportedComponents; use crate::calendar::SupportedComponents;
use crate::calendar::CalendarId;
use crate::item::Item; use crate::item::Item;
use crate::item::ItemId;
use crate::item::VersionTag; use crate::item::VersionTag;
use crate::item::SyncStatus; use crate::item::SyncStatus;
use crate::resource::Resource; use crate::resource::Resource;
use crate::utils::find_elem;
static TASKS_BODY: &str = r#" static TASKS_BODY: &str = r#"
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> <c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
@ -29,15 +29,6 @@ static TASKS_BODY: &str = r#"
</c:calendar-query> </c:calendar-query>
"#; "#;
static MULTIGET_BODY_PREFIX: &str = r#"
<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<c:calendar-data />
</d:prop>
"#;
static MULTIGET_BODY_SUFFIX: &str = r#"
</c:calendar-multiget>
"#;
@ -49,13 +40,13 @@ pub struct RemoteCalendar {
supported_components: SupportedComponents, supported_components: SupportedComponents,
color: Option<Color>, color: Option<Color>,
cached_version_tags: Mutex<Option<HashMap<Url, VersionTag>>>, cached_version_tags: Mutex<Option<HashMap<ItemId, VersionTag>>>,
} }
#[async_trait] #[async_trait]
impl BaseCalendar for RemoteCalendar { impl BaseCalendar for RemoteCalendar {
fn name(&self) -> &str { &self.name } fn name(&self) -> &str { &self.name }
fn url(&self) -> &Url { &self.resource.url() } fn id(&self) -> &CalendarId { &self.resource.url() }
fn supported_components(&self) -> crate::calendar::SupportedComponents { fn supported_components(&self) -> crate::calendar::SupportedComponents {
self.supported_components self.supported_components
} }
@ -67,7 +58,7 @@ impl BaseCalendar for RemoteCalendar {
let ical_text = crate::ical::build_from(&item)?; let ical_text = crate::ical::build_from(&item)?;
let response = reqwest::Client::new() let response = reqwest::Client::new()
.put(item.url().clone()) .put(item.id().as_url().clone())
.header("If-None-Match", "*") .header("If-None-Match", "*")
.header(CONTENT_TYPE, "text/calendar") .header(CONTENT_TYPE, "text/calendar")
.header(CONTENT_LENGTH, ical_text.len()) .header(CONTENT_LENGTH, ical_text.len())
@ -82,7 +73,7 @@ impl BaseCalendar for RemoteCalendar {
let reply_hdrs = response.headers(); let reply_hdrs = response.headers();
match reply_hdrs.get("ETag") { match reply_hdrs.get("ETag") {
None => Err(format!("No ETag in these response headers: {:?} (request was {:?})", reply_hdrs, item.url()).into()), None => Err(format!("No ETag in these response headers: {:?} (request was {:?})", reply_hdrs, item.id()).into()),
Some(etag) => { Some(etag) => {
let vtag_str = etag.to_str()?; let vtag_str = etag.to_str()?;
let vtag = VersionTag::from(String::from(vtag_str)); let vtag = VersionTag::from(String::from(vtag_str));
@ -101,7 +92,7 @@ impl BaseCalendar for RemoteCalendar {
let ical_text = crate::ical::build_from(&item)?; let ical_text = crate::ical::build_from(&item)?;
let request = reqwest::Client::new() let request = reqwest::Client::new()
.put(item.url().clone()) .put(item.id().as_url().clone())
.header("If-Match", old_etag.as_str()) .header("If-Match", old_etag.as_str())
.header(CONTENT_TYPE, "text/calendar") .header(CONTENT_TYPE, "text/calendar")
.header(CONTENT_LENGTH, ical_text.len()) .header(CONTENT_LENGTH, ical_text.len())
@ -116,7 +107,7 @@ impl BaseCalendar for RemoteCalendar {
let reply_hdrs = request.headers(); let reply_hdrs = request.headers();
match reply_hdrs.get("ETag") { match reply_hdrs.get("ETag") {
None => Err(format!("No ETag in these response headers: {:?} (request was {:?})", reply_hdrs, item.url()).into()), None => Err(format!("No ETag in these response headers: {:?} (request was {:?})", reply_hdrs, item.id()).into()),
Some(etag) => { Some(etag) => {
let vtag_str = etag.to_str()?; let vtag_str = etag.to_str()?;
let vtag = VersionTag::from(String::from(vtag_str)); let vtag = VersionTag::from(String::from(vtag_str));
@ -136,7 +127,7 @@ impl DavCalendar for RemoteCalendar {
} }
async fn get_item_version_tags(&self) -> Result<HashMap<Url, VersionTag>, Box<dyn Error>> { async fn get_item_version_tags(&self) -> Result<HashMap<ItemId, VersionTag>, Box<dyn Error>> {
if let Some(map) = &*self.cached_version_tags.lock().unwrap() { if let Some(map) = &*self.cached_version_tags.lock().unwrap() {
log::debug!("Version tags are already cached."); log::debug!("Version tags are already cached.");
return Ok(map.clone()); return Ok(map.clone());
@ -148,19 +139,19 @@ impl DavCalendar for RemoteCalendar {
for response in responses { for response in responses {
let item_url = crate::utils::find_elem(&response, "href") let item_url = crate::utils::find_elem(&response, "href")
.map(|elem| self.resource.combine(&elem.text())); .map(|elem| self.resource.combine(&elem.text()));
let item_url = match item_url { let item_id = match item_url {
None => { None => {
log::warn!("Unable to extract HREF"); log::warn!("Unable to extract HREF");
continue; continue;
}, },
Some(resource) => { Some(resource) => {
resource.url().clone() ItemId::from(&resource)
}, },
}; };
let version_tag = match crate::utils::find_elem(&response, "getetag") { let version_tag = match crate::utils::find_elem(&response, "getetag") {
None => { None => {
log::warn!("Unable to extract ETAG for item {}, ignoring it", item_url); log::warn!("Unable to extract ETAG for item {}, ignoring it", item_id);
continue; continue;
}, },
Some(etag) => { Some(etag) => {
@ -168,7 +159,7 @@ impl DavCalendar for RemoteCalendar {
} }
}; };
items.insert(item_url.clone(), version_tag); items.insert(item_id, version_tag);
} }
// 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) // 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)
@ -176,9 +167,9 @@ impl DavCalendar for RemoteCalendar {
Ok(items) Ok(items)
} }
async fn get_item_by_url(&self, url: &Url) -> Result<Option<Item>, Box<dyn Error>> { async fn get_item_by_id(&self, id: &ItemId) -> Result<Option<Item>, Box<dyn Error>> {
let res = reqwest::Client::new() let res = reqwest::Client::new()
.get(url.clone()) .get(id.as_url().clone())
.header(CONTENT_TYPE, "text/calendar") .header(CONTENT_TYPE, "text/calendar")
.basic_auth(self.resource.username(), Some(self.resource.password())) .basic_auth(self.resource.username(), Some(self.resource.password()))
.send() .send()
@ -192,52 +183,18 @@ impl DavCalendar for RemoteCalendar {
// This is supposed to be cached // This is supposed to be cached
let version_tags = self.get_item_version_tags().await?; let version_tags = self.get_item_version_tags().await?;
let vt = match version_tags.get(url) { let vt = match version_tags.get(id) {
None => return Err(format!("Inconsistent data: {} has no version tag", url).into()), None => return Err(format!("Inconsistent data: {} has no version tag", id).into()),
Some(vt) => vt, Some(vt) => vt,
}; };
let item = crate::ical::parse(&text, url.clone(), SyncStatus::Synced(vt.clone()))?; let item = crate::ical::parse(&text, id.clone(), SyncStatus::Synced(vt.clone()))?;
Ok(Some(item)) Ok(Some(item))
} }
async fn get_items_by_url(&self, urls: &[Url]) -> Result<Vec<Option<Item>>, Box<dyn Error>> { async fn delete_item(&mut self, item_id: &ItemId) -> Result<(), Box<dyn Error>> {
// Build the request body
let mut hrefs = String::new();
for url in urls {
hrefs.push_str(&format!(" <d:href>{}</d:href>\n", url.path()));
}
let body = format!("{}{}{}", MULTIGET_BODY_PREFIX, hrefs, MULTIGET_BODY_SUFFIX);
// Send the request
let xml_replies = crate::client::sub_request_and_extract_elems(&self.resource, "REPORT", body, "response").await?;
// This is supposed to be cached
let version_tags = self.get_item_version_tags().await?;
// Parse the results
let mut results = Vec::new();
for xml_reply in xml_replies {
let href = find_elem(&xml_reply, "href").ok_or("Missing HREF")?.text();
let mut url = self.resource.url().clone();
url.set_path(&href);
let ical_data = find_elem(&xml_reply, "calendar-data").ok_or("Missing calendar-data")?.text();
let vt = match version_tags.get(&url) {
None => return Err(format!("Inconsistent data: {} has no version tag", url).into()),
Some(vt) => vt,
};
let item = crate::ical::parse(&ical_data, url.clone(), SyncStatus::Synced(vt.clone()))?;
results.push(Some(item));
}
Ok(results)
}
async fn delete_item(&mut self, item_url: &Url) -> Result<(), Box<dyn Error>> {
let del_response = reqwest::Client::new() let del_response = reqwest::Client::new()
.delete(item_url.clone()) .delete(item_id.as_url().clone())
.basic_auth(self.resource.username(), Some(self.resource.password())) .basic_auth(self.resource.username(), Some(self.resource.password()))
.send() .send()
.await?; .await?;

46
src/client.rs

@ -15,6 +15,7 @@ use csscolorparser::Color;
use crate::resource::Resource; use crate::resource::Resource;
use crate::utils::{find_elem, find_elems}; use crate::utils::{find_elem, find_elems};
use crate::calendar::remote_calendar::RemoteCalendar; use crate::calendar::remote_calendar::RemoteCalendar;
use crate::calendar::CalendarId;
use crate::calendar::SupportedComponents; use crate::calendar::SupportedComponents;
use crate::traits::CalDavSource; use crate::traits::CalDavSource;
use crate::traits::BaseCalendar; use crate::traits::BaseCalendar;
@ -112,7 +113,7 @@ pub struct Client {
struct CachedReplies { struct CachedReplies {
principal: Option<Resource>, principal: Option<Resource>,
calendar_home_set: Option<Resource>, calendar_home_set: Option<Resource>,
calendars: Option<HashMap<Url, Arc<Mutex<RemoteCalendar>>>>, calendars: Option<HashMap<CalendarId, Arc<Mutex<RemoteCalendar>>>>,
} }
impl Client { impl Client {
@ -215,7 +216,7 @@ impl Client {
let this_calendar = RemoteCalendar::new(display_name, this_calendar_url, supported_components, this_calendar_color); let this_calendar = RemoteCalendar::new(display_name, this_calendar_url, supported_components, this_calendar_color);
log::info!("Found calendar {}", this_calendar.name()); log::info!("Found calendar {}", this_calendar.name());
calendars.insert(this_calendar.url().clone(), Arc::new(Mutex::new(this_calendar))); calendars.insert(this_calendar.id().clone(), Arc::new(Mutex::new(this_calendar)));
} }
let mut replies = self.cached_replies.lock().unwrap(); let mut replies = self.cached_replies.lock().unwrap();
@ -227,7 +228,7 @@ impl Client {
#[async_trait] #[async_trait]
impl CalDavSource<RemoteCalendar> for Client { impl CalDavSource<RemoteCalendar> for Client {
async fn get_calendars(&self) -> Result<HashMap<Url, Arc<Mutex<RemoteCalendar>>>, Box<dyn Error>> { async fn get_calendars(&self) -> Result<HashMap<CalendarId, Arc<Mutex<RemoteCalendar>>>, Box<dyn Error>> {
self.populate_calendars().await?; self.populate_calendars().await?;
match &self.cached_replies.lock().unwrap().calendars { match &self.cached_replies.lock().unwrap().calendars {
@ -238,7 +239,7 @@ impl CalDavSource<RemoteCalendar> for Client {
}; };
} }
async fn get_calendar(&self, url: &Url) -> Option<Arc<Mutex<RemoteCalendar>>> { async fn get_calendar(&self, id: &CalendarId) -> Option<Arc<Mutex<RemoteCalendar>>> {
if let Err(err) = self.populate_calendars().await { if let Err(err) = self.populate_calendars().await {
log::warn!("Unable to fetch calendars: {}", err); log::warn!("Unable to fetch calendars: {}", err);
return None; return None;
@ -247,26 +248,26 @@ impl CalDavSource<RemoteCalendar> for Client {
self.cached_replies.lock().unwrap() self.cached_replies.lock().unwrap()
.calendars .calendars
.as_ref() .as_ref()
.and_then(|cals| cals.get(url)) .and_then(|cals| cals.get(id))
.map(|cal| cal.clone()) .map(|cal| cal.clone())
} }
async fn create_calendar(&mut self, url: Url, name: String, supported_components: SupportedComponents, color: Option<Color>) -> Result<Arc<Mutex<RemoteCalendar>>, Box<dyn Error>> { async fn create_calendar(&mut self, id: CalendarId, name: String, supported_components: SupportedComponents, color: Option<Color>) -> Result<Arc<Mutex<RemoteCalendar>>, Box<dyn Error>> {
self.populate_calendars().await?; self.populate_calendars().await?;
match self.cached_replies.lock().unwrap().calendars.as_ref() { match self.cached_replies.lock().unwrap().calendars.as_ref() {
None => return Err("No calendars have been fetched".into()), None => return Err("No calendars have been fetched".into()),
Some(cals) => { Some(cals) => {
if cals.contains_key(&url) { if cals.contains_key(&id) {
return Err("This calendar already exists".into()); return Err("This calendar already exists".into());
} }
}, },
} }
let creation_body = calendar_body(name, supported_components, color); let creation_body = calendar_body(name, supported_components);
let response = reqwest::Client::new() let response = reqwest::Client::new()
.request(Method::from_bytes(b"MKCALENDAR").unwrap(), url.clone()) .request(Method::from_bytes(b"MKCALENDAR").unwrap(), id.clone())
.header(CONTENT_TYPE, "application/xml") .header(CONTENT_TYPE, "application/xml")
.basic_auth(self.resource.username(), Some(self.resource.password())) .basic_auth(self.resource.username(), Some(self.resource.password()))
.body(creation_body) .body(creation_body)
@ -278,30 +279,25 @@ impl CalDavSource<RemoteCalendar> for Client {
return Err(format!("Unexpected HTTP status code. Expected CREATED, got {}", status.as_u16()).into()); return Err(format!("Unexpected HTTP status code. Expected CREATED, got {}", status.as_u16()).into());
} }
self.get_calendar(&url).await.ok_or(format!("Unable to insert calendar {:?}", url).into()) self.get_calendar(&id).await.ok_or(format!("Unable to insert calendar {:?}", id).into())
} }
} }
fn calendar_body(name: String, supported_components: SupportedComponents, color: Option<Color>) -> String { fn calendar_body(name: String, supported_components: SupportedComponents) -> String {
let color_property = match color {
None => "".to_string(),
Some(color) => format!("<D:calendar-color xmlns:D=\"http://apple.com/ns/ical/\">{}FF</D:calendar-color>", color.to_hex_string().to_ascii_uppercase()),
};
// This is taken from https://tools.ietf.org/html/rfc4791#page-24 // This is taken from https://tools.ietf.org/html/rfc4791#page-24
format!(r#"<?xml version="1.0" encoding="utf-8" ?> format!(r#"<?xml version="1.0" encoding="utf-8" ?>
<B:mkcalendar xmlns:B="urn:ietf:params:xml:ns:caldav"> <C:mkcalendar xmlns:D="DAV:"
<A:set xmlns:A="DAV:"> xmlns:C="urn:ietf:params:xml:ns:caldav">
<A:prop> <D:set>
<A:displayname>{}</A:displayname> <D:prop>
<D:displayname>{}</D:displayname>
{} {}
{} </D:prop>
</A:prop> </D:set>
</A:set> </C:mkcalendar>
</B:mkcalendar>
"#, "#,
name, name,
color_property,
supported_components.to_xml_string(), supported_components.to_xml_string(),
) )
} }

15
src/config.rs

@ -1,12 +1,11 @@
//! Support for library configuration options //! Support for compile-time configuration options
use std::sync::{Arc, Mutex};
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
/// Part of the ProdID string that describes the organization (example of a ProdID string: `-//ABC Corporation//My Product//EN`). /// Part of the ProdID string that describes the organization (example of a ProdID string: `-//ABC Corporation//My Product//EN`)
/// Feel free to override it when initing this library. /// You can override it at compile-time with the `KITCHEN_FRIDGE_ICAL_ORG_NAME` environment variable, or keep the default value
pub static ORG_NAME: Lazy<Arc<Mutex<String>>> = Lazy::new(|| Arc::new(Mutex::new("My organization".to_string()))); pub static ORG_NAME: Lazy<String> = Lazy::new(|| option_env!("KITCHEN_FRIDGE_ICAL_ORG_NAME").unwrap_or("My organization").to_string() );
/// Part of the ProdID string that describes the product name (example of a ProdID string: `-//ABC Corporation//My Product//EN`). /// Part of the ProdID string that describes the product name (example of a ProdID string: `-//ABC Corporation//My Product//EN`)
/// Feel free to override it when initing this library. /// You can override it at compile-time with the `KITCHEN_FRIDGE_ICAL_PRODUCT_NAME` environment variable, or keep the default value
pub static PRODUCT_NAME: Lazy<Arc<Mutex<String>>> = Lazy::new(|| Arc::new(Mutex::new("KitchenFridge".to_string()))); pub static PRODUCT_NAME: Lazy<String> = Lazy::new(|| option_env!("KITCHEN_FRIDGE_ICAL_PRODUCT_NAME").unwrap_or("KitchenFridge").to_string() );

10
src/event.rs

@ -2,15 +2,15 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use url::Url;
use crate::item::ItemId;
use crate::item::SyncStatus; use crate::item::SyncStatus;
/// TODO: implement `Event` one day. /// TODO: implement `Event` one day.
/// This crate currently only supports tasks, not calendar events. /// This crate currently only supports tasks, not calendar events.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Event { pub struct Event {
uid: String, id: ItemId,
name: String, name: String,
sync_status: SyncStatus, sync_status: SyncStatus,
} }
@ -20,12 +20,12 @@ impl Event {
unimplemented!(); unimplemented!();
} }
pub fn url(&self) -> &Url { pub fn id(&self) -> &ItemId {
unimplemented!(); &self.id
} }
pub fn uid(&self) -> &str { pub fn uid(&self) -> &str {
&self.uid unimplemented!()
} }
pub fn name(&self) -> &str { pub fn name(&self) -> &str {

8
src/ical/builder.rs

@ -104,7 +104,7 @@ mod tests {
COMPLETED:{}\r\n\ COMPLETED:{}\r\n\
STATUS:COMPLETED\r\n\ STATUS:COMPLETED\r\n\
END:VTODO\r\n\ END:VTODO\r\n\
END:VCALENDAR\r\n", ORG_NAME.lock().unwrap(), PRODUCT_NAME.lock().unwrap(), uid, s_now, s_now, s_now, s_now); END:VCALENDAR\r\n", *ORG_NAME, *PRODUCT_NAME, uid, s_now, s_now, s_now, s_now);
assert_eq!(ical, expected_ical); assert_eq!(ical, expected_ical);
} }
@ -124,18 +124,18 @@ mod tests {
SUMMARY:This is a task with ÜTF-8 characters\r\n\ SUMMARY:This is a task with ÜTF-8 characters\r\n\
STATUS:NEEDS-ACTION\r\n\ STATUS:NEEDS-ACTION\r\n\
END:VTODO\r\n\ END:VTODO\r\n\
END:VCALENDAR\r\n", ORG_NAME.lock().unwrap(), PRODUCT_NAME.lock().unwrap(), uid, s_now, s_now, s_now); END:VCALENDAR\r\n", *ORG_NAME, *PRODUCT_NAME, uid, s_now, s_now, s_now);
assert_eq!(ical, expected_ical); assert_eq!(ical, expected_ical);
} }
fn build_task(completed: bool) -> (String, String, String) { fn build_task(completed: bool) -> (String, String, String) {
let cal_url = "http://my.calend.ar/id".parse().unwrap(); let cal_id = "http://my.calend.ar/id".parse().unwrap();
let now = Utc::now(); let now = Utc::now();
let s_now = format_date_time(&now); let s_now = format_date_time(&now);
let task = Item::Task(Task::new( let task = Item::Task(Task::new(
String::from("This is a task with ÜTF-8 characters"), completed, &cal_url String::from("This is a task with ÜTF-8 characters"), completed, &cal_id
)); ));
let ical = build_from(&task).unwrap(); let ical = build_from(&task).unwrap();

2
src/ical/mod.rs

@ -10,7 +10,7 @@ pub use builder::build_from;
use crate::config::{ORG_NAME, PRODUCT_NAME}; use crate::config::{ORG_NAME, PRODUCT_NAME};
pub fn default_prod_id() -> String { pub fn default_prod_id() -> String {
format!("-//{}//{}//EN", ORG_NAME.lock().unwrap(), PRODUCT_NAME.lock().unwrap()) format!("-//{}//{}//EN", *ORG_NAME, *PRODUCT_NAME)
} }

48
src/ical/parser.rs

@ -4,22 +4,22 @@ use std::error::Error;
use ical::parser::ical::component::{IcalCalendar, IcalEvent, IcalTodo}; use ical::parser::ical::component::{IcalCalendar, IcalEvent, IcalTodo};
use chrono::{DateTime, TimeZone, Utc}; use chrono::{DateTime, TimeZone, Utc};
use url::Url;
use crate::Item; use crate::Item;
use crate::item::SyncStatus; use crate::item::SyncStatus;
use crate::item::ItemId;
use crate::Task; use crate::Task;
use crate::task::CompletionStatus; use crate::task::CompletionStatus;
use crate::Event; use crate::Event;
/// Parse an iCal file into the internal representation [`crate::Item`] /// Parse an iCal file into the internal representation [`crate::Item`]
pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<Item, Box<dyn Error>> { pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result<Item, Box<dyn Error>> {
let mut reader = ical::IcalParser::new(content.as_bytes()); let mut reader = ical::IcalParser::new(content.as_bytes());
let parsed_item = match reader.next() { let parsed_item = match reader.next() {
None => return Err(format!("Invalid iCal data to parse for item {}", item_url).into()), None => return Err(format!("Invalid iCal data to parse for item {}", item_id).into()),
Some(item) => match item { Some(item) => match item {
Err(err) => return Err(format!("Unable to parse iCal data for item {}: {}", item_url, err).into()), Err(err) => return Err(format!("Unable to parse iCal data for item {}: {}", item_id, err).into()),
Ok(item) => item, Ok(item) => item,
} }
}; };
@ -50,17 +50,8 @@ pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<It
// The property can be specified once, but is not mandatory // The property can be specified once, but is not mandatory
// "This property specifies the date and time that the information associated with // "This property specifies the date and time that the information associated with
// the calendar component was last revised in the calendar store." // the calendar component was last revised in the calendar store."
// "In the case of an iCalendar object that doesn't specify a "METHOD" last_modified = parse_date_time_from_property(&prop.value)
// property [e.g.: VTODO and VEVENT], this property is equivalent to the "LAST-MODIFIED" property".
last_modified = parse_date_time_from_property(&prop.value);
}, },
"LAST-MODIFIED" => {
// The property can be specified once, but is not mandatory
// "This property specifies the date and time that the information associated with
// the calendar component was last revised in the calendar store."
// In practise, for VEVENT and VTODO, this is generally the same value as DTSTAMP.
last_modified = parse_date_time_from_property(&prop.value);
}
"COMPLETED" => { "COMPLETED" => {
// The property can be specified once, but is not mandatory // The property can be specified once, but is not mandatory
// "This property defines the date and time that a to-do was // "This property defines the date and time that a to-do was
@ -89,15 +80,15 @@ pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<It
} }
let name = match name { let name = match name {
Some(name) => name, Some(name) => name,
None => return Err(format!("Missing name for item {}", item_url).into()), None => return Err(format!("Missing name for item {}", item_id).into()),
}; };
let uid = match uid { let uid = match uid {
Some(uid) => uid, Some(uid) => uid,
None => return Err(format!("Missing UID for item {}", item_url).into()), None => return Err(format!("Missing UID for item {}", item_id).into()),
}; };
let last_modified = match last_modified { let last_modified = match last_modified {
Some(dt) => dt, Some(dt) => dt,
None => return Err(format!("Missing DTSTAMP for item {}, but this is required by RFC5545", item_url).into()), None => return Err(format!("Missing DTSTAMP for item {}, but this is required by RFC5545", item_id).into()),
}; };
let completion_status = match completed { let completion_status = match completed {
false => { false => {
@ -109,7 +100,7 @@ pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<It
true => CompletionStatus::Completed(completion_date), true => CompletionStatus::Completed(completion_date),
}; };
Item::Task(Task::new_with_parameters(name, uid, item_url, completion_status, sync_status, creation_date, last_modified, ical_prod_id, extra_parameters)) Item::Task(Task::new_with_parameters(name, uid, item_id, completion_status, sync_status, creation_date, last_modified, ical_prod_id, extra_parameters))
}, },
}; };
@ -123,8 +114,7 @@ pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<It
} }
fn parse_date_time(dt: &str) -> Result<DateTime<Utc>, chrono::format::ParseError> { fn parse_date_time(dt: &str) -> Result<DateTime<Utc>, chrono::format::ParseError> {
Utc.datetime_from_str(dt, "%Y%m%dT%H%M%SZ") Utc.datetime_from_str(dt, "%Y%m%dT%H%M%S")
.or_else(|_err| Utc.datetime_from_str(dt, "%Y%m%dT%H%M%S") )
} }
fn parse_date_time_from_property(value: &Option<String>) -> Option<DateTime<Utc>> { fn parse_date_time_from_property(value: &Option<String>) -> Option<DateTime<Utc>> {
@ -254,13 +244,13 @@ END:VCALENDAR
fn test_ical_parsing() { fn test_ical_parsing() {
let version_tag = VersionTag::from(String::from("test-tag")); let version_tag = VersionTag::from(String::from("test-tag"));
let sync_status = SyncStatus::Synced(version_tag); let sync_status = SyncStatus::Synced(version_tag);
let item_url: Url = "http://some.id/for/testing".parse().unwrap(); let item_id: ItemId = "http://some.id/for/testing".parse().unwrap();
let item = parse(EXAMPLE_ICAL, item_url.clone(), sync_status.clone()).unwrap(); let item = parse(EXAMPLE_ICAL, item_id.clone(), sync_status.clone()).unwrap();
let task = item.unwrap_task(); let task = item.unwrap_task();
assert_eq!(task.name(), "Do not forget to do this"); assert_eq!(task.name(), "Do not forget to do this");
assert_eq!(task.url(), &item_url); assert_eq!(task.id(), &item_id);
assert_eq!(task.uid(), "0633de27-8c32-42be-bcb8-63bc879c6185@some-domain.com"); assert_eq!(task.uid(), "0633de27-8c32-42be-bcb8-63bc879c6185@some-domain.com");
assert_eq!(task.completed(), false); assert_eq!(task.completed(), false);
assert_eq!(task.completion_status(), &CompletionStatus::Uncompleted); assert_eq!(task.completion_status(), &CompletionStatus::Uncompleted);
@ -272,9 +262,9 @@ END:VCALENDAR
fn test_completed_ical_parsing() { fn test_completed_ical_parsing() {
let version_tag = VersionTag::from(String::from("test-tag")); let version_tag = VersionTag::from(String::from("test-tag"));
let sync_status = SyncStatus::Synced(version_tag); let sync_status = SyncStatus::Synced(version_tag);
let item_url: Url = "http://some.id/for/testing".parse().unwrap(); let item_id: ItemId = "http://some.id/for/testing".parse().unwrap();
let item = parse(EXAMPLE_ICAL_COMPLETED, item_url.clone(), sync_status.clone()).unwrap(); let item = parse(EXAMPLE_ICAL_COMPLETED, item_id.clone(), sync_status.clone()).unwrap();
let task = item.unwrap_task(); let task = item.unwrap_task();
assert_eq!(task.completed(), true); assert_eq!(task.completed(), true);
@ -285,9 +275,9 @@ END:VCALENDAR
fn test_completed_without_date_ical_parsing() { fn test_completed_without_date_ical_parsing() {
let version_tag = VersionTag::from(String::from("test-tag")); let version_tag = VersionTag::from(String::from("test-tag"));
let sync_status = SyncStatus::Synced(version_tag); let sync_status = SyncStatus::Synced(version_tag);
let item_url: Url = "http://some.id/for/testing".parse().unwrap(); let item_id: ItemId = "http://some.id/for/testing".parse().unwrap();
let item = parse(EXAMPLE_ICAL_COMPLETED_WITHOUT_A_COMPLETION_DATE, item_url.clone(), sync_status.clone()).unwrap(); let item = parse(EXAMPLE_ICAL_COMPLETED_WITHOUT_A_COMPLETION_DATE, item_id.clone(), sync_status.clone()).unwrap();
let task = item.unwrap_task(); let task = item.unwrap_task();
assert_eq!(task.completed(), true); assert_eq!(task.completed(), true);
@ -298,9 +288,9 @@ END:VCALENDAR
fn test_multiple_items_in_ical() { fn test_multiple_items_in_ical() {
let version_tag = VersionTag::from(String::from("test-tag")); let version_tag = VersionTag::from(String::from("test-tag"));
let sync_status = SyncStatus::Synced(version_tag); let sync_status = SyncStatus::Synced(version_tag);
let item_url: Url = "http://some.id/for/testing".parse().unwrap(); let item_id: ItemId = "http://some.id/for/testing".parse().unwrap();
let item = parse(EXAMPLE_MULTIPLE_ICAL, item_url.clone(), sync_status.clone()); let item = parse(EXAMPLE_MULTIPLE_ICAL, item_id.clone(), sync_status.clone());
assert!(item.is_err()); assert!(item.is_err());
} }
} }

74
src/item.rs

@ -1,10 +1,17 @@
//! CalDAV items (todo, events, journals...) //! CalDAV items (todo, events, journals...)
// TODO: move Event and Task to nest them in crate::items::calendar::Calendar? // TODO: move Event and Task to nest them in crate::items::calendar::Calendar?
use serde::{Deserialize, Serialize}; use std::fmt::{Display, Formatter};
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use url::Url; use url::Url;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use crate::resource::Resource;
use crate::calendar::CalendarId;
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Item { pub enum Item {
@ -25,7 +32,7 @@ macro_rules! synthetise_common_getter {
} }
impl Item { impl Item {
synthetise_common_getter!(url, &Url); synthetise_common_getter!(id, &ItemId);
synthetise_common_getter!(uid, &str); synthetise_common_getter!(uid, &str);
synthetise_common_getter!(name, &str); synthetise_common_getter!(name, &str);
synthetise_common_getter!(creation_date, Option<&DateTime<Utc>>); synthetise_common_getter!(creation_date, Option<&DateTime<Utc>>);
@ -87,6 +94,67 @@ impl Item {
} }
#[derive(Clone, Debug, PartialEq, Hash)]
pub struct ItemId {
content: Url,
}
impl ItemId{
/// Generate a random ItemId.
pub fn random(parent_calendar: &CalendarId) -> Self {
let random = uuid::Uuid::new_v4().to_hyphenated().to_string();
let u = parent_calendar.join(&random).unwrap(/* this cannot panic since we've just created a string that is a valid URL */);
Self { content:u }
}
pub fn as_url(&self) -> &Url {
&self.content
}
}
impl From<Url> for ItemId {
fn from(url: Url) -> Self {
Self { content: url }
}
}
impl From<&Resource> for ItemId {
fn from(resource: &Resource) -> Self {
Self { content: resource.url().clone() }
}
}
impl FromStr for ItemId {
type Err = url::ParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let u: Url = s.parse()?;
Ok(Self::from(u))
}
}
impl Eq for ItemId {}
impl Display for ItemId {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(f, "{}", self.content)
}
}
/// Used to support serde
impl Serialize for ItemId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.content.as_str())
}
}
/// Used to support serde
impl<'de> Deserialize<'de> for ItemId {
fn deserialize<D>(deserializer: D) -> Result<ItemId, D::Error>
where
D: Deserializer<'de>,
{
let u = Url::deserialize(deserializer)?;
Ok(ItemId{ content: u })
}
}
/// A VersionTag is basically a CalDAV `ctag` or `etag`. Whenever it changes, this means the data has changed. /// A VersionTag is basically a CalDAV `ctag` or `etag`. Whenever it changes, this means the data has changed.
@ -107,7 +175,7 @@ impl VersionTag {
&self.tag &self.tag
} }
/// Generate a random VersionTag /// Generate a random VesionTag
#[cfg(feature = "local_calendar_mocks_remote_calendars")] #[cfg(feature = "local_calendar_mocks_remote_calendars")]
pub fn random() -> Self { pub fn random() -> Self {
let random = uuid::Uuid::new_v4().to_hyphenated().to_string(); let random = uuid::Uuid::new_v4().to_hyphenated().to_string();

16
src/lib.rs

@ -1,8 +1,6 @@
//! This crate provides a CalDAV client library. \ //! This crate provides a way to manage CalDAV data.
//! 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). \
//! 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.
//! //!
//! 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. \ //! Its initial implementation only supported TODO events, so that it could 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. \
//! 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 :-) //! 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 :-)
//! //!
//! ## Possible uses //! ## Possible uses
@ -14,20 +12,14 @@
//! //!
//! These two "data sources" (actual client and local cache) can be used together in a [`CalDavProvider`](CalDavProvider). \ //! These two "data sources" (actual client and local cache) can be used together in a [`CalDavProvider`](CalDavProvider). \
//! A `CalDavProvider` abstracts these two sources by merging them together into one virtual source. \ //! A `CalDavProvider` abstracts these two sources by merging them together into one virtual source. \
//! 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). //! It also handles synchronisation between the local cache and the server.
//! //!
//! Note that many methods are defined in common traits (see [`crate::traits`]). //! Note that many methods are defined in common traits (see [`crate::traits`]).
//! //!
//! ## Examples //! ## Examples
//! //!
//! See example usage in the `examples/` folder, that you can run using `cargo run --example <example-name>`. \ //! See example usage in the `examples/` folder, that you can run using `cargo run --example <example-name>`. \
//! You can also have a look at [`Voilà`](https://github.com/daladim/voila-tasks), a GUI app that uses `kitchen-fridge` under the hood. //! You can also have a look at `tasklist`, a GUI app that uses `kitchen-fridge` under the hood.
//!
//! ## Configuration options
//!
//! Have a look at the [`config`] module to see what default options can be overridden.
#![doc(html_logo_url = "https://raw.githubusercontent.com/daladim/kitchen-fridge/master/resources/kitchen-fridge.svg")]
pub mod traits; pub mod traits;

8
src/mock_behaviour.rs

@ -22,7 +22,7 @@ pub struct MockBehaviour {
// From the DavCalendar trait // From the DavCalendar trait
pub get_item_version_tags_behaviour: (u32, u32), pub get_item_version_tags_behaviour: (u32, u32),
pub get_item_by_url_behaviour: (u32, u32), pub get_item_by_id_behaviour: (u32, u32),
pub delete_item_behaviour: (u32, u32), pub delete_item_behaviour: (u32, u32),
} }
@ -41,7 +41,7 @@ impl MockBehaviour {
add_item_behaviour: (0, n_fails), add_item_behaviour: (0, n_fails),
update_item_behaviour: (0, n_fails), update_item_behaviour: (0, n_fails),
get_item_version_tags_behaviour: (0, n_fails), get_item_version_tags_behaviour: (0, n_fails),
get_item_by_url_behaviour: (0, n_fails), get_item_by_id_behaviour: (0, n_fails),
delete_item_behaviour: (0, n_fails), delete_item_behaviour: (0, n_fails),
} }
} }
@ -84,9 +84,9 @@ impl MockBehaviour {
if self.is_suspended { return Ok(()) } if self.is_suspended { return Ok(()) }
decrement(&mut self.get_item_version_tags_behaviour, "get_item_version_tags") decrement(&mut self.get_item_version_tags_behaviour, "get_item_version_tags")
} }
pub fn can_get_item_by_url(&mut self) -> Result<(), Box<dyn Error>> { pub fn can_get_item_by_id(&mut self) -> Result<(), Box<dyn Error>> {
if self.is_suspended { return Ok(()) } if self.is_suspended { return Ok(()) }
decrement(&mut self.get_item_by_url_behaviour, "get_item_by_url") decrement(&mut self.get_item_by_id_behaviour, "get_item_by_id")
} }
pub fn can_delete_item(&mut self) -> Result<(), Box<dyn Error>> { pub fn can_delete_item(&mut self) -> Result<(), Box<dyn Error>> {
if self.is_suspended { return Ok(()) } if self.is_suspended { return Ok(()) }

331
src/provider/mod.rs

@ -6,43 +6,16 @@ use std::error::Error;
use std::collections::HashSet; use std::collections::HashSet;
use std::marker::PhantomData; use std::marker::PhantomData;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::fmt::{Display, Formatter};
use url::Url;
use itertools::Itertools;
use crate::traits::{BaseCalendar, CalDavSource, DavCalendar}; use crate::traits::{BaseCalendar, CalDavSource, DavCalendar};
use crate::traits::CompleteCalendar; use crate::traits::CompleteCalendar;
use crate::item::SyncStatus; use crate::item::{ItemId, SyncStatus};
use crate::calendar::CalendarId;
pub mod sync_progress; pub mod sync_progress;
use sync_progress::SyncProgress; use sync_progress::SyncProgress;
use sync_progress::{FeedbackSender, SyncEvent}; use sync_progress::{FeedbackSender, SyncEvent};
/// How many items will be batched in a single HTTP request when downloading from the server
#[cfg(not(test))]
const DOWNLOAD_BATCH_SIZE: usize = 30;
/// How many items will be batched in a single HTTP request when downloading from the server
#[cfg(test)]
const DOWNLOAD_BATCH_SIZE: usize = 3;
// I am too lazy to actually make `fetch_and_apply` generic over an async closure.
// Let's work around by passing an enum, so that `fetch_and_apply` will know what to do
enum BatchDownloadType {
RemoteAdditions,
RemoteChanges,
}
impl Display for BatchDownloadType {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
match self {
Self::RemoteAdditions => write!(f, "remote additions"),
Self::RemoteChanges => write!(f, "remote changes"),
}
}
}
/// A data source that combines two `CalDavSource`s, which is able to sync both sources. /// A data source that combines two `CalDavSource`s, which is able to sync both sources.
/// ///
/// 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<Cache, CachedCalendar, Client, RemoteCalendar>`. \ /// 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<Cache, CachedCalendar, Client, RemoteCalendar>`. \
@ -129,39 +102,39 @@ where
// Sync every remote calendar // Sync every remote calendar
let cals_remote = self.remote.get_calendars().await?; let cals_remote = self.remote.get_calendars().await?;
for (cal_url, cal_remote) in cals_remote { for (cal_id, cal_remote) in cals_remote {
let counterpart = match self.get_or_insert_local_counterpart_calendar(&cal_url, cal_remote.clone()).await { let counterpart = match self.get_or_insert_local_counterpart_calendar(&cal_id, cal_remote.clone()).await {
Err(err) => { Err(err) => {
progress.warn(&format!("Unable to get or insert local counterpart calendar for {} ({}). Skipping this time", cal_url, err)); progress.warn(&format!("Unable to get or insert local counterpart calendar for {} ({}). Skipping this time", cal_id, err));
continue; continue;
}, },
Ok(arc) => arc, Ok(arc) => arc,
}; };
if let Err(err) = Self::sync_calendar_pair(counterpart, cal_remote, progress).await { if let Err(err) = Self::sync_calendar_pair(counterpart, cal_remote, progress).await {
progress.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_url, err)); progress.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_id, err));
continue; continue;
} }
handled_calendars.insert(cal_url); handled_calendars.insert(cal_id);
} }
// Sync every local calendar that would not be in the remote yet // Sync every local calendar that would not be in the remote yet
let cals_local = self.local.get_calendars().await?; let cals_local = self.local.get_calendars().await?;
for (cal_url, cal_local) in cals_local { for (cal_id, cal_local) in cals_local {
if handled_calendars.contains(&cal_url) { if handled_calendars.contains(&cal_id) {
continue; continue;
} }
let counterpart = match self.get_or_insert_remote_counterpart_calendar(&cal_url, cal_local.clone()).await { let counterpart = match self.get_or_insert_remote_counterpart_calendar(&cal_id, cal_local.clone()).await {
Err(err) => { Err(err) => {
progress.warn(&format!("Unable to get or insert remote counterpart calendar for {} ({}). Skipping this time", cal_url, err)); progress.warn(&format!("Unable to get or insert remote counterpart calendar for {} ({}). Skipping this time", cal_id, err));
continue; continue;
}, },
Ok(arc) => arc, Ok(arc) => arc,
}; };
if let Err(err) = Self::sync_calendar_pair(cal_local, counterpart, progress).await { if let Err(err) = Self::sync_calendar_pair(cal_local, counterpart, progress).await {
progress.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_url, err)); progress.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_id, err));
continue; continue;
} }
} }
@ -172,11 +145,11 @@ where
} }
async fn get_or_insert_local_counterpart_calendar(&mut self, cal_url: &Url, needle: Arc<Mutex<U>>) -> Result<Arc<Mutex<T>>, Box<dyn Error>> { async fn get_or_insert_local_counterpart_calendar(&mut self, cal_id: &CalendarId, needle: Arc<Mutex<U>>) -> Result<Arc<Mutex<T>>, Box<dyn Error>> {
get_or_insert_counterpart_calendar("local", &mut self.local, cal_url, needle).await get_or_insert_counterpart_calendar("local", &mut self.local, cal_id, needle).await
} }
async fn get_or_insert_remote_counterpart_calendar(&mut self, cal_url: &Url, needle: Arc<Mutex<T>>) -> Result<Arc<Mutex<U>>, Box<dyn Error>> { async fn get_or_insert_remote_counterpart_calendar(&mut self, cal_id: &CalendarId, needle: Arc<Mutex<T>>) -> Result<Arc<Mutex<U>>, Box<dyn Error>> {
get_or_insert_counterpart_calendar("remote", &mut self.remote, cal_url, needle).await get_or_insert_counterpart_calendar("remote", &mut self.remote, cal_id, needle).await
} }
@ -186,10 +159,8 @@ where
let cal_name = cal_local.name().to_string(); let cal_name = cal_local.name().to_string();
progress.info(&format!("Syncing calendar {}", cal_name)); progress.info(&format!("Syncing calendar {}", cal_name));
progress.reset_counter();
progress.feedback(SyncEvent::InProgress{ progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(), calendar: cal_name.clone(),
items_done_already: 0,
details: "started".to_string() details: "started".to_string()
}); });
@ -205,56 +176,55 @@ where
let remote_items = cal_remote.get_item_version_tags().await?; let remote_items = cal_remote.get_item_version_tags().await?;
progress.feedback(SyncEvent::InProgress{ progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(), calendar: cal_name.clone(),
items_done_already: 0,
details: format!("{} remote items", remote_items.len()), details: format!("{} remote items", remote_items.len()),
}); });
let mut local_items_to_handle = cal_local.get_item_urls().await?; let mut local_items_to_handle = cal_local.get_item_ids().await?;
for (url, remote_tag) in remote_items { for (id, remote_tag) in remote_items {
progress.trace(&format!("***** Considering remote item {}...", url)); progress.trace(&format!("***** Considering remote item {}...", id));
match cal_local.get_item_by_url(&url).await { match cal_local.get_item_by_id(&id).await {
None => { None => {
// This was created on the remote // This was created on the remote
progress.debug(&format!("* {} is a remote addition", url)); progress.debug(&format!("* {} is a remote addition", id));
remote_additions.insert(url); remote_additions.insert(id);
}, },
Some(local_item) => { Some(local_item) => {
if local_items_to_handle.remove(&url) == false { if local_items_to_handle.remove(&id) == false {
progress.error(&format!("Inconsistent state: missing task {} from the local tasks", url)); progress.error(&format!("Inconsistent state: missing task {} from the local tasks", id));
} }
match local_item.sync_status() { match local_item.sync_status() {
SyncStatus::NotSynced => { SyncStatus::NotSynced => {
progress.error(&format!("URL reuse between remote and local sources ({}). Ignoring this item in the sync", url)); progress.error(&format!("ID reuse between remote and local sources ({}). Ignoring this item in the sync", id));
continue; continue;
}, },
SyncStatus::Synced(local_tag) => { SyncStatus::Synced(local_tag) => {
if &remote_tag != local_tag { if &remote_tag != local_tag {
// This has been modified on the remote // This has been modified on the remote
progress.debug(&format!("* {} is a remote change", url)); progress.debug(&format!("* {} is a remote change", id));
remote_changes.insert(url); remote_changes.insert(id);
} }
}, },
SyncStatus::LocallyModified(local_tag) => { SyncStatus::LocallyModified(local_tag) => {
if &remote_tag == local_tag { if &remote_tag == local_tag {
// This has been changed locally // This has been changed locally
progress.debug(&format!("* {} is a local change", url)); progress.debug(&format!("* {} is a local change", id));
local_changes.insert(url); local_changes.insert(id);
} else { } else {
progress.info(&format!("Conflict: task {} has been modified in both sources. Using the remote version.", url)); progress.info(&format!("Conflict: task {} has been modified in both sources. Using the remote version.", id));
progress.debug(&format!("* {} is considered a remote change", url)); progress.debug(&format!("* {} is considered a remote change", id));
remote_changes.insert(url); remote_changes.insert(id);
} }
}, },
SyncStatus::LocallyDeleted(local_tag) => { SyncStatus::LocallyDeleted(local_tag) => {
if &remote_tag == local_tag { if &remote_tag == local_tag {
// This has been locally deleted // This has been locally deleted
progress.debug(&format!("* {} is a local deletion", url)); progress.debug(&format!("* {} is a local deletion", id));
local_del.insert(url); local_del.insert(id);
} else { } else {
progress.info(&format!("Conflict: task {} has been locally deleted and remotely modified. Reverting to the remote version.", url)); progress.info(&format!("Conflict: task {} has been locally deleted and remotely modified. Reverting to the remote version.", id));
progress.debug(&format!("* {} is a considered a remote change", url)); progress.debug(&format!("* {} is a considered a remote change", id));
remote_changes.insert(url); remote_changes.insert(id);
} }
}, },
} }
@ -263,11 +233,11 @@ where
} }
// Also iterate on the local tasks that are not on the remote // Also iterate on the local tasks that are not on the remote
for url in local_items_to_handle { for id in local_items_to_handle {
progress.trace(&format!("##### Considering local item {}...", url)); progress.trace(&format!("##### Considering local item {}...", id));
let local_item = match cal_local.get_item_by_url(&url).await { let local_item = match cal_local.get_item_by_id(&id).await {
None => { None => {
progress.error(&format!("Inconsistent state: missing task {} from the local tasks", url)); progress.error(&format!("Inconsistent state: missing task {} from the local tasks", id));
continue; continue;
}, },
Some(item) => item, Some(item) => item,
@ -276,22 +246,22 @@ where
match local_item.sync_status() { match local_item.sync_status() {
SyncStatus::Synced(_) => { SyncStatus::Synced(_) => {
// This item has been removed from the remote // This item has been removed from the remote
progress.debug(&format!("# {} is a deletion from the server", url)); progress.debug(&format!("# {} is a deletion from the server", id));
remote_del.insert(url); remote_del.insert(id);
}, },
SyncStatus::NotSynced => { SyncStatus::NotSynced => {
// This item has just been locally created // This item has just been locally created
progress.debug(&format!("# {} has been locally created", url)); progress.debug(&format!("# {} has been locally created", id));
local_additions.insert(url); local_additions.insert(id);
}, },
SyncStatus::LocallyDeleted(_) => { SyncStatus::LocallyDeleted(_) => {
// This item has been deleted from both sources // This item has been deleted from both sources
progress.debug(&format!("# {} has been deleted from both sources", url)); progress.debug(&format!("# {} has been deleted from both sources", id));
remote_del.insert(url); remote_del.insert(id);
}, },
SyncStatus::LocallyModified(_) => { SyncStatus::LocallyModified(_) => {
progress.info(&format!("Conflict: item {} has been deleted from the server and locally modified. Deleting the local copy", url)); progress.info(&format!("Conflict: item {} has been deleted from the server and locally modified. Deleting the local copy", id));
remote_del.insert(url); remote_del.insert(id);
}, },
} }
} }
@ -299,74 +269,101 @@ where
// Step 2 - commit changes // Step 2 - commit changes
progress.trace("Committing changes..."); progress.trace("Committing changes...");
for url_del in local_del { for id_del in local_del {
progress.debug(&format!("> Pushing local deletion {} to the server", url_del)); progress.debug(&format!("> Pushing local deletion {} to the server", id_del));
progress.increment_counter(1);
progress.feedback(SyncEvent::InProgress{ progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(), calendar: cal_name.clone(),
items_done_already: progress.counter(), details: Self::item_name(&cal_local, &id_del).await,
details: Self::item_name(&cal_local, &url_del).await,
}); });
match cal_remote.delete_item(&id_del).await {
match cal_remote.delete_item(&url_del).await {
Err(err) => { Err(err) => {
progress.warn(&format!("Unable to delete remote item {}: {}", url_del, err)); progress.warn(&format!("Unable to delete remote item {}: {}", id_del, err));
}, },
Ok(()) => { Ok(()) => {
// Change the local copy from "marked to deletion" to "actually deleted" // Change the local copy from "marked to deletion" to "actually deleted"
if let Err(err) = cal_local.immediately_delete_item(&url_del).await { if let Err(err) = cal_local.immediately_delete_item(&id_del).await {
progress.error(&format!("Unable to permanently delete local item {}: {}", url_del, err)); progress.error(&format!("Unable to permanently delete local item {}: {}", id_del, err));
} }
}, },
} }
} }
for url_del in remote_del { for id_del in remote_del {
progress.debug(&format!("> Applying remote deletion {} locally", url_del)); progress.debug(&format!("> Applying remote deletion {} locally", id_del));
progress.increment_counter(1);
progress.feedback(SyncEvent::InProgress{ progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(), calendar: cal_name.clone(),
items_done_already: progress.counter(), details: Self::item_name(&cal_local, &id_del).await,
details: Self::item_name(&cal_local, &url_del).await,
}); });
if let Err(err) = cal_local.immediately_delete_item(&url_del).await { if let Err(err) = cal_local.immediately_delete_item(&id_del).await {
progress.warn(&format!("Unable to delete local item {}: {}", url_del, err)); progress.warn(&format!("Unable to delete local item {}: {}", id_del, err));
} }
} }
Self::apply_remote_additions( for id_add in remote_additions {
remote_additions, progress.debug(&format!("> Applying remote addition {} locally", id_add));
&mut *cal_local, progress.feedback(SyncEvent::InProgress{
&mut *cal_remote, calendar: cal_name.clone(),
progress, details: Self::item_name(&cal_local, &id_add).await,
&cal_name });
).await; match cal_remote.get_item_by_id(&id_add).await {
Err(err) => {
progress.warn(&format!("Unable to get remote item {}: {}. Skipping it.", id_add, err));
continue;
},
Ok(item) => match item {
None => {
progress.error(&format!("Inconsistency: new item {} has vanished from the remote end", id_add));
continue;
},
Some(new_item) => {
if let Err(err) = cal_local.add_item(new_item.clone()).await {
progress.error(&format!("Not able to add item {} to local calendar: {}", id_add, err));
}
},
},
}
}
Self::apply_remote_changes( for id_change in remote_changes {
remote_changes, progress.debug(&format!("> Applying remote change {} locally", id_change));
&mut *cal_local, progress.feedback(SyncEvent::InProgress{
&mut *cal_remote, calendar: cal_name.clone(),
progress, details: Self::item_name(&cal_local, &id_change).await,
&cal_name });
).await; match cal_remote.get_item_by_id(&id_change).await {
Err(err) => {
progress.warn(&format!("Unable to get remote item {}: {}. Skipping it", id_change, err));
continue;
},
Ok(item) => match item {
None => {
progress.error(&format!("Inconsistency: modified item {} has vanished from the remote end", id_change));
continue;
},
Some(item) => {
if let Err(err) = cal_local.update_item(item.clone()).await {
progress.error(&format!("Unable to update item {} in local calendar: {}", id_change, err));
}
},
}
}
}
for url_add in local_additions { for id_add in local_additions {
progress.debug(&format!("> Pushing local addition {} to the server", url_add)); progress.debug(&format!("> Pushing local addition {} to the server", id_add));
progress.increment_counter(1);
progress.feedback(SyncEvent::InProgress{ progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(), calendar: cal_name.clone(),
items_done_already: progress.counter(), details: Self::item_name(&cal_local, &id_add).await,
details: Self::item_name(&cal_local, &url_add).await,
}); });
match cal_local.get_item_by_url_mut(&url_add).await { match cal_local.get_item_by_id_mut(&id_add).await {
None => { None => {
progress.error(&format!("Inconsistency: created item {} has been marked for upload but is locally missing", url_add)); progress.error(&format!("Inconsistency: created item {} has been marked for upload but is locally missing", id_add));
continue; continue;
}, },
Some(item) => { Some(item) => {
match cal_remote.add_item(item.clone()).await { match cal_remote.add_item(item.clone()).await {
Err(err) => progress.error(&format!("Unable to add item {} to remote calendar: {}", url_add, err)), Err(err) => progress.error(&format!("Unable to add item {} to remote calendar: {}", id_add, err)),
Ok(new_ss) => { Ok(new_ss) => {
// Update local sync status // Update local sync status
item.set_sync_status(new_ss); item.set_sync_status(new_ss);
@ -376,22 +373,20 @@ where
}; };
} }
for url_change in local_changes { for id_change in local_changes {
progress.debug(&format!("> Pushing local change {} to the server", url_change)); progress.debug(&format!("> Pushing local change {} to the server", id_change));
progress.increment_counter(1);
progress.feedback(SyncEvent::InProgress{ progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(), calendar: cal_name.clone(),
items_done_already: progress.counter(), details: Self::item_name(&cal_local, &id_change).await,
details: Self::item_name(&cal_local, &url_change).await,
}); });
match cal_local.get_item_by_url_mut(&url_change).await { match cal_local.get_item_by_id_mut(&id_change).await {
None => { None => {
progress.error(&format!("Inconsistency: modified item {} has been marked for upload but is locally missing", url_change)); progress.error(&format!("Inconsistency: modified item {} has been marked for upload but is locally missing", id_change));
continue; continue;
}, },
Some(item) => { Some(item) => {
match cal_remote.update_item(item.clone()).await { match cal_remote.update_item(item.clone()).await {
Err(err) => progress.error(&format!("Unable to update item {} in remote calendar: {}", url_change, err)), Err(err) => progress.error(&format!("Unable to update item {} in remote calendar: {}", id_change, err)),
Ok(new_ss) => { Ok(new_ss) => {
// Update local sync status // Update local sync status
item.set_sync_status(new_ss); item.set_sync_status(new_ss);
@ -405,86 +400,14 @@ where
} }
async fn item_name(cal: &T, url: &Url) -> String { async fn item_name(cal: &T, id: &ItemId) -> String {
cal.get_item_by_url(url).await.map(|item| item.name()).unwrap_or_default().to_string() cal.get_item_by_id(id).await.map(|item| item.name()).unwrap_or_default().to_string()
}
async fn apply_remote_additions(
mut remote_additions: HashSet<Url>,
cal_local: &mut T,
cal_remote: &mut U,
progress: &mut SyncProgress,
cal_name: &str
) {
for batch in remote_additions.drain().chunks(DOWNLOAD_BATCH_SIZE).into_iter() {
Self::fetch_batch_and_apply(BatchDownloadType::RemoteAdditions, batch, cal_local, cal_remote, progress, cal_name).await;
}
}
async fn apply_remote_changes(
mut remote_changes: HashSet<Url>,
cal_local: &mut T,
cal_remote: &mut U,
progress: &mut SyncProgress,
cal_name: &str
) {
for batch in remote_changes.drain().chunks(DOWNLOAD_BATCH_SIZE).into_iter() {
Self::fetch_batch_and_apply(BatchDownloadType::RemoteChanges, batch, cal_local, cal_remote, progress, cal_name).await;
}
}
async fn fetch_batch_and_apply<I: Iterator<Item = Url>>(
batch_type: BatchDownloadType,
remote_additions: I,
cal_local: &mut T,
cal_remote: &mut U,
progress: &mut SyncProgress,
cal_name: &str
) {
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 */);
let list_of_additions: Vec<Url> = remote_additions.map(|url| url.clone()).collect();
match cal_remote.get_items_by_url(&list_of_additions).await {
Err(err) => {
progress.warn(&format!("Unable to get the batch of {} {:?}: {}. Skipping them.", batch_type, list_of_additions, err));
},
Ok(items) => {
for item in items {
match item {
None => {
progress.error(&format!("Inconsistency: an item from the batch has vanished from the remote end"));
continue;
},
Some(new_item) => {
let local_update_result = match batch_type {
BatchDownloadType::RemoteAdditions => cal_local.add_item(new_item.clone()).await,
BatchDownloadType::RemoteChanges => cal_local.update_item(new_item.clone()).await,
};
if let Err(err) = local_update_result {
progress.error(&format!("Not able to add item {} to local calendar: {}", new_item.url(), err));
}
},
}
} }
// Notifying every item at the same time would not make sense. Let's notify only one of them
let one_item_name = match list_of_additions.get(0) {
Some(url) => Self::item_name(&cal_local, &url).await,
None => String::from("<unable to get the name of the first batched item>"),
};
progress.increment_counter(list_of_additions.len());
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.to_string(),
items_done_already: progress.counter(),
details: one_item_name,
});
},
}
}
} }
async fn get_or_insert_counterpart_calendar<H, N, I>(haystack_descr: &str, haystack: &mut H, cal_url: &Url, needle: Arc<Mutex<N>>) async fn get_or_insert_counterpart_calendar<H, N, I>(haystack_descr: &str, haystack: &mut H, cal_id: &CalendarId, needle: Arc<Mutex<N>>)
-> Result<Arc<Mutex<I>>, Box<dyn Error>> -> Result<Arc<Mutex<I>>, Box<dyn Error>>
where where
H: CalDavSource<I>, H: CalDavSource<I>,
@ -492,18 +415,18 @@ where
N: BaseCalendar, N: BaseCalendar,
{ {
loop { loop {
if let Some(cal) = haystack.get_calendar(&cal_url).await { if let Some(cal) = haystack.get_calendar(&cal_id).await {
break Ok(cal); break Ok(cal);
} }
// This calendar does not exist locally yet, let's add it // This calendar does not exist locally yet, let's add it
log::debug!("Adding a {} calendar {}", haystack_descr, cal_url); log::debug!("Adding a {} calendar {}", haystack_descr, cal_id);
let src = needle.lock().unwrap(); let src = needle.lock().unwrap();
let name = src.name().to_string(); let name = src.name().to_string();
let supported_comps = src.supported_components(); let supported_comps = src.supported_components();
let color = src.color(); let color = src.color();
if let Err(err) = haystack.create_calendar( if let Err(err) = haystack.create_calendar(
cal_url.clone(), cal_id.clone(),
name, name,
supported_comps, supported_comps,
color.cloned(), color.cloned(),

27
src/provider/sync_progress.rs

@ -10,7 +10,7 @@ pub enum SyncEvent {
/// Sync has just started but no calendar is handled yet /// Sync has just started but no calendar is handled yet
Started, Started,
/// Sync is in progress. /// Sync is in progress.
InProgress{ calendar: String, items_done_already: usize, details: String}, InProgress{ calendar: String, details: String},
/// Sync is finished /// Sync is finished
Finished{ success: bool }, Finished{ success: bool },
} }
@ -20,7 +20,7 @@ impl Display for SyncEvent {
match self { match self {
SyncEvent::NotStarted => write!(f, "Not started"), SyncEvent::NotStarted => write!(f, "Not started"),
SyncEvent::Started => write!(f, "Sync has started..."), SyncEvent::Started => write!(f, "Sync has started..."),
SyncEvent::InProgress{calendar, items_done_already, details} => write!(f, "{} [{}/?] {}...", calendar, items_done_already, details), SyncEvent::InProgress{calendar, details} => write!(f, "[{}] {}...", calendar, details),
SyncEvent::Finished{success} => match success { SyncEvent::Finished{success} => match success {
true => write!(f, "Sync successfully finished"), true => write!(f, "Sync successfully finished"),
false => write!(f, "Sync finished with errors"), false => write!(f, "Sync finished with errors"),
@ -53,33 +53,16 @@ pub fn feedback_channel() -> (FeedbackSender, FeedbackReceiver) {
/// A structure that tracks the progression and the errors that happen during a sync /// A structure that tracks the progression and the errors that happen during a sync
pub struct SyncProgress { pub struct SyncProgress {
n_errors: u32, n_errors: u32,
feedback_channel: Option<FeedbackSender>, feedback_channel: Option<FeedbackSender>
counter: usize,
} }
impl SyncProgress { impl SyncProgress {
pub fn new() -> Self { pub fn new() -> Self {
Self { n_errors: 0, feedback_channel: None, counter: 0 } Self { n_errors: 0, feedback_channel: None }
} }
pub fn new_with_feedback_channel(channel: FeedbackSender) -> Self { pub fn new_with_feedback_channel(channel: FeedbackSender) -> Self {
Self { n_errors: 0, feedback_channel: Some(channel), counter: 0 } Self { n_errors: 0, feedback_channel: Some(channel) }
} }
/// Reset the user-info counter
pub fn reset_counter(&mut self) {
self.counter = 0;
}
/// Increments the user-info counter.
pub fn increment_counter(&mut self, increment: usize) {
self.counter += increment;
}
/// Retrieves the current user-info counter.
/// This counts "arbitrary things", that's provided as a convenience but it is not used internally
/// (e.g. that can be used to keep track of the items handled for the current calendar)
pub fn counter(&self) -> usize {
self.counter
}
pub fn is_success(&self) -> bool { pub fn is_success(&self) -> bool {
self.n_errors == 0 self.n_errors == 0

24
src/task.rs

@ -4,10 +4,10 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use ical::property::Property; use ical::property::Property;
use url::Url;
use crate::item::ItemId;
use crate::item::SyncStatus; use crate::item::SyncStatus;
use crate::utils::random_url; use crate::calendar::CalendarId;
/// RFC5545 defines the completion as several optional fields, yet some combinations make no sense. /// RFC5545 defines the completion as several optional fields, yet some combinations make no sense.
/// This enum provides an API that forbids such impossible combinations. /// This enum provides an API that forbids such impossible combinations.
@ -33,11 +33,10 @@ impl CompletionStatus {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Task { pub struct Task {
/// The task URL /// The task URL
url: Url, id: ItemId,
/// Persistent, globally unique identifier for the calendar component /// Persistent, globally unique identifier for the calendar component
/// The [RFC](https://tools.ietf.org/html/rfc5545#page-117) recommends concatenating a timestamp with the server's domain name. /// The [RFC](https://tools.ietf.org/html/rfc5545#page-117) recommends concatenating a timestamp with the server's domain name, but UUID are even better
/// 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.
uid: String, uid: String,
/// The sync status of this item /// The sync status of this item
@ -66,8 +65,8 @@ pub struct Task {
impl Task { impl Task {
/// Create a brand new Task that is not on a server yet. /// Create a brand new Task that is not on a server yet.
/// This will pick a new (random) task ID. /// This will pick a new (random) task ID.
pub fn new(name: String, completed: bool, parent_calendar_url: &Url) -> Self { pub fn new(name: String, completed: bool, parent_calendar_id: &CalendarId) -> Self {
let new_url = random_url(parent_calendar_url); let new_item_id = ItemId::random(parent_calendar_id);
let new_sync_status = SyncStatus::NotSynced; let new_sync_status = SyncStatus::NotSynced;
let new_uid = Uuid::new_v4().to_hyphenated().to_string(); let new_uid = Uuid::new_v4().to_hyphenated().to_string();
let new_creation_date = Some(Utc::now()); let new_creation_date = Some(Utc::now());
@ -77,18 +76,18 @@ impl Task {
} else { CompletionStatus::Uncompleted }; } else { CompletionStatus::Uncompleted };
let ical_prod_id = crate::ical::default_prod_id(); let ical_prod_id = crate::ical::default_prod_id();
let extra_parameters = Vec::new(); let extra_parameters = Vec::new();
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) Self::new_with_parameters(name, new_uid, new_item_id, new_completion_status, new_sync_status, new_creation_date, new_last_modified, ical_prod_id, extra_parameters)
} }
/// Create a new Task instance, that may be synced on the server already /// Create a new Task instance, that may be synced on the server already
pub fn new_with_parameters(name: String, uid: String, new_url: Url, pub fn new_with_parameters(name: String, uid: String, id: ItemId,
completion_status: CompletionStatus, completion_status: CompletionStatus,
sync_status: SyncStatus, creation_date: Option<DateTime<Utc>>, last_modified: DateTime<Utc>, sync_status: SyncStatus, creation_date: Option<DateTime<Utc>>, last_modified: DateTime<Utc>,
ical_prod_id: String, extra_parameters: Vec<Property>, ical_prod_id: String, extra_parameters: Vec<Property>,
) -> Self ) -> Self
{ {
Self { Self {
url: new_url, id,
uid, uid,
name, name,
completion_status, completion_status,
@ -100,7 +99,7 @@ impl Task {
} }
} }
pub fn url(&self) -> &Url { &self.url } pub fn id(&self) -> &ItemId { &self.id }
pub fn uid(&self) -> &str { &self.uid } pub fn uid(&self) -> &str { &self.uid }
pub fn name(&self) -> &str { &self.name } pub fn name(&self) -> &str { &self.name }
pub fn completed(&self) -> bool { self.completion_status.is_completed() } pub fn completed(&self) -> bool { self.completion_status.is_completed() }
@ -113,8 +112,7 @@ impl Task {
#[cfg(any(test, feature = "integration_tests"))] #[cfg(any(test, feature = "integration_tests"))]
pub fn has_same_observable_content_as(&self, other: &Task) -> bool { pub fn has_same_observable_content_as(&self, other: &Task) -> bool {
self.url == other.url self.id == other.id
&& self.uid == other.uid
&& self.name == other.name && self.name == other.name
// sync status must be the same variant, but we ignore its embedded version tag // sync status must be the same variant, but we ignore its embedded version tag
&& std::mem::discriminant(&self.sync_status) == std::mem::discriminant(&other.sync_status) && std::mem::discriminant(&self.sync_status) == std::mem::discriminant(&other.sync_status)

64
src/traits.rs

@ -1,46 +1,41 @@
//! Traits used by multiple structs in this crate
use std::error::Error; use std::error::Error;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use async_trait::async_trait; use async_trait::async_trait;
use csscolorparser::Color; use csscolorparser::Color;
use url::Url;
use crate::item::SyncStatus; use crate::item::SyncStatus;
use crate::item::Item; use crate::item::Item;
use crate::item::ItemId;
use crate::item::VersionTag; use crate::item::VersionTag;
use crate::calendar::CalendarId;
use crate::calendar::SupportedComponents; use crate::calendar::SupportedComponents;
use crate::resource::Resource; use crate::resource::Resource;
/// This trait must be implemented by data sources (either local caches or remote CalDAV clients) /// This trait must be implemented by data sources (either local caches or remote CalDAV clients)
///
/// Note that some concrete types (e.g. [`crate::cache::Cache`]) can also provide non-async versions of these functions
#[async_trait] #[async_trait]
pub trait CalDavSource<T: BaseCalendar> { pub trait CalDavSource<T: BaseCalendar> {
/// Returns the current calendars that this source contains /// Returns the current calendars that this source contains
/// 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) /// 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)
async fn get_calendars(&self) -> Result<HashMap<Url, Arc<Mutex<T>>>, Box<dyn Error>>; async fn get_calendars(&self) -> Result<HashMap<CalendarId, Arc<Mutex<T>>>, Box<dyn Error>>;
/// Returns the calendar matching the URL /// Returns the calendar matching the ID
async fn get_calendar(&self, url: &Url) -> Option<Arc<Mutex<T>>>; async fn get_calendar(&self, id: &CalendarId) -> Option<Arc<Mutex<T>>>;
/// Create a calendar if it did not exist, and return it /// Create a calendar if it did not exist, and return it
async fn create_calendar(&mut self, url: Url, name: String, supported_components: SupportedComponents, color: Option<Color>) async fn create_calendar(&mut self, id: CalendarId, name: String, supported_components: SupportedComponents, color: Option<Color>)
-> Result<Arc<Mutex<T>>, Box<dyn Error>>; -> Result<Arc<Mutex<T>>, Box<dyn Error>>;
// Removing a calendar is not supported yet // Removing a calendar is not supported yet
} }
/// This trait contains functions that are common to all calendars /// This trait contains functions that are common to all calendars
///
/// Note that some concrete types (e.g. [`crate::calendar::cached_calendar::CachedCalendar`]) can also provide non-async versions of these functions
#[async_trait] #[async_trait]
pub trait BaseCalendar { pub trait BaseCalendar {
/// Returns the calendar name /// Returns the calendar name
fn name(&self) -> &str; fn name(&self) -> &str;
/// Returns the calendar URL /// Returns the calendar unique ID
fn url(&self) -> &Url; fn id(&self) -> &CalendarId;
/// Returns the supported kinds of components for this calendar /// Returns the supported kinds of components for this calendar
fn supported_components(&self) -> crate::calendar::SupportedComponents; fn supported_components(&self) -> crate::calendar::SupportedComponents;
@ -70,31 +65,25 @@ pub trait BaseCalendar {
/// Functions availabe for calendars that are backed by a CalDAV server /// Functions availabe for calendars that are backed by a CalDAV server
///
/// Note that some concrete types (e.g. [`crate::calendar::cached_calendar::CachedCalendar`]) can also provide non-async versions of these functions
#[async_trait] #[async_trait]
pub trait DavCalendar : BaseCalendar { pub trait DavCalendar : BaseCalendar {
/// Create a new calendar /// Create a new calendar
fn new(name: String, resource: Resource, supported_components: SupportedComponents, color: Option<Color>) -> Self; fn new(name: String, resource: Resource, supported_components: SupportedComponents, color: Option<Color>) -> Self;
/// Get the URLs and the version tags of every item in this calendar /// Get the IDs and the version tags of every item in this calendar
async fn get_item_version_tags(&self) -> Result<HashMap<Url, VersionTag>, Box<dyn Error>>; async fn get_item_version_tags(&self) -> Result<HashMap<ItemId, VersionTag>, Box<dyn Error>>;
/// Returns a particular item /// Returns a particular item
async fn get_item_by_url(&self, url: &Url) -> Result<Option<Item>, Box<dyn Error>>; async fn get_item_by_id(&self, id: &ItemId) -> Result<Option<Item>, Box<dyn Error>>;
/// Returns a set of items.
/// This is usually faster than calling multiple consecutive [`DavCalendar::get_item_by_url`], since it only issues one HTTP request.
async fn get_items_by_url(&self, urls: &[Url]) -> Result<Vec<Option<Item>>, Box<dyn Error>>;
/// Delete an item /// Delete an item
async fn delete_item(&mut self, item_url: &Url) -> Result<(), Box<dyn Error>>; async fn delete_item(&mut self, item_id: &ItemId) -> Result<(), Box<dyn Error>>;
/// Get the URLs of all current items in this calendar /// Get the IDs of all current items in this calendar
async fn get_item_urls(&self) -> Result<HashSet<Url>, Box<dyn Error>> { async fn get_item_ids(&self) -> Result<HashSet<ItemId>, Box<dyn Error>> {
let items = self.get_item_version_tags().await?; let items = self.get_item_version_tags().await?;
Ok(items.iter() Ok(items.iter()
.map(|(url, _tag)| url.clone()) .map(|(id, _tag)| id.clone())
.collect()) .collect())
} }
@ -106,33 +95,30 @@ pub trait DavCalendar : BaseCalendar {
/// Functions availabe for calendars we have full knowledge of /// Functions availabe for calendars we have full knowledge of
/// ///
/// Usually, these are local calendars fully backed by a local folder /// Usually, these are local calendars fully backed by a local folder
///
/// Note that some concrete types (e.g. [`crate::calendar::cached_calendar::CachedCalendar`]) can also provide non-async versions of these functions
#[async_trait] #[async_trait]
pub trait CompleteCalendar : BaseCalendar { pub trait CompleteCalendar : BaseCalendar {
/// Create a new calendar /// Create a new calendar
fn new(name: String, url: Url, supported_components: SupportedComponents, color: Option<Color>) -> Self; fn new(name: String, id: CalendarId, supported_components: SupportedComponents, color: Option<Color>) -> Self;
/// Get the URLs of all current items in this calendar /// Get the IDs of all current items in this calendar
async fn get_item_urls(&self) -> Result<HashSet<Url>, Box<dyn Error>>; async fn get_item_ids(&self) -> Result<HashSet<ItemId>, Box<dyn Error>>;
/// Returns all items that this calendar contains
async fn get_items(&self) -> Result<HashMap<Url, &Item>, Box<dyn Error>>;
/// Returns all items that this calendar contains /// Returns all items that this calendar contains
async fn get_items_mut(&mut self) -> Result<HashMap<Url, &mut Item>, Box<dyn Error>>; ///
/// See [`crate::utils::comparison`] for helper functions that help sorting the results
async fn get_items(&self) -> Result<HashMap<ItemId, &Item>, Box<dyn Error>>;
/// Returns a particular item /// Returns a particular item
async fn get_item_by_url<'a>(&'a self, url: &Url) -> Option<&'a Item>; async fn get_item_by_id<'a>(&'a self, id: &ItemId) -> Option<&'a Item>;
/// Returns a particular item /// Returns a particular item
async fn get_item_by_url_mut<'a>(&'a mut self, url: &Url) -> Option<&'a mut Item>; async fn get_item_by_id_mut<'a>(&'a mut self, id: &ItemId) -> Option<&'a mut Item>;
/// Mark an item for deletion. /// Mark an item for deletion.
/// This is required so that the upcoming sync will know it should also also delete this task from the server /// This is required so that the upcoming sync will know it should also also delete this task from the server
/// (and then call [`CompleteCalendar::immediately_delete_item`] once it has been successfully deleted on the server) /// (and then call [`CompleteCalendar::immediately_delete_item`] once it has been successfully deleted on the server)
async fn mark_for_deletion(&mut self, item_id: &Url) -> Result<(), Box<dyn Error>>; async fn mark_for_deletion(&mut self, item_id: &ItemId) -> Result<(), Box<dyn Error>>;
/// Immediately remove an item. See [`CompleteCalendar::mark_for_deletion`] /// Immediately remove an item. See [`CompleteCalendar::mark_for_deletion`]
async fn immediately_delete_item(&mut self, item_id: &Url) -> Result<(), Box<dyn Error>>; async fn immediately_delete_item(&mut self, item_id: &ItemId) -> Result<(), Box<dyn Error>>;
} }

29
src/utils/mod.rs

@ -1,4 +1,4 @@
//! Some utility functions ///! Some utility functions
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -6,10 +6,10 @@ use std::hash::Hash;
use std::io::{stdin, stdout, Read, Write}; use std::io::{stdin, stdout, Read, Write};
use minidom::Element; use minidom::Element;
use url::Url;
use crate::traits::CompleteCalendar; use crate::traits::CompleteCalendar;
use crate::traits::DavCalendar; use crate::traits::DavCalendar;
use crate::calendar::CalendarId;
use crate::Item; use crate::Item;
use crate::item::SyncStatus; use crate::item::SyncStatus;
@ -62,12 +62,12 @@ pub fn print_xml(element: &Element) {
} }
/// A debug utility that pretty-prints calendars /// A debug utility that pretty-prints calendars
pub async fn print_calendar_list<C>(cals: &HashMap<Url, Arc<Mutex<C>>>) pub async fn print_calendar_list<C>(cals: &HashMap<CalendarId, Arc<Mutex<C>>>)
where where
C: CompleteCalendar, C: CompleteCalendar,
{ {
for (url, cal) in cals { for (id, cal) in cals {
println!("CAL {} ({})", cal.lock().unwrap().name(), url); println!("CAL {} ({})", cal.lock().unwrap().name(), id);
match cal.lock().unwrap().get_items().await { match cal.lock().unwrap().get_items().await {
Err(_err) => continue, Err(_err) => continue,
Ok(map) => { Ok(map) => {
@ -80,17 +80,17 @@ where
} }
/// A debug utility that pretty-prints calendars /// A debug utility that pretty-prints calendars
pub async fn print_dav_calendar_list<C>(cals: &HashMap<Url, Arc<Mutex<C>>>) pub async fn print_dav_calendar_list<C>(cals: &HashMap<CalendarId, Arc<Mutex<C>>>)
where where
C: DavCalendar, C: DavCalendar,
{ {
for (url, cal) in cals { for (id, cal) in cals {
println!("CAL {} ({})", cal.lock().unwrap().name(), url); println!("CAL {} ({})", cal.lock().unwrap().name(), id);
match cal.lock().unwrap().get_item_version_tags().await { match cal.lock().unwrap().get_item_version_tags().await {
Err(_err) => continue, Err(_err) => continue,
Ok(map) => { Ok(map) => {
for (url, version_tag) in map { for (id, version_tag) in map {
println!(" * {} (version {:?})", url, version_tag); println!(" * {} (version {:?})", id, version_tag);
} }
}, },
} }
@ -107,7 +107,7 @@ pub fn print_task(item: &Item) {
SyncStatus::LocallyModified(_) => "~", SyncStatus::LocallyModified(_) => "~",
SyncStatus::LocallyDeleted(_) => "x", SyncStatus::LocallyDeleted(_) => "x",
}; };
println!(" {}{} {}\t{}", completion, sync, task.name(), task.url()); println!(" {}{} {}\t{}", completion, sync, task.name(), task.id());
}, },
_ => return, _ => return,
} }
@ -148,10 +148,3 @@ pub fn pause() {
stdout.flush().unwrap(); stdout.flush().unwrap();
stdin().read_exact(&mut [0]).unwrap(); stdin().read_exact(&mut [0]).unwrap();
} }
/// Generate a random URL with a given prefix
pub fn random_url(parent_calendar: &Url) -> Url {
let random = uuid::Uuid::new_v4().to_hyphenated().to_string();
parent_calendar.join(&random).unwrap(/* this cannot panic since we've just created a string that is a valid URL */)
}

150
tests/scenarii.rs

@ -11,10 +11,10 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::error::Error; use std::error::Error;
use url::Url;
use chrono::Utc; use chrono::Utc;
use kitchen_fridge::calendar::CalendarId;
use kitchen_fridge::calendar::SupportedComponents; use kitchen_fridge::calendar::SupportedComponents;
use kitchen_fridge::traits::CalDavSource; use kitchen_fridge::traits::CalDavSource;
use kitchen_fridge::traits::BaseCalendar; use kitchen_fridge::traits::BaseCalendar;
@ -22,13 +22,13 @@ use kitchen_fridge::traits::CompleteCalendar;
use kitchen_fridge::traits::DavCalendar; use kitchen_fridge::traits::DavCalendar;
use kitchen_fridge::cache::Cache; use kitchen_fridge::cache::Cache;
use kitchen_fridge::Item; use kitchen_fridge::Item;
use kitchen_fridge::item::ItemId;
use kitchen_fridge::item::SyncStatus; use kitchen_fridge::item::SyncStatus;
use kitchen_fridge::Task; use kitchen_fridge::Task;
use kitchen_fridge::task::CompletionStatus; use kitchen_fridge::task::CompletionStatus;
use kitchen_fridge::calendar::cached_calendar::CachedCalendar; use kitchen_fridge::calendar::cached_calendar::CachedCalendar;
use kitchen_fridge::provider::Provider; use kitchen_fridge::provider::Provider;
use kitchen_fridge::mock_behaviour::MockBehaviour; use kitchen_fridge::mock_behaviour::MockBehaviour;
use kitchen_fridge::utils::random_url;
pub enum LocatedState { pub enum LocatedState {
/// Item does not exist yet or does not exist anymore /// Item does not exist yet or does not exist anymore
@ -44,7 +44,7 @@ pub enum LocatedState {
pub struct ItemState { pub struct ItemState {
// TODO: if/when this crate supports Events as well, we could add such events here // TODO: if/when this crate supports Events as well, we could add such events here
/// The calendar it is in /// The calendar it is in
calendar: Url, calendar: CalendarId,
/// Its name /// Its name
name: String, name: String,
/// Its completion status /// Its completion status
@ -54,15 +54,15 @@ pub struct ItemState {
pub enum ChangeToApply { pub enum ChangeToApply {
Rename(String), Rename(String),
SetCompletion(bool), SetCompletion(bool),
Create(Url, Item), Create(CalendarId, Item),
/// "remove" means "mark for deletion" in the local calendar, or "immediately delete" on the remote calendar /// "remove" means "mark for deletion" in the local calendar, or "immediately delete" on the remote calendar
Remove, Remove,
// ChangeCalendar(Url) is useless, as long as changing a calendar is implemented as "delete in one calendar and re-create it in another one" // ChangeCalendar(CalendarId) is useless, as long as changing a calendar is implemented as "delete in one calendar and re-create it in another one"
} }
pub struct ItemScenario { pub struct ItemScenario {
url: Url, id: ItemId,
initial_state: LocatedState, initial_state: LocatedState,
local_changes_to_apply: Vec<ChangeToApply>, local_changes_to_apply: Vec<ChangeToApply>,
remote_changes_to_apply: Vec<ChangeToApply>, remote_changes_to_apply: Vec<ChangeToApply>,
@ -87,13 +87,13 @@ pub struct ItemScenario {
pub fn scenarii_basic() -> Vec<ItemScenario> { pub fn scenarii_basic() -> Vec<ItemScenario> {
let mut tasks = Vec::new(); let mut tasks = Vec::new();
let first_cal = Url::from("https://some.calend.ar/calendar-1/".parse().unwrap()); let first_cal = CalendarId::from("https://some.calend.ar/calendar-1/".parse().unwrap());
let second_cal = Url::from("https://some.calend.ar/calendar-2/".parse().unwrap()); let second_cal = CalendarId::from("https://some.calend.ar/calendar-2/".parse().unwrap());
let third_cal = Url::from("https://some.calend.ar/calendar-3/".parse().unwrap()); let third_cal = CalendarId::from("https://some.calend.ar/calendar-3/".parse().unwrap());
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&first_cal), id: ItemId::random(&first_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: first_cal.clone(), calendar: first_cal.clone(),
name: String::from("Task A"), name: String::from("Task A"),
@ -111,7 +111,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&first_cal), id: ItemId::random(&first_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: first_cal.clone(), calendar: first_cal.clone(),
name: String::from("Task B"), name: String::from("Task B"),
@ -125,7 +125,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&first_cal), id: ItemId::random(&first_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: first_cal.clone(), calendar: first_cal.clone(),
name: String::from("Task C"), name: String::from("Task C"),
@ -139,7 +139,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&first_cal), id: ItemId::random(&first_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: first_cal.clone(), calendar: first_cal.clone(),
name: String::from("Task D"), name: String::from("Task D"),
@ -157,7 +157,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&first_cal), id: ItemId::random(&first_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: first_cal.clone(), calendar: first_cal.clone(),
name: String::from("Task E"), name: String::from("Task E"),
@ -175,7 +175,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&first_cal), id: ItemId::random(&first_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: first_cal.clone(), calendar: first_cal.clone(),
name: String::from("Task F"), name: String::from("Task F"),
@ -194,7 +194,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&second_cal), id: ItemId::random(&second_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: second_cal.clone(), calendar: second_cal.clone(),
name: String::from("Task G"), name: String::from("Task G"),
@ -212,7 +212,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&second_cal), id: ItemId::random(&second_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: second_cal.clone(), calendar: second_cal.clone(),
name: String::from("Task H"), name: String::from("Task H"),
@ -230,7 +230,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&second_cal), id: ItemId::random(&second_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: second_cal.clone(), calendar: second_cal.clone(),
name: String::from("Task I"), name: String::from("Task I"),
@ -249,7 +249,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&second_cal), id: ItemId::random(&second_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: second_cal.clone(), calendar: second_cal.clone(),
name: String::from("Task J"), name: String::from("Task J"),
@ -263,7 +263,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&second_cal), id: ItemId::random(&second_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: second_cal.clone(), calendar: second_cal.clone(),
name: String::from("Task K"), name: String::from("Task K"),
@ -281,7 +281,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&second_cal), id: ItemId::random(&second_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: second_cal.clone(), calendar: second_cal.clone(),
name: String::from("Task L"), name: String::from("Task L"),
@ -295,7 +295,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&second_cal), id: ItemId::random(&second_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: second_cal.clone(), calendar: second_cal.clone(),
name: String::from("Task M"), name: String::from("Task M"),
@ -313,7 +313,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&third_cal), id: ItemId::random(&third_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: third_cal.clone(), calendar: third_cal.clone(),
name: String::from("Task N"), name: String::from("Task N"),
@ -331,7 +331,7 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&third_cal), id: ItemId::random(&third_cal),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: third_cal.clone(), calendar: third_cal.clone(),
name: String::from("Task O"), name: String::from("Task O"),
@ -347,10 +347,10 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
} }
); );
let url_p = random_url(&third_cal); let id_p = ItemId::random(&third_cal);
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: url_p.clone(), id: id_p.clone(),
initial_state: LocatedState::BothSynced( ItemState{ initial_state: LocatedState::BothSynced( ItemState{
calendar: third_cal.clone(), calendar: third_cal.clone(),
name: String::from("Task P"), name: String::from("Task P"),
@ -369,16 +369,16 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
} }
); );
let url_q = random_url(&third_cal); let id_q = ItemId::random(&third_cal);
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: url_q.clone(), id: id_q.clone(),
initial_state: LocatedState::None, initial_state: LocatedState::None,
local_changes_to_apply: Vec::new(), local_changes_to_apply: Vec::new(),
remote_changes_to_apply: vec![ChangeToApply::Create(third_cal.clone(), Item::Task( remote_changes_to_apply: vec![ChangeToApply::Create(third_cal.clone(), Item::Task(
Task::new_with_parameters( Task::new_with_parameters(
String::from("Task Q, created on the server"), String::from("Task Q, created on the server"),
url_q.to_string(), url_q, id_q.to_string(), id_q,
CompletionStatus::Uncompleted, CompletionStatus::Uncompleted,
SyncStatus::random_synced(), Some(Utc::now()), Utc::now(), "prod_id".to_string(), Vec::new() ) SyncStatus::random_synced(), Some(Utc::now()), Utc::now(), "prod_id".to_string(), Vec::new() )
))], ))],
@ -390,15 +390,15 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
} }
); );
let url_r = random_url(&third_cal); let id_r = ItemId::random(&third_cal);
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: url_r.clone(), id: id_r.clone(),
initial_state: LocatedState::None, initial_state: LocatedState::None,
local_changes_to_apply: vec![ChangeToApply::Create(third_cal.clone(), Item::Task( local_changes_to_apply: vec![ChangeToApply::Create(third_cal.clone(), Item::Task(
Task::new_with_parameters( Task::new_with_parameters(
String::from("Task R, created locally"), String::from("Task R, created locally"),
url_r.to_string(), url_r, id_r.to_string(), id_r,
CompletionStatus::Uncompleted, CompletionStatus::Uncompleted,
SyncStatus::NotSynced, Some(Utc::now()), Utc::now(), "prod_id".to_string(), Vec::new() ) SyncStatus::NotSynced, Some(Utc::now()), Utc::now(), "prod_id".to_string(), Vec::new() )
))], ))],
@ -418,12 +418,12 @@ pub fn scenarii_basic() -> Vec<ItemScenario> {
pub fn scenarii_first_sync_to_local() -> Vec<ItemScenario> { pub fn scenarii_first_sync_to_local() -> Vec<ItemScenario> {
let mut tasks = Vec::new(); let mut tasks = Vec::new();
let cal1 = Url::from("https://some.calend.ar/first/".parse().unwrap()); let cal1 = CalendarId::from("https://some.calend.ar/first/".parse().unwrap());
let cal2 = Url::from("https://some.calend.ar/second/".parse().unwrap()); let cal2 = CalendarId::from("https://some.calend.ar/second/".parse().unwrap());
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&cal1), id: ItemId::random(&cal1),
initial_state: LocatedState::Remote( ItemState{ initial_state: LocatedState::Remote( ItemState{
calendar: cal1.clone(), calendar: cal1.clone(),
name: String::from("Task A1"), name: String::from("Task A1"),
@ -441,7 +441,7 @@ pub fn scenarii_first_sync_to_local() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&cal2), id: ItemId::random(&cal2),
initial_state: LocatedState::Remote( ItemState{ initial_state: LocatedState::Remote( ItemState{
calendar: cal2.clone(), calendar: cal2.clone(),
name: String::from("Task A2"), name: String::from("Task A2"),
@ -459,7 +459,7 @@ pub fn scenarii_first_sync_to_local() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&cal1), id: ItemId::random(&cal1),
initial_state: LocatedState::Remote( ItemState{ initial_state: LocatedState::Remote( ItemState{
calendar: cal1.clone(), calendar: cal1.clone(),
name: String::from("Task B1"), name: String::from("Task B1"),
@ -482,12 +482,12 @@ pub fn scenarii_first_sync_to_local() -> Vec<ItemScenario> {
pub fn scenarii_first_sync_to_server() -> Vec<ItemScenario> { pub fn scenarii_first_sync_to_server() -> Vec<ItemScenario> {
let mut tasks = Vec::new(); let mut tasks = Vec::new();
let cal3 = Url::from("https://some.calend.ar/third/".parse().unwrap()); let cal3 = CalendarId::from("https://some.calend.ar/third/".parse().unwrap());
let cal4 = Url::from("https://some.calend.ar/fourth/".parse().unwrap()); let cal4 = CalendarId::from("https://some.calend.ar/fourth/".parse().unwrap());
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&cal3), id: ItemId::random(&cal3),
initial_state: LocatedState::Local( ItemState{ initial_state: LocatedState::Local( ItemState{
calendar: cal3.clone(), calendar: cal3.clone(),
name: String::from("Task A3"), name: String::from("Task A3"),
@ -505,7 +505,7 @@ pub fn scenarii_first_sync_to_server() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&cal4), id: ItemId::random(&cal4),
initial_state: LocatedState::Local( ItemState{ initial_state: LocatedState::Local( ItemState{
calendar: cal4.clone(), calendar: cal4.clone(),
name: String::from("Task A4"), name: String::from("Task A4"),
@ -523,7 +523,7 @@ pub fn scenarii_first_sync_to_server() -> Vec<ItemScenario> {
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&cal3), id: ItemId::random(&cal3),
initial_state: LocatedState::Local( ItemState{ initial_state: LocatedState::Local( ItemState{
calendar: cal3.clone(), calendar: cal3.clone(),
name: String::from("Task B3"), name: String::from("Task B3"),
@ -547,11 +547,11 @@ pub fn scenarii_first_sync_to_server() -> Vec<ItemScenario> {
pub fn scenarii_transient_task() -> Vec<ItemScenario> { pub fn scenarii_transient_task() -> Vec<ItemScenario> {
let mut tasks = Vec::new(); let mut tasks = Vec::new();
let cal = Url::from("https://some.calend.ar/transient/".parse().unwrap()); let cal = CalendarId::from("https://some.calend.ar/transient/".parse().unwrap());
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: random_url(&cal), id: ItemId::random(&cal),
initial_state: LocatedState::Local( ItemState{ initial_state: LocatedState::Local( ItemState{
calendar: cal.clone(), calendar: cal.clone(),
name: String::from("A task, so that the calendar actually exists"), name: String::from("A task, so that the calendar actually exists"),
@ -567,16 +567,16 @@ pub fn scenarii_transient_task() -> Vec<ItemScenario> {
} }
); );
let url_transient = random_url(&cal); let id_transient = ItemId::random(&cal);
tasks.push( tasks.push(
ItemScenario { ItemScenario {
url: url_transient.clone(), id: id_transient.clone(),
initial_state: LocatedState::None, initial_state: LocatedState::None,
local_changes_to_apply: vec![ local_changes_to_apply: vec![
ChangeToApply::Create(cal, Item::Task( ChangeToApply::Create(cal, Item::Task(
Task::new_with_parameters( Task::new_with_parameters(
String::from("A transient task that will be deleted before the sync"), String::from("A transient task that will be deleted before the sync"),
url_transient.to_string(), url_transient, id_transient.to_string(), id_transient,
CompletionStatus::Uncompleted, CompletionStatus::Uncompleted,
SyncStatus::NotSynced, Some(Utc::now()), Utc::now(), SyncStatus::NotSynced, Some(Utc::now()), Utc::now(),
"prod_id".to_string(), Vec::new() ) "prod_id".to_string(), Vec::new() )
@ -637,8 +637,8 @@ async fn populate_test_provider(scenarii: &[ItemScenario], mock_behaviour: Arc<M
let new_item = Item::Task( let new_item = Item::Task(
Task::new_with_parameters( Task::new_with_parameters(
state.name.clone(), state.name.clone(),
item.url.to_string(), item.id.to_string(),
item.url.clone(), item.id.clone(),
completion_status, completion_status,
sync_status, sync_status,
Some(now), Some(now),
@ -667,54 +667,54 @@ async fn populate_test_provider(scenarii: &[ItemScenario], mock_behaviour: Arc<M
async fn apply_changes_on_provider(provider: &mut Provider<Cache, CachedCalendar, Cache, CachedCalendar>, scenarii: &[ItemScenario]) { async fn apply_changes_on_provider(provider: &mut Provider<Cache, CachedCalendar, Cache, CachedCalendar>, scenarii: &[ItemScenario]) {
// Apply changes to each item // Apply changes to each item
for item in scenarii { for item in scenarii {
let initial_calendar_url = match &item.initial_state { let initial_calendar_id = match &item.initial_state {
LocatedState::None => None, LocatedState::None => None,
LocatedState::Local(state) => Some(state.calendar.clone()), LocatedState::Local(state) => Some(state.calendar.clone()),
LocatedState::Remote(state) => Some(state.calendar.clone()), LocatedState::Remote(state) => Some(state.calendar.clone()),
LocatedState::BothSynced(state) => Some(state.calendar.clone()), LocatedState::BothSynced(state) => Some(state.calendar.clone()),
}; };
let mut calendar_url = initial_calendar_url.clone(); let mut calendar_id = initial_calendar_id.clone();
for local_change in &item.local_changes_to_apply { for local_change in &item.local_changes_to_apply {
calendar_url = Some(apply_change(provider.local(), calendar_url, &item.url, local_change, false).await); calendar_id = Some(apply_change(provider.local(), calendar_id, &item.id, local_change, false).await);
} }
let mut calendar_url = initial_calendar_url; let mut calendar_id = initial_calendar_id;
for remote_change in &item.remote_changes_to_apply { for remote_change in &item.remote_changes_to_apply {
calendar_url = Some(apply_change(provider.remote(), calendar_url, &item.url, remote_change, true).await); calendar_id = Some(apply_change(provider.remote(), calendar_id, &item.id, remote_change, true).await);
} }
} }
} }
async fn get_or_insert_calendar(source: &mut Cache, url: &Url) async fn get_or_insert_calendar(source: &mut Cache, id: &CalendarId)
-> Result<Arc<Mutex<CachedCalendar>>, Box<dyn Error>> -> Result<Arc<Mutex<CachedCalendar>>, Box<dyn Error>>
{ {
match source.get_calendar(url).await { match source.get_calendar(id).await {
Some(cal) => Ok(cal), Some(cal) => Ok(cal),
None => { None => {
let new_name = format!("Test calendar for URL {}", url); let new_name = format!("Test calendar for ID {}", id);
let supported_components = SupportedComponents::TODO; let supported_components = SupportedComponents::TODO;
let color = csscolorparser::parse("#ff8000").unwrap(); // TODO: we should rather have specific colors, depending on the calendars let color = csscolorparser::parse("#ff8000"); // TODO: we should rather have specific colors, depending on the calendars
source.create_calendar( source.create_calendar(
url.clone(), id.clone(),
new_name.to_string(), new_name.to_string(),
supported_components, supported_components,
Some(color), None,
).await ).await
} }
} }
} }
/// Apply a single change on a given source, and returns the calendar URL that was modified /// Apply a single change on a given source, and returns the calendar ID that was modified
async fn apply_change<S, C>(source: &S, calendar_url: Option<Url>, item_url: &Url, change: &ChangeToApply, is_remote: bool) -> Url async fn apply_change<S, C>(source: &S, calendar_id: Option<CalendarId>, item_id: &ItemId, change: &ChangeToApply, is_remote: bool) -> CalendarId
where where
S: CalDavSource<C>, S: CalDavSource<C>,
C: CompleteCalendar + DavCalendar, // in this test, we're using a calendar that mocks both kinds C: CompleteCalendar + DavCalendar, // in this test, we're using a calendar that mocks both kinds
{ {
match calendar_url { match calendar_id {
Some(cal) => { Some(cal) => {
apply_changes_on_an_existing_item(source, &cal, item_url, change, is_remote).await; apply_changes_on_an_existing_item(source, &cal, item_id, change, is_remote).await;
cal cal
}, },
None => { None => {
@ -723,14 +723,14 @@ where
} }
} }
async fn apply_changes_on_an_existing_item<S, C>(source: &S, calendar_url: &Url, item_url: &Url, change: &ChangeToApply, is_remote: bool) async fn apply_changes_on_an_existing_item<S, C>(source: &S, calendar_id: &CalendarId, item_id: &ItemId, change: &ChangeToApply, is_remote: bool)
where where
S: CalDavSource<C>, S: CalDavSource<C>,
C: CompleteCalendar + DavCalendar, // in this test, we're using a calendar that mocks both kinds C: CompleteCalendar + DavCalendar, // in this test, we're using a calendar that mocks both kinds
{ {
let cal = source.get_calendar(calendar_url).await.unwrap(); let cal = source.get_calendar(calendar_id).await.unwrap();
let mut cal = cal.lock().unwrap(); let mut cal = cal.lock().unwrap();
let task = cal.get_item_by_url_mut(item_url).await.unwrap().unwrap_task_mut(); let task = cal.get_item_by_id_mut(item_id).await.unwrap().unwrap_task_mut();
match change { match change {
ChangeToApply::Rename(new_name) => { ChangeToApply::Rename(new_name) => {
@ -753,18 +753,18 @@ where
}, },
ChangeToApply::Remove => { ChangeToApply::Remove => {
match is_remote { match is_remote {
false => cal.mark_for_deletion(item_url).await.unwrap(), false => cal.mark_for_deletion(item_id).await.unwrap(),
true => cal.delete_item(item_url).await.unwrap(), true => cal.delete_item(item_id).await.unwrap(),
}; };
}, },
ChangeToApply::Create(_calendar_url, _item) => { ChangeToApply::Create(_calendar_id, _item) => {
panic!("This function only handles already existing items"); panic!("This function only handles already existing items");
}, },
} }
} }
/// Create an item, and returns the URL of the calendar it was inserted in /// Create an item, and returns the calendar ID it was inserted in
async fn create_test_item<S, C>(source: &S, change: &ChangeToApply) -> Url async fn create_test_item<S, C>(source: &S, change: &ChangeToApply) -> CalendarId
where where
S: CalDavSource<C>, S: CalDavSource<C>,
C: CompleteCalendar + DavCalendar, // in this test, we're using a calendar that mocks both kinds C: CompleteCalendar + DavCalendar, // in this test, we're using a calendar that mocks both kinds
@ -775,10 +775,10 @@ where
ChangeToApply::Remove => { ChangeToApply::Remove => {
panic!("This function only creates items that do not exist yet"); panic!("This function only creates items that do not exist yet");
} }
ChangeToApply::Create(calendar_url, item) => { ChangeToApply::Create(calendar_id, item) => {
let cal = source.get_calendar(calendar_url).await.unwrap(); let cal = source.get_calendar(calendar_id).await.unwrap();
cal.lock().unwrap().add_item(item.clone()).await.unwrap(); cal.lock().unwrap().add_item(item.clone()).await.unwrap();
calendar_url.clone() calendar_id.clone()
}, },
} }
} }

1
tests/sync-reminder.rs

@ -3,5 +3,4 @@
fn do_not_forget_to_run_tests_with_specific_features() { fn do_not_forget_to_run_tests_with_specific_features() {
// This is just a reminder that there are tests that can be run only when cargo feature "integration_tests" is enabled. // This is just a reminder that there are tests that can be run only when cargo feature "integration_tests" is enabled.
// See `sync.rs` // See `sync.rs`
// See also the CI configuration in the `.gitlab` folder
} }

6
tests/sync.rs

@ -124,7 +124,7 @@ impl TestFlavour {
Self { Self {
scenarii: scenarii::scenarii_basic(), scenarii: scenarii::scenarii_basic(),
mock_behaviour: Arc::new(Mutex::new(MockBehaviour{ mock_behaviour: Arc::new(Mutex::new(MockBehaviour{
get_item_by_url_behaviour: (3,2), get_item_by_id_behaviour: (3,2),
..MockBehaviour::default() ..MockBehaviour::default()
})), })),
} }
@ -145,7 +145,7 @@ impl TestFlavour {
scenarii: scenarii::scenarii_basic(), scenarii: scenarii::scenarii_basic(),
mock_behaviour: Arc::new(Mutex::new(MockBehaviour{ mock_behaviour: Arc::new(Mutex::new(MockBehaviour{
add_item_behaviour: (2,3), add_item_behaviour: (2,3),
get_item_by_url_behaviour: (1,12), get_item_by_id_behaviour: (1,4),
..MockBehaviour::default() ..MockBehaviour::default()
})), })),
} }
@ -183,7 +183,7 @@ impl TestFlavour {
delete_item_behaviour: (1,1), delete_item_behaviour: (1,1),
create_calendar_behaviour: (1,4), create_calendar_behaviour: (1,4),
get_item_version_tags_behaviour: (3,1), get_item_version_tags_behaviour: (3,1),
get_item_by_url_behaviour: (0,41), get_item_by_id_behaviour: (0,41),
..MockBehaviour::default() ..MockBehaviour::default()
})), })),
} }

Loading…
Cancel
Save