Skip to main content

caldir_core/event/
reminder.rs

1use icalendar::{Component, Property};
2use serde::{Deserialize, Deserializer, Serialize, Serializer};
3use std::cmp::Reverse;
4use std::fmt;
5use std::time::Duration;
6
7const DEFAULT_REMINDER_DESCRIPTION: &str = "Reminder";
8const MINUTES_PER_HOUR: u64 = 60;
9const MINUTES_PER_DAY: u64 = 24 * MINUTES_PER_HOUR;
10const MINUTES_PER_WEEK: u64 = 7 * MINUTES_PER_DAY;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
13pub struct Reminder {
14    /// Minutes before the event start.
15    pub minutes_before_start: i64,
16}
17
18impl fmt::Display for Reminder {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        write!(f, "{} before start", self.to_human())
21    }
22}
23
24impl Serialize for Reminder {
25    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
26        serializer.serialize_str(&self.to_human())
27    }
28}
29
30impl<'de> Deserialize<'de> for Reminder {
31    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
32        let s = String::deserialize(deserializer)?;
33        Reminder::from_human(&s).map_err(serde::de::Error::custom)
34    }
35}
36
37impl Reminder {
38    pub fn from_minutes(minutes: i64) -> Self {
39        Reminder {
40            minutes_before_start: minutes,
41        }
42    }
43
44    pub fn from_human(input: &str) -> Result<Self, humantime::DurationError> {
45        let dur = humantime::parse_duration(input)?;
46        let minutes = (dur.as_secs() / 60) as i64;
47
48        Ok(Reminder {
49            minutes_before_start: minutes,
50        })
51    }
52
53    pub fn to_human(&self) -> String {
54        let seconds = self.minutes_before_start.unsigned_abs() * 60;
55        humantime::format_duration(Duration::from_secs(seconds)).to_string()
56    }
57
58    /// We intentionally treat all alarms the same,
59    /// and don't distinguish between ACTION:DISPLAY, ACTION:AUDIO, or ACTION:EMAIL.
60    /// (How alarms are handled should be up to the app layer instead)
61    pub(crate) fn from_ical_event(event: &icalendar::Event) -> Vec<Self> {
62        let mut reminders: Vec<Self> = event
63            .components()
64            .iter()
65            .filter(|c| c.component_kind() == "VALARM")
66            .filter_map(|c| Reminder::from_valarm(c).ok())
67            .collect();
68        reminders.sort_by_key(|reminder| Reverse(reminder.minutes_before_start));
69        reminders
70    }
71
72    fn from_valarm<C: Component + ?Sized>(value: &C) -> Result<Self, ()> {
73        let trigger_prop = value.properties().get("TRIGGER").ok_or(())?;
74        parse_trigger_minutes_before_start(trigger_prop).map(Reminder::from_minutes)
75    }
76
77    /// Format this reminder as a minimal DISPLAY `VALARM` block (RFC 5545).
78    ///
79    /// We emit the block ourselves rather than going through
80    /// `icalendar::Alarm` + the icalendar event serializer, because
81    /// `icalendar::Component::fmt_write` injects a random `UID:<uuid>` line
82    /// into every sub-component that doesn't already have one. VALARM doesn't
83    /// require a UID per RFC 5545, so letting that through would break
84    /// byte-stable round-trips and surface as spurious sync diffs.
85    pub(crate) fn ics_block(&self) -> String {
86        let mut block = String::from("BEGIN:VALARM\r\n");
87
88        for property in self.properties() {
89            let line: String = property
90                .try_into()
91                .expect("formatting an icalendar Property to String never fails");
92            block.push_str(&line);
93        }
94
95        block.push_str("END:VALARM\r\n");
96        block
97    }
98
99    fn properties(&self) -> Vec<Property> {
100        let mut trigger = Property::new(
101            "TRIGGER",
102            format_trigger_minutes_before_start(self.minutes_before_start),
103        );
104        trigger.add_parameter("RELATED", "START");
105
106        vec![
107            Property::new("ACTION", "DISPLAY").done(),
108            Property::new("DESCRIPTION", DEFAULT_REMINDER_DESCRIPTION).done(),
109            trigger.done(),
110        ]
111    }
112}
113
114fn parse_trigger_minutes_before_start(prop: &Property) -> Result<i64, ()> {
115    match prop.params().get("VALUE").map(|p| p.value()) {
116        None => {}
117        Some(value) if value.eq_ignore_ascii_case("DURATION") => {}
118        Some(_) => return Err(()),
119    }
120
121    match prop.params().get("RELATED").map(|p| p.value()) {
122        None => {}
123        Some(value) if value.eq_ignore_ascii_case("START") => {}
124        Some(_) => return Err(()),
125    }
126
127    let raw = prop.value().strip_prefix('-').ok_or(())?;
128    let minutes = parse_duration_minutes(raw)?;
129    i64::try_from(minutes).map_err(|_| ())
130}
131
132fn parse_duration_minutes(raw: &str) -> Result<u64, ()> {
133    let body = raw.strip_prefix('P').ok_or(())?;
134    if body.is_empty() {
135        return Err(());
136    }
137
138    if let Some(weeks) = body.strip_suffix('W') {
139        return parse_u64(weeks)?.checked_mul(MINUTES_PER_WEEK).ok_or(());
140    }
141
142    let (date_part, time_part) = match body.split_once('T') {
143        Some((_, "")) => return Err(()),
144        Some((date_part, time_part)) => (date_part, Some(time_part)),
145        None => (body, None),
146    };
147
148    let days = match date_part {
149        "" => 0,
150        value => parse_u64(value.strip_suffix('D').ok_or(())?)?,
151    };
152    let time_minutes = time_part.map(parse_time_minutes).unwrap_or(Ok(0))?;
153
154    days.checked_mul(MINUTES_PER_DAY)
155        .and_then(|minutes| minutes.checked_add(time_minutes))
156        .ok_or(())
157}
158
159fn parse_time_minutes(raw: &str) -> Result<u64, ()> {
160    let original = raw;
161    let (hours, raw) = consume_unit(raw, 'H')?.unwrap_or((0, raw));
162    let (minutes, raw) = consume_unit(raw, 'M')?.unwrap_or((0, raw));
163    let (seconds, raw) = consume_unit(raw, 'S')?.unwrap_or((0, raw));
164
165    if !raw.is_empty() || raw == original || seconds % 60 != 0 {
166        return Err(());
167    }
168
169    hours
170        .checked_mul(MINUTES_PER_HOUR)
171        .and_then(|total| total.checked_add(minutes))
172        .and_then(|total| total.checked_add(seconds / 60))
173        .ok_or(())
174}
175
176fn consume_unit(raw: &str, unit: char) -> Result<Option<(u64, &str)>, ()> {
177    let Some(unit_index) = raw.find(unit) else {
178        return Ok(None);
179    };
180    let (digits, rest) = raw.split_at(unit_index);
181    let rest = rest.strip_prefix(unit).ok_or(())?;
182    Ok(Some((parse_u64(digits)?, rest)))
183}
184
185fn parse_u64(raw: &str) -> Result<u64, ()> {
186    if raw.is_empty() || !raw.chars().all(|c| c.is_ascii_digit()) {
187        return Err(());
188    }
189    raw.parse().map_err(|_| ())
190}
191
192fn format_trigger_minutes_before_start(minutes: i64) -> String {
193    let minutes = minutes.unsigned_abs();
194    if minutes == 0 {
195        return "PT0S".to_string();
196    }
197
198    if minutes.is_multiple_of(MINUTES_PER_WEEK) {
199        return format!("-P{}W", minutes / MINUTES_PER_WEEK);
200    }
201    if minutes.is_multiple_of(MINUTES_PER_DAY) {
202        return format!("-P{}D", minutes / MINUTES_PER_DAY);
203    }
204
205    let days = minutes / MINUTES_PER_DAY;
206    let remainder = minutes % MINUTES_PER_DAY;
207    let hours = remainder / MINUTES_PER_HOUR;
208    let minutes = remainder % MINUTES_PER_HOUR;
209
210    let mut s = if days > 0 {
211        format!("-P{days}DT")
212    } else {
213        "-PT".to_string()
214    };
215    if hours > 0 {
216        s.push_str(&format!("{hours}H"));
217    }
218    if minutes > 0 {
219        s.push_str(&format!("{minutes}M"));
220    }
221    s
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use pretty_assertions::assert_eq;
228
229    fn parse_reminders(ics_alarm_blocks: &str) -> Vec<Reminder> {
230        let ics = format!(
231            "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:test@caldir\r\nDTSTART:20260101T120000Z\r\n{ics_alarm_blocks}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
232        );
233        let cal: icalendar::Calendar = ics.parse().unwrap();
234        let event = cal
235            .components
236            .into_iter()
237            .find_map(|c| match c {
238                icalendar::CalendarComponent::Event(e) => Some(e),
239                _ => None,
240            })
241            .expect("VEVENT should be present");
242        Reminder::from_ical_event(&event)
243    }
244
245    fn parse_reminder(ics_alarm_block: &str) -> Reminder {
246        parse_reminders(ics_alarm_block)
247            .into_iter()
248            .next()
249            .expect("VALARM should produce a Reminder")
250    }
251
252    #[test]
253    fn parses_display_alarm_minutes_before_start() {
254        let reminder = parse_reminder(
255            "BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Wake up\r\nTRIGGER:-PT10M\r\nEND:VALARM",
256        );
257
258        assert_eq!(reminder, Reminder::from_minutes(10));
259    }
260
261    #[test]
262    fn defaults_action_to_display_when_missing() {
263        let reminder = parse_reminder("BEGIN:VALARM\r\nTRIGGER:-PT10M\r\nEND:VALARM");
264
265        assert_eq!(reminder, Reminder::from_minutes(10));
266    }
267
268    #[test]
269    fn parses_hours_days_and_weeks() {
270        assert_eq!(parse_duration_minutes("PT1H"), Ok(60));
271        assert_eq!(parse_duration_minutes("P1D"), Ok(1_440));
272        assert_eq!(parse_duration_minutes("P1W"), Ok(10_080));
273    }
274
275    #[test]
276    fn parses_mixed_day_time_duration() {
277        let reminder = parse_reminder(
278            "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;VALUE=DURATION:-P1DT2H30M\r\nEND:VALARM",
279        );
280
281        assert_eq!(reminder, Reminder::from_minutes(1_590));
282    }
283
284    #[test]
285    fn parses_zero_duration() {
286        let reminder = parse_reminder("BEGIN:VALARM\r\nTRIGGER:-PT0S\r\nEND:VALARM");
287
288        assert_eq!(reminder, Reminder::from_minutes(0));
289    }
290
291    #[test]
292    fn skips_alarm_when_trigger_missing() {
293        let reminders = parse_reminders(
294            "BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:no trigger\r\nEND:VALARM",
295        );
296
297        assert!(reminders.is_empty());
298    }
299
300    #[test]
301    fn accepts_audio_alarm() {
302        // iCloud emits AUDIO alarms by default
303        let reminder = parse_reminder(
304            "BEGIN:VALARM\r\nACTION:AUDIO\r\nATTACH:Basso\r\nTRIGGER:-PT10M\r\nEND:VALARM",
305        );
306
307        assert_eq!(reminder, Reminder::from_minutes(10));
308    }
309
310    #[test]
311    fn accepts_email_alarm() {
312        let reminder = parse_reminder(
313            "BEGIN:VALARM\r\nACTION:EMAIL\r\nATTENDEE:mailto:a@b.com\r\nTRIGGER:-PT10M\r\nEND:VALARM",
314        );
315
316        assert_eq!(reminder, Reminder::from_minutes(10));
317    }
318
319    #[test]
320    fn skips_positive_offset_alarm() {
321        let reminders =
322            parse_reminders("BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:PT10M\r\nEND:VALARM");
323
324        assert!(reminders.is_empty());
325    }
326
327    #[test]
328    fn skips_end_relative_alarm() {
329        let reminders = parse_reminders(
330            "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;RELATED=END:-PT10M\r\nEND:VALARM",
331        );
332
333        assert!(reminders.is_empty());
334    }
335
336    #[test]
337    fn skips_absolute_alarm() {
338        let reminders = parse_reminders(
339            "BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER;VALUE=DATE-TIME:20260101T120000Z\r\nEND:VALARM",
340        );
341
342        assert!(reminders.is_empty());
343    }
344
345    #[test]
346    fn skips_sub_minute_alarm() {
347        let reminders =
348            parse_reminders("BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT30S\r\nEND:VALARM");
349
350        assert!(reminders.is_empty());
351    }
352
353    #[test]
354    fn skips_iso_year_and_month_durations() {
355        assert!(parse_duration_minutes("P1Y").is_err());
356        assert!(parse_duration_minutes("P1M").is_err());
357    }
358
359    #[test]
360    fn writes_full_valarm() {
361        let reminder = Reminder::from_minutes(10);
362
363        let block = reminder.ics_block();
364
365        assert_eq!(
366            block,
367            "BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER;RELATED=START:-PT10M\r\nEND:VALARM\r\n"
368        );
369    }
370
371    #[test]
372    fn does_not_emit_uid_inside_valarm() {
373        // icalendar's `Component::fmt_write` auto-injects a random UID into
374        // every sub-component. We sidestep that by formatting VALARM ourselves;
375        // this test guards against accidentally regressing back through it.
376        let block = Reminder::from_minutes(10).ics_block();
377
378        assert!(
379            !block.contains("UID"),
380            "VALARM block contained a UID:\n{block}"
381        );
382    }
383
384    #[test]
385    fn from_ical_event_sorts_by_alarm_time() {
386        let reminders = parse_reminders(
387            "BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT5M\r\nEND:VALARM\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT30M\r\nEND:VALARM\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT10M\r\nEND:VALARM",
388        );
389
390        let minutes: Vec<_> = reminders.iter().map(|r| r.minutes_before_start).collect();
391        assert_eq!(minutes, vec![30, 10, 5]);
392    }
393
394    #[test]
395    fn display_shows_humantime_with_before_start_suffix() {
396        assert_eq!(Reminder::from_minutes(10).to_string(), "10m before start");
397        assert_eq!(
398            Reminder::from_minutes(90).to_string(),
399            "1h 30m before start"
400        );
401    }
402
403    #[test]
404    fn formats_compact_trigger_durations() {
405        assert_eq!(format_trigger_minutes_before_start(0), "PT0S");
406        assert_eq!(format_trigger_minutes_before_start(10), "-PT10M");
407        assert_eq!(format_trigger_minutes_before_start(60), "-PT1H");
408        assert_eq!(format_trigger_minutes_before_start(90), "-PT1H30M");
409        assert_eq!(format_trigger_minutes_before_start(1_440), "-P1D");
410        assert_eq!(format_trigger_minutes_before_start(1_500), "-P1DT1H");
411        assert_eq!(format_trigger_minutes_before_start(20_160), "-P2W");
412    }
413}