15d7f5e873
- notescraft-lint (rules, safe fixes, learned style) and notescraft-blocks (block model and edits); engine lint.* and block.* commands with CLI and MCP parity - Lint tab, underlines, slash menu with preview in Source and Visual, block grip and menu, Settings > Editor and lint, Vim block keys - Visual view: tables, note embeds, %% comments, wrapping property chips - Vault knowledge (Related tab, assistant sources, Settings) and calendar plugin - Sidebar top padding fixed, dot grid follows PaperCraft spacing, right-card tabs no longer cover the collapse button Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
99 lines
5.0 KiB
Rust
99 lines
5.0 KiB
Rust
//! The iCalendar reader: real-looking feeds and hostile ones.
|
|
#![allow(clippy::unwrap_used)]
|
|
|
|
use notescraft_calendar::{MAX_ICS_BYTES, Priority, Status, parse_ics};
|
|
|
|
const FEED: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//y//EN\r\n\
|
|
BEGIN:VTIMEZONE\r\nTZID:Europe/Lisbon\r\nBEGIN:STANDARD\r\nDTSTART:19701025T020000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n\
|
|
BEGIN:VEVENT\r\nUID:a@x\r\nDTSTART;TZID=Europe/Lisbon:20261019T093000\r\nDTEND;TZID=Europe/Lisbon:20261019T094500\r\n\
|
|
SUMMARY:Stand\\, up\\; daily\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20261231T000000Z\r\nEXDATE;TZID=Europe/Lisbon:20261021T093000\r\n\
|
|
CATEGORIES:team,Q4\r\nPRIORITY:1\r\nSTATUS:TENTATIVE\r\nURL:https://example.com/call\r\n\
|
|
BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nBEGIN:VALARM\r\nTRIGGER:-P1DT2H\r\nEND:VALARM\r\nEND:VEVENT\r\n\
|
|
BEGIN:VEVENT\r\nUID:b@x\r\nDTSTART;VALUE=DATE:20261224\r\nDTEND;VALUE=DATE:20261227\r\nSUMMARY:Holiday\r\n\
|
|
DESCRIPTION:folded\r\n text\r\nEND:VEVENT\r\n\
|
|
BEGIN:VEVENT\r\nUID:c@x\r\nDTSTART:20261001T080000Z\r\nSUMMARY:Monthly\r\nRRULE:FREQ=MONTHLY;BYMONTHDAY=-1\r\nEND:VEVENT\r\n\
|
|
BEGIN:VEVENT\r\nUID:d@x\r\nDTSTART;VALUE=DATE:20261010\r\nDTEND;VALUE=DATE:20261011\r\nSUMMARY:One day\r\nEND:VEVENT\r\n\
|
|
BEGIN:VEVENT\r\nUID:bad@x\r\nDTSTART:2026-10-10\r\nSUMMARY:Broken\r\nEND:VEVENT\r\n\
|
|
BEGIN:VEVENT\r\nUID:none@x\r\nSUMMARY:No start\r\nEND:VEVENT\r\n\
|
|
END:VCALENDAR\r\n";
|
|
|
|
#[test]
|
|
fn a_feed_imports() {
|
|
let import = parse_ics(FEED).unwrap();
|
|
assert_eq!(import.events.len(), 4);
|
|
assert_eq!(import.skipped.len(), 2, "{:?}", import.skipped);
|
|
assert!(import.skipped.iter().all(|s| s.contains("bad@x") || s.contains("none@x")));
|
|
|
|
let a = &import.events[0];
|
|
assert_eq!(a.uid.as_deref(), Some("a@x"));
|
|
let e = &a.event;
|
|
assert_eq!(e.title, "Stand, up; daily");
|
|
assert_eq!((e.start.to_string().as_str(), e.end.unwrap().to_string().as_str()), ("2026-10-19T09:30", "2026-10-19T09:45"));
|
|
assert_eq!(e.tz.as_deref(), Some("Europe/Lisbon"));
|
|
assert_eq!(e.repeat.as_deref(), Some("FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=2026-12-31"));
|
|
assert_eq!(e.exceptions[0].to_string(), "2026-10-21T09:30");
|
|
assert_eq!((e.tags.as_slice(), e.priority, e.status), (["team".to_owned(), "Q4".to_owned()].as_slice(), Some(Priority::High), Some(Status::Tentative)));
|
|
assert_eq!(e.reminders.iter().map(|r| r.minutes_before).collect::<Vec<_>>(), [15, 1560]);
|
|
assert_eq!(e.link.as_deref(), Some("https://example.com/call"));
|
|
assert!(a.unsupported_rule.is_none());
|
|
|
|
let holiday = &import.events[1].event;
|
|
assert_eq!((holiday.start.to_string().as_str(), holiday.end.unwrap().to_string().as_str()), ("2026-12-24", "2026-12-26"));
|
|
|
|
let monthly = &import.events[2];
|
|
assert_eq!(monthly.unsupported_rule.as_deref(), Some("FREQ=MONTHLY;BYMONTHDAY=-1"));
|
|
assert!(monthly.event.repeat.is_none());
|
|
assert_eq!(monthly.event.tz.as_deref(), Some("UTC"));
|
|
|
|
assert!(import.events[3].event.end.is_none(), "a one-day all-day event has no end");
|
|
}
|
|
|
|
#[test]
|
|
fn lf_only_and_folded_lines_work() {
|
|
let text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20261001T0900\n 00\nSUMMARY:Long\n title\nEND:VEVENT\nEND:VCALENDAR";
|
|
let import = parse_ics(text).unwrap();
|
|
assert_eq!(import.events[0].event.title, "Long title");
|
|
assert_eq!(import.events[0].event.start.to_string(), "2026-10-01T09:00");
|
|
}
|
|
|
|
#[test]
|
|
fn whole_file_problems_are_errors() {
|
|
for text in ["", "hello", "BEGIN:VEVENT\nEND:VEVENT", " \n"] {
|
|
assert!(parse_ics(text).is_err(), "{text:?}");
|
|
}
|
|
assert!(parse_ics(&format!("BEGIN:VCALENDAR\nX-BIG:{}\nEND:VCALENDAR", "a".repeat(MAX_ICS_BYTES))).is_err(), "too large");
|
|
assert!(parse_ics(&format!("BEGIN:VCALENDAR\nX-LONG:{}\nEND:VCALENDAR", "a".repeat(9000))).is_err(), "line too long");
|
|
let deep = format!("BEGIN:VCALENDAR\n{}{}END:VCALENDAR", "BEGIN:X\n".repeat(20), "END:X\n".repeat(20));
|
|
assert!(parse_ics(&deep).is_err(), "too deep");
|
|
let many = format!("BEGIN:VCALENDAR\n{}END:VCALENDAR", "BEGIN:VEVENT\nDTSTART:20261001\nEND:VEVENT\n".repeat(10_001));
|
|
assert!(parse_ics(&many).is_err(), "too many events");
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_properties_never_panic() {
|
|
let junk = [
|
|
"DTSTART:",
|
|
"DTSTART:ABC",
|
|
"DTSTART:99999999",
|
|
"DTSTART:20261301",
|
|
"DTSTART:20261001T256000",
|
|
"DTSTART;VALUE=DATE:20261001T090000",
|
|
"DTSTART:20261001\nDTEND:19990101",
|
|
"DTSTART:20261001\nRRULE:FREQ=DAILY;COUNT=99999999999999",
|
|
"DTSTART:20261001\nRRULE:;;;=",
|
|
"DTSTART:20261001\nRRULE:FREQ=WEEKLY;BYDAY=1MO",
|
|
"DTSTART:20261001\nEXDATE:zzz",
|
|
"DTSTART:20261001\nBEGIN:VALARM\nTRIGGER:-P99999999999D\nEND:VALARM",
|
|
"DTSTART:20261001\nBEGIN:VALARM\nTRIGGER:-PT\nEND:VALARM",
|
|
"DTSTART:20261001\nSUMMARY:\\",
|
|
"DTSTART:20261001\nPRIORITY:999",
|
|
":",
|
|
";;;:::",
|
|
"\u{0}\u{1}",
|
|
];
|
|
for body in junk {
|
|
let text = format!("BEGIN:VCALENDAR\nBEGIN:VEVENT\n{body}\nEND:VEVENT\nEND:VCALENDAR");
|
|
let _ = parse_ics(&text);
|
|
}
|
|
}
|