use crate::cache::Cache;
use crate::client::auth::DynamicAuthLayer;
use crate::client::cert::NoVerifier;
use crate::client::middleware::{UserAgentLayer, UserAgentService};
use crate::config::Config;
use crate::context::AppContext;
use crate::journal::{Action, Journal};
use crate::model::{CalendarListEntry, IcsAdapter, Task};
use crate::storage::{LocalCalendarRegistry, LocalStorage};
use http::{Request, StatusCode};
use libdav::caldav::{FindCalendarHomeSet, FindCalendars, GetCalendarResources};
use libdav::dav::{Delete, GetProperty, ListResources, Propfind, PutResource};
use libdav::dav::{WebDavClient, WebDavError};
use libdav::{CalDavClient, PropertyName, names};
use roxmltree::Document;
use anyhow;
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
use futures::stream::{self, StreamExt};
use http::Uri;
use hyper_rustls::HttpsConnectorBuilder;
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
#[cfg(not(target_os = "android"))]
use rustls_native_certs;
use tower_layer::Layer;
pub const GET_CTAG: PropertyName = PropertyName::new("http://calendarserver.org/ns/", "getctag");
pub const APPLE_COLOR: PropertyName =
PropertyName::new("http://apple.com/ns/ical/", "calendar-color");
use crate::client::FollowRedirectLayer;
use crate::client::FollowRedirectService;
use crate::client::auth::DynamicAuthService;
pub(crate) type HttpsClient = FollowRedirectService<
DynamicAuthService<
UserAgentService<
Client<
hyper_rustls::HttpsConnector<hyper_util::client::legacy::connect::HttpConnector>,
String,
>,
>,
>,
>;
#[cfg(any(test, feature = "test_hooks"))]
pub mod test_hooks {
use super::Task;
use crate::journal::Action;
use std::sync::{Mutex, OnceLock};
pub type FetchRemoteHook = Box<dyn Fn(&str) -> Option<Task> + Send + Sync + 'static>;
pub type ForceSyncErrorHook =
Box<dyn Fn(&Action) -> Option<anyhow::Error> + Send + Sync + 'static>;
pub static TEST_FETCH_REMOTE_HOOK: OnceLock<Mutex<Option<FetchRemoteHook>>> = OnceLock::new();
pub static TEST_FORCE_SYNC_ERROR: OnceLock<Mutex<Option<ForceSyncErrorHook>>> = OnceLock::new();
}
#[cfg(any(test, feature = "test_hooks"))]
pub use test_hooks::{
FetchRemoteHook, ForceSyncErrorHook, TEST_FETCH_REMOTE_HOOK, TEST_FORCE_SYNC_ERROR,
};
pub(crate) fn strip_host(href: &str) -> String {
if href.starts_with("local://") {
return href.to_string();
}
if let Ok(uri) = href.parse::<Uri>()
&& (uri.scheme().is_some() || uri.authority().is_some())
{
let path = uri.path();
if path.is_empty() {
return href.to_string();
}
return uri
.path_and_query()
.map(|pq| pq.as_str().to_string())
.unwrap_or_else(|| path.to_string());
}
href.to_string()
}
#[derive(Clone, Debug)]
pub struct RustyClient {
pub client: Option<CalDavClient<HttpsClient>>,
pub ctx: Arc<dyn AppContext>,
}
impl RustyClient {
pub fn new(
ctx: Arc<dyn AppContext>,
url: &str,
user: &str,
pass: &str,
insecure: bool,
client_type: Option<&str>,
) -> anyhow::Result<Self> {
if url.is_empty() {
return Ok(Self {
client: None,
ctx: ctx.clone(),
});
}
let uri: Uri = url
.parse()
.map_err(|e: http::uri::InvalidUri| anyhow::anyhow!("Invalid URI: {}", e))?;
let tls_config_builder = rustls::ClientConfig::builder();
let tls_config = if insecure {
tls_config_builder
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerifier))
.with_no_client_auth()
} else {
#[cfg(not(target_os = "android"))]
{
let mut root_store = rustls::RootCertStore::empty();
let result = rustls_native_certs::load_native_certs();
root_store.add_parsable_certificates(result.certs);
if root_store.is_empty() {
return Err(anyhow::anyhow!(rust_i18n::t!("error_no_certs").to_string()));
}
tls_config_builder
.with_root_certificates(root_store)
.with_no_client_auth()
}
#[cfg(target_os = "android")]
{
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
tls_config_builder
.with_root_certificates(root_store)
.with_no_client_auth()
}
};
let https_connector = HttpsConnectorBuilder::new()
.with_tls_config(tls_config)
.https_or_http()
.enable_http1()
.build();
let http_client = Client::builder(TokioExecutor::new()).build(https_connector);
let version = env!("CARGO_PKG_VERSION");
let ua_string = if let Some(ctype) = client_type {
format!("Cfait/{} ({})", version, ctype)
} else {
format!("Cfait/{}", version)
};
let ua_client = UserAgentLayer::new(ua_string).layer(http_client);
let auth_client =
DynamicAuthLayer::new(user.to_string(), pass.to_string()).layer(ua_client);
let redirect_client = FollowRedirectLayer::new(10).layer(auth_client);
let webdav = WebDavClient::new(uri, redirect_client.clone());
let caldav = CalDavClient::new(webdav);
Ok(Self {
client: Some(caldav),
ctx,
})
}
pub async fn discover_calendar(&self) -> anyhow::Result<String> {
if let Some(client) = &self.client {
let base_path = client.base_url().path().to_string();
if let Ok(response) = client.request(ListResources::new(&base_path)).await
&& response.resources.iter().any(|r| r.href.ends_with(".ics"))
{
return Ok(base_path);
}
if let Ok(Some(principal)) = client.find_current_user_principal().await
&& let Ok(response) = client
.request(FindCalendarHomeSet::new(principal.path()))
.await
&& let Some(home_url) = response.home_sets.first()
&& let Ok(cals_resp) = client.request(FindCalendars::new(home_url.path())).await
&& let Some(first) = cals_resp.calendars.first()
{
return Ok(first.href.clone());
}
Ok(base_path)
} else {
Err(anyhow::anyhow!("Offline"))
}
}
pub async fn connect_with_fallback(
ctx: Arc<dyn AppContext>,
config: Config,
client_type: Option<&str>,
) -> anyhow::Result<(
Self,
Vec<CalendarListEntry>,
Vec<Task>,
Option<String>,
Option<String>,
)> {
let mut config_for_saving = config.clone();
let client = Self::new(
ctx.clone(),
&config.url,
&config.username,
&config.password,
config.allow_insecure_certs,
client_type,
)?;
let _ =
tokio::time::timeout(std::time::Duration::from_secs(10), client.sync_journal()).await;
let ((calendars, corrected_url_opt), warning) = match client.get_calendars().await {
Ok((c, corrected_url)) => {
if c.is_empty() {
let helpful_msg = rust_i18n::t!("error_no_calendars_found").to_string();
((c, corrected_url), Some(helpful_msg))
} else {
let _ = Cache::save_calendars(client.ctx.as_ref(), &c);
((c, corrected_url), None)
}
}
Err(e) => {
let error_msg = e.to_string();
let mut specific_warning = None;
if error_msg.contains("InvalidCertificate") {
return Err(anyhow::anyhow!(
"{}",
rust_i18n::t!("error_invalid_tls", error = error_msg)
));
}
if error_msg.contains("Unauthorized")
|| error_msg.contains("Forbidden")
|| error_msg.contains("401")
|| error_msg.contains("403")
{
specific_warning = Some(rust_i18n::t!("error_auth_failed").to_string());
} else if error_msg.contains("NotFound") || error_msg.contains("404") {
specific_warning = Some(rust_i18n::t!("error_404_not_found").to_string());
} else if error_msg.contains("Timeout") {
specific_warning = Some(rust_i18n::t!("error_timeout").to_string());
}
let cals = Cache::load_calendars(client.ctx.as_ref()).unwrap_or_default();
let final_warning = specific_warning.unwrap_or_else(|| {
rust_i18n::t!("error_offline_fallback", error = error_msg.clone()).to_string()
});
((cals, None), Some(final_warning))
}
};
let mut active_href: Option<String> = None;
if let Some(def_cal) = &config.default_calendar
&& let Some(found) = calendars
.iter()
.find(|c| c.name == *def_cal || c.href == *def_cal)
{
active_href = Some(found.href.clone());
}
let mut needs_config_save = false;
if active_href.is_none()
&& warning.is_none()
&& let Ok(href) = client.discover_calendar().await
{
active_href = Some(href.clone());
config_for_saving.default_calendar = Some(href);
needs_config_save = true;
}
if let Some(corrected_url) = corrected_url_opt {
config_for_saving.url = corrected_url;
needs_config_save = true;
}
if needs_config_save {
let ctx_clone = client.ctx.clone();
tokio::spawn(async move {
if let Err(e) = config_for_saving.save(ctx_clone.as_ref()) {
#[cfg(not(target_os = "android"))]
eprintln!("[Warning] Failed to auto-save config corrections: {}", e);
#[cfg(target_os = "android")]
log::warn!("Failed to auto-save config corrections: {}", e);
}
});
}
let tasks = if warning.is_none() {
if let Some(ref h) = active_href {
client.get_tasks(h).await.unwrap_or_default()
} else {
vec![]
}
} else if let Some(ref h) = active_href {
let (mut t, _) = Cache::load(client.ctx.as_ref(), h).unwrap_or((vec![], None));
Journal::apply_to_tasks(client.ctx.as_ref(), &mut t, h);
t
} else {
vec![]
};
Ok((client, calendars, tasks, active_href, warning))
}
async fn perform_calendar_discovery(
&self,
_discovery_path: &str,
) -> anyhow::Result<Vec<CalendarListEntry>> {
let client = self
.client
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Offline"))?;
let principal_res = client.find_current_user_principal().await?;
let Some(principal) = principal_res else {
return Err(anyhow::anyhow!(
rust_i18n::t!("error_no_principal").to_string()
));
};
let home_set_resp = client
.request(FindCalendarHomeSet::new(principal.path()))
.await?;
let home_url = home_set_resp
.home_sets
.first()
.ok_or_else(|| anyhow::anyhow!(rust_i18n::t!("error_no_home_set").to_string()))?;
let cals_resp = client.request(FindCalendars::new(home_url.path())).await?;
let mut calendars = Vec::new();
for col in cals_resp.calendars {
let name = client
.request(GetProperty::new(&col.href, &names::DISPLAY_NAME))
.await
.ok()
.and_then(|r| r.value)
.unwrap_or_else(|| col.href.clone());
let color = client
.request(GetProperty::new(&col.href, &APPLE_COLOR))
.await
.ok()
.and_then(|r| r.value);
let (comps, can_write) = self
.get_supported_components(&col.href)
.await
.unwrap_or_else(|_| (Vec::new(), true));
if can_write && comps.iter().any(|c| c.eq_ignore_ascii_case("VTODO")) {
calendars.push(CalendarListEntry {
name,
href: col.href,
color,
});
}
}
Ok(calendars)
}
pub async fn get_calendars(&self) -> anyhow::Result<(Vec<CalendarListEntry>, Option<String>)> {
if let Some(_client) = &self.client {
let user_configured_path = self.client.as_ref().unwrap().base_url().path();
let mut corrected_url = None;
let mut calendars = self
.perform_calendar_discovery(user_configured_path)
.await?;
if calendars.is_empty()
&& user_configured_path != "/"
&& let Ok(fallback) = self.perform_calendar_discovery("/").await
&& !fallback.is_empty()
{
calendars = fallback;
let base_uri = self.client.as_ref().unwrap().base_url();
if let (Some(scheme), Some(authority)) = (base_uri.scheme(), base_uri.authority()) {
corrected_url = Some(format!("{}://{}", scheme, authority));
}
}
if let Ok(local_cals) = LocalCalendarRegistry::load(self.ctx.as_ref()) {
for local_cal in local_cals {
if local_cal.href == "local://recovery"
|| local_cal.href == crate::storage::LOCAL_TRASH_HREF
{
if let Ok(tasks) =
LocalStorage::load_for_href(self.ctx.as_ref(), &local_cal.href)
&& !tasks.is_empty()
{
calendars.push(local_cal);
}
} else {
calendars.push(local_cal);
}
}
}
Ok((calendars, corrected_url))
} else {
let mut calendars = Cache::load_calendars(self.ctx.as_ref()).unwrap_or_default();
if let Ok(local_cals) = LocalCalendarRegistry::load(self.ctx.as_ref()) {
for local_cal in local_cals {
if calendars.iter().any(|c| c.href == local_cal.href) {
continue;
}
if local_cal.href == "local://recovery"
|| local_cal.href == crate::storage::LOCAL_TRASH_HREF
{
if let Ok(tasks) =
LocalStorage::load_for_href(self.ctx.as_ref(), &local_cal.href)
&& !tasks.is_empty()
{
calendars.push(local_cal);
}
} else {
calendars.push(local_cal);
}
}
}
Ok((calendars, None))
}
}
pub async fn get_supported_components(
&self,
calendar_href: &str,
) -> anyhow::Result<(Vec<String>, bool)> {
if let Some(_client) = &self.client {
let privilege_set_prop = PropertyName::new("DAV:", "current-user-privilege-set");
let req = Propfind::new(calendar_href)
.with_properties(&[
&names::SUPPORTED_CALENDAR_COMPONENT_SET,
&privilege_set_prop,
])
.with_depth(libdav::Depth::Zero);
let response = self.client.as_ref().unwrap().request(req).await?;
let xml_str = std::str::from_utf8(&response.body)?;
let doc = Document::parse(xml_str)?;
let mut components = Vec::new();
let mut can_write = false;
let mut has_privilege_set = false;
for node in doc.descendants() {
if node.tag_name().name().eq_ignore_ascii_case("comp")
&& let Some(name) = node.attribute("name")
{
components.push(name.to_uppercase());
}
if node
.tag_name()
.name()
.eq_ignore_ascii_case("current-user-privilege-set")
{
has_privilege_set = true;
}
if node.tag_name().name().eq_ignore_ascii_case("write")
|| node.tag_name().name().eq_ignore_ascii_case("write-content")
|| node.tag_name().name().eq_ignore_ascii_case("bind")
{
can_write = true;
}
}
if !has_privilege_set {
can_write = true;
}
Ok((components, can_write))
} else {
Err(anyhow::anyhow!("Offline"))
}
}
pub async fn get_companion_events(
&self,
calendar_href: &str,
task_uid: Option<&str>,
) -> anyhow::Result<Vec<String>> {
let client = self
.client
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Offline"))?;
let path = strip_host(calendar_href);
let body = if let Some(uid) = task_uid {
format!(
r#"<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:prop-filter name="X-CFAIT-TASK-UID">
<C:text-match collation="i;ascii-casemap">{}</C:text-match>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>"#,
xml_escape(uid)
)
} else {
r#"<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:prop-filter name="X-CFAIT-TASK-UID">
<C:is-defined/>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>"#
.to_string()
};
let base = client.base_url();
let scheme = base.scheme_str().unwrap_or("https");
let authority = base.authority().map(|a| a.as_str()).unwrap_or("");
let clean_path = if path.starts_with('/') {
path.clone()
} else {
format!("/{}", path)
};
let absolute_destination = format!("{}://{}{}", scheme, authority, clean_path);
let req = Request::builder()
.method("REPORT")
.uri(absolute_destination)
.header("Content-Type", "application/xml; charset=utf-8")
.header("Depth", "1")
.body(body)
.map_err(|e| anyhow::anyhow!("Request build failed: {}", e))?;
let (parts, body_bytes) = client
.webdav_client
.request_raw(req)
.await
.map_err(|e| anyhow::anyhow!("REPORT failed: {:?}", e))?;
if !parts.status.is_success() && parts.status != StatusCode::MULTI_STATUS {
let list_resp = client.request(ListResources::new(&path)).await?;
let mut hrefs = Vec::new();
for res in list_resp.resources {
let filename = res.href.split('/').next_back().unwrap_or("");
if filename.starts_with("evt-") && filename.ends_with(".ics") {
if let Some(uid) = task_uid {
if filename.starts_with(&format!("evt-{}", uid)) {
hrefs.push(res.href);
}
} else {
hrefs.push(res.href);
}
}
}
return Ok(hrefs);
}
let xml_str = std::str::from_utf8(&body_bytes).unwrap_or("");
let mut hrefs = Vec::new();
if let Ok(doc) = roxmltree::Document::parse(xml_str) {
for node in doc.descendants() {
if node.tag_name().name().eq_ignore_ascii_case("href")
&& let Some(text) = node.text()
{
hrefs.push(text.to_string());
}
}
}
Ok(hrefs)
}
pub(crate) async fn sync_companion_event(
&self,
task: &Task,
config_enabled: bool,
delete_on_completion: bool,
is_delete_intent: bool,
) -> bool {
if task.calendar_href.starts_with("local://") {
return false;
}
let should_create_events = task.create_event.unwrap_or(config_enabled);
let base_uid = format!("evt-{}", task.uid);
let cal_path = if task.calendar_href.ends_with('/') {
task.calendar_href.clone()
} else {
let p = strip_host(&task.href);
if let Some(idx) = p.rfind('/') {
p[..=idx].to_string()
} else {
task.calendar_href.clone()
}
};
let client = match &self.client {
Some(c) => c,
None => return false,
};
let has_calendar_data =
task.due.is_some() || task.dtstart.is_some() || !task.sessions.is_empty();
let keep_completed = !delete_on_completion && task.status.is_done();
let should_delete = is_delete_intent
|| (delete_on_completion && task.status.is_done())
|| (!has_calendar_data && !keep_completed)
|| !should_create_events;
if !should_create_events
&& !is_delete_intent
&& !delete_on_completion
&& task.create_event.is_none()
{
return true;
}
let existing_hrefs = self
.get_companion_events(&cal_path, Some(&task.uid))
.await
.unwrap_or_default();
let mut existing_filenames: std::collections::HashSet<String> = existing_hrefs
.iter()
.map(|h| h.split('/').next_back().unwrap_or("").to_string())
.collect();
let mut futures: Vec<futures::future::BoxFuture<'_, Result<(), ()>>> = Vec::new();
let generated_events = if should_delete {
vec![]
} else {
IcsAdapter::to_event_ics(task)
};
for (suffix, ics_body) in generated_events.iter() {
let event_filename = format!("{}{}.ics", base_uid, suffix);
existing_filenames.remove(&event_filename);
let event_path = format!("{}{}", strip_host(&cal_path), event_filename);
let c = client.clone();
let body_clone = ics_body.clone();
futures.push(Box::pin(async move {
let create_req = PutResource::new(&event_path)
.create(body_clone.clone(), "text/calendar; charset=utf-8");
match c.request(create_req).await {
Ok(_) => Ok(()),
Err(WebDavError::BadStatusCode(http::StatusCode::PRECONDITION_FAILED))
| Err(WebDavError::PreconditionFailed(_)) => {
let update_req = PutResource::new(&event_path).update(
body_clone,
"text/calendar; charset=utf-8",
"",
);
if c.request(update_req).await.is_err() {
Err(())
} else {
Ok(())
}
}
Err(_) => Err(()),
}
}));
}
for obsolete_filename in existing_filenames {
let event_path = format!("{}{}", strip_host(&cal_path), obsolete_filename);
let c = client.clone();
futures.push(Box::pin(async move {
match c.request(Delete::new(&event_path).force()).await {
Ok(_) => Ok(()),
Err(WebDavError::BadStatusCode(http::StatusCode::NOT_FOUND)) => Ok(()),
Err(_) => Err(()),
}
}));
}
let static_suffixes = ["", "-start", "-due"];
for suffix in static_suffixes {
let event_filename = format!("{}{}.ics", base_uid, suffix);
if !generated_events.iter().any(|(s, _)| s == suffix) {
let event_path = format!("{}{}", strip_host(&cal_path), event_filename);
let c = client.clone();
futures.push(Box::pin(async move {
match c.request(Delete::new(&event_path).force()).await {
Ok(_) => Ok(()),
Err(WebDavError::BadStatusCode(http::StatusCode::NOT_FOUND)) => Ok(()),
Err(_) => Err(()),
}
}));
}
}
let results = futures::future::join_all(futures).await;
results.into_iter().all(|r| r.is_ok())
}
pub async fn sync_task_companion_event(
&self,
task: &Task,
config_enabled: bool,
) -> anyhow::Result<bool> {
let cfg = Config::load(self.ctx.as_ref()).unwrap_or_default();
let delete_on_completion = cfg.delete_events_on_completion;
let res = self
.sync_companion_event(task, config_enabled, delete_on_completion, false)
.await;
Ok(res)
}
pub async fn sync_multiple_companion_events(
&self,
tasks: &[Task],
config_enabled: bool,
delete_on_completion: bool,
) -> anyhow::Result<usize> {
let client = self
.client
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Offline"))?;
let mut by_calendar: HashMap<String, Vec<&Task>> = HashMap::new();
for task in tasks {
if !task.calendar_href.starts_with("local://") {
let cal_path = if task.calendar_href.ends_with('/') {
task.calendar_href.clone()
} else {
let p = strip_host(&task.href);
if let Some(idx) = p.rfind('/') {
p[..=idx].to_string()
} else {
task.calendar_href.clone()
}
};
by_calendar.entry(cal_path).or_default().push(task);
}
}
let mut success_count = 0;
for (cal_path, cal_tasks) in by_calendar {
let existing_hrefs = self
.get_companion_events(&cal_path, None)
.await
.unwrap_or_default();
let mut all_existing_filenames: std::collections::HashSet<String> = existing_hrefs
.into_iter()
.map(|h| h.split('/').next_back().unwrap_or("").to_string())
.collect();
let mut futures: Vec<futures::future::BoxFuture<'_, Result<(), ()>>> = Vec::new();
for task in cal_tasks {
let should_create_events = task.create_event.unwrap_or(config_enabled);
let base_uid = format!("evt-{}", task.uid);
let has_calendar_data =
task.due.is_some() || task.dtstart.is_some() || !task.sessions.is_empty();
let keep_completed = !delete_on_completion && task.status.is_done();
let should_delete = (delete_on_completion && task.status.is_done())
|| (!has_calendar_data && !keep_completed)
|| !should_create_events;
if !should_create_events && !delete_on_completion && task.create_event.is_none() {
continue;
}
let mut task_existing_filenames = std::collections::HashSet::new();
let mut retain_list = Vec::new();
for filename in all_existing_filenames.into_iter() {
if filename.starts_with(&base_uid) && filename.ends_with(".ics") {
task_existing_filenames.insert(filename);
} else {
retain_list.push(filename);
}
}
all_existing_filenames = retain_list.into_iter().collect();
let generated_events = if should_delete {
vec![]
} else {
IcsAdapter::to_event_ics(task)
};
for (suffix, ics_body) in generated_events.iter() {
let event_filename = format!("{}{}.ics", base_uid, suffix);
task_existing_filenames.remove(&event_filename);
let event_path = format!("{}{}", strip_host(&cal_path), event_filename);
let c = client.clone();
let body_clone = ics_body.clone();
futures.push(Box::pin(async move {
let create_req = PutResource::new(&event_path)
.create(body_clone.clone(), "text/calendar; charset=utf-8");
match c.request(create_req).await {
Ok(_) => Ok(()),
Err(_) => {
let update_req = PutResource::new(&event_path).update(
body_clone,
"text/calendar; charset=utf-8",
"",
);
if c.request(update_req).await.is_err() {
Err(())
} else {
Ok(())
}
}
}
}));
}
for obsolete_filename in task_existing_filenames {
let event_path = format!("{}{}", strip_host(&cal_path), obsolete_filename);
let c = client.clone();
futures.push(Box::pin(async move {
match c.request(Delete::new(&event_path).force()).await {
Ok(_) => Ok(()),
Err(_) => Ok(()),
}
}));
}
let static_suffixes = ["", "-start", "-due"];
for suffix in static_suffixes {
let event_filename = format!("{}{}.ics", base_uid, suffix);
if !generated_events.iter().any(|(s, _)| s == suffix) {
let event_path = format!("{}{}", strip_host(&cal_path), event_filename);
let c = client.clone();
futures.push(Box::pin(async move {
match c.request(Delete::new(&event_path).force()).await {
Ok(_) => Ok(()),
Err(_) => Ok(()),
}
}));
}
}
}
let mut stream = futures::stream::iter(futures).buffer_unordered(8);
while let Some(res) = stream.next().await {
if res.is_ok() {
success_count += 1;
}
}
}
Ok(success_count)
}
pub async fn delete_all_companion_events(&self, calendar_href: &str) -> anyhow::Result<usize> {
if calendar_href.starts_with("local://") {
return Ok(0);
}
let client = self
.client
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Offline"))?;
let path = strip_host(calendar_href);
let hrefs = self.get_companion_events(&path, None).await?;
let count = hrefs.len();
if count == 0 {
return Ok(0);
}
let futures = hrefs.into_iter().map(|href| {
let c = client.clone();
async move {
let _ = c.request(Delete::new(&strip_host(&href)).force()).await;
}
});
let mut stream = stream::iter(futures).buffer_unordered(8);
while stream.next().await.is_some() {}
Ok(count)
}
pub(crate) async fn fetch_remote_task(&self, task_href: &str) -> Option<Task> {
#[cfg(any(test, feature = "test_hooks"))]
{
if let Some(h) = TEST_FETCH_REMOTE_HOOK.get()
&& let Some(cb) = &*h.lock().unwrap()
{
return cb(task_href);
}
}
if let Some(client) = &self.client {
let path_href = strip_host(task_href);
let parent_path = if let Some(idx) = path_href.rfind('/') {
&path_href[..=idx]
} else {
"/"
};
let req = GetCalendarResources::new(parent_path).with_hrefs(vec![path_href.clone()]);
if let Ok(resp) = client.request(req).await
&& let Some(item) = resp.resources.into_iter().next()
&& let Ok(content) = item.content
{
return IcsAdapter::from_ics(
&content.data,
content.etag,
item.href,
parent_path.to_string(),
)
.ok();
}
}
None
}
async fn fetch_calendar_tasks_internal(
&self,
calendar_href: &str,
apply_journal: bool,
) -> anyhow::Result<Vec<Task>> {
if calendar_href.starts_with("local://") {
let mut tasks = LocalStorage::load_for_href(self.ctx.as_ref(), calendar_href)?;
if apply_journal {
Journal::apply_to_tasks(self.ctx.as_ref(), &mut tasks, calendar_href);
}
return Ok(tasks);
}
let (mut cached_tasks, cached_token) =
Cache::load(self.ctx.as_ref(), calendar_href).unwrap_or((vec![], None));
if let Some(client) = &self.client {
let path_href = strip_host(calendar_href);
let (pending_deletions, pending_active) = if apply_journal {
let journal = Journal::load(self.ctx.as_ref());
let mut dels = HashSet::new();
let mut active = HashSet::new();
for action in journal.queue {
match action {
Action::Delete(t) => {
if t.calendar_href == calendar_href {
dels.insert(t.uid);
}
}
Action::Move(t, _) => {
if t.calendar_href == calendar_href {
dels.insert(t.uid.clone());
}
active.insert(t.uid);
}
Action::Create(t) | Action::Update(t) => {
active.insert(t.uid);
}
}
}
(dels, active)
} else {
(HashSet::new(), HashSet::new())
};
let remote_token = if let Ok(resp) = client
.request(GetProperty::new(&path_href, &GET_CTAG))
.await
{
resp.value
} else if let Ok(resp) = client
.request(GetProperty::new(&path_href, &names::SYNC_TOKEN))
.await
{
resp.value
} else {
None
};
let has_ghosts = cached_tasks
.iter()
.any(|t| t.etag.is_empty() && !t.href.is_empty());
if !has_ghosts
&& let (Some(r_tok), Some(c_tok)) = (&remote_token, &cached_token)
&& r_tok == c_tok
{
if apply_journal {
Journal::apply_to_tasks(self.ctx.as_ref(), &mut cached_tasks, calendar_href);
}
return Ok(cached_tasks);
}
let list_resp = client
.request(ListResources::new(&path_href))
.await
.map_err(|e| anyhow::anyhow!("PROPFIND: {:?}", e))?;
let mut cache_map: HashMap<String, Task> = HashMap::new();
for t in cached_tasks {
cache_map.insert(strip_host(&t.href), t);
}
let mut final_tasks = Vec::new();
let mut to_fetch = Vec::new();
let mut server_hrefs = HashSet::new();
for resource in list_resp.resources {
if !resource.href.ends_with(".ics") {
continue;
}
let res_href_stripped = strip_host(&resource.href);
let filename = res_href_stripped.split('/').next_back().unwrap_or("");
if filename.starts_with("evt-") && filename.len() >= 40 {
continue;
}
let should_skip = if let Some(cached) = cache_map.get(&res_href_stripped) {
pending_deletions.contains(&cached.uid)
} else {
false
};
if should_skip {
cache_map.remove(&res_href_stripped);
continue;
}
server_hrefs.insert(res_href_stripped.clone());
let remote_etag = resource.etag;
if let Some(local_task) = cache_map.remove(&res_href_stripped) {
if let Some(r_etag) = &remote_etag {
if !r_etag.is_empty() && *r_etag == local_task.etag {
final_tasks.push(local_task);
} else {
to_fetch.push(res_href_stripped);
}
} else {
to_fetch.push(res_href_stripped);
}
} else {
to_fetch.push(res_href_stripped);
}
}
for (_href, task) in cache_map {
let is_unsynced = task.etag.is_empty() || task.href.is_empty();
if is_unsynced {
if apply_journal && !pending_active.contains(&task.uid) {
continue;
}
final_tasks.push(task);
}
}
if !to_fetch.is_empty() {
let mut success = false;
let fetch_attempts = vec![(4, 100), (1, 50)];
for (concurrency, chunk_size) in fetch_attempts {
let chunks: Vec<Vec<String>> =
to_fetch.chunks(chunk_size).map(|c| c.to_vec()).collect();
let futures = chunks.into_iter().map(|chunk| {
let c = client.clone();
let p = path_href.clone();
async move {
c.request(GetCalendarResources::new(&p).with_hrefs(chunk))
.await
}
});
let mut stream = stream::iter(futures).buffer_unordered(concurrency);
let mut batch_results = Vec::new();
let mut batch_error = false;
while let Some(res) = stream.next().await {
match res {
Ok(fetched_resp) => batch_results.push(fetched_resp),
Err(_) => {
batch_error = true;
break;
}
}
}
if !batch_error {
for fetched_resp in batch_results {
for item in fetched_resp.resources {
if let Ok(content) = item.content
&& let Ok(task) = IcsAdapter::from_ics(
&content.data,
content.etag,
item.href,
calendar_href.to_string(),
)
{
if apply_journal && pending_deletions.contains(&task.uid) {
continue;
}
final_tasks.push(task);
}
}
}
success = true;
break; }
}
if !success {
return Err(anyhow::anyhow!("Server failed to process task requests."));
}
}
if apply_journal {
Journal::apply_to_tasks(self.ctx.as_ref(), &mut final_tasks, calendar_href);
}
let _ = Cache::save(self.ctx.as_ref(), calendar_href, &final_tasks, remote_token);
Ok(final_tasks)
} else {
if apply_journal {
Journal::apply_to_tasks(self.ctx.as_ref(), &mut cached_tasks, calendar_href);
}
Ok(cached_tasks)
}
}
pub async fn get_tasks(&self, calendar_href: &str) -> anyhow::Result<Vec<Task>> {
let sync_res =
tokio::time::timeout(std::time::Duration::from_secs(10), self.sync_journal()).await;
if sync_res.is_err() || sync_res.unwrap().is_err() {
if calendar_href.starts_with("local://") {
let mut tasks =
crate::storage::LocalStorage::load_for_href(self.ctx.as_ref(), calendar_href)?;
crate::journal::Journal::apply_to_tasks(
self.ctx.as_ref(),
&mut tasks,
calendar_href,
);
return Ok(tasks);
} else {
let (mut tasks, _) = crate::cache::Cache::load(self.ctx.as_ref(), calendar_href)?;
crate::journal::Journal::apply_to_tasks(
self.ctx.as_ref(),
&mut tasks,
calendar_href,
);
return Ok(tasks);
}
}
self.fetch_calendar_tasks_internal(calendar_href, true)
.await
}
pub async fn get_all_tasks(
&self,
calendars: &[CalendarListEntry],
) -> anyhow::Result<Vec<(String, Vec<Task>)>> {
let _ = tokio::time::timeout(std::time::Duration::from_secs(10), self.sync_journal()).await;
let hrefs: Vec<String> = calendars.iter().map(|c| c.href.clone()).collect();
let futures = hrefs.into_iter().map(|href| {
let client = self.clone();
async move {
(
href.clone(),
client.fetch_calendar_tasks_internal(&href, true).await,
)
}
});
let mut stream = stream::iter(futures).buffer_unordered(4);
let mut final_results = Vec::new();
while let Some((href, res)) = stream.next().await {
if let Ok(tasks) = res {
final_results.push((href, tasks));
}
}
Ok(final_results)
}
pub async fn migrate_tasks(
&self,
tasks: Vec<Task>,
target_calendar_href: &str,
) -> anyhow::Result<usize> {
let mut count = 0;
for task in tasks.into_iter() {
let is_source_local = task.calendar_href.starts_with("local://");
let is_target_local = target_calendar_href.starts_with("local://");
if is_source_local && !is_target_local {
let _ =
LocalStorage::modify_for_href(self.ctx.as_ref(), &task.calendar_href, |all| {
all.retain(|t| t.uid != task.uid);
});
let mut new_task = task.clone();
new_task.calendar_href = target_calendar_href.to_string();
new_task.href = String::new();
new_task.etag = String::new();
Journal::push(self.ctx.as_ref(), Action::Create(new_task))?;
count += 1;
} else if !is_source_local && is_target_local {
Journal::push(self.ctx.as_ref(), Action::Delete(task.clone()))?;
let mut new_task = task.clone();
new_task.calendar_href = target_calendar_href.to_string();
new_task.href = String::new();
new_task.etag = String::new();
let _ =
LocalStorage::modify_for_href(self.ctx.as_ref(), target_calendar_href, |all| {
all.push(new_task);
});
count += 1;
} else if is_source_local && is_target_local {
let _ =
LocalStorage::modify_for_href(self.ctx.as_ref(), &task.calendar_href, |all| {
all.retain(|t| t.uid != task.uid);
});
let mut new_task = task.clone();
new_task.calendar_href = target_calendar_href.to_string();
let _ =
LocalStorage::modify_for_href(self.ctx.as_ref(), target_calendar_href, |all| {
all.push(new_task);
});
count += 1;
} else {
Journal::push(
self.ctx.as_ref(),
Action::Move(task, target_calendar_href.to_string()),
)?;
count += 1;
}
}
match self.sync_journal().await {
Ok((_warns, _synced)) => Ok(count),
Err(e) => Err(anyhow::anyhow!(e)),
}
}
pub(crate) async fn fetch_etag(&self, path: &str) -> Option<String> {
if let Some(client) = &self.client
&& let Ok(resp) = client
.request(GetProperty::new(path, &names::GETETAG))
.await
{
return resp.value;
}
None
}
pub async fn create_calendar(&self, name: &str, color: Option<&str>) -> anyhow::Result<String> {
let client = self
.client
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Offline"))?;
let principal_res = client.find_current_user_principal().await?;
let principal = principal_res.ok_or_else(|| anyhow::anyhow!("No principal found"))?;
let home_set_resp = client
.request(libdav::caldav::FindCalendarHomeSet::new(principal.path()))
.await?;
let home_url = home_set_resp
.home_sets
.first()
.ok_or_else(|| anyhow::anyhow!("No home set found"))?;
let new_uuid = uuid::Uuid::new_v4().to_string();
let home_path = home_url.path();
let new_path = if home_path.ends_with('/') {
format!("{}{}/", home_path, new_uuid)
} else {
format!("{}/{}/", home_path, new_uuid)
};
let mut color_xml = String::new();
if let Some(c) = color {
color_xml = format!(
r#"<IC:calendar-color xmlns:IC="http://apple.com/ns/ical/">{}</IC:calendar-color>"#,
xml_escape(c)
);
}
let body = 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>
<C:supported-calendar-component-set>
<C:comp name="VTODO"/>
<C:comp name="VEVENT"/>
<C:comp name="VJOURNAL"/>
</C:supported-calendar-component-set>
{}
</D:prop>
</D:set>
</C:mkcalendar>"#,
xml_escape(name),
color_xml
);
let req = http::Request::builder()
.method("MKCALENDAR")
.uri(client.webdav_client.relative_uri(&new_path)?)
.header("Content-Type", "application/xml; charset=utf-8")
.body(body)?;
let (parts, body_bytes) = client.webdav_client.request_raw(req).await?;
if parts.status.is_success() {
Ok(new_path)
} else {
let err_body = String::from_utf8_lossy(&body_bytes);
Err(anyhow::anyhow!(
"MKCALENDAR failed: {} - {}",
parts.status,
err_body
))
}
}
pub async fn update_calendar(
&self,
href: &str,
name: &str,
color: Option<&str>,
) -> anyhow::Result<()> {
let client = self
.client
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Offline"))?;
let color_set = if let Some(c) = color {
format!(
r#"<IC:calendar-color xmlns:IC="http://apple.com/ns/ical/">{}</IC:calendar-color>"#,
xml_escape(c)
)
} else {
String::new()
};
let color_remove = if color.is_none() {
r#"<D:remove>
<D:prop>
<IC:calendar-color xmlns:IC="http://apple.com/ns/ical/"/>
</D:prop>
</D:remove>"#
} else {
""
};
let body = format!(
r#"<?xml version="1.0" encoding="utf-8" ?>
<D:propertyupdate xmlns:D="DAV:" xmlns:IC="http://apple.com/ns/ical/">
<D:set>
<D:prop>
<D:displayname>{}</D:displayname>
{}
</D:prop>
</D:set>
{}
</D:propertyupdate>"#,
xml_escape(name),
color_set,
color_remove
);
let req = http::Request::builder()
.method("PROPPATCH")
.uri(client.webdav_client.relative_uri(&strip_host(href))?)
.header("Content-Type", "application/xml; charset=utf-8")
.body(body)?;
let (parts, body_bytes) = client.webdav_client.request_raw(req).await?;
if parts.status.is_success() || parts.status == http::StatusCode::MULTI_STATUS {
Ok(())
} else {
let err_body = String::from_utf8_lossy(&body_bytes);
Err(anyhow::anyhow!(
"PROPPATCH failed: {} - {}",
parts.status,
err_body
))
}
}
}