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::{borrow::Cow, fmt::Display, num::NonZeroU32, str::FromStr};
6
7use aimcal_ical as ical;
8use aimcal_ical::{Description, DtEnd, DtStamp, DtStart, EventStatusValue, Summary, Uid, VEvent};
9use jiff::{Span, ToSpan, Zoned};
10
11use crate::{DateTimeAnchor, LooseDateTime};
12
13/// Trait representing a calendar event.
14pub trait Event {
15    /// The short identifier for the event.
16    /// It will be `None` if the event does not have a short ID.
17    /// It is used for display purposes and may not be unique.
18    fn short_id(&self) -> Option<NonZeroU32> {
19        None
20    }
21
22    /// The unique identifier for the event.
23    fn uid(&self) -> Cow<'_, str>;
24
25    /// The description of the event, if available.
26    fn description(&self) -> Option<Cow<'_, str>>;
27
28    /// The location of the event, if available.
29    fn start(&self) -> Option<LooseDateTime>;
30
31    /// The start date and time of the event, if available.
32    fn end(&self) -> Option<LooseDateTime>;
33
34    /// The status of the event, if available.
35    fn status(&self) -> Option<EventStatus>;
36
37    /// The summary of the event.
38    fn summary(&self) -> Cow<'_, str>;
39}
40
41impl Event for VEvent<String> {
42    fn uid(&self) -> Cow<'_, str> {
43        self.uid.content.to_string().into() // PERF: avoid allocation
44    }
45
46    fn description(&self) -> Option<Cow<'_, str>> {
47        self.description
48            .as_ref()
49            .map(|a| a.content.to_string().into()) // PERF: avoid allocation
50    }
51
52    fn start(&self) -> Option<LooseDateTime> {
53        Some(self.dt_start.inner.clone().into())
54    }
55
56    fn end(&self) -> Option<LooseDateTime> {
57        self.dt_end.as_ref().map(|dt| dt.inner.clone().into())
58    }
59
60    fn status(&self) -> Option<EventStatus> {
61        self.status.as_ref().map(|s| s.value.into())
62    }
63
64    fn summary(&self) -> Cow<'_, str> {
65        self.summary
66            .as_ref()
67            .map_or_else(|| "".into(), |s| s.content.to_string().into()) // PERF: avoid allocation
68    }
69}
70
71/// Darft for an event, used for creating new events.
72#[derive(Debug, Clone)]
73pub struct EventDraft {
74    /// The description of the event, if available.
75    pub description: Option<String>,
76
77    /// The start date and time of the event, if available.
78    pub start: Option<LooseDateTime>,
79
80    /// The end date and time of the event, if available.
81    pub end: Option<LooseDateTime>,
82
83    /// The status of the event.
84    pub status: EventStatus,
85
86    /// The summary of the event.
87    pub summary: String,
88}
89
90impl EventDraft {
91    /// Creates a new empty patch.
92    pub(crate) fn default(now: &Zoned) -> Self {
93        // next 00 or 30 minute
94        let start = if now.time().minute() < 30 {
95            now.with()
96                .minute(30)
97                .second(0)
98                .subsec_nanosecond(0)
99                .build()
100                .unwrap()
101        } else {
102            (now + Span::new().hours(1))
103                .with()
104                .minute(0)
105                .second(0)
106                .subsec_nanosecond(0)
107                .build()
108                .unwrap()
109        };
110
111        Self {
112            description: None,
113            start: Some(start.clone().into()),
114            end: Some((start.checked_add(1.hours()).unwrap()).into()),
115            status: EventStatus::default(),
116            summary: String::new(),
117        }
118    }
119
120    pub(crate) fn resolve<'a>(&'a self, now: &'a Zoned) -> ResolvedEventDraft<'a> {
121        let default_duration = 1.hours();
122        let (start, end) = match (self.start.as_ref(), self.end.as_ref()) {
123            (Some(start), Some(end)) => (start.clone(), end.clone()),
124            (None, Some(end)) => {
125                // If start is not specified, but end is, set start to end - duration
126                let neg_duration = Span::new().hours(-1);
127                let start = match end {
128                    LooseDateTime::DateOnly(d) => (*d).into(),
129                    LooseDateTime::Floating(dt) => {
130                        LooseDateTime::Floating(dt.checked_add(neg_duration).unwrap())
131                    }
132                    LooseDateTime::Local(dt) => {
133                        LooseDateTime::Local(dt.checked_add(neg_duration).unwrap())
134                    }
135                };
136                (start, end.clone())
137            }
138            (Some(start), None) => {
139                // If end is not specified, but start is, set it to start + duration
140                let end = match start {
141                    LooseDateTime::DateOnly(d) => (*d).into(),
142                    LooseDateTime::Floating(dt) => {
143                        LooseDateTime::Floating(dt.checked_add(default_duration).unwrap())
144                    }
145                    LooseDateTime::Local(dt) => {
146                        LooseDateTime::Local(dt.checked_add(default_duration).unwrap())
147                    }
148                };
149                (start.clone(), end)
150            }
151            (None, None) => {
152                let end = now.checked_add(default_duration).unwrap();
153                (LooseDateTime::Local(now.clone()), LooseDateTime::Local(end))
154            }
155        };
156
157        ResolvedEventDraft {
158            description: self.description.as_deref(),
159            start,
160            end,
161            status: self.status,
162            summary: &self.summary,
163
164            now,
165        }
166    }
167}
168
169#[derive(Debug, Clone)]
170pub struct ResolvedEventDraft<'a> {
171    pub description: Option<&'a str>,
172    pub start: LooseDateTime,
173    pub end: LooseDateTime,
174    pub status: EventStatus,
175    pub summary: &'a str,
176
177    pub now: &'a Zoned,
178}
179
180impl ResolvedEventDraft<'_> {
181    /// Converts the draft into an aimcal-ical `VEvent` component.
182    pub(crate) fn into_ics(self, uid: &str) -> VEvent<String> {
183        VEvent {
184            uid: Uid::new(uid.to_string()),
185            dt_stamp: DtStamp::new(ical::DateTime::from(LooseDateTime::Local(self.now.clone()))),
186            dt_start: DtStart::new(self.start.into()),
187            dt_end: Some(DtEnd::new(self.end.into())),
188            duration: None,
189            summary: Some(Summary::new(self.summary.to_string())),
190            description: self.description.map(|d| Description::new(d.to_string())),
191            status: Some(ical::EventStatus::new(self.status.into())),
192            location: None,
193            geo: None,
194            url: None,
195            organizer: None,
196            attendees: Vec::new(),
197            last_modified: None,
198            transparency: None,
199            sequence: None,
200            priority: None,
201            classification: None,
202            resources: None,
203            categories: None,
204            rrule: None,
205            rdates: Vec::new(),
206            ex_dates: Vec::new(),
207            x_properties: Vec::new(),
208            retained_properties: Vec::new(),
209            alarms: Vec::new(),
210        }
211    }
212}
213
214/// Patch for an event, allowing partial updates.
215#[derive(Debug, Default, Clone)]
216pub struct EventPatch {
217    /// The description of the event, if available.
218    pub description: Option<Option<String>>,
219
220    /// The start date and time of the event, if available.
221    pub start: Option<Option<LooseDateTime>>,
222
223    /// The end date and time of the event, if available.
224    pub end: Option<Option<LooseDateTime>>,
225
226    /// The status of the event, if available.
227    pub status: Option<EventStatus>,
228
229    /// The summary of the event, if available.
230    pub summary: Option<String>,
231}
232
233impl EventPatch {
234    /// Is this patch empty, meaning no fields are set
235    #[must_use]
236    pub fn is_empty(&self) -> bool {
237        self.description.is_none()
238            && self.start.is_none()
239            && self.end.is_none()
240            && self.status.is_none()
241            && self.summary.is_none()
242    }
243
244    pub(crate) fn resolve(&self, now: Zoned) -> ResolvedEventPatch<'_> {
245        ResolvedEventPatch {
246            description: self.description.as_ref().map(|opt| opt.as_deref()),
247            start: self.start.clone(),
248            end: self.end.clone(),
249            status: self.status,
250            summary: self.summary.as_deref(),
251
252            now,
253        }
254    }
255}
256
257/// Patch for an event, allowing partial updates.
258#[derive(Debug, Default, Clone)]
259pub struct ResolvedEventPatch<'a> {
260    pub description: Option<Option<&'a str>>,
261    pub start: Option<Option<LooseDateTime>>,
262    pub end: Option<Option<LooseDateTime>>,
263    pub status: Option<EventStatus>,
264    pub summary: Option<&'a str>,
265
266    pub now: Zoned,
267}
268
269impl ResolvedEventPatch<'_> {
270    /// Applies the patch to a mutable event, modifying it in place.
271    pub fn apply_to<'a>(&self, e: &'a mut VEvent<String>) -> &'a mut VEvent<String> {
272        if let Some(Some(desc)) = &self.description {
273            e.description = Some(Description::new((*desc).to_string()));
274        } else if self.description.is_some() {
275            e.description = None;
276        }
277
278        if let Some(Some(ref start)) = self.start {
279            e.dt_start = DtStart::new(start.clone().into());
280        }
281
282        if let Some(Some(ref end)) = self.end {
283            e.dt_end = Some(DtEnd::new(end.clone().into()));
284        } else if self.end.is_some() {
285            e.dt_end = None;
286        }
287
288        if let Some(status) = self.status {
289            e.status = Some(ical::EventStatus::new(status.into()));
290        }
291
292        if let Some(summary) = &self.summary {
293            e.summary = Some(Summary::new((*summary).to_string()));
294        }
295
296        // Set the creation time to now if it is not already set
297        if e.dt_stamp.inner.date().year == 1970 {
298            e.dt_stamp = DtStamp::new(ical::DateTime::from(LooseDateTime::Local(self.now.clone())));
299        }
300
301        e
302    }
303}
304
305/// The status of an event, which can be tentative, confirmed, or cancelled.
306#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
307#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
308pub enum EventStatus {
309    /// The event is tentative.
310    Tentative,
311
312    /// The event is confirmed.
313    #[default]
314    Confirmed,
315
316    /// The event is cancelled.
317    Cancelled,
318}
319
320// TODO: should be removed
321const STATUS_TENTATIVE: &str = "TENTATIVE";
322const STATUS_CONFIRMED: &str = "CONFIRMED";
323const STATUS_CANCELLED: &str = "CANCELLED";
324
325impl AsRef<str> for EventStatus {
326    fn as_ref(&self) -> &str {
327        match self {
328            EventStatus::Tentative => STATUS_TENTATIVE,
329            EventStatus::Confirmed => STATUS_CONFIRMED,
330            EventStatus::Cancelled => STATUS_CANCELLED,
331        }
332    }
333}
334
335impl Display for EventStatus {
336    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        self.as_ref().fmt(f)
338    }
339}
340
341impl FromStr for EventStatus {
342    type Err = ();
343
344    fn from_str(value: &str) -> Result<Self, Self::Err> {
345        match value {
346            STATUS_TENTATIVE => Ok(EventStatus::Tentative),
347            STATUS_CONFIRMED => Ok(EventStatus::Confirmed),
348            STATUS_CANCELLED => Ok(EventStatus::Cancelled),
349            _ => Err(()),
350        }
351    }
352}
353
354impl From<EventStatusValue> for EventStatus {
355    fn from(value: EventStatusValue) -> Self {
356        match value {
357            EventStatusValue::Tentative => EventStatus::Tentative,
358            EventStatusValue::Confirmed => EventStatus::Confirmed,
359            EventStatusValue::Cancelled => EventStatus::Cancelled,
360        }
361    }
362}
363
364impl From<EventStatus> for EventStatusValue {
365    fn from(value: EventStatus) -> Self {
366        match value {
367            EventStatus::Tentative => EventStatusValue::Tentative,
368            EventStatus::Confirmed => EventStatusValue::Confirmed,
369            EventStatus::Cancelled => EventStatusValue::Cancelled,
370        }
371    }
372}
373
374/// Conditions for filtering events in a calendar.
375#[derive(Debug, Default, Clone)]
376pub struct EventConditions {
377    /// Whether to include only startable events.
378    pub startable: Option<DateTimeAnchor>,
379
380    /// The cutoff date and time, events ending after this will be excluded.
381    pub cutoff: Option<DateTimeAnchor>,
382}
383
384impl EventConditions {
385    pub(crate) fn resolve(&self, now: &Zoned) -> Result<ResolvedEventConditions, String> {
386        Ok(ResolvedEventConditions {
387            start_before: self
388                .cutoff
389                .as_ref()
390                .map(|w| w.resolve_at_end_of_day(now))
391                .transpose()?,
392            end_after: self
393                .startable
394                .as_ref()
395                .map(|w| w.resolve_at_start_of_day(now))
396                .transpose()?,
397        })
398    }
399}
400
401#[derive(Debug, Clone)]
402pub struct ResolvedEventConditions {
403    /// The date and time after which the event must start
404    pub start_before: Option<Zoned>,
405
406    /// The date and time after which the event must end
407    pub end_after: Option<Zoned>,
408}