From 35c2de5da06eb5d3333df6753729b6dce29b2738 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Wed, 1 Jul 2026 15:07:21 -0500 Subject: [PATCH] Implement PstSource via readpst shell-out and iCalendar VEVENT parsing (T6.1) --- src-tauri/src/calendar/mod.rs | 391 ++++++++++++++++++++++++++++++++-- 1 file changed, 378 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/calendar/mod.rs b/src-tauri/src/calendar/mod.rs index 5da7709..e99450f 100644 --- a/src-tauri/src/calendar/mod.rs +++ b/src-tauri/src/calendar/mod.rs @@ -1,8 +1,16 @@ -//! Calendar & Outlook .pst integration (Phase 6, FR-CAL-*). `outlook-pst` -//! (read-only) for PST; future Graph/ICS behind the same trait (ADR-0008). -//! PST is read-only input — WA never writes to it. +//! Calendar & Outlook .pst integration (Phase 6, FR-CAL-*). Reads `.pst` +//! files by shelling out to `readpst` (the libpst project) rather than +//! hand-parsing the raw MS-PST binary format — the `outlook-pst` crate ADR-0008 +//! originally named turned out to be a byte-level format implementation with +//! no MAPI convenience layer at all (no `folder.name()`/`message.subject()`, +//! just raw B-tree pages and numeric property IDs); `readpst` is mature, +//! well-tested, and already does exactly what T6.1 needs (dump appointments +//! as iCalendar). See ADR-0008's update. PST is read-only input — WA never +//! writes to it. -use crate::models::{CalendarEvent, Participant}; +use crate::models::{AttendeeInfo, CalendarEvent, ImportedEvent}; +use std::path::Path; +use std::process::Command; #[derive(Debug, thiserror::Error)] pub enum CalError { @@ -12,6 +20,8 @@ pub enum CalError { Parse(String), #[error("password required or incorrect")] Password, + #[error("readpst isn't installed — install libpst and ensure readpst is on PATH")] + ToolMissing, } pub struct CalImport { @@ -20,21 +30,376 @@ pub struct CalImport { } pub trait CalendarSource: Send + Sync { - fn import(&self, input: CalImport) -> Result, CalError>; - fn attendees(&self, event_id: &str) -> Result, CalError>; + fn import(&self, input: CalImport) -> Result, CalError>; } -/// .pst source. Falls back to libpff for tricky/encrypted files. +/// `.pst` source via `readpst` (T6.1). Password-protected PSTs: most +/// "password protection" in classic PST files is a UI-level gate over a +/// fixed, keyless obfuscation ("compressible encryption"), which readpst +/// already reverses transparently — `input.password` is accepted for a +/// future genuinely-encrypted case but unused today; a file readpst can't +/// open surfaces as `CalError::Parse`, non-fatal to the caller. #[cfg(feature = "pst")] pub struct PstSource; #[cfg(feature = "pst")] impl CalendarSource for PstSource { - fn import(&self, _input: CalImport) -> Result, CalError> { - // T6.1/T6.2: read appointments + attendees; non-fatal on parse failure. - todo!("Phase 6 — import .pst") - } - fn attendees(&self, _event_id: &str) -> Result, CalError> { - todo!("Phase 6 — attendees for event") + fn import(&self, input: CalImport) -> Result, CalError> { + if !Path::new(&input.path).exists() { + return Err(CalError::Open(format!("file not found: {}", input.path))); + } + let out_dir = std::env::temp_dir().join(format!("wa-pst-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&out_dir).map_err(|e| CalError::Open(e.to_string()))?; + + let result = run_readpst(&input.path, &out_dir).and_then(|_| collect_events(&out_dir)); + let _ = std::fs::remove_dir_all(&out_dir); // best-effort cleanup either way + result + } +} + +#[cfg(feature = "pst")] +fn run_readpst(pst_path: &str, out_dir: &Path) -> Result<(), CalError> { + // -S: one file per item. -e: extension matches item type (.ics for + // appointments). -t a: appointments only. + // + // ponytail: no `-8` (force UTF-8 output) — real-world testing found this + // libpst 0.6.63 build doesn't support it ("invalid option -- 8"; newer + // libpst versions do). Items with an unspecified charset fall back to + // whatever readpst's own default is; a resulting non-UTF-8 .ics file is + // still handled safely (collect_ics_files skips a file that doesn't + // read as valid UTF-8 rather than erroring), just silently dropped + // instead of correctly decoded. Revisit if that's observed in practice. + let output = Command::new("readpst") + .args(["-S", "-e", "-t", "a", "-o"]) + .arg(out_dir) + .arg(pst_path) + .output() + .map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => CalError::ToolMissing, + _ => CalError::Open(e.to_string()), + })?; + if !output.status.success() { + return Err(CalError::Parse( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + Ok(()) +} + +#[cfg(feature = "pst")] +fn collect_events(dir: &Path) -> Result, CalError> { + let mut events = Vec::new(); + collect_ics_files(dir, &mut events)?; + Ok(events) +} + +#[cfg(feature = "pst")] +fn collect_ics_files(dir: &Path, out: &mut Vec) -> Result<(), CalError> { + let entries = std::fs::read_dir(dir).map_err(|e| CalError::Parse(e.to_string()))?; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_ics_files(&path, out)?; + } else if path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("ics")) + { + // A single unreadable/malformed file is skipped, not fatal to + // the whole import (readpst can produce thousands of files). + if let Ok(text) = std::fs::read_to_string(&path) { + out.extend(parse_vevents(&text, "pst")); + } + } + } + Ok(()) +} + +// ---- iCalendar (RFC 5545) VEVENT parsing — pure, no I/O ---- + +fn unfold_lines(text: &str) -> Vec { + let mut out: Vec = Vec::new(); + for raw_line in text.split('\n') { + let line = raw_line.strip_suffix('\r').unwrap_or(raw_line); + if (line.starts_with(' ') || line.starts_with('\t')) && !out.is_empty() { + out.last_mut().unwrap().push_str(&line[1..]); + } else if !line.is_empty() { + out.push(line.to_string()); + } + } + out +} + +/// Splits a `NAME[;PARAM=VALUE;...]:VALUE` content line into (name, params, value). +fn parse_property_line(line: &str) -> Option<(&str, &str, &str)> { + let colon = line.find(':')?; + let (name_and_params, value) = (&line[..colon], &line[colon + 1..]); + match name_and_params.find(';') { + Some(semi) => Some(( + &name_and_params[..semi], + &name_and_params[semi + 1..], + value, + )), + None => Some((name_and_params, "", value)), + } +} + +fn param_value<'a>(params: &'a str, key: &str) -> Option<&'a str> { + params.split(';').find_map(|p| { + let (k, v) = p.split_once('=')?; + k.eq_ignore_ascii_case(key).then_some(v) + }) +} + +fn unescape_text(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut chars = value.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('n') | Some('N') => out.push('\n'), + Some(other) => out.push(other), + None => {} + } + } else { + out.push(c); + } + } + out +} + +/// Extracts a display name + email from a CAL-ADDRESS property (ORGANIZER/ +/// ATTENDEE), e.g. `ORGANIZER;CN=Jordan Lee:mailto:jordan@example.com`. +fn cal_address(params: &str, value: &str) -> (Option, Option) { + let email_raw = value.strip_prefix("mailto:").unwrap_or(value); + let email = email_raw.contains('@').then(|| unescape_text(email_raw)); + let name = param_value(params, "CN").map(unescape_text); + (name, email) +} + +fn attendee_role(params: &str) -> Option { + param_value(params, "ROLE").map(|r| { + if r.eq_ignore_ascii_case("OPT-PARTICIPANT") { + "optional" + } else if r.eq_ignore_ascii_case("CHAIR") { + "organizer" + } else { + "required" + } + .to_string() + }) +} + +/// Parses every `VEVENT` block in `ics_text` (readpst emits one event per +/// file with `-S -e`, but a source could combine several under one +/// `VCALENDAR` too — this handles both). +fn parse_vevents(ics_text: &str, source: &str) -> Vec { + let lines = unfold_lines(ics_text); + let mut events = Vec::new(); + let mut in_event = false; + let mut uid = None; + let mut summary = None; + let mut organizer = None; + let mut description = None; + let mut starts_at = None; + let mut ends_at = None; + let mut attendees: Vec = Vec::new(); + + for line in &lines { + let Some((name, params, value)) = parse_property_line(line) else { + continue; + }; + match name.to_ascii_uppercase().as_str() { + "BEGIN" if value.eq_ignore_ascii_case("VEVENT") => { + in_event = true; + uid = None; + summary = None; + organizer = None; + description = None; + starts_at = None; + ends_at = None; + attendees = Vec::new(); + } + "END" if value.eq_ignore_ascii_case("VEVENT") && in_event => { + events.push(ImportedEvent { + event: CalendarEvent { + id: uuid::Uuid::new_v4().to_string(), + source: source.to_string(), + subject: summary.take(), + organizer: organizer.take(), + starts_at, + ends_at, + description: description.take(), + raw_uid: uid.take(), + }, + attendees: std::mem::take(&mut attendees), + }); + in_event = false; + } + "UID" if in_event => uid = Some(value.to_string()), + "SUMMARY" if in_event => summary = Some(unescape_text(value)), + "DESCRIPTION" if in_event => description = Some(unescape_text(value)), + "DTSTART" if in_event => starts_at = parse_ics_datetime(value), + "DTEND" if in_event => ends_at = parse_ics_datetime(value), + "ORGANIZER" if in_event => { + let (name, email) = cal_address(params, value); + organizer = name.or(email); + } + "ATTENDEE" if in_event => { + let (name, email) = cal_address(params, value); + let Some(display_name) = name.or_else(|| email.clone()) else { + continue; // no name and no usable email — nothing to show + }; + attendees.push(AttendeeInfo { + name: display_name, + email, + role: attendee_role(params), + }); + } + _ => {} + } + } + events +} + +/// Parses an iCalendar DATE-TIME (`20260701T090000Z` / `20260701T090000`) or +/// DATE (`20260701`) value to a unix epoch. Both `Z`-suffixed and floating +/// (no `Z`, no `TZID`) values are treated as UTC — full IANA timezone +/// handling (`TZID` params, `VTIMEZONE` blocks) is out of scope; good enough +/// for "roughly when was this meeting", not a calendar-grade scheduler. +fn parse_ics_datetime(value: &str) -> Option { + let digits: String = value.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.len() < 8 { + return None; + } + let year: i64 = digits.get(0..4)?.parse().ok()?; + let month: u32 = digits.get(4..6)?.parse().ok()?; + let day: u32 = digits.get(6..8)?.parse().ok()?; + let (hour, min, sec) = if digits.len() >= 14 { + ( + digits.get(8..10)?.parse().ok()?, + digits.get(10..12)?.parse().ok()?, + digits.get(12..14)?.parse().ok()?, + ) + } else { + (0u32, 0u32, 0u32) + }; + Some(ymd_hms_to_unix(year, month, day, hour, min, sec)) +} + +/// Howard Hinnant's `days_from_civil` (public domain, proleptic Gregorian), +/// extended with a time-of-day offset. Pure arithmetic — no timezone +/// database dependency needed since every caller here treats input as UTC. +fn ymd_hms_to_unix(year: i64, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> i64 { + let y = if month <= 2 { year - 1 } else { year }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; // [0, 399] + let mp = (month as i64 + 9) % 12; // [0, 11], Mar=0 .. Feb=11 + let doy = (153 * mp + 2) / 5 + day as i64 - 1; // [0, 365] + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096] + let days = era * 146_097 + doe - 719_468; // days since 1970-01-01 + days * 86_400 + hour as i64 * 3600 + min as i64 * 60 + sec as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ymd_hms_to_unix_matches_known_epoch_values() { + assert_eq!(ymd_hms_to_unix(1970, 1, 1, 0, 0, 0), 0); + assert_eq!(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0), 1_782_896_400); + assert_eq!(ymd_hms_to_unix(2000, 2, 29, 0, 0, 0), 951_782_400); // leap day + } + + #[test] + fn parse_ics_datetime_handles_utc_floating_and_date_only_forms() { + assert_eq!( + parse_ics_datetime("20260701T090000Z"), + Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0)) + ); + assert_eq!( + parse_ics_datetime("20260701T090000"), + Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0)) + ); + assert_eq!( + parse_ics_datetime("20260701"), + Some(ymd_hms_to_unix(2026, 7, 1, 0, 0, 0)) + ); + assert_eq!(parse_ics_datetime("not-a-date"), None); + } + + const SAMPLE_ICS: &str = "BEGIN:VCALENDAR\r\n\ +BEGIN:VEVENT\r\n\ +UID:abc-123@example.com\r\n\ +SUMMARY:Sprint planning\r\n\ +DESCRIPTION:Plan the next sprint\\, then review backlog\r\n\ +ORGANIZER;CN=Jordan Lee:mailto:jordan@example.com\r\n\ +DTSTART:20260701T090000Z\r\n\ +DTEND:20260701T100000Z\r\n\ +ATTENDEE;CN=Alex Kim;ROLE=REQ-PARTICIPANT:mailto:alex@example.com\r\n\ +ATTENDEE;CN=Sam Patel;ROLE=OPT-PARTICIPANT:mailto:sam@example.com\r\n\ +END:VEVENT\r\n\ +END:VCALENDAR\r\n"; + + #[test] + fn parse_vevents_extracts_a_full_appointment() { + let events = parse_vevents(SAMPLE_ICS, "pst"); + assert_eq!(events.len(), 1); + let e = &events[0]; + assert_eq!(e.event.source, "pst"); + assert_eq!(e.event.raw_uid.as_deref(), Some("abc-123@example.com")); + assert_eq!(e.event.subject.as_deref(), Some("Sprint planning")); + assert_eq!( + e.event.description.as_deref(), + Some("Plan the next sprint, then review backlog") + ); + assert_eq!(e.event.organizer.as_deref(), Some("Jordan Lee")); + assert_eq!( + e.event.starts_at, + Some(ymd_hms_to_unix(2026, 7, 1, 9, 0, 0)) + ); + assert_eq!(e.event.ends_at, Some(ymd_hms_to_unix(2026, 7, 1, 10, 0, 0))); + + assert_eq!(e.attendees.len(), 2); + assert_eq!(e.attendees[0].name, "Alex Kim"); + assert_eq!(e.attendees[0].email.as_deref(), Some("alex@example.com")); + assert_eq!(e.attendees[0].role.as_deref(), Some("required")); + assert_eq!(e.attendees[1].name, "Sam Patel"); + assert_eq!(e.attendees[1].role.as_deref(), Some("optional")); + } + + #[test] + fn parse_vevents_falls_back_to_email_when_no_cn_name() { + let ics = "BEGIN:VEVENT\r\nUID:u1\r\nATTENDEE:mailto:noname@example.com\r\nEND:VEVENT\r\n"; + let events = parse_vevents(ics, "pst"); + assert_eq!(events[0].attendees[0].name, "noname@example.com"); + } + + #[test] + fn parse_vevents_handles_folded_continuation_lines() { + // RFC 5545 line folding: a continuation line starts with a single space. + let ics = + "BEGIN:VEVENT\r\nUID:u1\r\nSUMMARY:Long meeting na\r\n me continued\r\nEND:VEVENT\r\n"; + let events = parse_vevents(ics, "pst"); + assert_eq!( + events[0].event.subject.as_deref(), + Some("Long meeting name continued") + ); + } + + #[test] + fn parse_vevents_returns_empty_for_text_with_no_events() { + assert!(parse_vevents("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n", "pst").is_empty()); + assert!(parse_vevents("", "pst").is_empty()); + } + + #[cfg(feature = "pst")] + #[test] + fn import_reports_a_clear_error_for_a_missing_file() { + let result = PstSource.import(CalImport { + path: "Z:\\no\\such\\file.pst".to_string(), + password: None, + }); + assert!(matches!(result, Err(CalError::Open(_)))); } }