aimcal_core/
event.rs

1// SPDX-FileCopyrightText: 2025-2026 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use std::{fmt::Display, num::NonZeroU32, str::FromStr};
6
7use chrono::{DateTime, Duration, Local, Timelike, Utc};
8use icalendar::{Component, EventLike};
9
10use crate::{DateTimeAnchor, LooseDateTime};
11
12/// Trait representing a calendar event.
13pub trait Event {
14    /// The short identifier for the event.
15    /// It will be `None` if the event does not have a short ID.
16    /// It is used for display purposes and may not be unique.
17    fn short_id(&self) -> Option<NonZeroU32> {
18        None
19    }
20
21    /// The unique identifier for the event.
22    fn uid(&self) -> &str;
23
24    /// The description of the event, if available.
25    fn description(&self) -> Option<&str>;
26
27    /// The location of the event, if available.
28    fn start(&self) -> Option<LooseDateTime>;
29
30    /// The start date and time of the event, if available.
31    fn end(&self) -> Option<LooseDateTime>;
32
33    /// The status of the event, if available.
34    fn status(&self) -> Option<EventStatus>;
35
36    /// The summary of the event.
37    fn summary(&self) -> &str;
38}
39
40impl Event for icalendar::Event {
41    fn uid(&self) -> &str {
42        self.get_uid().unwrap_or_default()
43    }
44
45    fn description(&self) -> Option<&str> {
46        self.get_description()
47    }
48
49    fn start(&self) -> Option<LooseDateTime> {
50        self.get_start().map(Into::into)
51    }
52
53    fn end(&self) -> Option<LooseDateTime> {
54        self.get_end().map(Into::into)
55    }
56
57    fn status(&self) -> Option<EventStatus> {
58        self.get_status().map(EventStatus::from)
59    }
60
61    fn summary(&self) -> &str {
62        self.get_summary().unwrap_or_default()
63    }
64}
65
66/// Darft for an event, used for creating new events.
67#[derive(Debug, Clone)]
68pub struct EventDraft {
69    /// The description of the event, if available.
70    pub description: Option<String>,
71
72    /// The start date and time of the event, if available.
73    pub start: Option<LooseDateTime>,
74
75    /// The end date and time of the event, if available.
76    pub end: Option<LooseDateTime>,
77
78    /// The status of the event.
79    pub status: EventStatus,
80
81    /// The summary of the event.
82    pub summary: String,
83}
84
85impl EventDraft {
86    /// Creates a new empty patch.
87    pub(crate) fn default(now: &DateTime<Local>) -> Self {
88        // next 00 or 30 minute
89        let start = if now.minute() < 30 {
90            now.with_minute(30).unwrap().with_second(0).unwrap()
91        } else {
92            (*now + Duration::hours(1))
93                .with_minute(0)
94                .unwrap()
95                .with_second(0)
96                .unwrap()
97        };
98
99        Self {
100            description: None,
101            start: Some(start.into()),
102            end: Some((start + Duration::hours(1)).into()),
103            status: EventStatus::default(),
104            summary: String::new(),
105        }
106    }
107
108    pub(crate) fn resolve<'a>(&'a self, now: &'a DateTime<Local>) -> ResolvedEventDraft<'a> {
109        let default_duration = Duration::hours(1);
110        let (start, end) = match (self.start, self.end) {
111            (Some(start), Some(end)) => (start, end),
112            (None, Some(end)) => {
113                // If start is not specified, but end is, set start to end - duration
114                let start = match end {
115                    LooseDateTime::DateOnly(d) => d.into(),
116                    LooseDateTime::Floating(dt) => (dt - default_duration).into(),
117                    LooseDateTime::Local(dt) => (dt - default_duration).into(),
118                };
119                (start, end)
120            }
121            (Some(start), None) => {
122                // If end is not specified, but start is, set it to start + duration
123                let end = match start {
124                    LooseDateTime::DateOnly(d) => d.into(),
125                    LooseDateTime::Floating(dt) => (dt + default_duration).into(),
126                    LooseDateTime::Local(dt) => (dt + default_duration).into(),
127                };
128                (start, end)
129            }
130            (None, None) => {
131                let start = *now;
132                let end = (start + default_duration).into();
133                (start.into(), end)
134            }
135        };
136
137        ResolvedEventDraft {
138            description: self.description.as_deref(),
139            start,
140            end,
141            status: self.status,
142            summary: &self.summary,
143
144            now,
145        }
146    }
147}
148
149#[derive(Debug, Clone, Copy)]
150pub struct ResolvedEventDraft<'a> {
151    pub description: Option<&'a str>,
152    pub start: LooseDateTime,
153    pub end: LooseDateTime,
154    pub status: EventStatus,
155    pub summary: &'a str,
156
157    pub now: &'a DateTime<Local>,
158}
159
160impl ResolvedEventDraft<'_> {
161    /// Converts the draft into a icalendar Event component.
162    pub(crate) fn into_ics(self, uid: &str) -> icalendar::Event {
163        let mut event = icalendar::Event::with_uid(uid);
164
165        if let Some(description) = self.description {
166            Component::description(&mut event, description);
167        }
168
169        EventLike::starts(&mut event, self.start);
170        EventLike::ends(&mut event, self.end);
171
172        icalendar::Event::status(&mut event, self.status.into());
173
174        Component::summary(&mut event, self.summary);
175
176        // Set the creation time to now
177        Component::created(&mut event, self.now.with_timezone(&Utc));
178        event
179    }
180}
181
182/// Patch for an event, allowing partial updates.
183#[derive(Debug, Default, Clone)]
184pub struct EventPatch {
185    /// The description of the event, if available.
186    pub description: Option<Option<String>>,
187
188    /// The start date and time of the event, if available.
189    pub start: Option<Option<LooseDateTime>>,
190
191    /// The end date and time of the event, if available.
192    pub end: Option<Option<LooseDateTime>>,
193
194    /// The status of the event, if available.
195    pub status: Option<EventStatus>,
196
197    /// The summary of the event, if available.
198    pub summary: Option<String>,
199}
200
201impl EventPatch {
202    /// Is this patch empty, meaning no fields are set
203    #[must_use]
204    pub fn is_empty(&self) -> bool {
205        self.description.is_none()
206            && self.start.is_none()
207            && self.end.is_none()
208            && self.status.is_none()
209            && self.summary.is_none()
210    }
211
212    pub(crate) fn resolve(&self, now: DateTime<Local>) -> ResolvedEventPatch<'_> {
213        ResolvedEventPatch {
214            description: self.description.as_ref().map(|opt| opt.as_deref()),
215            start: self.start,
216            end: self.end,
217            status: self.status,
218            summary: self.summary.as_deref(),
219
220            now,
221        }
222    }
223}
224
225/// Patch for an event, allowing partial updates.
226#[derive(Debug, Default, Clone, Copy)]
227pub struct ResolvedEventPatch<'a> {
228    pub description: Option<Option<&'a str>>,
229    pub start: Option<Option<LooseDateTime>>,
230    pub end: Option<Option<LooseDateTime>>,
231    pub status: Option<EventStatus>,
232    pub summary: Option<&'a str>,
233
234    pub now: DateTime<Local>,
235}
236
237impl ResolvedEventPatch<'_> {
238    /// Applies the patch to a mutable event, modifying it in place.
239    pub fn apply_to<'b>(&self, e: &'b mut icalendar::Event) -> &'b mut icalendar::Event {
240        match self.description {
241            Some(Some(desc)) => e.description(desc),
242            Some(None) => e.remove_description(),
243            None => e,
244        };
245
246        match self.start {
247            Some(Some(start)) => e.starts(start),
248            Some(None) => e.remove_starts(),
249            None => e,
250        };
251
252        match self.end {
253            Some(Some(end)) => e.ends(end),
254            Some(None) => e.remove_ends(),
255            None => e,
256        };
257
258        if let Some(status) = self.status {
259            e.status(status.into());
260        }
261
262        if let Some(summary) = &self.summary {
263            e.summary(summary);
264        }
265
266        // Set the creation time to now if it is not already set
267        if e.get_created().is_none() {
268            Component::created(e, self.now.with_timezone(&Utc));
269        }
270        e
271    }
272}
273
274/// The status of an event, which can be tentative, confirmed, or cancelled.
275#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
276#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
277pub enum EventStatus {
278    /// The event is tentative.
279    Tentative,
280
281    /// The event is confirmed.
282    #[default]
283    Confirmed,
284
285    /// The event is cancelled.
286    Cancelled,
287}
288
289const STATUS_TENTATIVE: &str = "TENTATIVE";
290const STATUS_CONFIRMED: &str = "CONFIRMED";
291const STATUS_CANCELLED: &str = "CANCELLED";
292
293impl AsRef<str> for EventStatus {
294    fn as_ref(&self) -> &str {
295        match self {
296            EventStatus::Tentative => STATUS_TENTATIVE,
297            EventStatus::Confirmed => STATUS_CONFIRMED,
298            EventStatus::Cancelled => STATUS_CANCELLED,
299        }
300    }
301}
302
303impl Display for EventStatus {
304    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305        self.as_ref().fmt(f)
306    }
307}
308
309impl FromStr for EventStatus {
310    type Err = ();
311
312    fn from_str(value: &str) -> Result<Self, Self::Err> {
313        match value {
314            STATUS_TENTATIVE => Ok(EventStatus::Tentative),
315            STATUS_CONFIRMED => Ok(EventStatus::Confirmed),
316            STATUS_CANCELLED => Ok(EventStatus::Cancelled),
317            _ => Err(()),
318        }
319    }
320}
321
322impl From<EventStatus> for icalendar::EventStatus {
323    fn from(status: EventStatus) -> Self {
324        match status {
325            EventStatus::Tentative => icalendar::EventStatus::Tentative,
326            EventStatus::Confirmed => icalendar::EventStatus::Confirmed,
327            EventStatus::Cancelled => icalendar::EventStatus::Cancelled,
328        }
329    }
330}
331
332impl From<icalendar::EventStatus> for EventStatus {
333    fn from(status: icalendar::EventStatus) -> Self {
334        match status {
335            icalendar::EventStatus::Tentative => EventStatus::Tentative,
336            icalendar::EventStatus::Confirmed => EventStatus::Confirmed,
337            icalendar::EventStatus::Cancelled => EventStatus::Cancelled,
338        }
339    }
340}
341
342/// Conditions for filtering events in a calendar.
343#[derive(Debug, Default, Clone, Copy)]
344pub struct EventConditions {
345    /// Whether to include only startable events.
346    pub startable: Option<DateTimeAnchor>,
347
348    /// The cutoff date and time, events ending after this will be excluded.
349    pub cutoff: Option<DateTimeAnchor>,
350}
351
352impl EventConditions {
353    pub(crate) fn resolve(&self, now: &DateTime<Local>) -> ResolvedEventConditions {
354        ResolvedEventConditions {
355            start_before: self.cutoff.map(|w| w.resolve_at_end_of_day(now)),
356            end_after: self.startable.map(|w| w.resolve_at_start_of_day(now)),
357        }
358    }
359}
360
361#[derive(Debug, Clone, Copy)]
362pub struct ResolvedEventConditions {
363    /// The date and time after which the event must start
364    pub start_before: Option<DateTime<Local>>,
365
366    /// The date and time after which the event must end
367    pub end_after: Option<DateTime<Local>>,
368}