bundle.rs
⎇
Raw
1//! Feeds, export and import splitting.
2
3use pimdav::bundle::{self, Detail};
4use pimdav::object;
5
6const BERLIN: &str = "BEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nBEGIN:STANDARD\r\nDTSTART:19701025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n";
7
8fn uids() -> impl FnMut(&str) -> String {
9 let mut n = 0;
10 move |_| {
11 n += 1;
12 format!("new-{n}")
13 }
14}
15
16#[test]
17fn feed_merges_objects_and_deduplicates_zones() {
18 let a = format!(
19 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:x\r\n{BERLIN}BEGIN:VEVENT\r\nUID:a\r\nDTSTART;TZID=Europe/Berlin:20240101T100000\r\nSUMMARY:Long\r\n folded\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT5M\r\nEND:VALARM\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
20 );
21 // LF endings in storage become CRLF.
22 let b = format!(
23 "BEGIN:VCALENDAR\nVERSION:2.0\n{}BEGIN:VEVENT\nUID:b\nDTSTART;TZID=Europe/Berlin:20240102T100000\nATTENDEE:mailto:x@y\nEND:VEVENT\nBEGIN:VTODO\nUID:c\nEND:VTODO\nEND:VCALENDAR\n",
24 BERLIN.replace("\r\n", "\n")
25 );
26 let full = bundle::calendar(&[&a, &b], Some("Work; home"), Detail::All);
27 assert!(full.starts_with("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"));
28 assert!(full.contains(r"X-WR-CALNAME:Work\; home"));
29 assert!(full.contains(r"NAME:Work\; home"));
30 let long = bundle::calendar(&[&a], Some(&"x".repeat(200)), Detail::All);
31 assert!(long.lines().all(|l| l.len() <= 75), "{long}");
32 assert!(full.contains("REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n"));
33 assert_eq!(full.matches("BEGIN:VTIMEZONE").count(), 1);
34 assert!(full.contains("SUMMARY:Long\r\n folded\r\n"));
35 assert!(full.contains("BEGIN:VTODO\r\nUID:c\r\n"));
36 assert!(!full.replace("\r\n", "").contains('\n'));
37 assert!(full.ends_with("END:VCALENDAR\r\n"));
38
39 let busy = bundle::calendar(&[&a, &b], None, Detail::Busy);
40 assert_eq!(busy.matches("SUMMARY:Busy").count(), 2);
41 assert!(!busy.contains("Long"));
42 assert!(!busy.contains("VALARM"));
43 assert!(!busy.contains("ATTENDEE"));
44 assert!(!busy.contains("VTODO"));
45 assert!(busy.contains("DTSTART;TZID=Europe/Berlin:20240102T100000\r\n"));
46 assert!(busy.contains("BEGIN:VTIMEZONE"));
47}
48
49fn object(components: &str) -> String {
50 format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{components}END:VCALENDAR\r\n")
51}
52
53#[test]
54fn busy_time_hides_what_blocks_nothing() {
55 let series = object(
56 "BEGIN:VEVENT\r\nUID:series@corp.example\r\nDTSTART:20240101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\n\
57 BEGIN:VEVENT\r\nUID:series@corp.example\r\nRECURRENCE-ID;RANGE=THISANDFUTURE:20240102T100000Z\r\nDTSTART:20240102T100000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n\
58 BEGIN:VEVENT\r\nUID:series@corp.example\r\nRECURRENCE-ID:20240103T100000Z\r\nDTSTART:20240103T120000Z\r\nEND:VEVENT\r\n",
59 );
60 let free = object(
61 "BEGIN:VEVENT\r\nUID:free\r\nDTSTART:20240105T100000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n",
62 );
63 let busy = bundle::calendar(&[&series, &free], None, Detail::Busy);
64 // The master and the moved instance stay; the cancelled one is excluded.
65 assert_eq!(busy.matches("BEGIN:VEVENT").count(), 2, "{busy}");
66 assert!(busy.contains("EXDATE:20240102T100000Z\r\n"), "{busy}");
67 assert!(!busy.contains("RANGE"));
68 assert!(!busy.contains("corp.example") && !busy.contains("UID:free"));
69 let uids: Vec<&str> = busy.lines().filter(|l| l.starts_with("UID:")).collect();
70 assert_eq!(uids.len(), 2);
71 assert_eq!(uids[0], uids[1], "master and override keep one UID");
72}
73
74#[test]
75fn public_feeds_reduce_private_objects() {
76 let open = object(
77 "BEGIN:VEVENT\r\nUID:open\r\nDTSTART:20240101T100000Z\r\nSUMMARY:Standup\r\nEND:VEVENT\r\n",
78 );
79 let private = object(
80 "BEGIN:VEVENT\r\nUID:doc\r\nDTSTART:20240101T120000Z\r\nRRULE:FREQ=WEEKLY\r\nSUMMARY:Doctor\r\nCLASS:PRIVATE\r\nEND:VEVENT\r\n\
81 BEGIN:VEVENT\r\nUID:doc\r\nRECURRENCE-ID:20240108T120000Z\r\nDTSTART:20240108T130000Z\r\nSUMMARY:Doctor\r\nEND:VEVENT\r\n",
82 );
83 let task = object(
84 "BEGIN:VTODO\r\nUID:t\r\nSUMMARY:Secret task\r\nCLASS:CONFIDENTIAL\r\nEND:VTODO\r\n",
85 );
86 let public = bundle::calendar(&[&open, &private, &task], None, Detail::Public);
87 assert!(public.contains("SUMMARY:Standup"));
88 assert!(
89 !public.contains("Doctor") && !public.contains("Secret task"),
90 "{public}"
91 );
92 // The whole object is reduced, so its override still matches its master.
93 assert_eq!(public.matches("SUMMARY:Busy").count(), 2);
94 let all = bundle::calendar(&[&open, &private, &task], None, Detail::All);
95 assert!(all.contains("Doctor") && all.contains("Secret task"));
96}
97
98#[test]
99fn import_splits_by_uid_with_overrides_and_zones() {
100 let file = format!(
101 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:src\r\nMETHOD:PUBLISH\r\nX-WR-CALNAME:Old\r\n{BERLIN}BEGIN:VTIMEZONE\r\nTZID:Unused\r\nEND:VTIMEZONE\r\n\
102 BEGIN:VEVENT\r\nUID:m\r\nDTSTART;TZID=Europe/Berlin:20240101T100000\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n\
103 BEGIN:VEVENT\r\nUID:plain\r\nDTSTART:20240105T100000Z\r\nEND:VEVENT\r\n\
104 BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID;TZID=Europe/Berlin:20240102T100000\r\nDTSTART;TZID=Europe/Berlin:20240102T120000\r\nEND:VEVENT\r\n\
105 BEGIN:VEVENT\r\nDTSTART:20240106T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
106 );
107 let parts = bundle::split_calendar(&file, &mut uids());
108 assert_eq!(parts.len(), 3);
109 let master = &parts[0];
110 assert_eq!(master.matches("BEGIN:VEVENT").count(), 2);
111 assert!(master.contains("RECURRENCE-ID"));
112 assert!(master.contains("TZID:Europe/Berlin"));
113 assert!(!master.contains("Unused"));
114 assert!(!master.contains("METHOD"));
115 assert!(!master.contains("X-WR-CALNAME"));
116 assert!(master.contains("PRODID:src\r\n"));
117 assert!(!parts[1].contains("VTIMEZONE"));
118 assert!(parts[2].contains("BEGIN:VEVENT\r\nUID:new-1\r\n"));
119 for p in &parts {
120 assert!(object::calendar(p.as_bytes(), &["VEVENT"]).is_ok(), "{p}");
121 }
122 // With a UID from the content, as the server makes it, a file holding
123 // everything twice gives the same objects.
124 let mut by_content = |t: &str| format!("len-{}", t.len());
125 let once = bundle::split_calendar(&file, &mut by_content);
126 let twice = bundle::split_calendar(&format!("{file}{file}"), &mut by_content);
127 assert_eq!(twice, once);
128}
129
130#[test]
131fn escaped_tzid_finds_its_zone() {
132 let file = "BEGIN:VCALENDAR\r\nBEGIN:VTIMEZONE\r\nTZID:(UTC+02:00) Athens\\, Bucharest\r\nEND:VTIMEZONE\r\n\
133 BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=\"(UTC+02:00) Athens, Bucharest\":20130412T150000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
134 let parts = bundle::split_calendar(file, &mut uids());
135 assert!(parts[0].contains("BEGIN:VTIMEZONE"), "{}", parts[0]);
136 assert_eq!(
137 bundle::calendar(&[&parts[0]], None, Detail::All)
138 .matches("BEGIN:VTIMEZONE")
139 .count(),
140 1
141 );
142}
143
144#[test]
145fn import_splits_cards() {
146 let file = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:one\r\nFN:One\r\nEND:VCARD\r\n\
147 BEGIN:VCARD\nVERSION:4.0\nFN:Two\nEND:VCARD\n";
148 let cards = bundle::split_cards(file, &mut uids());
149 assert_eq!(cards.len(), 2);
150 assert_eq!(
151 cards[0],
152 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:one\r\nFN:One\r\nEND:VCARD\r\n"
153 );
154 // Before END: vCard 4.0 wants VERSION right after BEGIN.
155 assert_eq!(
156 cards[1],
157 "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Two\r\nUID:new-1\r\nEND:VCARD\r\n"
158 );
159 for c in &cards {
160 assert!(object::vcard(c.as_bytes()).unwrap().is_some());
161 }
162 assert_eq!(bundle::cards(&[&cards[0], &cards[1]]), cards.concat());
163}
164
165#[test]
166fn quoted_tzid_keeps_its_zone() {
167 let zone = "BEGIN:VTIMEZONE\r\nTZID:Work\\; late: shift\r\nBEGIN:STANDARD\r\nDTSTART:19700101T000000\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0100\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n";
168 let file = format!(
169 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{zone}BEGIN:VEVENT\r\nUID:a\r\nDTSTART;TZID=\"Work; late: shift\":20240101T100000\r\nRECURRENCE-ID;TZID=\"Work; late: shift\":20240101T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
170 );
171 let objects = bundle::split_calendar(&file, &mut uids());
172 assert_eq!(objects.len(), 1);
173 assert!(objects[0].contains("TZID:Work\\; late: shift"));
174}
175
176#[test]
177fn calendar_names_itself() {
178 let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nX-WR-CALNAME:Ferien\\, Bayern\r\nX-APPLE-CALENDAR-COLOR:#FF2968FF\r\nBEGIN:VEVENT\r\nUID:a\r\nSUMMARY:x\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
179 assert_eq!(
180 bundle::calendar_meta(ics),
181 (Some("Ferien, Bayern".into()), Some("#FF2968FF".into()))
182 );
183 // RFC 7986 wins over the X- names, and a component's own NAME does not count.
184 let ics = ics.replace(
185 "VERSION:2.0\r\n",
186 "VERSION:2.0\r\nNAME:Holidays\r\nCOLOR:teal\r\n",
187 );
188 assert_eq!(
189 bundle::calendar_meta(&ics),
190 (Some("Holidays".into()), Some("teal".into()))
191 );
192 let bare = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:a\r\nNAME:not me\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
193 assert_eq!(bundle::calendar_meta(bare), (None, None));
194}
195
196#[test]
197fn byte_order_mark_is_ignored() {
198 let file = format!(
199 "\u{feff}{}",
200 object("BEGIN:VEVENT\r\nUID:a\r\nEND:VEVENT\r\n")
201 );
202 assert_eq!(bundle::split_calendar(&file, &mut uids()).len(), 1);
203 assert_eq!(
204 bundle::calendar_meta("\u{feff}BEGIN:VCALENDAR\r\nNAME:N\r\nEND:VCALENDAR\r\n")
205 .0
206 .as_deref(),
207 Some("N")
208 );
209 let card = "\u{feff}BEGIN:VCARD\r\nVERSION:3.0\r\nUID:c\r\nFN:C\r\nEND:VCARD\r\n";
210 assert_eq!(bundle::split_cards(card, &mut uids()).len(), 1);
211 // A stored object keeps its mark; feeds still show it.
212 assert!(bundle::calendar(&[&file], None, Detail::All).contains("UID:a"));
213}
214
215#[test]
216fn shared_uid_across_types_splits_and_empty_uid_is_missing() {
217 let file = object(
218 "BEGIN:VEVENT\r\nUID:x\r\nDTSTART:20240101T100000Z\r\nEND:VEVENT\r\n\
219 BEGIN:VTODO\r\nUID:x\r\nSUMMARY:t\r\nEND:VTODO\r\n\
220 BEGIN:VTODO\r\nUID:x\r\nRECURRENCE-ID:20240102T100000Z\r\nEND:VTODO\r\n\
221 BEGIN:VEVENT\r\nUID:\r\nDTSTART:20240103T100000Z\r\nEND:VEVENT\r\n",
222 );
223 let parts = bundle::split_calendar(&file, &mut uids());
224 assert_eq!(parts.len(), 3, "{parts:#?}");
225 assert!(parts[0].contains("BEGIN:VEVENT\r\nUID:x\r\n"));
226 assert_eq!(parts[1].matches("UID:new-1\r\n").count(), 2, "{}", parts[1]);
227 assert!(!parts[1].contains("UID:x"));
228 assert!(parts[2].contains("UID:new-2\r\n"));
229 assert!(!parts[2].contains("UID:\r\n"));
230 for p in &parts {
231 assert!(
232 object::calendar(p.as_bytes(), &["VEVENT", "VTODO"]).is_ok(),
233 "{p}"
234 );
235 }
236 let cards = bundle::split_cards(
237 "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:\r\nFN:C\r\nEND:VCARD\r\n",
238 &mut uids(),
239 );
240 assert_eq!(
241 cards[0],
242 "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:C\r\nUID:new-1\r\nEND:VCARD\r\n"
243 );
244}
245