feat(calendar): GraphSource for MS Graph calendar import (T8.9, M4.4, FR-CAL-6)

This commit is contained in:
iamdoubz committed 2026-07-07 17:51:40 -05:00
1 parent 8a80580851
commit d407be8bb0
1 file changed
+266
+266
View File
@@ -10,6 +10,7 @@
use crate::models::{AttendeeInfo, CalendarEvent, ImportedEvent};
use chrono::{Datelike, Duration, Local, NaiveDate, TimeZone, Timelike, Utc};
use serde::Deserialize;
use std::path::Path;
use std::process::Command;
@@ -23,11 +24,18 @@ pub enum CalError {
Password,
#[error("readpst isn't installed — install libpst and ensure readpst is on PATH")]
ToolMissing,
#[error("network request failed: {0}")]
Network(String),
}
pub struct CalImport {
pub path: String,
pub password: Option<String>,
/// Date-range window (unix seconds) for a source that fetches by range
/// (Graph's `calendarView`, M4.4); ignored by file-based sources like
/// `PstSource`, which import everything a `.pst` contains.
pub from: Option<i64>,
pub to: Option<i64>,
}
pub trait CalendarSource: Send + Sync {
@@ -121,6 +129,198 @@ fn collect_ics_files(dir: &Path, out: &mut Vec<ImportedEvent>) -> Result<(), Cal
Ok(())
}
// ---- Microsoft Graph calendar source (M4.4, T8.9, FR-CAL-6) ----
//
// Opt-in, explicit-consent (OAuth 2.0 PKCE via `sync::oauth` — same identity
// platform and token-set shape as the OneDrive sync target, ADR-0010) and
// metadata-only per ADR-0008: subject, organizer, start/end, attendees —
// never the event body. Uses Graph's `calendarView` endpoint, which expands
// recurring series into concrete occurrences server-side, so unlike
// `PstSource` there's no local RRULE expansion to do.
#[cfg(feature = "sync")]
pub struct GraphSource {
pub credential_ref: String,
}
// ponytail: one page (no `@odata.nextLink` follow) — plenty for a personal
// calendar's near-term window; add pagination if a real user's date range
// ever needs more than this in one import.
#[cfg(feature = "sync")]
const GRAPH_EVENTS_PAGE_SIZE: u32 = 250;
#[cfg(feature = "sync")]
impl GraphSource {
/// Graph API base — overridable via `WA_GRAPH_CALENDAR_BASE_URL` so tests
/// can point this at a local mock. Kept distinct from sync's
/// `WA_GRAPH_BASE_URL` (used by `OneDriveTarget`) so calendar and sync
/// tests never race on the same process-global env var.
fn graph_base() -> String {
std::env::var("WA_GRAPH_CALENDAR_BASE_URL")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "https://graph.microsoft.com/v1.0".to_string())
}
async fn fetch_events(&self, from: i64, to: i64) -> Result<Vec<ImportedEvent>, CalError> {
let token = crate::sync::resolve_access_token("graph-calendar", &self.credential_ref)
.await
.map_err(|e| CalError::Network(e.to_string()))?;
let url = format!(
"{}/me/calendarView?startDateTime={}&endDateTime={}&$select=id,subject,organizer,start,end,attendees&$top={}",
Self::graph_base(),
iso_datetime(from),
iso_datetime(to),
GRAPH_EVENTS_PAGE_SIZE,
);
let resp = reqwest::Client::new()
.get(&url)
// Ask Graph to return every dateTime already normalized to UTC —
// avoids needing a timezone database (same tradeoff PST parsing
// makes: see `parse_ics_datetime`'s doc comment).
.header("Prefer", r#"outlook.timezone="UTC""#)
.bearer_auth(token)
.send()
.await
.map_err(|e| CalError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(CalError::Network(format!(
"graph calendarView returned {}",
resp.status()
)));
}
let body: GraphEventsResponse = resp
.json()
.await
.map_err(|e| CalError::Parse(e.to_string()))?;
Ok(body.value.into_iter().map(graph_event_to_imported).collect())
}
}
#[cfg(feature = "sync")]
impl CalendarSource for GraphSource {
/// Sync per the trait — bridges to the async Graph call via
/// `tauri::async_runtime::block_on`. Callers (the `import_graph_calendar`
/// command) run this inside `spawn_blocking`, exactly like `PstSource`'s
/// blocking subprocess call.
fn import(&self, input: CalImport) -> Result<Vec<ImportedEvent>, CalError> {
let now = Utc::now().timestamp();
let from = input.from.unwrap_or(now - 30 * 86_400);
let to = input.to.unwrap_or(now + 90 * 86_400);
tauri::async_runtime::block_on(self.fetch_events(from, to))
}
}
#[cfg(feature = "sync")]
#[derive(Deserialize)]
struct GraphEventsResponse {
value: Vec<GraphEvent>,
}
#[cfg(feature = "sync")]
#[derive(Deserialize)]
struct GraphEvent {
id: String,
subject: Option<String>,
organizer: Option<GraphOrganizer>,
start: Option<GraphDateTime>,
end: Option<GraphDateTime>,
attendees: Option<Vec<GraphAttendee>>,
}
#[cfg(feature = "sync")]
#[derive(Deserialize)]
struct GraphOrganizer {
#[serde(rename = "emailAddress")]
email_address: GraphEmailAddress,
}
#[cfg(feature = "sync")]
#[derive(Deserialize)]
struct GraphEmailAddress {
name: Option<String>,
address: Option<String>,
}
#[cfg(feature = "sync")]
#[derive(Deserialize)]
struct GraphDateTime {
#[serde(rename = "dateTime")]
date_time: String,
}
#[cfg(feature = "sync")]
#[derive(Deserialize)]
struct GraphAttendee {
#[serde(rename = "emailAddress")]
email_address: GraphEmailAddress,
// required|optional|resource, passed through as-is (Graph's own vocabulary
// is a superset of the organizer|required|optional convention the rest of
// WA uses for attendee role).
#[serde(rename = "type")]
kind: Option<String>,
}
#[cfg(feature = "sync")]
fn graph_event_to_imported(e: GraphEvent) -> ImportedEvent {
let attendees = e
.attendees
.unwrap_or_default()
.into_iter()
.filter_map(|a| {
let name = a.email_address.name.or_else(|| a.email_address.address.clone())?;
Some(AttendeeInfo {
name,
email: a.email_address.address,
role: a.kind,
})
})
.collect();
let organizer = e
.organizer
.and_then(|o| o.email_address.name.or(o.email_address.address));
ImportedEvent {
event: CalendarEvent {
id: uuid::Uuid::new_v4().to_string(),
source: "graph".to_string(),
subject: e.subject,
organizer,
starts_at: e.start.and_then(|s| parse_graph_datetime(&s.date_time)),
ends_at: e.end.and_then(|s| parse_graph_datetime(&s.date_time)),
description: None, // metadata only (FR-CAL-6) — the event body is never fetched
raw_uid: Some(e.id),
},
attendees,
}
}
/// Formats a unix timestamp as the `YYYY-MM-DDTHH:MM:SS` Graph's
/// `calendarView` query params expect.
#[cfg(feature = "sync")]
fn iso_datetime(unix_secs: i64) -> String {
Utc.timestamp_opt(unix_secs, 0)
.single()
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%S").to_string())
.unwrap_or_default()
}
/// Parses a Graph `dateTime` value (`"2026-07-01T09:00:00.0000000"`, already
/// normalized to UTC by the `Prefer: outlook.timezone="UTC"` request header)
/// to a unix epoch. Fixed-width slicing, not a general datetime parser — the
/// fractional-second suffix (if any) is simply ignored.
#[cfg(feature = "sync")]
fn parse_graph_datetime(value: &str) -> Option<i64> {
if value.len() < 19 {
return None;
}
let year: i64 = value.get(0..4)?.parse().ok()?;
let month: u32 = value.get(5..7)?.parse().ok()?;
let day: u32 = value.get(8..10)?.parse().ok()?;
let hour: u32 = value.get(11..13)?.parse().ok()?;
let min: u32 = value.get(14..16)?.parse().ok()?;
let sec: u32 = value.get(17..19)?.parse().ok()?;
Some(ymd_hms_to_unix(year, month, day, hour, min, sec))
}
// ---- iCalendar (RFC 5545) VEVENT parsing — pure, no I/O ----
fn unfold_lines(text: &str) -> Vec<String> {
@@ -600,6 +800,70 @@ fn ymd_hms_to_unix(year: i64, month: u32, day: u32, hour: u32, min: u32, sec: u3
mod tests {
use super::*;
#[cfg(feature = "sync")]
#[test]
fn parse_graph_datetime_ignores_fractional_seconds() {
assert_eq!(
parse_graph_datetime("2026-07-01T09:00:00.0000000"),
Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0))
);
assert_eq!(
parse_graph_datetime("2026-07-01T09:00:00"),
Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0))
);
assert_eq!(parse_graph_datetime("not-a-date"), None);
}
#[cfg(feature = "sync")]
#[test]
fn iso_datetime_formats_for_graph_query_params() {
assert_eq!(
iso_datetime(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0)),
"2026-07-01T09:00:00"
);
}
#[cfg(feature = "sync")]
#[test]
fn graph_event_to_imported_extracts_metadata_only_no_body() {
let event = GraphEvent {
id: "AAMk...".to_string(),
subject: Some("Sprint planning".to_string()),
organizer: Some(GraphOrganizer {
email_address: GraphEmailAddress {
name: Some("Jordan Lee".to_string()),
address: Some("jordan@example.com".to_string()),
},
}),
start: Some(GraphDateTime {
date_time: "2026-07-01T09:00:00.0000000".to_string(),
}),
end: Some(GraphDateTime {
date_time: "2026-07-01T10:00:00.0000000".to_string(),
}),
attendees: Some(vec![GraphAttendee {
email_address: GraphEmailAddress {
name: Some("Alex Kim".to_string()),
address: Some("alex@example.com".to_string()),
},
kind: Some("required".to_string()),
}]),
};
let imported = graph_event_to_imported(event);
assert_eq!(imported.event.source, "graph");
assert_eq!(imported.event.raw_uid.as_deref(), Some("AAMk..."));
assert_eq!(imported.event.subject.as_deref(), Some("Sprint planning"));
assert_eq!(imported.event.organizer.as_deref(), Some("Jordan Lee"));
assert_eq!(imported.event.description, None);
assert_eq!(
imported.event.starts_at,
Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0))
);
assert_eq!(imported.attendees.len(), 1);
assert_eq!(imported.attendees[0].name, "Alex Kim");
assert_eq!(imported.attendees[0].role.as_deref(), Some("required"));
}
#[test]
fn ymd_hms_to_unix_matches_known_epoch_values() {
assert_eq!(ymd_hms_to_unix(1970, 1, 1, 0, 0, 0), 0);
@@ -695,6 +959,8 @@ END:VCALENDAR\r\n";
let result = PstSource.import(CalImport {
path: "Z:\\no\\such\\file.pst".to_string(),
password: None,
from: None,
to: None,
});
assert!(matches!(result, Err(CalError::Open(_))));
}