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