/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 John Coffey * * SPDX-License-Identifier: Apache-2.0 OR MIT */ use rusqlite::{Connection, params}; use serde_json::Value; use crate::db::exchange_ews_ids; use crate::error::Error; use crate::exchange_ews::calendar_map::to_jscalendar_with_exceptions; use crate::exchange_ews::parse::{CalendarItemRaw, parse_calendar_item}; use crate::exchange_ews::types::{CalendarItemType, ItemId}; use crate::exchange_ews::xml::ItemShape; use crate::logging::LEVEL_PROGRESS; use crate::sync::TypeCounts; use super::attachments::{fetch_attachments, intern_attachment}; use super::folders::FolderPlan; use super::items::{ EnumerationMode, ItemRunCtx, delete_vanished, enumerate_folder, for_each_fetched_item, get_items, plan_for, }; pub fn reconcile_all( conn: &mut Connection, ctx: &ItemRunCtx<'_>, plan: &FolderPlan, counts: &mut TypeCounts, ) -> Result<(), Error> { for folder in &plan.calendar { let folder_id = &folder.folder.folder_id; let local_folder_id = match exchange_ews_ids::local_for_item( conn, ctx.source_id, exchange_ews_ids::CALENDAR, &folder_id.id, ) .map_err(|e| Error::Partial(e.to_string()))? { Some(id) => id, None => continue, }; if let Err(e) = reconcile_one( conn, ctx, folder_id, &folder.folder.display_name, local_folder_id, counts, ) { ctx.logger.warn(&format!( "calendar folder {:?} failed: {}", folder.folder.display_name, e )); counts.failed += 1; } } Ok(()) } fn reconcile_one( conn: &mut Connection, ctx: &ItemRunCtx<'_>, folder: &crate::exchange_ews::types::FolderId, folder_name: &str, local_folder_id: i64, counts: &mut TypeCounts, ) -> Result<(), Error> { let prior = exchange_ews_ids::get_sync_state( conn, ctx.source_id, exchange_ews_ids::CALENDAR, &folder.id, ) .map_err(|e| Error::Partial(e.to_string()))?; let mut outcome = enumerate_folder(ctx, folder, prior.as_deref()).map_err(Error::from)?; outcome.items.retain(|s| { matches!( s.element.to_ascii_lowercase().as_str(), "calendaritem" | "item" ) }); let local = exchange_ews_ids::items_in_folder( conn, ctx.source_id, exchange_ews_ids::CALENDAR_EVENT, &folder.id, ) .map_err(|e| Error::Partial(e.to_string()))?; let plan = plan_for(&outcome, &local); if ctx.logger.enabled(LEVEL_PROGRESS) { eprintln!( "EWS calendar folder {:?}: new={} changed={} vanished={}", folder_name, plan.new.len(), plan.present_changed.len(), plan.vanished.len() ); } let mut to_fetch: Vec = plan.new.clone(); for (id, _) in &plan.present_changed { to_fetch.push(id.clone()); } if !to_fetch.is_empty() { let failed_items = for_each_fetched_item( ctx, ItemShape::CalendarItem, &to_fetch, &outcome.sizes, |msg| { if !msg.success { if matches!( msg.response_code, crate::exchange_ews::types::ResponseCode::ItemNotFound ) { counts.skipped += 1; } else { counts.failed += 1; ctx.logger .warn(&format!("GetItem (calendar) error: {}", msg.response_code)); } return Ok(()); } let parsed = parse_calendar_item(&msg.inner_xml).map_err(Error::from)?; if parsed.id.id.is_empty() { counts.failed += 1; return Ok(()); } if matches!( parsed.calendar_item_type, Some(CalendarItemType::Occurrence) | Some(CalendarItemType::Exception) ) { counts.skipped += 1; return Ok(()); } let existing = plan .present_changed .iter() .find(|(id, _)| id.id == parsed.id.id) .map(|(_, local)| *local); apply_event( conn, ctx, &parsed, local_folder_id, &folder.id, existing, counts, ) }, )?; counts.failed += failed_items; } delete_vanished( conn, ctx.source_id, exchange_ews_ids::CALENDAR_EVENT, "calendar_events", &plan.vanished, counts, )?; if let EnumerationMode::Delta { new_sync_state, .. } = &outcome.mode { exchange_ews_ids::set_sync_state( conn, ctx.source_id, exchange_ews_ids::CALENDAR, &folder.id, new_sync_state, ) .map_err(|e| Error::Partial(e.to_string()))?; } Ok(()) } fn apply_event( conn: &mut Connection, ctx: &ItemRunCtx<'_>, raw: &CalendarItemRaw, local_folder_id: i64, folder_id: &str, existing_local_id: Option, counts: &mut TypeCounts, ) -> Result<(), Error> { let modified_full = fetch_modified_occurrences(ctx, raw); let mut built = to_jscalendar_with_exceptions(raw, &modified_full); attach_calendar_links(conn, ctx, raw, &mut built.data)?; let calendar_ids = serde_json::json!([local_folder_id]).to_string(); let data = built.data.to_string(); let is_draft = if built.is_draft { 1 } else { 0 }; let use_default_alerts = if built.use_default_alerts { 1 } else { 0 }; let tx = conn .unchecked_transaction() .map_err(|e| Error::Partial(e.to_string()))?; match existing_local_id { Some(id) => { tx.execute( "UPDATE calendar_events SET calendar_ids = ?1, is_draft = ?2, \ use_default_alerts = ?3, data = ?4, data_type = 'Event' WHERE id = ?5", params![calendar_ids, is_draft, use_default_alerts, data, id], ) .map_err(|e| Error::Partial(e.to_string()))?; exchange_ews_ids::update_change_key( &tx, ctx.source_id, exchange_ews_ids::CALENDAR_EVENT, &raw.id.id, &raw.id.change_key, ) .map_err(|e| Error::Partial(e.to_string()))?; counts.fetched += 1; } None => { tx.execute( "INSERT INTO calendar_events (calendar_ids, is_draft, use_default_alerts, data, data_type) \ VALUES (?1, ?2, ?3, ?4, 'Event')", params![calendar_ids, is_draft, use_default_alerts, data], ) .map_err(|e| Error::Partial(e.to_string()))?; let new_id = tx.last_insert_rowid(); exchange_ews_ids::insert( &tx, ctx.source_id, exchange_ews_ids::CALENDAR_EVENT, folder_id, &raw.id.id, &raw.id.change_key, new_id, ) .map_err(|e| Error::Partial(e.to_string()))?; counts.created += 1; } } tx.commit().map_err(|e| Error::Partial(e.to_string()))?; Ok(()) } fn fetch_modified_occurrences(ctx: &ItemRunCtx<'_>, raw: &CalendarItemRaw) -> Vec { let ids: Vec = raw .modified_occurrences .iter() .filter(|o| !o.item_id.id.is_empty()) .map(|o| o.item_id.clone()) .collect(); if ids.is_empty() { return Vec::new(); } match get_items(ctx, ItemShape::CalendarItem, &ids) { Ok(outcome) => outcome .messages .iter() .filter(|m| m.success) .filter_map(|m| parse_calendar_item(&m.inner_xml).ok()) .filter(|occ| !occ.id.id.is_empty()) .collect(), Err(e) => { ctx.logger.warn(&format!( "GetItem (calendar exceptions) failed: {e}; overrides keep time-only changes" )); Vec::new() } } } fn attach_calendar_links( conn: &mut Connection, ctx: &ItemRunCtx<'_>, raw: &CalendarItemRaw, data: &mut Value, ) -> Result<(), Error> { let refs: Vec<_> = raw .attachments .iter() .filter(|a| !a.is_item_attachment && !a.attachment_id.is_empty()) .collect(); if refs.is_empty() { return Ok(()); } let ids: Vec<&str> = refs.iter().map(|a| a.attachment_id.as_str()).collect(); let fetched = match fetch_attachments(ctx, &ids) { Ok(f) => f, Err(e) => { ctx.logger.warn(&format!( "calendar attachments fetch failed: {e}; continuing without enclosures" )); return Ok(()); } }; let mut links = serde_json::Map::new(); for (idx, att) in (1u32..).zip(fetched) { let blob_id = intern_attachment(conn, &att.bytes)?; let declared = refs .iter() .find(|r| r.attachment_id == att.attachment_id) .and_then(|r| r.content_type.as_deref()); let name = refs .iter() .find(|r| r.attachment_id == att.attachment_id) .and_then(|r| r.name.clone()); let key = idx.to_string(); let mut entry = serde_json::Map::new(); entry.insert("@type".to_owned(), Value::String("Link".to_owned())); entry.insert("@blob".to_owned(), Value::from(blob_id)); entry.insert( "contentType".to_owned(), Value::String(content_type_or_from(declared, &att.media_type)), ); if let Some(n) = name { entry.insert("title".to_owned(), Value::String(n)); } entry.insert("rel".to_owned(), Value::String("enclosure".to_owned())); links.insert(key, Value::Object(entry)); } if links.is_empty() { return Ok(()); } if let Some(obj) = data.as_object_mut() { if let Some(existing) = obj.get_mut("links").and_then(Value::as_object_mut) { for (k, v) in links { existing.insert(k, v); } } else { obj.insert("links".to_owned(), Value::Object(links)); } } Ok(()) } fn content_type_or_from(declared: Option<&str>, fallback: &str) -> String { declared .filter(|s| !s.is_empty()) .map(str::to_owned) .unwrap_or_else(|| fallback.to_owned()) }