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 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 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 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 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 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}