diff --git a/src-tauri/src/calendar/mod.rs b/src-tauri/src/calendar/mod.rs index 29eda2b..d5a3973 100644 --- a/src-tauri/src/calendar/mod.rs +++ b/src-tauri/src/calendar/mod.rs @@ -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, + /// 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, + pub to: Option, } pub trait CalendarSource: Send + Sync { @@ -121,6 +129,198 @@ fn collect_ics_files(dir: &Path, out: &mut Vec) -> 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, 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, 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, +} + +#[cfg(feature = "sync")] +#[derive(Deserialize)] +struct GraphEvent { + id: String, + subject: Option, + organizer: Option, + start: Option, + end: Option, + attendees: Option>, +} + +#[cfg(feature = "sync")] +#[derive(Deserialize)] +struct GraphOrganizer { + #[serde(rename = "emailAddress")] + email_address: GraphEmailAddress, +} + +#[cfg(feature = "sync")] +#[derive(Deserialize)] +struct GraphEmailAddress { + name: Option, + address: Option, +} + +#[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, +} + +#[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 { + 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 { @@ -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(_)))); }