Compare commits

..

59 Commits
0.2 ... master

Author SHA1 Message Date
daladim 2281c34529 Version 0.4.0 2022-01-19 09:22:24 +01:00
daladim c36c06ba94 Upgrade dependencies 2022-01-19 09:22:24 +01:00
daladim fd216ff7c3 [doc] improvements and rewordings 2022-01-19 09:17:09 +01:00
daladim 511f2123e6 [minor] Examples have different data folders 2022-01-19 09:16:25 +01:00
daladim 7cc1e778bb Added a progress counter 2021-12-24 15:36:34 +01:00
daladim 7b13d74edc [optim] Download 30 changed items at once in a single HTTP request from the server 2021-12-24 14:56:23 +01:00
daladim 159b6199fa Poor man's generic function to work around passing an async closure 2021-12-24 14:56:22 +01:00
daladim 313f2ed0e9 [minor] Unused variable 2021-12-24 14:56:22 +01:00
daladim 6c64bcce8e [optim] Download 30 added items at once in a single HTTP request from the server 2021-12-24 14:56:22 +01:00
daladim 56484a6d4d Split a function 2021-12-24 11:47:56 +01:00
daladim a973146366 Added get_items_by_url, to fetch multiple items at once 2021-12-24 11:47:56 +01:00
daladim f7110c254f Better timestamp parsing 2021-12-24 11:47:56 +01:00
daladim ac7d6952eb Better support for iCal DTSTAMP/LAST-MODIFIED 2021-12-24 11:47:56 +01:00
daladim 5505a17081 [doc] 2021-12-24 11:47:55 +01:00
daladim 039965966f Added a second example 2021-12-24 11:47:55 +01:00
daladim 94b3bb27ba Added a function 2021-12-19 18:50:00 +01:00
daladim 3d142edf3e Merge remote-tracking branch 'daladim/master' 2021-12-07 21:36:10 +01:00
daladim 7f8529b837 [fmt] 2021-12-07 21:35:55 +01:00
daladim 5018ae9b97 Using a published version of ical 2021-12-06 22:59:29 +01:00
daladim ea6cd47b5d Fixed icon trasnparency 2021-11-26 15:25:48 +01:00
daladim 1be59891ca Newer dependecies (minor versions) 2021-11-24 00:23:46 +01:00
daladim ac4d37d086 Newer dependencies (major versions) 2021-11-24 00:23:40 +01:00
daladim e765812fa9 Added info into Cargo.toml 2021-11-24 00:22:30 +01:00
daladim 96b8feff5e [doc] 2021-11-24 00:21:18 +01:00
daladim a8e5bfbc63 Added color support for calendar creation 2021-11-24 00:21:18 +01:00
daladim d28309b21d [minor] Fixed the doc of a test 2021-11-23 23:26:48 +01:00
daladim f6ae164dda Removed a [patch] from crates.io 2021-11-23 23:02:40 +01:00
daladim c18516b9c8 Added icon in the README 2021-11-23 23:00:49 +01:00
daladim 5acb4e1c5b [doc] Link to Voilà 2021-11-23 22:56:35 +01:00
daladim b8e945cf93 Added icon in the generated doc 2021-11-22 19:14:42 +01:00
daladim 0022a8ffe7 Added icon 2021-11-22 19:11:47 +01:00
daladim e152b73c51 Better config API 2021-11-16 23:26:11 +01:00
daladim 38a44e2773 Merge branch 'deprecate_id_for_uid' 2021-11-16 00:11:33 +01:00
daladim 0075ebfbdb More ID -> URL renaming 2021-11-16 00:10:47 +01:00
daladim 2f7c14d0aa Better distinction ID/UID 2021-11-15 23:52:26 +01:00
daladim 56b86adf02 [doc] 2021-11-15 23:00:02 +01:00
daladim 91966b9d5c [minor] Format 2021-11-15 22:22:02 +01:00
daladim f4c0160f1e [doc] 2021-11-10 23:06:37 +01:00
daladim fbf3005a6f
Merge pull request #1 from daladim/github_actions
Added a github actions file
2021-11-10 22:58:23 +01:00
daladim 96eec57951 Added a github actions file 2021-11-10 22:52:47 +01:00
daladim 0f55850b6d [cleanup] Deprecated settings.rs for config.rs 2021-11-10 22:47:33 +01:00
daladim 413b2b285e [cleanup] 2021-11-10 22:45:46 +01:00
daladim 582c187e04 Merge branch 'ical_unknown_fields' 2021-11-10 22:22:57 +01:00
daladim 9dcf7e0499 Fixed tests to support unknown iCal fields 2021-11-10 22:22:24 +01:00
daladim b0db19b586 Forked ical-rs to support serde 2021-11-10 22:16:40 +01:00
daladim 75fe00983d Added a parsing/building round-trip test 2021-11-10 22:16:40 +01:00
daladim 39867a9f15 PRODID is parsed, stored and generated 2021-11-10 22:16:40 +01:00
daladim f7acadc3e2 Unhandled lines from iCal files are stored 2021-11-10 22:16:40 +01:00
daladim 49bdb3d199 Macro for code de-duplication 2021-11-05 23:37:25 +01:00
daladim d6e93d846e [minor] Turned a if list into a match 2021-11-04 18:55:12 +01:00
daladim 40503f46d5 typo 2021-11-04 18:54:53 +01:00
daladim 7d09d72175 SyncEvent is more usable 2021-11-04 09:01:25 +01:00
daladim d8c89ec727 [doc] 2021-11-03 21:58:00 +01:00
daladim 9bd45d5a07 Merge branch 'feedback' 2021-10-13 08:19:41 +02:00
daladim b404fc68e8 Sending sync progress 2021-10-09 00:25:23 +02:00
daladim 7fb98a471b Feedback infrastructure 2021-10-09 00:23:50 +02:00
daladim 04c8b3a2ee Renamed SyncResult -> SyncProgress 2021-10-08 23:26:48 +02:00
daladim f7ec5d29be [doc] 2021-10-08 23:03:43 +02:00
daladim 38fd04c3a4 Moved SyncResult 2021-10-08 23:03:24 +02:00
33 changed files with 2080 additions and 1337 deletions

24
.github/workflows/rust.yml vendored Normal file
View File

@ -0,0 +1,24 @@
name: Rust
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build
run: cargo build --verbose
- name: Run regular tests
run: cargo test --verbose
- name: Run specific integration tests
run: cargo test --verbose --features=integration_tests

520
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,5 +1,15 @@
# kitchen-fridge
kitchen-fridge is a CalDAV (iCal file transfer over WebDAV) Rust library.
<p align="center">
<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.
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).

View File

@ -1,24 +1,22 @@
use std::path::Path;
//! This is an example of how kitchen-fridge can be used
use chrono::{Utc};
use url::Url;
use kitchen_fridge::{client::Client, traits::CalDavSource};
use kitchen_fridge::calendar::{CalendarId, SupportedComponents};
use kitchen_fridge::traits::CalDavSource;
use kitchen_fridge::calendar::SupportedComponents;
use kitchen_fridge::Item;
use kitchen_fridge::Task;
use kitchen_fridge::task::CompletionStatus;
use kitchen_fridge::item::ItemId;
use kitchen_fridge::cache::Cache;
use kitchen_fridge::CalDavProvider;
use kitchen_fridge::traits::BaseCalendar;
use kitchen_fridge::traits::CompleteCalendar;
use kitchen_fridge::settings::URL;
use kitchen_fridge::settings::USERNAME;
use kitchen_fridge::settings::PASSWORD;
use kitchen_fridge::settings::EXAMPLE_CREATED_CALENDAR_URL;
use kitchen_fridge::settings::EXAMPLE_EXISTING_CALENDAR_URL;
use kitchen_fridge::utils::pause;
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";
@ -26,67 +24,48 @@ const CACHE_FOLDER: &str = "test_cache/provider_sync";
async fn main() {
env_logger::init();
println!("This examples show how to sync a remote server with a local cache, using a Provider.");
println!("Make sure you have edited your settings.rs to include correct URLs and credentials.");
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);
println!(" * EXAMPLE_EXISTING_CALENDAR_URL = {}", EXAMPLE_EXISTING_CALENDAR_URL);
println!(" * EXAMPLE_CREATED_CALENDAR_URL = {}", EXAMPLE_CREATED_CALENDAR_URL);
pause();
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;
let mut provider = initial_sync(CACHE_FOLDER).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.");
pause();
// Create a new calendar...
let new_calendar_id: CalendarId = EXAMPLE_CREATED_CALENDAR_URL.parse().unwrap();
let new_calendar_url: Url = EXAMPLE_CREATED_CALENDAR_URL.parse().unwrap();
let new_calendar_name = "A brave new calendar".to_string();
if let Err(_err) = provider.local_mut()
.create_calendar(new_calendar_id.clone(), new_calendar_name.clone(), SupportedComponents::TODO, None)
.create_calendar(new_calendar_url.clone(), new_calendar_name.clone(), SupportedComponents::TODO, Some("#ff8000".parse().unwrap()))
.await {
println!("Unable to add calendar, maybe it exists already. We're not adding it after all.");
}
// ...and add a task in it
let new_name = "This is a new task in a new calendar";
let new_task = Task::new(String::from(new_name), true, &new_calendar_id);
provider.local().get_calendar(&new_calendar_id).await.unwrap()
let new_task = Task::new(String::from(new_name), true, &new_calendar_url);
provider.local().get_calendar(&new_calendar_url).await.unwrap()
.lock().unwrap().add_item(Item::Task(new_task)).await.unwrap();
// Also create a task in a previously existing calendar
let changed_calendar_id: CalendarId = EXAMPLE_EXISTING_CALENDAR_URL.parse().unwrap();
let changed_calendar_url: Url = 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 = Task::new(String::from(new_task_name), false, &changed_calendar_id);
let new_id = new_task.id().clone();
provider.local().get_calendar(&changed_calendar_id).await.unwrap()
let new_task = Task::new(String::from(new_task_name), false, &changed_calendar_url);
let new_url = new_task.url().clone();
provider.local().get_calendar(&changed_calendar_url).await.unwrap()
.lock().unwrap().add_item(Item::Task(new_task)).await.unwrap();
@ -97,20 +76,20 @@ async fn add_items_and_sync_again(provider: &mut CalDavProvider)
}
provider.local().save_to_folder().unwrap();
complete_item_and_sync_again(provider, &changed_calendar_id, &new_id).await;
complete_item_and_sync_again(provider, &changed_calendar_url, &new_url).await;
}
async fn complete_item_and_sync_again(
provider: &mut CalDavProvider,
changed_calendar_id: &CalendarId,
id_to_complete: &ItemId)
changed_calendar_url: &Url,
url_to_complete: &Url)
{
println!("\nNow, we'll mark this last task as completed, and run the sync again.");
pause();
let completion_status = CompletionStatus::Completed(Some(Utc::now()));
provider.local().get_calendar(changed_calendar_id).await.unwrap()
.lock().unwrap().get_item_by_id_mut(id_to_complete).await.unwrap()
provider.local().get_calendar(changed_calendar_url).await.unwrap()
.lock().unwrap().get_item_by_url_mut(url_to_complete).await.unwrap()
.unwrap_task_mut()
.set_completion_status(completion_status);
@ -121,19 +100,19 @@ async fn complete_item_and_sync_again(
}
provider.local().save_to_folder().unwrap();
remove_items_and_sync_again(provider, changed_calendar_id, id_to_complete).await;
remove_items_and_sync_again(provider, changed_calendar_url, url_to_complete).await;
}
async fn remove_items_and_sync_again(
provider: &mut CalDavProvider,
changed_calendar_id: &CalendarId,
id_to_remove: &ItemId)
changed_calendar_url: &Url,
id_to_remove: &Url)
{
println!("\nNow, we'll delete this last task, and run the sync again.");
pause();
// Remove the task we had created
provider.local().get_calendar(changed_calendar_id).await.unwrap()
provider.local().get_calendar(changed_calendar_url).await.unwrap()
.lock().unwrap()
.mark_for_deletion(id_to_remove).await.unwrap();

54
examples/shared.rs Normal file
View File

@ -0,0 +1,54 @@
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
}

View File

@ -0,0 +1,65 @@
//! 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(())
}

View File

@ -0,0 +1,388 @@
<?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>

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

12
src/config.rs Normal file
View File

@ -0,0 +1,12 @@
//! Support for library configuration options
use std::sync::{Arc, Mutex};
use once_cell::sync::Lazy;
/// 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.
pub static ORG_NAME: Lazy<Arc<Mutex<String>>> = Lazy::new(|| Arc::new(Mutex::new("My organization".to_string())));
/// 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.
pub static PRODUCT_NAME: Lazy<Arc<Mutex<String>>> = Lazy::new(|| Arc::new(Mutex::new("KitchenFridge".to_string())));

View File

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

View File

@ -5,51 +5,57 @@ use std::error::Error;
use chrono::{DateTime, Utc};
use ics::properties::{Completed, Created, LastModified, PercentComplete, Status, Summary};
use ics::{ICalendar, ToDo};
use ics::components::Parameter as IcsParameter;
use ics::components::Property as IcsProperty;
use ical::property::Property as IcalProperty;
use crate::Task;
use crate::item::Item;
use crate::task::CompletionStatus;
use crate::settings::{ORG_NAME, PRODUCT_NAME};
fn ical_product_id() -> String {
format!("-//{}//{}//EN", ORG_NAME, PRODUCT_NAME)
}
/// Create an iCal item from a `crate::item::Item`
pub fn build_from(item: &Item) -> Result<String, Box<dyn Error>> {
let s_last_modified = format_date_time(item.last_modified());
match item {
Item::Task(t) => build_from_task(t),
_ => unimplemented!(),
}
}
pub fn build_from_task(task: &Task) -> Result<String, Box<dyn Error>> {
let s_last_modified = format_date_time(task.last_modified());
let mut todo = ToDo::new(
item.uid(),
task.uid(),
s_last_modified.clone(),
);
item.creation_date().map(|dt|
task.creation_date().map(|dt|
todo.push(Created::new(format_date_time(dt)))
);
todo.push(LastModified::new(s_last_modified));
todo.push(Summary::new(item.name()));
todo.push(Summary::new(task.name()));
match item {
Item::Task(t) => {
match t.completion_status() {
CompletionStatus::Uncompleted => {
todo.push(Status::needs_action());
},
CompletionStatus::Completed(completion_date) => {
todo.push(PercentComplete::new("100"));
completion_date.as_ref().map(|dt| todo.push(
Completed::new(format_date_time(dt))
));
todo.push(Status::completed());
}
}
},
_ => {
unimplemented!()
match task.completion_status() {
CompletionStatus::Uncompleted => {
todo.push(Status::needs_action());
},
CompletionStatus::Completed(completion_date) => {
todo.push(PercentComplete::new("100"));
completion_date.as_ref().map(|dt| todo.push(
Completed::new(format_date_time(dt))
));
todo.push(Status::completed());
}
}
let mut calendar = ICalendar::new("2.0", ical_product_id());
// Also add fields that we have not handled
for ical_property in task.extra_parameters() {
let ics_property = ical_to_ics_property(ical_property.clone());
todo.push(ics_property);
}
let mut calendar = ICalendar::new("2.0", task.ical_prod_id());
calendar.add_todo(todo);
Ok(calendar.to_string())
@ -60,10 +66,26 @@ fn format_date_time(dt: &DateTime<Utc>) -> String {
}
fn ical_to_ics_property(prop: IcalProperty) -> IcsProperty<'static> {
let mut ics_prop = match prop.value {
Some(value) => IcsProperty::new(prop.name, value),
None => IcsProperty::new(prop.name, ""),
};
prop.params.map(|v| {
for (key, vec_values) in v {
let values = vec_values.join(";");
ics_prop.add(IcsParameter::new(key, values));
}
});
ics_prop
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Task;
use crate::config::{ORG_NAME, PRODUCT_NAME};
#[test]
fn test_ical_from_completed_task() {
@ -82,7 +104,7 @@ mod tests {
COMPLETED:{}\r\n\
STATUS:COMPLETED\r\n\
END:VTODO\r\n\
END:VCALENDAR\r\n", ORG_NAME, PRODUCT_NAME, uid, s_now, s_now, s_now, s_now);
END:VCALENDAR\r\n", ORG_NAME.lock().unwrap(), PRODUCT_NAME.lock().unwrap(), uid, s_now, s_now, s_now, s_now);
assert_eq!(ical, expected_ical);
}
@ -102,18 +124,18 @@ mod tests {
SUMMARY:This is a task with ÜTF-8 characters\r\n\
STATUS:NEEDS-ACTION\r\n\
END:VTODO\r\n\
END:VCALENDAR\r\n", ORG_NAME, PRODUCT_NAME, uid, s_now, s_now, s_now);
END:VCALENDAR\r\n", ORG_NAME.lock().unwrap(), PRODUCT_NAME.lock().unwrap(), uid, s_now, s_now, s_now);
assert_eq!(ical, expected_ical);
}
fn build_task(completed: bool) -> (String, String, String) {
let cal_id = "http://my.calend.ar/id".parse().unwrap();
let cal_url = "http://my.calend.ar/id".parse().unwrap();
let now = Utc::now();
let s_now = format_date_time(&now);
let task = Item::Task(Task::new(
String::from("This is a task with ÜTF-8 characters"), completed, &cal_id
String::from("This is a task with ÜTF-8 characters"), completed, &cal_url
));
let ical = build_from(&task).unwrap();

View File

@ -6,3 +6,51 @@ mod parser;
pub use parser::parse;
mod builder;
pub use builder::build_from;
use crate::config::{ORG_NAME, PRODUCT_NAME};
pub fn default_prod_id() -> String {
format!("-//{}//{}//EN", ORG_NAME.lock().unwrap(), PRODUCT_NAME.lock().unwrap())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use crate::item::SyncStatus;
#[test]
fn test_ical_round_trip_serde() {
let ical_with_unknown_fields = std::fs::read_to_string("tests/assets/ical_with_unknown_fields.ics").unwrap();
let item_id = "http://item.id".parse().unwrap();
let sync_status = SyncStatus::NotSynced;
let deserialized = parse(&ical_with_unknown_fields, item_id, sync_status).unwrap();
let serialized = build_from(&deserialized).unwrap();
assert_same_fields(&ical_with_unknown_fields, &serialized);
}
/// Assert the properties are present (possibly in another order)
/// RFC5545 "imposes no ordering of properties within an iCalendar object."
fn assert_same_fields(left: &str, right: &str) {
let left_parts: HashSet<&str> = left.split("\r\n").collect();
let right_parts: HashSet<&str> = right.split("\r\n").collect();
// Let's be more explicit than assert_eq!(left_parts, right_parts);
if left_parts != right_parts {
println!("Only in left:");
for item in left_parts.difference(&right_parts) {
println!(" * {}", item);
}
println!("Only in right:");
for item in right_parts.difference(&left_parts) {
println!(" * {}", item);
}
assert_eq!(left_parts, right_parts);
}
}
}

View File

@ -4,26 +4,30 @@ use std::error::Error;
use ical::parser::ical::component::{IcalCalendar, IcalEvent, IcalTodo};
use chrono::{DateTime, TimeZone, Utc};
use url::Url;
use crate::Item;
use crate::item::SyncStatus;
use crate::item::ItemId;
use crate::Task;
use crate::task::CompletionStatus;
use crate::Event;
/// Parse an iCal file into the internal representation [`crate::Item`]
pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result<Item, Box<dyn Error>> {
pub fn parse(content: &str, item_url: Url, sync_status: SyncStatus) -> Result<Item, Box<dyn Error>> {
let mut reader = ical::IcalParser::new(content.as_bytes());
let parsed_item = match reader.next() {
None => return Err(format!("Invalid uCal data to parse for item {}", item_id).into()),
None => return Err(format!("Invalid iCal data to parse for item {}", item_url).into()),
Some(item) => match item {
Err(err) => return Err(format!("Unable to parse uCal data for item {}: {}", item_id, err).into()),
Err(err) => return Err(format!("Unable to parse iCal data for item {}: {}", item_url, err).into()),
Ok(item) => item,
}
};
let ical_prod_id = extract_ical_prod_id(&parsed_item)
.map(|s| s.to_string())
.unwrap_or_else(|| super::default_prod_id());
let item = match assert_single_type(&parsed_item)? {
CurrentType::Event(_) => {
Item::Event(Event::new())
@ -36,51 +40,64 @@ pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result<
let mut last_modified = None;
let mut completion_date = None;
let mut creation_date = None;
let mut extra_parameters = Vec::new();
for prop in &todo.properties {
if prop.name == "SUMMARY" {
name = prop.value.clone();
}
if prop.name == "STATUS" {
// Possible values:
// "NEEDS-ACTION" ;Indicates to-do needs action.
// "COMPLETED" ;Indicates to-do completed.
// "IN-PROCESS" ;Indicates to-do in process of.
// "CANCELLED" ;Indicates to-do was cancelled.
if prop.value.as_ref().map(|s| s.as_str()) == Some("COMPLETED") {
completed = true;
match prop.name.as_str() {
"SUMMARY" => { name = prop.value.clone() },
"UID" => { uid = prop.value.clone() },
"DTSTAMP" => {
// The property can be specified once, but is not mandatory
// "This property specifies the date and time that the information associated with
// the calendar component was last revised in the calendar store."
// "In the case of an iCalendar object that doesn't specify a "METHOD"
// 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" => {
// The property can be specified once, but is not mandatory
// "This property defines the date and time that a to-do was
// actually completed."
completion_date = parse_date_time_from_property(&prop.value)
},
"CREATED" => {
// The property can be specified once, but is not mandatory
creation_date = parse_date_time_from_property(&prop.value)
},
"STATUS" => {
// Possible values:
// "NEEDS-ACTION" ;Indicates to-do needs action.
// "COMPLETED" ;Indicates to-do completed.
// "IN-PROCESS" ;Indicates to-do in process of.
// "CANCELLED" ;Indicates to-do was cancelled.
if prop.value.as_ref().map(|s| s.as_str()) == Some("COMPLETED") {
completed = true;
}
}
_ => {
// This field is not supported. Let's store it anyway, so that we are able to re-create an identical iCal file
extra_parameters.push(prop.clone());
}
}
if prop.name == "UID" {
uid = prop.value.clone();
}
if prop.name == "DTSTAMP" {
// The property can be specified once, but is not mandatory
// "This property specifies the date and time that the information associated with
// the calendar component was last revised in the calendar store."
last_modified = parse_date_time_from_property(&prop.value)
}
if prop.name == "COMPLETED" {
// The property can be specified once, but is not mandatory
// "This property defines the date and time that a to-do was
// actually completed."
completion_date = parse_date_time_from_property(&prop.value)
}
if prop.name == "CREATED" {
// The property can be specified once, but is not mandatory
creation_date = parse_date_time_from_property(&prop.value)
}
}
let name = match name {
Some(name) => name,
None => return Err(format!("Missing name for item {}", item_id).into()),
None => return Err(format!("Missing name for item {}", item_url).into()),
};
let uid = match uid {
Some(uid) => uid,
None => return Err(format!("Missing UID for item {}", item_id).into()),
None => return Err(format!("Missing UID for item {}", item_url).into()),
};
let last_modified = match last_modified {
Some(dt) => dt,
None => return Err(format!("Missing DTSTAMP for item {}, but this is required by RFC5545", item_id).into()),
None => return Err(format!("Missing DTSTAMP for item {}, but this is required by RFC5545", item_url).into()),
};
let completion_status = match completed {
false => {
@ -92,7 +109,7 @@ pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result<
true => CompletionStatus::Completed(completion_date),
};
Item::Task(Task::new_with_parameters(name, uid, item_id, completion_status, sync_status, creation_date, last_modified))
Item::Task(Task::new_with_parameters(name, uid, item_url, completion_status, sync_status, creation_date, last_modified, ical_prod_id, extra_parameters))
},
};
@ -106,7 +123,8 @@ pub fn parse(content: &str, item_id: ItemId, sync_status: SyncStatus) -> Result<
}
fn parse_date_time(dt: &str) -> Result<DateTime<Utc>, chrono::format::ParseError> {
Utc.datetime_from_str(dt, "%Y%m%dT%H%M%S")
Utc.datetime_from_str(dt, "%Y%m%dT%H%M%SZ")
.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>> {
@ -122,6 +140,16 @@ fn parse_date_time_from_property(value: &Option<String>) -> Option<DateTime<Utc>
}
fn extract_ical_prod_id(item: &IcalCalendar) -> Option<&str> {
for prop in &item.properties {
if &prop.name == "PRODID" {
return prop.value.as_ref().map(|s| s.as_str())
}
}
None
}
enum CurrentType<'a> {
Event(&'a IcalEvent),
Todo(&'a IcalTodo),
@ -226,13 +254,13 @@ END:VCALENDAR
fn test_ical_parsing() {
let version_tag = VersionTag::from(String::from("test-tag"));
let sync_status = SyncStatus::Synced(version_tag);
let item_id: ItemId = "http://some.id/for/testing".parse().unwrap();
let item_url: Url = "http://some.id/for/testing".parse().unwrap();
let item = parse(EXAMPLE_ICAL, item_id.clone(), sync_status.clone()).unwrap();
let item = parse(EXAMPLE_ICAL, item_url.clone(), sync_status.clone()).unwrap();
let task = item.unwrap_task();
assert_eq!(task.name(), "Do not forget to do this");
assert_eq!(task.id(), &item_id);
assert_eq!(task.url(), &item_url);
assert_eq!(task.uid(), "0633de27-8c32-42be-bcb8-63bc879c6185@some-domain.com");
assert_eq!(task.completed(), false);
assert_eq!(task.completion_status(), &CompletionStatus::Uncompleted);
@ -244,9 +272,9 @@ END:VCALENDAR
fn test_completed_ical_parsing() {
let version_tag = VersionTag::from(String::from("test-tag"));
let sync_status = SyncStatus::Synced(version_tag);
let item_id: ItemId = "http://some.id/for/testing".parse().unwrap();
let item_url: Url = "http://some.id/for/testing".parse().unwrap();
let item = parse(EXAMPLE_ICAL_COMPLETED, item_id.clone(), sync_status.clone()).unwrap();
let item = parse(EXAMPLE_ICAL_COMPLETED, item_url.clone(), sync_status.clone()).unwrap();
let task = item.unwrap_task();
assert_eq!(task.completed(), true);
@ -257,9 +285,9 @@ END:VCALENDAR
fn test_completed_without_date_ical_parsing() {
let version_tag = VersionTag::from(String::from("test-tag"));
let sync_status = SyncStatus::Synced(version_tag);
let item_id: ItemId = "http://some.id/for/testing".parse().unwrap();
let item_url: Url = "http://some.id/for/testing".parse().unwrap();
let item = parse(EXAMPLE_ICAL_COMPLETED_WITHOUT_A_COMPLETION_DATE, item_id.clone(), sync_status.clone()).unwrap();
let item = parse(EXAMPLE_ICAL_COMPLETED_WITHOUT_A_COMPLETION_DATE, item_url.clone(), sync_status.clone()).unwrap();
let task = item.unwrap_task();
assert_eq!(task.completed(), true);
@ -270,9 +298,9 @@ END:VCALENDAR
fn test_multiple_items_in_ical() {
let version_tag = VersionTag::from(String::from("test-tag"));
let sync_status = SyncStatus::Synced(version_tag);
let item_id: ItemId = "http://some.id/for/testing".parse().unwrap();
let item_url: Url = "http://some.id/for/testing".parse().unwrap();
let item = parse(EXAMPLE_MULTIPLE_ICAL, item_id.clone(), sync_status.clone());
let item = parse(EXAMPLE_MULTIPLE_ICAL, item_url.clone(), sync_status.clone());
assert!(item.is_err());
}
}

View File

@ -1,17 +1,10 @@
//! CalDAV items (todo, events, journals...)
// TODO: move Event and Task to nest them in crate::items::calendar::Calendar?
use std::fmt::{Display, Formatter};
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde::{Deserialize, Serialize};
use url::Url;
use chrono::{DateTime, Utc};
use crate::resource::Resource;
use crate::calendar::CalendarId;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Item {
@ -19,48 +12,27 @@ pub enum Item {
Task(crate::task::Task),
}
/// Returns `task.$property_name` or `event.$property_name`, depending on whether self is a Task or an Event
macro_rules! synthetise_common_getter {
($property_name:ident, $return_type:ty) => {
pub fn $property_name(&self) -> $return_type {
match self {
Item::Event(e) => e.$property_name(),
Item::Task(t) => t.$property_name(),
}
}
}
}
impl Item {
pub fn id(&self) -> &ItemId {
match self {
Item::Event(e) => e.id(),
Item::Task(t) => t.id(),
}
}
synthetise_common_getter!(url, &Url);
synthetise_common_getter!(uid, &str);
synthetise_common_getter!(name, &str);
synthetise_common_getter!(creation_date, Option<&DateTime<Utc>>);
synthetise_common_getter!(last_modified, &DateTime<Utc>);
synthetise_common_getter!(sync_status, &SyncStatus);
synthetise_common_getter!(ical_prod_id, &str);
pub fn uid(&self) -> &str {
match self {
Item::Event(e) => e.uid(),
Item::Task(t) => t.uid(),
}
}
pub fn name(&self) -> &str {
match self {
Item::Event(e) => e.name(),
Item::Task(t) => t.name(),
}
}
pub fn creation_date(&self) -> Option<&DateTime<Utc>> {
match self {
Item::Event(e) => e.creation_date(),
Item::Task(t) => t.creation_date(),
}
}
pub fn last_modified(&self) -> &DateTime<Utc> {
match self {
Item::Event(e) => e.last_modified(),
Item::Task(t) => t.last_modified(),
}
}
pub fn sync_status(&self) -> &SyncStatus {
match self {
Item::Event(e) => e.sync_status(),
Item::Task(t) => t.sync_status(),
}
}
pub fn set_sync_status(&mut self, new_status: SyncStatus) {
match self {
Item::Event(e) => e.set_sync_status(new_status),
@ -115,67 +87,6 @@ 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.
@ -196,7 +107,7 @@ impl VersionTag {
&self.tag
}
/// Generate a random VesionTag
/// Generate a random VersionTag
#[cfg(feature = "local_calendar_mocks_remote_calendars")]
pub fn random() -> Self {
let random = uuid::Uuid::new_v4().to_hyphenated().to_string();

View File

@ -1,6 +1,8 @@
//! This crate provides a way to manage CalDAV data.
//! This crate provides a CalDAV 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 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. \
//! 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. \
//! 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
@ -12,13 +14,20 @@
//!
//! 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. \
//! It also handles synchronisation between the local cache and the server.
//! 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).
//!
//! Note that many methods are defined in common traits (see [`crate::traits`]).
//!
//! ## 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.
//!
//! ## 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;
@ -38,7 +47,7 @@ pub mod cache;
pub use cache::Cache;
pub mod ical;
pub mod settings;
pub mod config;
pub mod utils;
pub mod resource;

View File

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

View File

@ -1,406 +0,0 @@
//! This modules abstracts data sources and merges them in a single virtual one
use std::error::Error;
use std::collections::HashSet;
use std::marker::PhantomData;
use std::sync::{Arc, Mutex};
use crate::traits::{BaseCalendar, CalDavSource, DavCalendar};
use crate::traits::CompleteCalendar;
use crate::item::SyncStatus;
use crate::calendar::CalendarId;
/// A counter of errors that happen during a sync
struct SyncResult {
n_errors: u32,
}
impl SyncResult {
pub fn new() -> Self {
Self { n_errors: 0 }
}
pub fn is_success(&self) -> bool {
self.n_errors == 0
}
pub fn error(&mut self, text: &str) {
log::error!("{}", text);
self.n_errors += 1;
}
pub fn warn(&mut self, text: &str) {
log::warn!("{}", text);
self.n_errors += 1;
}
pub fn info(&mut self, text: &str) {
log::info!("{}", text);
}
pub fn debug(&mut self, text: &str) {
log::debug!("{}", text);
}
pub fn trace(&mut self, text: &str) {
log::trace!("{}", text);
}
}
/// 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>`. \
/// However, providers can be used for integration tests, where the remote source is mocked by a `Cache`.
#[derive(Debug)]
pub struct Provider<L, T, R, U>
where
L: CalDavSource<T>,
T: CompleteCalendar + Sync + Send,
R: CalDavSource<U>,
U: DavCalendar + Sync + Send,
{
/// The remote source (usually a server)
remote: R,
/// The local cache
local: L,
phantom_t: PhantomData<T>,
phantom_u: PhantomData<U>,
}
impl<L, T, R, U> Provider<L, T, R, U>
where
L: CalDavSource<T>,
T: CompleteCalendar + Sync + Send,
R: CalDavSource<U>,
U: DavCalendar + Sync + Send,
{
/// Create a provider.
///
/// `remote` is usually a [`Client`](crate::client::Client), `local` is usually a [`Cache`](crate::cache::Cache).
/// However, both can be interchangeable. The only difference is that `remote` always wins in case of a sync conflict
pub fn new(remote: R, local: L) -> Self {
Self { remote, local,
phantom_t: PhantomData, phantom_u: PhantomData,
}
}
/// Returns the data source described as `local`
pub fn local(&self) -> &L { &self.local }
/// Returns the data source described as `local`
pub fn local_mut(&mut self) -> &mut L { &mut self.local }
/// Returns the data source described as `remote`.
///
/// Apart from tests, there are very few (if any) reasons to access `remote` directly.
/// Usually, you should rather use the `local` source, which (usually) is a much faster local cache.
/// To be sure `local` accurately mirrors the `remote` source, you can run [`Provider::sync`]
pub fn remote(&self) -> &R { &self.remote }
/// Performs a synchronisation between `local` and `remote`.
///
/// This bidirectional sync applies additions/deletions made on a source to the other source.
/// In case of conflicts (the same item has been modified on both ends since the last sync, `remote` always wins)
///
/// It returns whether the sync was totally successful (details about errors are logged using the `log::*` macros).
/// In case errors happened, the sync might have been partially executed, and you can safely run this function again, since it has been designed to gracefully recover from errors.
pub async fn sync(&mut self) -> bool {
let mut result = SyncResult::new();
if let Err(err) = self.run_sync(&mut result).await {
result.error(&format!("Sync terminated because of an error: {}", err));
}
result.is_success()
}
async fn run_sync(&mut self, result: &mut SyncResult) -> Result<(), Box<dyn Error>> {
result.info("Starting a sync");
let mut handled_calendars = HashSet::new();
// Sync every remote calendar
let cals_remote = self.remote.get_calendars().await?;
for (cal_id, cal_remote) in cals_remote {
let counterpart = match self.get_or_insert_local_counterpart_calendar(&cal_id, cal_remote.clone()).await {
Err(err) => {
result.warn(&format!("Unable to get or insert local counterpart calendar for {} ({}). Skipping this time", cal_id, err));
continue;
},
Ok(arc) => arc,
};
if let Err(err) = Self::sync_calendar_pair(counterpart, cal_remote, result).await {
result.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_id, err));
continue;
}
handled_calendars.insert(cal_id);
}
// Sync every local calendar that would not be in the remote yet
let cals_local = self.local.get_calendars().await?;
for (cal_id, cal_local) in cals_local {
if handled_calendars.contains(&cal_id) {
continue;
}
let counterpart = match self.get_or_insert_remote_counterpart_calendar(&cal_id, cal_local.clone()).await {
Err(err) => {
result.warn(&format!("Unable to get or insert remote counterpart calendar for {} ({}). Skipping this time", cal_id, err));
continue;
},
Ok(arc) => arc,
};
if let Err(err) = Self::sync_calendar_pair(cal_local, counterpart, result).await {
result.warn(&format!("Unable to sync calendar {}: {}, skipping this time.", cal_id, err));
continue;
}
}
result.info("Sync ended");
Ok(())
}
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_id, needle).await
}
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_id, needle).await
}
async fn sync_calendar_pair(cal_local: Arc<Mutex<T>>, cal_remote: Arc<Mutex<U>>, result: &mut SyncResult) -> Result<(), Box<dyn Error>> {
let mut cal_remote = cal_remote.lock().unwrap();
let mut cal_local = cal_local.lock().unwrap();
// Step 1 - find the differences
result.debug("Finding the differences to sync...");
let mut local_del = HashSet::new();
let mut remote_del = HashSet::new();
let mut local_changes = HashSet::new();
let mut remote_changes = HashSet::new();
let mut local_additions = HashSet::new();
let mut remote_additions = HashSet::new();
let remote_items = cal_remote.get_item_version_tags().await?;
let mut local_items_to_handle = cal_local.get_item_ids().await?;
for (id, remote_tag) in remote_items {
result.trace(&format!("***** Considering remote item {}...", id));
match cal_local.get_item_by_id(&id).await {
None => {
// This was created on the remote
result.debug(&format!("* {} is a remote addition", id));
remote_additions.insert(id);
},
Some(local_item) => {
if local_items_to_handle.remove(&id) == false {
result.error(&format!("Inconsistent state: missing task {} from the local tasks", id));
}
match local_item.sync_status() {
SyncStatus::NotSynced => {
result.error(&format!("ID reuse between remote and local sources ({}). Ignoring this item in the sync", id));
continue;
},
SyncStatus::Synced(local_tag) => {
if &remote_tag != local_tag {
// This has been modified on the remote
result.debug(&format!("* {} is a remote change", id));
remote_changes.insert(id);
}
},
SyncStatus::LocallyModified(local_tag) => {
if &remote_tag == local_tag {
// This has been changed locally
result.debug(&format!("* {} is a local change", id));
local_changes.insert(id);
} else {
result.info(&format!("Conflict: task {} has been modified in both sources. Using the remote version.", id));
result.debug(&format!("* {} is considered a remote change", id));
remote_changes.insert(id);
}
},
SyncStatus::LocallyDeleted(local_tag) => {
if &remote_tag == local_tag {
// This has been locally deleted
result.debug(&format!("* {} is a local deletion", id));
local_del.insert(id);
} else {
result.info(&format!("Conflict: task {} has been locally deleted and remotely modified. Reverting to the remote version.", id));
result.debug(&format!("* {} is a considered a remote change", id));
remote_changes.insert(id);
}
},
}
}
}
}
// Also iterate on the local tasks that are not on the remote
for id in local_items_to_handle {
result.trace(&format!("##### Considering local item {}...", id));
let local_item = match cal_local.get_item_by_id(&id).await {
None => {
result.error(&format!("Inconsistent state: missing task {} from the local tasks", id));
continue;
},
Some(item) => item,
};
match local_item.sync_status() {
SyncStatus::Synced(_) => {
// This item has been removed from the remote
result.debug(&format!("# {} is a deletion from the server", id));
remote_del.insert(id);
},
SyncStatus::NotSynced => {
// This item has just been locally created
result.debug(&format!("# {} has been locally created", id));
local_additions.insert(id);
},
SyncStatus::LocallyDeleted(_) => {
// This item has been deleted from both sources
result.debug(&format!("# {} has been deleted from both sources", id));
remote_del.insert(id);
},
SyncStatus::LocallyModified(_) => {
result.info(&format!("Conflict: item {} has been deleted from the server and locally modified. Deleting the local copy", id));
remote_del.insert(id);
},
}
}
// Step 2 - commit changes
result.trace("Committing changes...");
for id_del in local_del {
result.debug(&format!("> Pushing local deletion {} to the server", id_del));
match cal_remote.delete_item(&id_del).await {
Err(err) => {
result.warn(&format!("Unable to delete remote item {}: {}", id_del, err));
},
Ok(()) => {
// Change the local copy from "marked to deletion" to "actually deleted"
if let Err(err) = cal_local.immediately_delete_item(&id_del).await {
result.error(&format!("Unable to permanently delete local item {}: {}", id_del, err));
}
},
}
}
for id_del in remote_del {
result.debug(&format!("> Applying remote deletion {} locally", id_del));
if let Err(err) = cal_local.immediately_delete_item(&id_del).await {
result.warn(&format!("Unable to delete local item {}: {}", id_del, err));
}
}
for id_add in remote_additions {
result.debug(&format!("> Applying remote addition {} locally", id_add));
match cal_remote.get_item_by_id(&id_add).await {
Err(err) => {
result.warn(&format!("Unable to get remote item {}: {}. Skipping it.", id_add, err));
continue;
},
Ok(item) => match item {
None => {
result.error(&format!("Inconsistency: new item {} has vanished from the remote end", id_add));
continue;
},
Some(new_item) => {
if let Err(err) = cal_local.add_item(new_item.clone()).await {
result.error(&format!("Not able to add item {} to local calendar: {}", id_add, err));
}
},
},
}
}
for id_change in remote_changes {
result.debug(&format!("> Applying remote change {} locally", id_change));
match cal_remote.get_item_by_id(&id_change).await {
Err(err) => {
result.warn(&format!("Unable to get remote item {}: {}. Skipping it", id_change, err));
continue;
},
Ok(item) => match item {
None => {
result.error(&format!("Inconsistency: modified item {} has vanished from the remote end", id_change));
continue;
},
Some(item) => {
if let Err(err) = cal_local.update_item(item.clone()).await {
result.error(&format!("Unable to update item {} in local calendar: {}", id_change, err));
}
},
}
}
}
for id_add in local_additions {
result.debug(&format!("> Pushing local addition {} to the server", id_add));
match cal_local.get_item_by_id_mut(&id_add).await {
None => {
result.error(&format!("Inconsistency: created item {} has been marked for upload but is locally missing", id_add));
continue;
},
Some(item) => {
match cal_remote.add_item(item.clone()).await {
Err(err) => result.error(&format!("Unable to add item {} to remote calendar: {}", id_add, err)),
Ok(new_ss) => {
// Update local sync status
item.set_sync_status(new_ss);
},
}
},
};
}
for id_change in local_changes {
result.debug(&format!("> Pushing local change {} to the server", id_change));
match cal_local.get_item_by_id_mut(&id_change).await {
None => {
result.error(&format!("Inconsistency: modified item {} has been marked for upload but is locally missing", id_change));
continue;
},
Some(item) => {
match cal_remote.update_item(item.clone()).await {
Err(err) => result.error(&format!("Unable to update item {} in remote calendar: {}", id_change, err)),
Ok(new_ss) => {
// Update local sync status
item.set_sync_status(new_ss);
},
};
}
};
}
Ok(())
}
}
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>>
where
H: CalDavSource<I>,
I: BaseCalendar,
N: BaseCalendar,
{
loop {
if let Some(cal) = haystack.get_calendar(&cal_id).await {
break Ok(cal);
}
// This calendar does not exist locally yet, let's add it
log::debug!("Adding a {} calendar {}", haystack_descr, cal_id);
let src = needle.lock().unwrap();
let name = src.name().to_string();
let supported_comps = src.supported_components();
let color = src.color();
if let Err(err) = haystack.create_calendar(
cal_id.clone(),
name,
supported_comps,
color.cloned(),
).await{
return Err(err);
}
}
}

515
src/provider/mod.rs Normal file
View File

@ -0,0 +1,515 @@
//! This modules abstracts data sources and merges them in a single virtual one
//!
//! It is also responsible for syncing them together
use std::error::Error;
use std::collections::HashSet;
use std::marker::PhantomData;
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::CompleteCalendar;
use crate::item::SyncStatus;
pub mod sync_progress;
use sync_progress::SyncProgress;
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.
///
/// 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>`. \
/// However, providers can be used for integration tests, where the remote source is mocked by a `Cache`.
#[derive(Debug)]
pub struct Provider<L, T, R, U>
where
L: CalDavSource<T>,
T: CompleteCalendar + Sync + Send,
R: CalDavSource<U>,
U: DavCalendar + Sync + Send,
{
/// The remote source (usually a server)
remote: R,
/// The local cache
local: L,
phantom_t: PhantomData<T>,
phantom_u: PhantomData<U>,
}
impl<L, T, R, U> Provider<L, T, R, U>
where
L: CalDavSource<T>,
T: CompleteCalendar + Sync + Send,
R: CalDavSource<U>,
U: DavCalendar + Sync + Send,
{
/// Create a provider.
///
/// `remote` is usually a [`Client`](crate::client::Client), `local` is usually a [`Cache`](crate::cache::Cache).
/// However, both can be interchangeable. The only difference is that `remote` always wins in case of a sync conflict
pub fn new(remote: R, local: L) -> Self {
Self { remote, local,
phantom_t: PhantomData, phantom_u: PhantomData,
}
}
/// Returns the data source described as `local`
pub fn local(&self) -> &L { &self.local }
/// Returns the data source described as `local`
pub fn local_mut(&mut self) -> &mut L { &mut self.local }
/// Returns the data source described as `remote`.
///
/// Apart from tests, there are very few (if any) reasons to access `remote` directly.
/// Usually, you should rather use the `local` source, which (usually) is a much faster local cache.
/// To be sure `local` accurately mirrors the `remote` source, you can run [`Provider::sync`]
pub fn remote(&self) -> &R { &self.remote }
/// Performs a synchronisation between `local` and `remote`, and provide feeedback to the user about the progress.
///
/// This bidirectional sync applies additions/deletions made on a source to the other source.
/// In case of conflicts (the same item has been modified on both ends since the last sync, `remote` always wins).
///
/// It returns whether the sync was totally successful (details about errors are logged using the `log::*` macros).
/// In case errors happened, the sync might have been partially executed but your data will never be correupted (either locally nor in the server).
/// Simply run this function again, it will re-start a sync, picking up where it failed.
pub async fn sync_with_feedback(&mut self, feedback_sender: FeedbackSender) -> bool {
let mut progress = SyncProgress::new_with_feedback_channel(feedback_sender);
self.run_sync(&mut progress).await
}
/// Performs a synchronisation between `local` and `remote`, without giving any feedback.
///
/// See [`Self::sync_with_feedback`]
pub async fn sync(&mut self) -> bool {
let mut progress = SyncProgress::new();
self.run_sync(&mut progress).await
}
async fn run_sync(&mut self, progress: &mut SyncProgress) -> bool {
if let Err(err) = self.run_sync_inner(progress).await {
progress.error(&format!("Sync terminated because of an error: {}", err));
}
progress.feedback(SyncEvent::Finished{ success: progress.is_success() });
progress.is_success()
}
async fn run_sync_inner(&mut self, progress: &mut SyncProgress) -> Result<(), Box<dyn Error>> {
progress.info("Starting a sync.");
progress.feedback(SyncEvent::Started);
let mut handled_calendars = HashSet::new();
// Sync every remote calendar
let cals_remote = self.remote.get_calendars().await?;
for (cal_url, cal_remote) in cals_remote {
let counterpart = match self.get_or_insert_local_counterpart_calendar(&cal_url, cal_remote.clone()).await {
Err(err) => {
progress.warn(&format!("Unable to get or insert local counterpart calendar for {} ({}). Skipping this time", cal_url, err));
continue;
},
Ok(arc) => arc,
};
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));
continue;
}
handled_calendars.insert(cal_url);
}
// Sync every local calendar that would not be in the remote yet
let cals_local = self.local.get_calendars().await?;
for (cal_url, cal_local) in cals_local {
if handled_calendars.contains(&cal_url) {
continue;
}
let counterpart = match self.get_or_insert_remote_counterpart_calendar(&cal_url, cal_local.clone()).await {
Err(err) => {
progress.warn(&format!("Unable to get or insert remote counterpart calendar for {} ({}). Skipping this time", cal_url, err));
continue;
},
Ok(arc) => arc,
};
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));
continue;
}
}
progress.info("Sync ended");
Ok(())
}
async fn get_or_insert_local_counterpart_calendar(&mut self, cal_url: &Url, needle: Arc<Mutex<U>>) -> Result<Arc<Mutex<T>>, Box<dyn Error>> {
get_or_insert_counterpart_calendar("local", &mut self.local, cal_url, 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>> {
get_or_insert_counterpart_calendar("remote", &mut self.remote, cal_url, needle).await
}
async fn sync_calendar_pair(cal_local: Arc<Mutex<T>>, cal_remote: Arc<Mutex<U>>, progress: &mut SyncProgress) -> Result<(), Box<dyn Error>> {
let mut cal_remote = cal_remote.lock().unwrap();
let mut cal_local = cal_local.lock().unwrap();
let cal_name = cal_local.name().to_string();
progress.info(&format!("Syncing calendar {}", cal_name));
progress.reset_counter();
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
items_done_already: 0,
details: "started".to_string()
});
// Step 1 - find the differences
progress.debug("Finding the differences to sync...");
let mut local_del = HashSet::new();
let mut remote_del = HashSet::new();
let mut local_changes = HashSet::new();
let mut remote_changes = HashSet::new();
let mut local_additions = HashSet::new();
let mut remote_additions = HashSet::new();
let remote_items = cal_remote.get_item_version_tags().await?;
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
items_done_already: 0,
details: format!("{} remote items", remote_items.len()),
});
let mut local_items_to_handle = cal_local.get_item_urls().await?;
for (url, remote_tag) in remote_items {
progress.trace(&format!("***** Considering remote item {}...", url));
match cal_local.get_item_by_url(&url).await {
None => {
// This was created on the remote
progress.debug(&format!("* {} is a remote addition", url));
remote_additions.insert(url);
},
Some(local_item) => {
if local_items_to_handle.remove(&url) == false {
progress.error(&format!("Inconsistent state: missing task {} from the local tasks", url));
}
match local_item.sync_status() {
SyncStatus::NotSynced => {
progress.error(&format!("URL reuse between remote and local sources ({}). Ignoring this item in the sync", url));
continue;
},
SyncStatus::Synced(local_tag) => {
if &remote_tag != local_tag {
// This has been modified on the remote
progress.debug(&format!("* {} is a remote change", url));
remote_changes.insert(url);
}
},
SyncStatus::LocallyModified(local_tag) => {
if &remote_tag == local_tag {
// This has been changed locally
progress.debug(&format!("* {} is a local change", url));
local_changes.insert(url);
} else {
progress.info(&format!("Conflict: task {} has been modified in both sources. Using the remote version.", url));
progress.debug(&format!("* {} is considered a remote change", url));
remote_changes.insert(url);
}
},
SyncStatus::LocallyDeleted(local_tag) => {
if &remote_tag == local_tag {
// This has been locally deleted
progress.debug(&format!("* {} is a local deletion", url));
local_del.insert(url);
} else {
progress.info(&format!("Conflict: task {} has been locally deleted and remotely modified. Reverting to the remote version.", url));
progress.debug(&format!("* {} is a considered a remote change", url));
remote_changes.insert(url);
}
},
}
}
}
}
// Also iterate on the local tasks that are not on the remote
for url in local_items_to_handle {
progress.trace(&format!("##### Considering local item {}...", url));
let local_item = match cal_local.get_item_by_url(&url).await {
None => {
progress.error(&format!("Inconsistent state: missing task {} from the local tasks", url));
continue;
},
Some(item) => item,
};
match local_item.sync_status() {
SyncStatus::Synced(_) => {
// This item has been removed from the remote
progress.debug(&format!("# {} is a deletion from the server", url));
remote_del.insert(url);
},
SyncStatus::NotSynced => {
// This item has just been locally created
progress.debug(&format!("# {} has been locally created", url));
local_additions.insert(url);
},
SyncStatus::LocallyDeleted(_) => {
// This item has been deleted from both sources
progress.debug(&format!("# {} has been deleted from both sources", url));
remote_del.insert(url);
},
SyncStatus::LocallyModified(_) => {
progress.info(&format!("Conflict: item {} has been deleted from the server and locally modified. Deleting the local copy", url));
remote_del.insert(url);
},
}
}
// Step 2 - commit changes
progress.trace("Committing changes...");
for url_del in local_del {
progress.debug(&format!("> Pushing local deletion {} to the server", url_del));
progress.increment_counter(1);
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
items_done_already: progress.counter(),
details: Self::item_name(&cal_local, &url_del).await,
});
match cal_remote.delete_item(&url_del).await {
Err(err) => {
progress.warn(&format!("Unable to delete remote item {}: {}", url_del, err));
},
Ok(()) => {
// Change the local copy from "marked to deletion" to "actually deleted"
if let Err(err) = cal_local.immediately_delete_item(&url_del).await {
progress.error(&format!("Unable to permanently delete local item {}: {}", url_del, err));
}
},
}
}
for url_del in remote_del {
progress.debug(&format!("> Applying remote deletion {} locally", url_del));
progress.increment_counter(1);
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
items_done_already: progress.counter(),
details: Self::item_name(&cal_local, &url_del).await,
});
if let Err(err) = cal_local.immediately_delete_item(&url_del).await {
progress.warn(&format!("Unable to delete local item {}: {}", url_del, err));
}
}
Self::apply_remote_additions(
remote_additions,
&mut *cal_local,
&mut *cal_remote,
progress,
&cal_name
).await;
Self::apply_remote_changes(
remote_changes,
&mut *cal_local,
&mut *cal_remote,
progress,
&cal_name
).await;
for url_add in local_additions {
progress.debug(&format!("> Pushing local addition {} to the server", url_add));
progress.increment_counter(1);
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
items_done_already: progress.counter(),
details: Self::item_name(&cal_local, &url_add).await,
});
match cal_local.get_item_by_url_mut(&url_add).await {
None => {
progress.error(&format!("Inconsistency: created item {} has been marked for upload but is locally missing", url_add));
continue;
},
Some(item) => {
match cal_remote.add_item(item.clone()).await {
Err(err) => progress.error(&format!("Unable to add item {} to remote calendar: {}", url_add, err)),
Ok(new_ss) => {
// Update local sync status
item.set_sync_status(new_ss);
},
}
},
};
}
for url_change in local_changes {
progress.debug(&format!("> Pushing local change {} to the server", url_change));
progress.increment_counter(1);
progress.feedback(SyncEvent::InProgress{
calendar: cal_name.clone(),
items_done_already: progress.counter(),
details: Self::item_name(&cal_local, &url_change).await,
});
match cal_local.get_item_by_url_mut(&url_change).await {
None => {
progress.error(&format!("Inconsistency: modified item {} has been marked for upload but is locally missing", url_change));
continue;
},
Some(item) => {
match cal_remote.update_item(item.clone()).await {
Err(err) => progress.error(&format!("Unable to update item {} in remote calendar: {}", url_change, err)),
Ok(new_ss) => {
// Update local sync status
item.set_sync_status(new_ss);
},
};
}
};
}
Ok(())
}
async fn item_name(cal: &T, url: &Url) -> String {
cal.get_item_by_url(url).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>>)
-> Result<Arc<Mutex<I>>, Box<dyn Error>>
where
H: CalDavSource<I>,
I: BaseCalendar,
N: BaseCalendar,
{
loop {
if let Some(cal) = haystack.get_calendar(&cal_url).await {
break Ok(cal);
}
// This calendar does not exist locally yet, let's add it
log::debug!("Adding a {} calendar {}", haystack_descr, cal_url);
let src = needle.lock().unwrap();
let name = src.name().to_string();
let supported_comps = src.supported_components();
let color = src.color();
if let Err(err) = haystack.create_calendar(
cal_url.clone(),
name,
supported_comps,
color.cloned(),
).await{
return Err(err);
}
}
}

View File

@ -0,0 +1,118 @@
//! Utilities to track the progression of a sync
use std::fmt::{Display, Error, Formatter};
/// An event that happens during a sync
#[derive(Clone, Debug)]
pub enum SyncEvent {
/// Sync has not started
NotStarted,
/// Sync has just started but no calendar is handled yet
Started,
/// Sync is in progress.
InProgress{ calendar: String, items_done_already: usize, details: String},
/// Sync is finished
Finished{ success: bool },
}
impl Display for SyncEvent {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> {
match self {
SyncEvent::NotStarted => write!(f, "Not started"),
SyncEvent::Started => write!(f, "Sync has started..."),
SyncEvent::InProgress{calendar, items_done_already, details} => write!(f, "{} [{}/?] {}...", calendar, items_done_already, details),
SyncEvent::Finished{success} => match success {
true => write!(f, "Sync successfully finished"),
false => write!(f, "Sync finished with errors"),
}
}
}
}
impl Default for SyncEvent {
fn default() -> Self {
Self::NotStarted
}
}
/// See [`feedback_channel`]
pub type FeedbackSender = tokio::sync::watch::Sender<SyncEvent>;
/// See [`feedback_channel`]
pub type FeedbackReceiver = tokio::sync::watch::Receiver<SyncEvent>;
/// Create a feeback channel, that can be used to retrieve the current progress of a sync operation
pub fn feedback_channel() -> (FeedbackSender, FeedbackReceiver) {
tokio::sync::watch::channel(SyncEvent::default())
}
/// A structure that tracks the progression and the errors that happen during a sync
pub struct SyncProgress {
n_errors: u32,
feedback_channel: Option<FeedbackSender>,
counter: usize,
}
impl SyncProgress {
pub fn new() -> Self {
Self { n_errors: 0, feedback_channel: None, counter: 0 }
}
pub fn new_with_feedback_channel(channel: FeedbackSender) -> Self {
Self { n_errors: 0, feedback_channel: Some(channel), counter: 0 }
}
/// 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 {
self.n_errors == 0
}
/// Log an error
pub fn error(&mut self, text: &str) {
log::error!("{}", text);
self.n_errors += 1;
}
/// Log a warning
pub fn warn(&mut self, text: &str) {
log::warn!("{}", text);
self.n_errors += 1;
}
/// Log an info
pub fn info(&mut self, text: &str) {
log::info!("{}", text);
}
/// Log a debug message
pub fn debug(&mut self, text: &str) {
log::debug!("{}", text);
}
/// Log a trace message
pub fn trace(&mut self, text: &str) {
log::trace!("{}", text);
}
/// Send an event as a feedback to the listener (if any).
pub fn feedback(&mut self, event: SyncEvent) {
self.feedback_channel
.as_ref()
.map(|sender| {
sender.send(event)
});
}
}

View File

@ -1,12 +0,0 @@
// TODO: change these values with yours
pub const URL: &str = "https://my.server.com/remote.php/dav/files/john";
pub const USERNAME: &str = "username";
pub const PASSWORD: &str = "secret_password";
pub const EXAMPLE_TASK_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/6121A0BE-C2E0-4F16-A3FA-658E54E7062A/74439558-CDFF-426C-92CD-ECDDACE971B0.ics";
pub const EXAMPLE_EXISTING_CALENDAR_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/a_calendar_name/";
pub const EXAMPLE_CREATED_CALENDAR_URL: &str = "https://my.server.com/remote.php/dav/calendars/john/a_calendar_that_we_have_created/";
pub const ORG_NAME: &str = "My organisation";
pub const PRODUCT_NAME: &str = "My CalDAV client";

View File

@ -3,10 +3,11 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use chrono::{DateTime, Utc};
use ical::property::Property;
use url::Url;
use crate::item::ItemId;
use crate::item::SyncStatus;
use crate::calendar::CalendarId;
use crate::utils::random_url;
/// RFC5545 defines the completion as several optional fields, yet some combinations make no sense.
/// This enum provides an API that forbids such impossible combinations.
@ -32,10 +33,11 @@ impl CompletionStatus {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Task {
/// The task URL
id: ItemId,
url: Url,
/// 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, but UUID are even better
/// The [RFC](https://tools.ietf.org/html/rfc5545#page-117) recommends concatenating a timestamp with the server's domain name.
/// 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,
/// The sync status of this item
@ -51,14 +53,21 @@ pub struct Task {
/// The display name of the task
name: String,
/// The PRODID, as defined in iCal files
ical_prod_id: String,
/// Extra parameters that have not been parsed from the iCal file (because they're not supported (yet) by this crate).
/// They are needed to serialize this item into an equivalent iCal file
extra_parameters: Vec<Property>,
}
impl Task {
/// Create a brand new Task that is not on a server yet.
/// This will pick a new (random) task ID.
pub fn new(name: String, completed: bool, parent_calendar_id: &CalendarId) -> Self {
let new_item_id = ItemId::random(parent_calendar_id);
pub fn new(name: String, completed: bool, parent_calendar_url: &Url) -> Self {
let new_url = random_url(parent_calendar_url);
let new_sync_status = SyncStatus::NotSynced;
let new_uid = Uuid::new_v4().to_hyphenated().to_string();
let new_creation_date = Some(Utc::now());
@ -66,37 +75,46 @@ impl Task {
let new_completion_status = if completed {
CompletionStatus::Completed(Some(Utc::now()))
} else { CompletionStatus::Uncompleted };
Self::new_with_parameters(name, new_uid, new_item_id, new_completion_status, new_sync_status, new_creation_date, new_last_modified)
let ical_prod_id = crate::ical::default_prod_id();
let extra_parameters = Vec::new();
Self::new_with_parameters(name, new_uid, new_url, new_completion_status, new_sync_status, new_creation_date, new_last_modified, ical_prod_id, extra_parameters)
}
/// Create a new Task instance, that may be synced on the server already
pub fn new_with_parameters(name: String, uid: String, id: ItemId,
pub fn new_with_parameters(name: String, uid: String, new_url: Url,
completion_status: CompletionStatus,
sync_status: SyncStatus, creation_date: Option<DateTime<Utc>>, last_modified: DateTime<Utc>) -> Self
sync_status: SyncStatus, creation_date: Option<DateTime<Utc>>, last_modified: DateTime<Utc>,
ical_prod_id: String, extra_parameters: Vec<Property>,
) -> Self
{
Self {
id,
url: new_url,
uid,
name,
completion_status,
sync_status,
creation_date,
last_modified,
ical_prod_id,
extra_parameters,
}
}
pub fn id(&self) -> &ItemId { &self.id }
pub fn url(&self) -> &Url { &self.url }
pub fn uid(&self) -> &str { &self.uid }
pub fn name(&self) -> &str { &self.name }
pub fn completed(&self) -> bool { self.completion_status.is_completed() }
pub fn ical_prod_id(&self) -> &str { &self.ical_prod_id }
pub fn sync_status(&self) -> &SyncStatus { &self.sync_status }
pub fn last_modified(&self) -> &DateTime<Utc> { &self.last_modified }
pub fn creation_date(&self) -> Option<&DateTime<Utc>> { self.creation_date.as_ref() }
pub fn completion_status(&self) -> &CompletionStatus { &self.completion_status }
pub fn extra_parameters(&self) -> &[Property] { &self.extra_parameters }
#[cfg(any(test, feature = "integration_tests"))]
pub fn has_same_observable_content_as(&self, other: &Task) -> bool {
self.id == other.id
self.url == other.url
&& self.uid == other.uid
&& self.name == other.name
// 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)

View File

@ -1,41 +1,46 @@
//! Traits used by multiple structs in this crate
use std::error::Error;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use csscolorparser::Color;
use url::Url;
use crate::item::SyncStatus;
use crate::item::Item;
use crate::item::ItemId;
use crate::item::VersionTag;
use crate::calendar::CalendarId;
use crate::calendar::SupportedComponents;
use crate::resource::Resource;
/// 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]
pub trait CalDavSource<T: BaseCalendar> {
/// 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)
async fn get_calendars(&self) -> Result<HashMap<CalendarId, Arc<Mutex<T>>>, Box<dyn Error>>;
/// Returns the calendar matching the ID
async fn get_calendar(&self, id: &CalendarId) -> Option<Arc<Mutex<T>>>;
async fn get_calendars(&self) -> Result<HashMap<Url, Arc<Mutex<T>>>, Box<dyn Error>>;
/// Returns the calendar matching the URL
async fn get_calendar(&self, url: &Url) -> Option<Arc<Mutex<T>>>;
/// Create a calendar if it did not exist, and return it
async fn create_calendar(&mut self, id: CalendarId, name: String, supported_components: SupportedComponents, color: Option<Color>)
async fn create_calendar(&mut self, url: Url, name: String, supported_components: SupportedComponents, color: Option<Color>)
-> Result<Arc<Mutex<T>>, Box<dyn Error>>;
// Removing a calendar is not supported yet
}
/// 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]
pub trait BaseCalendar {
/// Returns the calendar name
fn name(&self) -> &str;
/// Returns the calendar unique ID
fn id(&self) -> &CalendarId;
/// Returns the calendar URL
fn url(&self) -> &Url;
/// Returns the supported kinds of components for this calendar
fn supported_components(&self) -> crate::calendar::SupportedComponents;
@ -65,25 +70,31 @@ pub trait BaseCalendar {
/// 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]
pub trait DavCalendar : BaseCalendar {
/// Create a new calendar
fn new(name: String, resource: Resource, supported_components: SupportedComponents, color: Option<Color>) -> Self;
/// Get the IDs and the version tags of every item in this calendar
async fn get_item_version_tags(&self) -> Result<HashMap<ItemId, VersionTag>, Box<dyn Error>>;
/// Get the URLs and the version tags of every item in this calendar
async fn get_item_version_tags(&self) -> Result<HashMap<Url, VersionTag>, Box<dyn Error>>;
/// Returns a particular item
async fn get_item_by_id(&self, id: &ItemId) -> Result<Option<Item>, Box<dyn Error>>;
async fn get_item_by_url(&self, url: &Url) -> 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
async fn delete_item(&mut self, item_id: &ItemId) -> Result<(), Box<dyn Error>>;
async fn delete_item(&mut self, item_url: &Url) -> Result<(), Box<dyn Error>>;
/// Get the IDs of all current items in this calendar
async fn get_item_ids(&self) -> Result<HashSet<ItemId>, Box<dyn Error>> {
/// Get the URLs of all current items in this calendar
async fn get_item_urls(&self) -> Result<HashSet<Url>, Box<dyn Error>> {
let items = self.get_item_version_tags().await?;
Ok(items.iter()
.map(|(id, _tag)| id.clone())
.map(|(url, _tag)| url.clone())
.collect())
}
@ -95,30 +106,33 @@ pub trait DavCalendar : BaseCalendar {
/// Functions availabe for calendars we have full knowledge of
///
/// 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]
pub trait CompleteCalendar : BaseCalendar {
/// Create a new calendar
fn new(name: String, id: CalendarId, supported_components: SupportedComponents, color: Option<Color>) -> Self;
fn new(name: String, url: Url, supported_components: SupportedComponents, color: Option<Color>) -> Self;
/// Get the IDs of all current items in this calendar
async fn get_item_ids(&self) -> Result<HashSet<ItemId>, Box<dyn Error>>;
/// Get the URLs of all current items in this calendar
async fn get_item_urls(&self) -> Result<HashSet<Url>, Box<dyn Error>>;
/// Returns all items that this calendar contains
///
/// See [`crate::utils::comparison`] for helper functions that help sorting the results
async fn get_items(&self) -> Result<HashMap<ItemId, &Item>, Box<dyn Error>>;
async fn get_items(&self) -> Result<HashMap<Url, &Item>, Box<dyn Error>>;
/// Returns all items that this calendar contains
async fn get_items_mut(&mut self) -> Result<HashMap<Url, &mut Item>, Box<dyn Error>>;
/// Returns a particular item
async fn get_item_by_id<'a>(&'a self, id: &ItemId) -> Option<&'a Item>;
async fn get_item_by_url<'a>(&'a self, url: &Url) -> Option<&'a Item>;
/// Returns a particular item
async fn get_item_by_id_mut<'a>(&'a mut self, id: &ItemId) -> Option<&'a mut Item>;
async fn get_item_by_url_mut<'a>(&'a mut self, url: &Url) -> Option<&'a mut Item>;
/// 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
/// (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: &ItemId) -> Result<(), Box<dyn Error>>;
async fn mark_for_deletion(&mut self, item_id: &Url) -> Result<(), Box<dyn Error>>;
/// Immediately remove an item. See [`CompleteCalendar::mark_for_deletion`]
async fn immediately_delete_item(&mut self, item_id: &ItemId) -> Result<(), Box<dyn Error>>;
async fn immediately_delete_item(&mut self, item_id: &Url) -> Result<(), Box<dyn Error>>;
}

View File

@ -1,4 +1,4 @@
///! Some utility functions
//! Some utility functions
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
@ -6,10 +6,10 @@ use std::hash::Hash;
use std::io::{stdin, stdout, Read, Write};
use minidom::Element;
use url::Url;
use crate::traits::CompleteCalendar;
use crate::traits::DavCalendar;
use crate::calendar::CalendarId;
use crate::Item;
use crate::item::SyncStatus;
@ -62,12 +62,12 @@ pub fn print_xml(element: &Element) {
}
/// A debug utility that pretty-prints calendars
pub async fn print_calendar_list<C>(cals: &HashMap<CalendarId, Arc<Mutex<C>>>)
pub async fn print_calendar_list<C>(cals: &HashMap<Url, Arc<Mutex<C>>>)
where
C: CompleteCalendar,
{
for (id, cal) in cals {
println!("CAL {} ({})", cal.lock().unwrap().name(), id);
for (url, cal) in cals {
println!("CAL {} ({})", cal.lock().unwrap().name(), url);
match cal.lock().unwrap().get_items().await {
Err(_err) => continue,
Ok(map) => {
@ -80,17 +80,17 @@ where
}
/// A debug utility that pretty-prints calendars
pub async fn print_dav_calendar_list<C>(cals: &HashMap<CalendarId, Arc<Mutex<C>>>)
pub async fn print_dav_calendar_list<C>(cals: &HashMap<Url, Arc<Mutex<C>>>)
where
C: DavCalendar,
{
for (id, cal) in cals {
println!("CAL {} ({})", cal.lock().unwrap().name(), id);
for (url, cal) in cals {
println!("CAL {} ({})", cal.lock().unwrap().name(), url);
match cal.lock().unwrap().get_item_version_tags().await {
Err(_err) => continue,
Ok(map) => {
for (id, version_tag) in map {
println!(" * {} (version {:?})", id, version_tag);
for (url, version_tag) in map {
println!(" * {} (version {:?})", url, version_tag);
}
},
}
@ -107,7 +107,7 @@ pub fn print_task(item: &Item) {
SyncStatus::LocallyModified(_) => "~",
SyncStatus::LocallyDeleted(_) => "x",
};
println!(" {}{} {}\t{}", completion, sync, task.name(), task.id());
println!(" {}{} {}\t{}", completion, sync, task.name(), task.url());
},
_ => return,
}
@ -148,3 +148,10 @@ pub fn pause() {
stdout.flush().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 */)
}

View File

@ -0,0 +1,20 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Todo Corp LTD//Awesome Product ®//EN
BEGIN:VTODO
UID:20f57387-e116-4702-b463-d352aeaf80d0
X_FAVOURITE_PAINT_FINISH:matte
DTSTAMP:20211103T214742
CREATED:20211103T212345
LAST-MODIFIED:20211103T214742
SUMMARY:This is a task with ÜTF-8 characters
STATUS:NEEDS-ACTION
DUE:20211103T220000
PRIORITY:6
PERCENT-COMPLETE:48
IMAGE;DISPLAY=BADGE;FMTTYPE=image/png;VALUE=URI:http://example.com/images/p
arty.png
CONFERENCE;FEATURE=PHONE;LABEL=Attendee dial-in;VALUE=URI:tel:+1-888-555-04
56,,,555123
END:VTODO
END:VCALENDAR

View File

@ -1,118 +0,0 @@
//! Some tests of a CalDAV client.
//! Most of them are not really integration tests, but just development tests that should be cleaned up one day.
use reqwest::Method;
use reqwest::header::CONTENT_TYPE;
use minidom::Element;
use url::Url;
use kitchen_fridge::{calendar::SupportedComponents, client::Client};
use kitchen_fridge::traits::CalDavSource;
use kitchen_fridge::settings::URL;
use kitchen_fridge::settings::USERNAME;
use kitchen_fridge::settings::PASSWORD;
use kitchen_fridge::settings::EXAMPLE_TASK_URL;
use kitchen_fridge::settings::EXAMPLE_CREATED_CALENDAR_URL;
static EXAMPLE_TASKS_BODY_LAST_MODIFIED: &str = r#"
<C:calendar-query xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data />
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VTODO">
<C:prop-filter name="LAST-MODIFIED">
<C:time-range start="20210228T002308Z"
end="20260105T000000Z"/>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"#;
#[tokio::test]
#[ignore]
async fn show_calendars() {
let _ = env_logger::builder().is_test(true).try_init();
let client = Client::new(URL, USERNAME, PASSWORD).unwrap();
let calendars = client.get_calendars().await.unwrap();
println!("Calendars:");
kitchen_fridge::utils::print_dav_calendar_list(&calendars).await;
}
#[tokio::test]
#[ignore]
async fn create_cal() {
let _ = env_logger::builder().is_test(true).try_init();
let mut client = Client::new(URL, USERNAME, PASSWORD).unwrap();
let id: Url = kitchen_fridge::settings::EXAMPLE_CREATED_CALENDAR_URL.parse().unwrap();
let name = "a created calendar".into();
let supported_components = SupportedComponents::TODO;
client.create_calendar(id, name, supported_components, Some(csscolorparser::parse("gold").unwrap())).await.unwrap();
}
#[tokio::test]
#[ignore]
async fn profind() {
let _ = env_logger::builder().is_test(true).try_init();
let url: Url = EXAMPLE_TASK_URL.parse().unwrap();
let method = Method::from_bytes(b"PROPFIND")
.expect("cannot create PROPFIND method.");
let res = reqwest::Client::new()
.request(method, url.as_str())
.header("Depth", 0)
.header(CONTENT_TYPE, "application/xml")
.basic_auth(USERNAME, Some(PASSWORD))
//.body(body)
.send()
.await
.unwrap();
println!("{:?}", res.text().await);
}
#[tokio::test]
#[ignore]
async fn last_modified() {
let _ = env_logger::builder().is_test(true).try_init();
let url: Url = EXAMPLE_CREATED_CALENDAR_URL.parse().unwrap();
let method = Method::from_bytes(b"REPORT")
.expect("cannot create REPORT method.");
let res = reqwest::Client::new()
.request(method, url.as_str())
.header("Depth", 1)
.header(CONTENT_TYPE, "application/xml")
.basic_auth(USERNAME, Some(PASSWORD))
.body(EXAMPLE_TASKS_BODY_LAST_MODIFIED)
.send()
.await
.unwrap();
let el: Element = res.text().await.unwrap().parse().unwrap();
kitchen_fridge::utils::print_xml(&el);
}
//
//
// TODO: test w/ wrong creds
// TODO: test withou connection
//

View File

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

View File

@ -3,4 +3,5 @@
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.
// See `sync.rs`
// See also the CI configuration in the `.gitlab` folder
}

View File

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