Skip to main content

pidge_core/
event.rs

1//! Normalized calendar event representation, provider-agnostic.
2
3use chrono::{DateTime, NaiveDate, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::message::BodyContentType;
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct Event {
10    pub account: String,
11    pub calendar_id: String,
12    pub id: String,
13    pub subject: String,
14    pub start: EventTime,
15    pub end: EventTime,
16    #[serde(default)]
17    pub all_day: bool,
18    #[serde(default)]
19    pub location: Option<String>,
20    pub organizer: Attendee,
21    #[serde(default)]
22    pub attendees: Vec<Attendee>,
23    #[serde(default)]
24    pub body_preview: String,
25    #[serde(default)]
26    pub body_content: String,
27    #[serde(default)]
28    pub body_content_type: BodyContentType,
29    #[serde(default)]
30    pub recurrence: Option<RecurrencePattern>,
31    #[serde(default)]
32    pub is_organizer: bool,
33    #[serde(default)]
34    pub response_status: ResponseStatus,
35    #[serde(default)]
36    pub online_meeting_url: Option<String>,
37    #[serde(default)]
38    pub series_master_id: Option<String>,
39    /// Minutes before `start` that Outlook raises a reminder; `None` when the
40    /// reminder is switched off.
41    #[serde(default)]
42    pub reminder_minutes: Option<u32>,
43}
44
45/// A calendar instant. `at` is the canonical UTC time; `tz` is the IANA zone
46/// the event was scheduled in (e.g. `"Europe/Stockholm"`). Display layers
47/// honour `tz` so a Stockholm-scheduled meeting still says "10:00 Stockholm"
48/// when read from a laptop in Sydney.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct EventTime {
51    pub at: DateTime<Utc>,
52    pub tz: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Attendee {
57    pub name: String,
58    pub address: String,
59    #[serde(default)]
60    pub kind: AttendeeKind,
61    #[serde(default)]
62    pub response: ResponseStatus,
63}
64
65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub enum AttendeeKind {
68    #[default]
69    Required,
70    Optional,
71    Resource,
72}
73
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "camelCase")]
76pub enum ResponseStatus {
77    #[default]
78    None,
79    Organizer,
80    Accepted,
81    Tentative,
82    Declined,
83    NotResponded,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct RecurrencePattern {
88    pub freq: RecurrenceFreq,
89    /// 1 = every freq, 2 = every other, etc.
90    pub interval: u32,
91    /// Only meaningful when `freq == Weekly`.
92    #[serde(default)]
93    pub by_weekday: Vec<Weekday>,
94    pub range: RecurrenceRange,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "camelCase")]
99pub enum RecurrenceFreq {
100    Daily,
101    Weekly,
102    Monthly,
103    Yearly,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub enum Weekday {
109    Monday,
110    Tuesday,
111    Wednesday,
112    Thursday,
113    Friday,
114    Saturday,
115    Sunday,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(tag = "kind", content = "value", rename_all = "camelCase")]
120pub enum RecurrenceRange {
121    EndDate(NaiveDate),
122    Count(u32),
123    NoEnd,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127pub struct Calendar {
128    pub account: String,
129    pub id: String,
130    pub name: String,
131    #[serde(default)]
132    pub is_default: bool,
133    #[serde(default)]
134    pub color: Option<String>,
135    #[serde(default)]
136    pub can_edit: bool,
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn event_roundtrips_through_json() {
145        let e = Event {
146            account: "u@e.com".into(),
147            calendar_id: "cal-1".into(),
148            id: "evt-1".into(),
149            subject: "Team sync".into(),
150            start: EventTime {
151                at: chrono::DateTime::parse_from_rfc3339("2026-05-22T13:00:00Z")
152                    .unwrap()
153                    .to_utc(),
154                tz: "Europe/Stockholm".into(),
155            },
156            end: EventTime {
157                at: chrono::DateTime::parse_from_rfc3339("2026-05-22T14:00:00Z")
158                    .unwrap()
159                    .to_utc(),
160                tz: "Europe/Stockholm".into(),
161            },
162            all_day: false,
163            location: Some("Office".into()),
164            organizer: Attendee {
165                name: "Kristofer".into(),
166                address: "k@mklab.se".into(),
167                kind: AttendeeKind::Required,
168                response: ResponseStatus::Organizer,
169            },
170            attendees: vec![],
171            body_preview: "Weekly".into(),
172            body_content: "Weekly sync".into(),
173            body_content_type: BodyContentType::Text,
174            recurrence: Some(RecurrencePattern {
175                freq: RecurrenceFreq::Weekly,
176                interval: 1,
177                by_weekday: vec![Weekday::Monday],
178                range: RecurrenceRange::NoEnd,
179            }),
180            is_organizer: true,
181            response_status: ResponseStatus::Organizer,
182            online_meeting_url: None,
183            series_master_id: None,
184            reminder_minutes: Some(15),
185        };
186        let json = serde_json::to_string(&e).unwrap();
187        let e2: Event = serde_json::from_str(&json).unwrap();
188        assert_eq!(e, e2);
189    }
190
191    #[test]
192    fn recurrence_range_roundtrips_tagged() {
193        for r in [
194            RecurrenceRange::NoEnd,
195            RecurrenceRange::Count(10),
196            RecurrenceRange::EndDate(NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()),
197        ] {
198            let j = serde_json::to_string(&r).unwrap();
199            let r2: RecurrenceRange = serde_json::from_str(&j).unwrap();
200            assert_eq!(r, r2);
201        }
202    }
203
204    #[test]
205    fn response_status_serializes_camel_case() {
206        assert_eq!(
207            serde_json::to_string(&ResponseStatus::NotResponded).unwrap(),
208            "\"notResponded\""
209        );
210    }
211}