Skip to main content

caldir_core/event/
slugify.rs

1use crate::event::{Event, EventTime};
2use chrono::Local;
3
4const EMPTY_SUMMARY_SLUG: &str = "untitled";
5
6impl Event {
7    /// Generate a slug for an event based on its start time and summary.
8    /// The slug is used as the filename for the event's .ics file.
9    ///
10    /// Recurring events happen on many dates, so a single date prefix would be
11    /// misleading. They get a `_recurring__` prefix instead, which also groups
12    /// them together when browsing the directory.
13    pub fn base_slug(&self) -> String {
14        if self.recurrence.is_some() {
15            return format!("_recurring__{}", self.summary_slug());
16        }
17
18        format!("{}__{}", self.time_slug(), self.summary_slug())
19    }
20
21    fn summary_slug(&self) -> String {
22        match &self.summary {
23            Some(summary) => {
24                // Strip non-alphanumeric chars (e.g. emoji) before slugifying.
25                // Otherwise `slug` transliterates symbols via `deunicode` (☕ → "coffee").
26                let cleaned: String = summary
27                    .chars()
28                    .filter(|c| c.is_alphanumeric() || c.is_whitespace())
29                    .collect();
30
31                // Delegate to the shared slugifier so the length cap that keeps
32                // filenames under the filesystem limit lives in one place.
33                let slug = crate::utils::slugify(&cleaned);
34
35                if slug.is_empty() {
36                    EMPTY_SUMMARY_SLUG.to_string()
37                } else {
38                    slug
39                }
40            }
41            None => EMPTY_SUMMARY_SLUG.to_string(),
42        }
43    }
44
45    /// Always uses local time (it's the most intuitive when browsing files).
46    /// If a co-worker on the other side of the world creates an event at 9am their time,
47    /// my filename should show what time it is for me, not for them.
48    fn time_slug(&self) -> String {
49        match &self.start {
50            // date only:
51            EventTime::Date(date) => date.format("%Y-%m-%d").to_string(),
52
53            // date + time:
54            _ => self
55                .start
56                .to_local_tz(&Local)
57                .format("%Y-%m-%dT%H%M")
58                .to_string(),
59        }
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use chrono::NaiveDate;
67
68    #[test]
69    fn generates_expected_slug_for_emoji_summary() {
70        let event = Event::new(
71            "Café ☕️ meeting",
72            EventTime::Date(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
73        );
74
75        assert_eq!(event.summary_slug(), "cafe-meeting");
76    }
77
78    #[test]
79    fn generates_expected_slug_for_empty_summary() {
80        let event = Event::new(
81            "",
82            EventTime::Date(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
83        );
84
85        assert_eq!(event.summary_slug(), "untitled");
86    }
87
88    #[test]
89    fn caps_long_summary_slug_to_avoid_filename_too_long() {
90        // A pathologically long title must not produce a filename that exceeds
91        // the filesystem's per-component limit (otherwise: ENAMETOOLONG on write).
92        let event = Event::new(
93            "a ".repeat(200),
94            EventTime::Date(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
95        );
96
97        assert_eq!(event.summary_slug().chars().count(), 50);
98    }
99
100    #[test]
101    fn generates_correct_base_slug_for_all_day_event() {
102        let event = Event::new(
103            "Test Event",
104            EventTime::Date(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
105        );
106
107        assert_eq!(event.base_slug(), "2024-01-01__test-event");
108    }
109
110    #[test]
111    fn generates_correct_base_slug_for_timed_event() {
112        let event = Event::new(
113            "Test Event",
114            EventTime::DateTimeFloating(
115                NaiveDate::from_ymd_opt(2024, 1, 1)
116                    .unwrap()
117                    .and_hms_opt(15, 30, 20)
118                    .unwrap(),
119            ),
120        );
121
122        assert_eq!(event.base_slug(), "2024-01-01T1530__test-event");
123    }
124
125    #[test]
126    fn uses_recurring_prefix_instead_of_date_for_recurring_events() {
127        let mut event = Event::new(
128            "Standup",
129            EventTime::DateTimeFloating(
130                NaiveDate::from_ymd_opt(2024, 1, 1)
131                    .unwrap()
132                    .and_hms_opt(9, 0, 0)
133                    .unwrap(),
134            ),
135        );
136        event.recurrence = Some(crate::event::Recurrence::new("FREQ=DAILY"));
137
138        assert_eq!(event.base_slug(), "_recurring__standup");
139    }
140
141    #[test]
142    fn generates_untitled_base_slug_for_event_without_summary() {
143        let event = Event::new(
144            "",
145            EventTime::Date(NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()),
146        );
147
148        assert_eq!(event.base_slug(), "2024-01-01__untitled");
149    }
150}