//! Feeds, export and import splitting. use pimdav::bundle::{self, Detail}; use pimdav::object; const 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"; fn uids() -> impl FnMut(&str) -> String { let mut n = 0; move |_| { n += 1; format!("new-{n}") } } #[test] fn feed_merges_objects_and_deduplicates_zones() { let a = format!( "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" ); // LF endings in storage become CRLF. let b = format!( "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", BERLIN.replace("\r\n", "\n") ); let full = bundle::calendar(&[&a, &b], Some("Work; home"), Detail::All); assert!(full.starts_with("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n")); assert!(full.contains(r"X-WR-CALNAME:Work\; home")); assert!(full.contains(r"NAME:Work\; home")); let long = bundle::calendar(&[&a], Some(&"x".repeat(200)), Detail::All); assert!(long.lines().all(|l| l.len() <= 75), "{long}"); assert!(full.contains("REFRESH-INTERVAL;VALUE=DURATION:PT1H\r\nX-PUBLISHED-TTL:PT1H\r\n")); assert_eq!(full.matches("BEGIN:VTIMEZONE").count(), 1); assert!(full.contains("SUMMARY:Long\r\n folded\r\n")); assert!(full.contains("BEGIN:VTODO\r\nUID:c\r\n")); assert!(!full.replace("\r\n", "").contains('\n')); assert!(full.ends_with("END:VCALENDAR\r\n")); let busy = bundle::calendar(&[&a, &b], None, Detail::Busy); assert_eq!(busy.matches("SUMMARY:Busy").count(), 2); assert!(!busy.contains("Long")); assert!(!busy.contains("VALARM")); assert!(!busy.contains("ATTENDEE")); assert!(!busy.contains("VTODO")); assert!(busy.contains("DTSTART;TZID=Europe/Berlin:20240102T100000\r\n")); assert!(busy.contains("BEGIN:VTIMEZONE")); } fn object(components: &str) -> String { format!("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n{components}END:VCALENDAR\r\n") } #[test] fn busy_time_hides_what_blocks_nothing() { let series = object( "BEGIN:VEVENT\r\nUID:series@corp.example\r\nDTSTART:20240101T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:series@corp.example\r\nRECURRENCE-ID;RANGE=THISANDFUTURE:20240102T100000Z\r\nDTSTART:20240102T100000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:series@corp.example\r\nRECURRENCE-ID:20240103T100000Z\r\nDTSTART:20240103T120000Z\r\nEND:VEVENT\r\n", ); let free = object( "BEGIN:VEVENT\r\nUID:free\r\nDTSTART:20240105T100000Z\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\n", ); let busy = bundle::calendar(&[&series, &free], None, Detail::Busy); // The master and the moved instance stay; the cancelled one is excluded. assert_eq!(busy.matches("BEGIN:VEVENT").count(), 2, "{busy}"); assert!(busy.contains("EXDATE:20240102T100000Z\r\n"), "{busy}"); assert!(!busy.contains("RANGE")); assert!(!busy.contains("corp.example") && !busy.contains("UID:free")); let uids: Vec<&str> = busy.lines().filter(|l| l.starts_with("UID:")).collect(); assert_eq!(uids.len(), 2); assert_eq!(uids[0], uids[1], "master and override keep one UID"); } #[test] fn public_feeds_reduce_private_objects() { let open = object( "BEGIN:VEVENT\r\nUID:open\r\nDTSTART:20240101T100000Z\r\nSUMMARY:Standup\r\nEND:VEVENT\r\n", ); let private = object( "BEGIN:VEVENT\r\nUID:doc\r\nDTSTART:20240101T120000Z\r\nRRULE:FREQ=WEEKLY\r\nSUMMARY:Doctor\r\nCLASS:PRIVATE\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:doc\r\nRECURRENCE-ID:20240108T120000Z\r\nDTSTART:20240108T130000Z\r\nSUMMARY:Doctor\r\nEND:VEVENT\r\n", ); let task = object( "BEGIN:VTODO\r\nUID:t\r\nSUMMARY:Secret task\r\nCLASS:CONFIDENTIAL\r\nEND:VTODO\r\n", ); let public = bundle::calendar(&[&open, &private, &task], None, Detail::Public); assert!(public.contains("SUMMARY:Standup")); assert!( !public.contains("Doctor") && !public.contains("Secret task"), "{public}" ); // The whole object is reduced, so its override still matches its master. assert_eq!(public.matches("SUMMARY:Busy").count(), 2); let all = bundle::calendar(&[&open, &private, &task], None, Detail::All); assert!(all.contains("Doctor") && all.contains("Secret task")); } #[test] fn import_splits_by_uid_with_overrides_and_zones() { let file = format!( "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\ BEGIN:VEVENT\r\nUID:m\r\nDTSTART;TZID=Europe/Berlin:20240101T100000\r\nRRULE:FREQ=DAILY\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:plain\r\nDTSTART:20240105T100000Z\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nUID:m\r\nRECURRENCE-ID;TZID=Europe/Berlin:20240102T100000\r\nDTSTART;TZID=Europe/Berlin:20240102T120000\r\nEND:VEVENT\r\n\ BEGIN:VEVENT\r\nDTSTART:20240106T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" ); let parts = bundle::split_calendar(&file, &mut uids()); assert_eq!(parts.len(), 3); let master = &parts[0]; assert_eq!(master.matches("BEGIN:VEVENT").count(), 2); assert!(master.contains("RECURRENCE-ID")); assert!(master.contains("TZID:Europe/Berlin")); assert!(!master.contains("Unused")); assert!(!master.contains("METHOD")); assert!(!master.contains("X-WR-CALNAME")); assert!(master.contains("PRODID:src\r\n")); assert!(!parts[1].contains("VTIMEZONE")); assert!(parts[2].contains("BEGIN:VEVENT\r\nUID:new-1\r\n")); for p in &parts { assert!(object::calendar(p.as_bytes(), &["VEVENT"]).is_ok(), "{p}"); } // With a UID from the content, as the server makes it, a file holding // everything twice gives the same objects. let mut by_content = |t: &str| format!("len-{}", t.len()); let once = bundle::split_calendar(&file, &mut by_content); let twice = bundle::split_calendar(&format!("{file}{file}"), &mut by_content); assert_eq!(twice, once); } #[test] fn escaped_tzid_finds_its_zone() { let file = "BEGIN:VCALENDAR\r\nBEGIN:VTIMEZONE\r\nTZID:(UTC+02:00) Athens\\, Bucharest\r\nEND:VTIMEZONE\r\n\ BEGIN:VEVENT\r\nUID:e\r\nDTSTART;TZID=\"(UTC+02:00) Athens, Bucharest\":20130412T150000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; let parts = bundle::split_calendar(file, &mut uids()); assert!(parts[0].contains("BEGIN:VTIMEZONE"), "{}", parts[0]); assert_eq!( bundle::calendar(&[&parts[0]], None, Detail::All) .matches("BEGIN:VTIMEZONE") .count(), 1 ); } #[test] fn import_splits_cards() { let file = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:one\r\nFN:One\r\nEND:VCARD\r\n\ BEGIN:VCARD\nVERSION:4.0\nFN:Two\nEND:VCARD\n"; let cards = bundle::split_cards(file, &mut uids()); assert_eq!(cards.len(), 2); assert_eq!( cards[0], "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:one\r\nFN:One\r\nEND:VCARD\r\n" ); // Before END: vCard 4.0 wants VERSION right after BEGIN. assert_eq!( cards[1], "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Two\r\nUID:new-1\r\nEND:VCARD\r\n" ); for c in &cards { assert!(object::vcard(c.as_bytes()).unwrap().is_some()); } assert_eq!(bundle::cards(&[&cards[0], &cards[1]]), cards.concat()); } #[test] fn quoted_tzid_keeps_its_zone() { 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"; let file = format!( "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" ); let objects = bundle::split_calendar(&file, &mut uids()); assert_eq!(objects.len(), 1); assert!(objects[0].contains("TZID:Work\\; late: shift")); } #[test] fn calendar_names_itself() { 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"; assert_eq!( bundle::calendar_meta(ics), (Some("Ferien, Bayern".into()), Some("#FF2968FF".into())) ); // RFC 7986 wins over the X- names, and a component's own NAME does not count. let ics = ics.replace( "VERSION:2.0\r\n", "VERSION:2.0\r\nNAME:Holidays\r\nCOLOR:teal\r\n", ); assert_eq!( bundle::calendar_meta(&ics), (Some("Holidays".into()), Some("teal".into())) ); let bare = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:a\r\nNAME:not me\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; assert_eq!(bundle::calendar_meta(bare), (None, None)); }