use chrono::TimeDelta;
use ical::property::Property;
use itertools::Itertools;
use reqwest::{
StatusCode,
header::{HeaderMap, HeaderValue},
};
use reqwest_dav::{Client as WebDavClient, ClientBuilder, Depth, list_cmd::ListEntity, re_exports::reqwest};
use serde::{Deserialize, Serialize};
use std::{
error::Error,
path::{Path, PathBuf},
};
use tatuin_core::{
StringError,
task::{DateTimeUtc, PatchPolicy, Priority, State},
task_patch::DatePatchItem,
};
use tokio::io::AsyncWriteExt;
use crate::{
caldav::AuthType,
ical::{Task, property_to_str},
};
const INDEX_FILE_NAME: &str = "index.toml";
const DEFAULT_EVENT_DURATION: TimeDelta = TimeDelta::hours(1);
pub struct Config {
pub url: String,
pub login: String,
pub password: String,
pub auth_type: AuthType,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
struct CachedFile {
href: String,
last_modified: DateTimeUtc,
etag: Option<String>,
file_name: String,
}
#[derive(Default, Debug, Serialize, Deserialize, Clone)]
struct CachedFiles {
files: Vec<CachedFile>,
}
#[derive(PartialEq, Eq, Clone, Debug)]
enum StorageType {
Calendar,
Todo,
}
pub struct Client {
cfg: Config,
storage_type: Option<StorageType>,
c: Option<WebDavClient>,
cache_folder: PathBuf,
}
impl Client {
pub fn new(cfg: Config) -> Self {
Self {
cfg,
storage_type: None,
c: None,
cache_folder: tatuin_core::folders::temp_folder(),
}
}
pub fn set_cache_folder(&mut self, p: &Path) {
self.cache_folder = p.to_path_buf()
}
pub async fn download(&mut self) -> Result<(), Box<dyn Error>> {
let url = url::Url::parse(&self.cfg.url)?;
let mut current_cached_files = self.load_cached_files().await;
let mut new_cached_files = CachedFiles::default();
tracing::debug!(uri = url.path(), "Get file list");
let c = self.client()?;
let files = c.list(url.path(), Depth::Number(1)).await?;
for f in files {
if let ListEntity::File(f) = f {
if let Some(pos) = current_cached_files.files.iter().position(|cf| cf.href == f.href) {
let cached_file = current_cached_files.files.remove(pos);
if cached_file.etag == f.tag && cached_file.last_modified == f.last_modified {
tracing::debug!(href = f.href, "The file wasn't changed");
new_cached_files.files.push(cached_file);
continue;
}
tracing::debug!(href = f.href, "The file was changed");
}
new_cached_files.files.push(CachedFile {
file_name: self.download_and_save_file(f.href.as_str()).await?,
href: f.href.to_string(),
last_modified: f.last_modified,
etag: f.tag,
});
}
}
self.clean_missed_files(¤t_cached_files.files).await;
self.save_cached_files(&new_cached_files).await?;
Ok(())
}
pub async fn parse_calendars(&mut self) -> Result<Vec<Task>, Box<dyn Error>> {
let mut result = Vec::new();
for f in self.load_cached_files().await.files {
let mut tasks = self.parse_calendar(&f.file_name).await?;
tasks.iter_mut().for_each(|t| {
t.href = f.href.clone();
t.etag = f.etag.clone();
t.patch_policy = PatchPolicy {
is_editable: true,
is_removable: true,
available_states: vec![State::Uncompleted, State::Completed, State::InProgress],
available_priorities: Priority::values(),
available_due_items: DatePatchItem::values(),
available_scheduled_items: Vec::new(),
};
});
result.append(&mut tasks);
}
if !result.is_empty() {
self.storage_type = Some(if result[0].task_type == crate::ical::TaskType::Event {
StorageType::Calendar
} else {
StorageType::Todo
});
}
Ok(result)
}
pub async fn create_or_update(&mut self, t: &Task) -> Result<(), Box<dyn Error>> {
if let Some(st) = self.storage_type.clone() {
let r = self.send_create_or_update_request(&st, t).await?;
let st = r.status();
if st != StatusCode::CREATED && st != StatusCode::NO_CONTENT {
let response_text = r.text().await?;
tracing::error!(target:"caldav_client", response_text=?response_text, "Send create or update request");
return Err(StringError::new(format!("Wrong response status {st}").as_str()).into());
} else {
return r
.error_for_status()
.map(|_| ())
.map_err(|e| Box::new(e) as Box<dyn Error>);
}
}
tracing::info!(target:"caldav_client", "Try to detect storage type");
let mut r = self.send_create_or_update_request(&StorageType::Calendar, t).await?;
if r.status() == StatusCode::FORBIDDEN {
let response_text = r.text().await;
tracing::error!(target:"caldav_client", text=?response_text, "Wrong storage type");
r = self.send_create_or_update_request(&StorageType::Calendar, t).await?;
self.storage_type = Some(StorageType::Todo);
} else {
self.storage_type = Some(StorageType::Calendar);
}
tracing::info!(target:"caldav_client", storage_type=?self.storage_type.as_ref().unwrap(), "The storage type has been detected");
let r = r.error_for_status();
r.map(|_| ()).map_err(|e| Box::new(e) as Box<dyn Error>)
}
pub async fn delete(&mut self, t: &Task) -> Result<(), Box<dyn Error>> {
self.send_delete_request(t).await
}
}
impl Client {
fn client(&mut self) -> Result<&WebDavClient, Box<dyn Error>> {
if self.c.is_none() {
let mut u = url::Url::parse(&self.cfg.url)?;
u.set_path("/");
tracing::debug!(
url = u.to_string(),
login = self.cfg.login,
password = self.cfg.password,
"Connect to the server"
);
self.c = Some(
ClientBuilder::new()
.set_host(u.to_string())
.set_auth(self.cfg.auth_type.to_dav_auth(&self.cfg.login, &self.cfg.password))
.build()?,
);
}
Ok(self.c.as_ref().unwrap())
}
async fn load_cached_files(&self) -> CachedFiles {
if let Ok(s) = tokio::fs::read_to_string(self.cache_folder.join(INDEX_FILE_NAME)).await {
match toml::from_str(s.as_str()) {
Ok(files) => return files,
Err(e) => tracing::error!(error=?e, "Load cached files"),
}
}
CachedFiles::default()
}
async fn save_cached_files(&self, files: &CachedFiles) -> Result<(), Box<dyn Error>> {
let s = toml::to_string(files)?;
tokio::fs::write(self.cache_folder.join(INDEX_FILE_NAME), s).await?;
Ok(())
}
async fn clean_missed_files(&self, files: &[CachedFile]) {
for f in files {
tracing::debug!(href = f.href, file_name = f.file_name, "Remove cached file");
if let Err(e) = tokio::fs::remove_file(self.cache_folder.join(f.file_name.as_str())).await {
tracing::error!(error=?e, path=?self.cache_folder, file=f.file_name, "Remove cached file");
}
}
}
async fn download_and_save_file(&mut self, href: &str) -> Result<String, Box<dyn Error>> {
let c = self.client()?;
let mut r = c.get(href).await?;
let file_name = file_name_from_href(href)?;
tracing::debug!(href = href, file_name = file_name, "Download the file");
let mut f = tokio::fs::File::create(self.cache_folder.join(file_name.as_str())).await?;
while let Some(chunk) = r.chunk().await? {
f.write_all(&chunk).await?;
}
Ok(file_name)
}
async fn parse_calendar(&self, file_name: &str) -> Result<Vec<Task>, Box<dyn Error>> {
crate::ical::parse_calendar(&self.cache_folder.join(file_name)).await
}
fn create_or_update_request_body(&self, storage_type: &StorageType, t: &Task) -> String {
let mut task = t.clone();
let task_type = if *storage_type == StorageType::Calendar {
task.start = task.start.or(task.due);
task.end = task
.start
.map(|d| d.checked_add_signed(DEFAULT_EVENT_DURATION).unwrap_or_default());
"VEVENT"
} else {
"VTODO"
};
let properties: Vec<Property> = (&task).into();
format!(
r#"BEGIN:VCALENDAR
BEGIN:{task_type}
{}
END:{task_type}
END:VCALENDAR"#,
properties.iter().map(property_to_str).join("\n")
)
}
fn task_href(&self, t: &Task) -> Result<String, Box<dyn Error>> {
Ok(if t.href.is_empty() {
let url = url::Url::parse(&self.cfg.url)?;
url.join(format!("{}.ics", uuid::Uuid::new_v4()).as_str())?
.path()
.to_string()
} else {
t.href.clone()
})
}
async fn send_create_or_update_request(
&mut self,
storage_type: &StorageType,
t: &Task,
) -> Result<reqwest::Response, Box<dyn Error>> {
let body = self.create_or_update_request_body(storage_type, t);
let href = self.task_href(t)?;
tracing::debug!(body=?body, task=?&t, href=href, "Create or update a task");
let c = self.client()?;
c.start_request(reqwest::Method::PUT, &href)
.await?
.headers({
let mut map = HeaderMap::new();
map.insert(
reqwest::header::CONTENT_TYPE,
HeaderValue::from_str("text/calendar; charset=utf-8")?,
);
if let Some(etag) = &t.etag {
map.insert(reqwest::header::IF_MATCH, HeaderValue::from_str(etag)?);
}
map
})
.body(body)
.send()
.await
.map_err(|e| Box::new(e) as Box<dyn Error>)
}
async fn send_delete_request(&mut self, t: &Task) -> Result<(), Box<dyn Error>> {
let href = self.task_href(t)?;
let etag = t.etag.as_ref().ok_or("ETag should be set")?;
tracing::debug!(etag=?etag, task=?&t, href=href, "Delete the task");
let c = self.client()?;
c.start_request(reqwest::Method::DELETE, &href)
.await?
.headers({
let mut map = HeaderMap::new();
map.insert(reqwest::header::IF_MATCH, HeaderValue::from_str(etag.as_str())?);
map
})
.send()
.await?
.error_for_status()
.map(|_| ())
.map_err(|e| Box::new(e) as Box<dyn Error>)
}
}
fn file_name_from_href(href: &str) -> Result<String, StringError> {
if href.is_empty() {
return Err(StringError::new("empty string"));
}
href.rfind('/')
.map(|pos| href[pos + 1..].to_string())
.ok_or(StringError::new("wrong href format"))
}
#[cfg(test)]
mod test {
use super::file_name_from_href;
#[test]
fn file_name_from_href_test() {
struct Case<'a> {
name: &'a str,
href: &'a str,
file_name: Option<&'a str>,
error: Option<&'a str>,
}
const CASES: &[Case] = &[
Case {
name: "regular href",
href: "/remote.php/dav/calendars/user@domain.org/tasks/81CAB8BE-2B83-4ABF-921B-EC697FFC293D.ics",
file_name: Some("81CAB8BE-2B83-4ABF-921B-EC697FFC293D.ics"),
error: None,
},
Case {
name: "empty href",
href: "",
file_name: None,
error: Some("empty string"),
},
];
for c in CASES {
let r = file_name_from_href(c.href);
if let Some(name) = c.file_name {
assert!(r.is_ok());
assert_eq!(r.as_ref().unwrap(), name, "Test '{}' was failed", c.name);
}
if let Some(error) = c.error {
assert!(r.is_err());
assert_eq!(
r.as_ref().unwrap_err().to_string(),
error,
"Test '{}' was failed",
c.name
);
}
}
}
}