293 lines
9.4 KiB
Rust
293 lines
9.4 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
|
*
|
|
* 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;
|
|
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, 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, local_folder_id, counts) {
|
|
ctx.logger
|
|
.warn(&format!("calendar folder {} failed: {}", folder_id.id, e));
|
|
counts.failed += 1;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn reconcile_one(
|
|
conn: &mut Connection,
|
|
ctx: &ItemRunCtx<'_>,
|
|
folder: &crate::exchange_ews::types::FolderId,
|
|
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.id,
|
|
plan.new.len(),
|
|
plan.present_changed.len(),
|
|
plan.vanished.len()
|
|
);
|
|
}
|
|
let mut to_fetch: Vec<ItemId> = plan.new.clone();
|
|
for (id, _) in &plan.present_changed {
|
|
to_fetch.push(id.clone());
|
|
}
|
|
if !to_fetch.is_empty() {
|
|
let outcome = get_items(ctx, ItemShape::CalendarItem, &to_fetch).map_err(Error::from)?;
|
|
counts.failed += outcome.failed_items;
|
|
for msg in outcome.messages {
|
|
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));
|
|
}
|
|
continue;
|
|
}
|
|
let parsed = parse_calendar_item(&msg.inner_xml).map_err(Error::from)?;
|
|
if parsed.id.id.is_empty() {
|
|
counts.failed += 1;
|
|
continue;
|
|
}
|
|
if matches!(
|
|
parsed.calendar_item_type,
|
|
Some(CalendarItemType::Occurrence) | Some(CalendarItemType::Exception)
|
|
) {
|
|
counts.skipped += 1;
|
|
continue;
|
|
}
|
|
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,
|
|
)?;
|
|
}
|
|
}
|
|
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<i64>,
|
|
counts: &mut TypeCounts,
|
|
) -> Result<(), Error> {
|
|
let mut built = to_jscalendar(raw);
|
|
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 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())
|
|
}
|