bare_address sliced trimmed[..7] after a len() >= 7 check — byte count against a byte slice is fine, but the guard and the slice disagree once the text is lossy-decoded elsewhere; an ATTENDEE line whose value opens with a multibyte run panicked the reminder source on a feed the operator does not control. The slice is get(..7) now, and a test feeds multibyte prefixes through both the ATTENDEE and ORGANIZER paths.
1204 lines
45 KiB
Rust
1204 lines
45 KiB
Rust
//! The ICS adapter: one HTTP GET of an iCalendar feed, expanded.
|
|
//!
|
|
//! The workhorse backend: Google Calendar's "secret address" (the
|
|
//! private iCal export), Nextcloud, Radicale, Fastmail, and most other
|
|
//! servers publish one URL that speaks it. Auth, when a feed demands
|
|
//! it, is the token in `Authorization: Bearer`; the token never lands
|
|
//! in a URL or a log line.
|
|
//!
|
|
//! Wire choices and the constraints they pin:
|
|
//! - Only `VEVENT` components count; `VTODO`/`VJOURNAL`/`VFREEBUSY`
|
|
//! are not reminders. Nested components inside an event (`VALARM`)
|
|
//! are skipped, not mistaken for properties.
|
|
//! - A feed carries master components plus overrides. The adapter
|
|
//! expands a master's `RRULE` client-side and applies
|
|
//! `RECURRENCE-ID` overrides on top: an override replaces the
|
|
//! occurrence at the instant it names (a cancelled one removes it),
|
|
//! and one with no master occurrence stands alone. `EXDATE` rows
|
|
//! drop occurrences the same way.
|
|
//! - The expansion support matrix, deliberately small: `FREQ` daily,
|
|
//! weekly (with `BYDAY`), monthly (with `BYMONTHDAY`, else
|
|
//! `DTSTART`'s day), yearly; `INTERVAL`; `COUNT`; `UNTIL`. Anything
|
|
//! else (`SECONDLY` and finer, `BYSETPOS`, `BYDAY` in a
|
|
//! monthly/yearly rule, `BYMONTH`, `WKST` other than the default
|
|
//! `MO`) degrades to the `DTSTART` occurrence only, so a rule the
|
|
//! adapter cannot expand still surfaces its first instance rather
|
|
//! than silently emptying the calendar.
|
|
//! - Text values are unescaped (`\n`, `\,`, `\;`); property names and
|
|
//! parameters are case-insensitive; continuation lines are unfolded;
|
|
//! quoted parameter values may contain colons.
|
|
//! - A floating time (no `Z`) reads as UTC, and a `TZID` parameter
|
|
//! rides [`CalendarEvent::raw`] for a consumer that can resolve
|
|
//! zones. All-day rows (`VALUE=DATE` or a bare date) start at their
|
|
//! midnight UTC. RFC 3339 spellings are accepted too, because some
|
|
//! exporters emit them.
|
|
//! - `STATUS:CANCELLED` rows are dropped. A row without `UID` or
|
|
//! `DTSTART` is a loud parse error: feeds are machine-generated,
|
|
//! and a shape break must surface, not shrink the calendar.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
use serde_json::{Map, Value};
|
|
use time::Date;
|
|
use time::Month;
|
|
use time::OffsetDateTime;
|
|
use time::Time;
|
|
use time::Weekday;
|
|
use time::format_description::well_known::Rfc3339;
|
|
|
|
use crate::calendar::{Calendar, finish};
|
|
use crate::error::{CalendarError, ensure_success, required};
|
|
use crate::event::{CalendarEvent, Window};
|
|
|
|
/// The user agent the adapter presents; feeds log it, and some reject
|
|
/// requests without one.
|
|
const USER_AGENT: &str = concat!("rhema-calendar-integration/", env!("CARGO_PKG_VERSION"));
|
|
|
|
/// The most occurrence candidates one expansion may feed before the
|
|
/// walk is declared pathological rather than continued. `COUNT` and
|
|
/// the window bound every well-formed rule; this cap bounds the rest.
|
|
const MAX_CANDIDATES: u64 = 100_000;
|
|
|
|
/// ICS feed backend: a URL, an optional bearer token, one reqwest
|
|
/// client. An empty token polls anonymous, which is how most published
|
|
/// feed URLs work (the secret rides the URL itself).
|
|
pub struct IcsCalendar {
|
|
url: String,
|
|
token: String,
|
|
http: reqwest::Client,
|
|
}
|
|
|
|
impl IcsCalendar {
|
|
/// A feed at `url` (an `https://` or `webcal://` address; webcal
|
|
/// is fetched as https).
|
|
pub fn new(url: impl Into<String>) -> Self {
|
|
let http = reqwest::Client::builder()
|
|
// One connect attempt: a dead feed fails fast instead of
|
|
// stalling the poll loop.
|
|
.connect_timeout(Duration::from_secs(10))
|
|
// Every feed call is a bounded request/response, so one
|
|
// overall timeout per request is the right guard.
|
|
.timeout(Duration::from_secs(20))
|
|
.user_agent(USER_AGENT)
|
|
.build()
|
|
.expect("a client builder with only timeouts cannot fail");
|
|
Self {
|
|
url: normalize_url(url.into()),
|
|
token: String::new(),
|
|
http,
|
|
}
|
|
}
|
|
|
|
/// Authenticates feeds that demand it: the token rides the
|
|
/// `Authorization: Bearer` header only.
|
|
pub fn with_token(mut self, token: impl Into<String>) -> Self {
|
|
self.token = token.into();
|
|
self
|
|
}
|
|
}
|
|
|
|
/// `webcal://` is iCalendar over https by convention; normalize it so
|
|
/// the reqwest client never sees a scheme it cannot speak.
|
|
fn normalize_url(url: String) -> String {
|
|
let trimmed = url.trim();
|
|
trimmed
|
|
.strip_prefix("webcal://")
|
|
.map(|rest| format!("https://{rest}"))
|
|
.unwrap_or_else(|| trimmed.to_owned())
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Calendar for IcsCalendar {
|
|
async fn events(&self, window: &Window) -> Result<Vec<CalendarEvent>, CalendarError> {
|
|
let mut request = self.http.get(&self.url);
|
|
if !self.token.is_empty() {
|
|
request = request.bearer_auth(&self.token);
|
|
}
|
|
let response = request.send().await.map_err(CalendarError::network)?;
|
|
let body = ensure_success(response)
|
|
.await?
|
|
.text()
|
|
.await
|
|
.map_err(CalendarError::network)?;
|
|
expand(&body, window)
|
|
}
|
|
}
|
|
|
|
/// One content line: `NAME;PARAM=value:value`, names and parameters
|
|
/// uppercased, the value untouched (unescaping is a text property's
|
|
/// business, not the line's).
|
|
#[derive(Debug, Clone)]
|
|
struct ContentLine {
|
|
name: String,
|
|
params: Vec<(String, String)>,
|
|
value: String,
|
|
}
|
|
|
|
impl ContentLine {
|
|
/// A parameter's value, compared case-insensitively on the name.
|
|
fn param(&self, name: &str) -> Option<&str> {
|
|
self.params
|
|
.iter()
|
|
.find(|(key, _)| key == name)
|
|
.map(|(_, value)| value.as_str())
|
|
}
|
|
}
|
|
|
|
/// A run of ASCII digits at `range`, as its number.
|
|
fn digits(raw: &str, range: std::ops::Range<usize>) -> Option<i64> {
|
|
let slice = raw.get(range)?;
|
|
if slice.is_empty() || !slice.bytes().all(|b| b.is_ascii_digit()) {
|
|
return None;
|
|
}
|
|
slice.parse().ok()
|
|
}
|
|
|
|
/// Splits one unfolded line at the first colon outside double quotes,
|
|
/// so a quoted parameter value (`CN="Smith: Boss"`) cannot split the
|
|
/// line in half.
|
|
fn split_content_line(line: &str) -> Option<ContentLine> {
|
|
let mut quoted = false;
|
|
let mut split_at = None;
|
|
for (index, ch) in line.char_indices() {
|
|
match ch {
|
|
'"' => quoted = !quoted,
|
|
':' if !quoted => {
|
|
split_at = Some(index);
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let split_at = split_at?;
|
|
let head = &line[..split_at];
|
|
let value = line[split_at + 1..].to_owned();
|
|
let mut parts = head.split(';');
|
|
let name = parts.next()?.trim().to_ascii_uppercase();
|
|
if name.is_empty() {
|
|
return None;
|
|
}
|
|
let params = parts
|
|
.filter_map(|part| {
|
|
let (key, value) = part.split_once('=')?;
|
|
Some((
|
|
key.trim().to_ascii_uppercase(),
|
|
value.trim().trim_matches('"').to_owned(),
|
|
))
|
|
})
|
|
.collect();
|
|
Some(ContentLine {
|
|
name,
|
|
params,
|
|
value,
|
|
})
|
|
}
|
|
|
|
/// Unfolds a feed: a line starting with a single space or tab
|
|
/// continues the previous one, per RFC 5545 §3.1.
|
|
fn unfold(raw: &str) -> Vec<String> {
|
|
let mut lines: Vec<String> = Vec::new();
|
|
for raw_line in raw.split('\n') {
|
|
let raw_line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
|
|
if raw_line.starts_with(' ') || raw_line.starts_with('\t') {
|
|
// A continuation with nothing to continue is skipped.
|
|
if let Some(last) = lines.last_mut() {
|
|
last.push_str(&raw_line[1..]);
|
|
}
|
|
} else {
|
|
lines.push(raw_line.to_owned());
|
|
}
|
|
}
|
|
lines
|
|
}
|
|
|
|
/// Walks the component stack and collects each `VEVENT`'s own lines:
|
|
/// nested components (`VALARM`) inside an event contribute nothing.
|
|
fn vevent_blocks(raw: &str) -> Vec<Vec<ContentLine>> {
|
|
let mut blocks = Vec::new();
|
|
let mut current: Option<Vec<ContentLine>> = None;
|
|
let mut stack: Vec<String> = Vec::new();
|
|
for line in unfold(raw) {
|
|
let Some(line) = split_content_line(&line) else {
|
|
continue;
|
|
};
|
|
match line.name.as_str() {
|
|
"BEGIN" => {
|
|
let component = line.value.trim().to_ascii_uppercase();
|
|
let is_event = component == "VEVENT";
|
|
stack.push(component);
|
|
if is_event && current.is_none() {
|
|
current = Some(Vec::new());
|
|
}
|
|
}
|
|
"END" => {
|
|
let component = line.value.trim().to_ascii_uppercase();
|
|
if component == "VEVENT"
|
|
&& let Some(block) = current.take()
|
|
{
|
|
blocks.push(block);
|
|
}
|
|
stack.pop();
|
|
}
|
|
_ => {
|
|
if stack.last().map(String::as_str) == Some("VEVENT")
|
|
&& let Some(block) = current.as_mut()
|
|
{
|
|
block.push(line);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
blocks
|
|
}
|
|
|
|
/// Unescapes a text value: the four RFC 5545 escapes, nothing more.
|
|
fn unescape(value: &str) -> String {
|
|
let mut out = String::with_capacity(value.len());
|
|
let mut chars = value.chars();
|
|
while let Some(ch) = chars.next() {
|
|
if ch != '\\' {
|
|
out.push(ch);
|
|
continue;
|
|
}
|
|
match chars.next() {
|
|
Some('n') | Some('N') => out.push('\n'),
|
|
Some(',') => out.push(','),
|
|
Some(';') => out.push(';'),
|
|
Some('\\') => out.push('\\'),
|
|
// An unknown escape keeps both characters: the feed's
|
|
// bytes, not this parser's guess, survive.
|
|
Some(other) => {
|
|
out.push('\\');
|
|
out.push(other);
|
|
}
|
|
None => out.push('\\'),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Parses iCalendar's compact datetime, `YYYYMMDDTHHMMSS` with an
|
|
/// optional trailing `Z`. The `Z` decides nothing here: both forms
|
|
/// read as UTC (a floating time is the feed saying "UTC or bust" to a
|
|
/// reader without a zone database).
|
|
fn parse_compact(value: &str) -> Option<OffsetDateTime> {
|
|
let bytes = value.as_bytes();
|
|
if bytes.len() != 15 && bytes.len() != 16 {
|
|
return None;
|
|
}
|
|
if bytes[8] != b'T' && bytes[8] != b't' {
|
|
return None;
|
|
}
|
|
if bytes.len() == 16 && !(bytes[15] == b'Z' || bytes[15] == b'z') {
|
|
return None;
|
|
}
|
|
let year = digits(value, 0..4)?;
|
|
let month = digits(value, 4..6)?;
|
|
let day = digits(value, 6..8)?;
|
|
let hour = digits(value, 9..11)?;
|
|
let minute = digits(value, 11..13)?;
|
|
let second = digits(value, 13..15)?;
|
|
let date = Date::from_calendar_date(year as i32, Month::try_from(month as u8).ok()?, day as u8)
|
|
.ok()?;
|
|
let clock = Time::from_hms(hour as u8, minute as u8, second as u8).ok()?;
|
|
Some(date.with_time(clock).assume_utc())
|
|
}
|
|
|
|
/// Parses an iCalendar date, `YYYYMMDD`, as its midnight UTC.
|
|
fn parse_date(value: &str) -> Option<OffsetDateTime> {
|
|
if value.len() != 8 {
|
|
return None;
|
|
}
|
|
let year = digits(value, 0..4)?;
|
|
let month = digits(value, 4..6)?;
|
|
let day = digits(value, 6..8)?;
|
|
let date = Date::from_calendar_date(year as i32, Month::try_from(month as u8).ok()?, day as u8)
|
|
.ok()?;
|
|
Some(date.with_time(Time::MIDNIGHT).assume_utc())
|
|
}
|
|
|
|
/// Parses any `DTSTART`-shape value into an instant plus its all-day
|
|
/// flag. Accepts `VALUE=DATE`, the compact forms, a bare date, and an
|
|
/// RFC 3339 spelling (some exporters emit one).
|
|
fn parse_when(line: &ContentLine) -> Option<(OffsetDateTime, bool)> {
|
|
let value = line.value.trim();
|
|
if line.param("VALUE").map(str::to_ascii_uppercase).as_deref() == Some("DATE") {
|
|
return parse_date(value).map(|start| (start, true));
|
|
}
|
|
if let Some(start) = parse_compact(value) {
|
|
return Some((start, false));
|
|
}
|
|
if let Some(start) = parse_date(value) {
|
|
return Some((start, true));
|
|
}
|
|
OffsetDateTime::parse(value, &Rfc3339)
|
|
.ok()
|
|
.map(|start| (start, false))
|
|
}
|
|
|
|
/// Parses an iCalendar duration, `[+-]P[nW][nD][T[nH][nM][nS]]`.
|
|
/// Negative and unparsable values read as none: a duration is a
|
|
/// nicety on an end-less row, never worth failing a feed over.
|
|
fn parse_duration(value: &str) -> Option<time::Duration> {
|
|
let (negative, rest) = match value.strip_prefix('-') {
|
|
Some(rest) => (true, rest),
|
|
None => (false, value.strip_prefix('+').unwrap_or(value)),
|
|
};
|
|
let rest = rest.strip_prefix('P').or_else(|| rest.strip_prefix('p'))?;
|
|
let mut weeks = 0i64;
|
|
let mut days = 0i64;
|
|
let mut hours = 0i64;
|
|
let mut minutes = 0i64;
|
|
let mut seconds = 0i64;
|
|
let mut in_time = false;
|
|
let mut number = String::new();
|
|
for ch in rest.chars() {
|
|
if ch.is_ascii_digit() {
|
|
number.push(ch);
|
|
continue;
|
|
}
|
|
// The T introducing the time part arrives with no number
|
|
// before it; every unit letter must follow one.
|
|
if !number.is_empty() {
|
|
let Ok(amount) = number.parse::<i64>() else {
|
|
return None;
|
|
};
|
|
number.clear();
|
|
match ch {
|
|
'W' if !in_time => weeks = amount,
|
|
'D' if !in_time => days = amount,
|
|
'H' if in_time => hours = amount,
|
|
'M' if in_time => minutes = amount,
|
|
'S' if in_time => seconds = amount,
|
|
_ => return None,
|
|
}
|
|
} else if matches!(ch, 'T' | 't') && !in_time {
|
|
in_time = true;
|
|
} else {
|
|
return None;
|
|
}
|
|
}
|
|
if !number.is_empty() {
|
|
return None;
|
|
}
|
|
let total = weeks * 604_800 + days * 86_400 + hours * 3600 + minutes * 60 + seconds;
|
|
let duration = time::Duration::seconds(if negative { -total } else { total });
|
|
(duration > time::Duration::ZERO).then_some(duration)
|
|
}
|
|
|
|
/// Which cadence a rule repeats on. `Unsupported` is the honest
|
|
/// spelling for every shape the expansion matrix leaves out.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Freq {
|
|
Daily,
|
|
Weekly,
|
|
Monthly,
|
|
Yearly,
|
|
Unsupported,
|
|
}
|
|
|
|
/// One `RRULE`, reduced to the shapes feeds emit.
|
|
#[derive(Debug, Clone)]
|
|
struct Rrule {
|
|
freq: Freq,
|
|
interval: u32,
|
|
count: Option<u32>,
|
|
until: Option<OffsetDateTime>,
|
|
by_day: Vec<Weekday>,
|
|
by_month_day: Vec<u8>,
|
|
}
|
|
|
|
/// Parses an `RRULE` value. Any part outside the support matrix marks
|
|
/// the whole rule unsupported, and an unparsable piece degrades to its
|
|
/// default rather than failing the feed: an expansion that guesses is
|
|
/// worse than an expansion that declines.
|
|
fn parse_rrule(value: &str) -> Rrule {
|
|
let mut freq = Freq::Unsupported;
|
|
let mut interval = 1u32;
|
|
let mut count = None;
|
|
let mut until = None;
|
|
let mut by_day = Vec::new();
|
|
let mut by_month_day = Vec::new();
|
|
let mut unsupported = false;
|
|
for part in value.trim().split(';') {
|
|
let Some((key, value)) = part.split_once('=') else {
|
|
continue;
|
|
};
|
|
match key.trim().to_ascii_uppercase().as_str() {
|
|
"FREQ" => {
|
|
freq = match value.trim().to_ascii_uppercase().as_str() {
|
|
"DAILY" => Freq::Daily,
|
|
"WEEKLY" => Freq::Weekly,
|
|
"MONTHLY" => Freq::Monthly,
|
|
"YEARLY" => Freq::Yearly,
|
|
_ => Freq::Unsupported,
|
|
};
|
|
}
|
|
"INTERVAL" => {
|
|
interval = value.trim().parse().unwrap_or(1).max(1);
|
|
}
|
|
"COUNT" => count = value.trim().parse().ok(),
|
|
"UNTIL" => {
|
|
let line = ContentLine {
|
|
name: String::new(),
|
|
params: Vec::new(),
|
|
value: value.trim().to_owned(),
|
|
};
|
|
until = parse_when(&line).map(|(when, _)| when);
|
|
}
|
|
"BYDAY" => {
|
|
for item in value.trim().split(',') {
|
|
// An ordinal prefix (`1MO`, `-1FR`) is the
|
|
// nth-weekday-of-month shape the matrix leaves out.
|
|
let bare = item.trim().trim_start_matches(|ch: char| {
|
|
ch.is_ascii_digit() || ch == '-' || ch == '+'
|
|
});
|
|
if bare.len() != item.trim().len() {
|
|
unsupported = true;
|
|
}
|
|
match bare {
|
|
"MO" => by_day.push(Weekday::Monday),
|
|
"TU" => by_day.push(Weekday::Tuesday),
|
|
"WE" => by_day.push(Weekday::Wednesday),
|
|
"TH" => by_day.push(Weekday::Thursday),
|
|
"FR" => by_day.push(Weekday::Friday),
|
|
"SA" => by_day.push(Weekday::Saturday),
|
|
"SU" => by_day.push(Weekday::Sunday),
|
|
_ => unsupported = true,
|
|
}
|
|
}
|
|
}
|
|
"BYMONTHDAY" => {
|
|
for item in value.trim().split(',') {
|
|
let item = item.trim();
|
|
match item.parse::<u8>() {
|
|
Ok(day) if (1..=31).contains(&day) => by_month_day.push(day),
|
|
// Negative ordinals count from the month's end;
|
|
// the matrix leaves them out.
|
|
_ => unsupported = true,
|
|
}
|
|
}
|
|
}
|
|
// BYSETPOS, BYMONTH, BYWEEKNO, BYYEARDAY, WKST, and every
|
|
// unknown part: unsupported, loudly, by the same rule.
|
|
_ => unsupported = true,
|
|
}
|
|
}
|
|
if unsupported {
|
|
freq = Freq::Unsupported;
|
|
}
|
|
Rrule {
|
|
freq,
|
|
interval,
|
|
count,
|
|
until,
|
|
by_day,
|
|
by_month_day,
|
|
}
|
|
}
|
|
|
|
/// One parsed `VEVENT`: the fields the vocabulary lifts, the rule and
|
|
/// override marks the expansion needs, and the template event the
|
|
/// occurrences clone.
|
|
struct Parsed<'a> {
|
|
provider: &'a str,
|
|
uid: String,
|
|
start: OffsetDateTime,
|
|
duration: time::Duration,
|
|
template: CalendarEvent,
|
|
status: Option<String>,
|
|
recurrence_id: Option<OffsetDateTime>,
|
|
exdates: Vec<i64>,
|
|
rrule: Option<Rrule>,
|
|
}
|
|
|
|
/// Parses one `VEVENT` block. A row without `UID` or `DTSTART` fails
|
|
/// the feed loudly; everything else degrades politely.
|
|
fn parse_event<'a>(block: &[ContentLine], provider: &'a str) -> Result<Parsed<'a>, CalendarError> {
|
|
let first = |name: &str| block.iter().find(|line| line.name == name);
|
|
let uid = required(first("UID"), "a UID")?.value.trim().to_owned();
|
|
let dtstart = required(first("DTSTART"), "a DTSTART")?;
|
|
let (start, all_day) = parse_when(dtstart)
|
|
.ok_or_else(|| CalendarError::Parse(format!("event {uid:?} has an unreadable DTSTART")))?;
|
|
|
|
// The end rides DTEND when present, else DURATION, else nothing.
|
|
// A negative span is the feed's typo, not ours to widen: it reads
|
|
// as zero.
|
|
let end = first("DTEND")
|
|
.and_then(parse_when)
|
|
.map(|(when, _)| when)
|
|
.or_else(|| {
|
|
first("DURATION")
|
|
.and_then(|line| parse_duration(&line.value))
|
|
.and_then(|duration| start.checked_add(duration))
|
|
});
|
|
let duration = end
|
|
.map(|end| (end - start).max(time::Duration::ZERO))
|
|
.unwrap_or(time::Duration::ZERO);
|
|
|
|
let text = |name: &str| first(name).map(|line| unescape(&line.value));
|
|
// `mailto:` is the scheme ICS addresses ride; strip it wherever it
|
|
// appears, case-insensitively. `get` over indexing: the prefix can
|
|
// end inside a multi-byte character, and a slice there would panic
|
|
// on a remote feed's value.
|
|
let bare_address = |value: &str| {
|
|
let trimmed = value.trim();
|
|
match trimmed.get(..7) {
|
|
Some(prefix) if prefix.eq_ignore_ascii_case("mailto:") => {
|
|
trimmed[7..].trim().to_owned()
|
|
}
|
|
_ => trimmed.to_owned(),
|
|
}
|
|
};
|
|
|
|
let mut raw = Map::new();
|
|
for line in block {
|
|
let mut key = line.name.clone();
|
|
if !line.params.is_empty() {
|
|
key.push(';');
|
|
key.push_str(
|
|
&line
|
|
.params
|
|
.iter()
|
|
.map(|(key, value)| format!("{key}={value}"))
|
|
.collect::<Vec<_>>()
|
|
.join(";"),
|
|
);
|
|
}
|
|
use serde_json::map::Entry;
|
|
match raw.entry(key) {
|
|
Entry::Vacant(slot) => {
|
|
slot.insert(Value::String(line.value.clone()));
|
|
}
|
|
Entry::Occupied(mut slot) => match slot.get_mut() {
|
|
// A repeated property (ATTENDEE, for one) becomes an
|
|
// array on its second occurrence.
|
|
Value::Array(items) => items.push(Value::String(line.value.clone())),
|
|
existing => {
|
|
let first = existing.take();
|
|
*slot.get_mut() = Value::Array(vec![first, Value::String(line.value.clone())]);
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
let template = CalendarEvent {
|
|
id: format!("{provider}:{uid}@{}", start.unix_timestamp()),
|
|
title: text("SUMMARY").unwrap_or_default(),
|
|
start,
|
|
end,
|
|
all_day,
|
|
location: text("LOCATION"),
|
|
description: text("DESCRIPTION"),
|
|
attendees: block
|
|
.iter()
|
|
.filter(|line| line.name == "ATTENDEE")
|
|
.map(|line| bare_address(&line.value))
|
|
.collect(),
|
|
organizer: first("ORGANIZER").map(|line| bare_address(&line.value)),
|
|
url: text("URL"),
|
|
raw: Value::Object(raw),
|
|
};
|
|
|
|
Ok(Parsed {
|
|
provider,
|
|
uid,
|
|
start,
|
|
duration,
|
|
template,
|
|
status: first("STATUS").map(|line| line.value.trim().to_ascii_uppercase()),
|
|
recurrence_id: first("RECURRENCE-ID")
|
|
.and_then(parse_when)
|
|
.map(|(when, _)| when),
|
|
exdates: first("EXDATE")
|
|
.map(|line| {
|
|
line.value
|
|
.split(',')
|
|
.map(|value| {
|
|
let line = ContentLine {
|
|
name: String::new(),
|
|
params: line.params.clone(),
|
|
value: value.to_owned(),
|
|
};
|
|
parse_when(&line).map(|(when, _)| when.unix_timestamp())
|
|
})
|
|
.collect::<Option<Vec<_>>>()
|
|
.unwrap_or_default()
|
|
})
|
|
.unwrap_or_default(),
|
|
rrule: first("RRULE").map(|line| parse_rrule(&line.value)),
|
|
})
|
|
}
|
|
|
|
/// Expands a whole feed: parse, expand the masters, apply the
|
|
/// overrides, keep what starts in `window`.
|
|
pub(crate) fn expand(raw: &str, window: &Window) -> Result<Vec<CalendarEvent>, CalendarError> {
|
|
expand_as(raw, "ics", window)
|
|
}
|
|
|
|
/// Expands a whole feed, occurrence ids carrying `provider` as their
|
|
/// prefix: the CalDAV adapter re-runs this over the iCalendar objects
|
|
/// a `REPORT` returns, spelling itself `caldav`.
|
|
pub(crate) fn expand_as(
|
|
raw: &str,
|
|
provider: &str,
|
|
window: &Window,
|
|
) -> Result<Vec<CalendarEvent>, CalendarError> {
|
|
let mut singles = Vec::new();
|
|
let mut masters = Vec::new();
|
|
let mut overrides: HashMap<i64, Parsed> = HashMap::new();
|
|
// A cancelled override is the tombstone of one occurrence: its
|
|
// slot suppresses the master occurrence, and nothing answers for
|
|
// it. A cancelled master takes its whole rule with it.
|
|
let mut cancelled_slots: HashSet<i64> = HashSet::new();
|
|
for block in vevent_blocks(raw) {
|
|
let parsed = parse_event(&block, provider)?;
|
|
if parsed.status.as_deref() == Some("CANCELLED") {
|
|
if let Some(recurrence_id) = parsed.recurrence_id {
|
|
cancelled_slots.insert(recurrence_id.unix_timestamp());
|
|
}
|
|
continue;
|
|
}
|
|
match parsed.recurrence_id {
|
|
Some(recurrence_id) => {
|
|
overrides.insert(recurrence_id.unix_timestamp(), parsed);
|
|
}
|
|
None if parsed.rrule.is_some() => masters.push(parsed),
|
|
None => singles.push(parsed),
|
|
}
|
|
}
|
|
|
|
let mut suppress_all: HashSet<i64> = overrides.keys().copied().collect();
|
|
suppress_all.extend(cancelled_slots);
|
|
let mut events = Vec::new();
|
|
for master in &masters {
|
|
let mut suppress = suppress_all.clone();
|
|
suppress.extend(master.exdates.iter().copied());
|
|
expand_rule(master, window, &suppress, &mut events);
|
|
}
|
|
// Every override answers with its own occurrence, moved start
|
|
// included; the master occurrence it names is the one dropped.
|
|
// One whose master never generated its slot stands alone, which is
|
|
// the only reading a broken feed leaves open.
|
|
for parsed in overrides.into_values() {
|
|
events.push(parsed.template);
|
|
}
|
|
for single in singles {
|
|
events.push(single.template);
|
|
}
|
|
Ok(finish(events, window))
|
|
}
|
|
|
|
/// One occurrence of a master: the template with the occurrence's own
|
|
/// start, end, and id.
|
|
fn occurrence(parsed: &Parsed, start: OffsetDateTime) -> CalendarEvent {
|
|
let mut event = parsed.template.clone();
|
|
event.id = format!(
|
|
"{}:{}@{}",
|
|
parsed.provider,
|
|
parsed.uid,
|
|
start.unix_timestamp()
|
|
);
|
|
event.start = start;
|
|
event.end = start.checked_add(parsed.duration);
|
|
event
|
|
}
|
|
|
|
/// Whole days since the epoch, UTC: the ruler every interval is
|
|
/// counted against.
|
|
fn day_number(date: Date) -> i64 {
|
|
date.with_time(Time::MIDNIGHT)
|
|
.assume_utc()
|
|
.unix_timestamp()
|
|
.div_euclid(86_400)
|
|
}
|
|
|
|
/// Whole weeks since the epoch's Monday (1970-01-05), Monday-based:
|
|
/// the invariant weekly `INTERVAL`s are counted against.
|
|
fn week_number(date: Date) -> i64 {
|
|
(day_number(date) - 4).div_euclid(7)
|
|
}
|
|
|
|
/// `year`/`month` shifted by `months`, out-of-range days handled by
|
|
/// the caller (a February 30th is a month without that day, not an
|
|
/// error).
|
|
fn add_months(year: i32, month: Month, months: i64) -> (i32, Month) {
|
|
let total = year as i64 * 12 + i64::from(u8::from(month)) - 1 + months;
|
|
(
|
|
total.div_euclid(12) as i32,
|
|
Month::try_from((total.rem_euclid(12) + 1) as u8).expect("month is 1..=12"),
|
|
)
|
|
}
|
|
|
|
/// Expands one master's rule into `events`, candidates in
|
|
/// chronological order. A candidate an override or `EXDATE` claims is
|
|
/// walked (so `COUNT` stays true to the feed) but not emitted: the
|
|
/// claimant answers for it instead.
|
|
fn expand_rule(
|
|
parsed: &Parsed,
|
|
window: &Window,
|
|
suppress: &HashSet<i64>,
|
|
events: &mut Vec<CalendarEvent>,
|
|
) {
|
|
let Some(rule) = parsed.rrule.as_ref() else {
|
|
return;
|
|
};
|
|
let mut count_seen = 0u64;
|
|
let mut walked = 0u64;
|
|
let limit = rule.count.map(u64::from).unwrap_or(u64::MAX);
|
|
|
|
// Feeds one candidate; false when the walk must stop. Candidates
|
|
// arrive in chronological order, so the first one past `until` or
|
|
// the window's end ends it, and `COUNT` counts occurrences, not
|
|
// walk steps. The walk cap is the pathological-feed guard: every
|
|
// well-formed rule is already bounded by `COUNT`, `until`, or the
|
|
// window itself.
|
|
let mut candidate = |start: OffsetDateTime| -> bool {
|
|
walked += 1;
|
|
if walked > MAX_CANDIDATES {
|
|
return false;
|
|
}
|
|
if let Some(until) = rule.until
|
|
&& start > until
|
|
{
|
|
return false;
|
|
}
|
|
if start > window.to {
|
|
return false;
|
|
}
|
|
if count_seen >= limit {
|
|
return false;
|
|
}
|
|
count_seen += 1;
|
|
if !suppress.contains(&start.unix_timestamp()) && window.starts_within(start) {
|
|
events.push(occurrence(parsed, start));
|
|
}
|
|
count_seen < limit
|
|
};
|
|
|
|
match rule.freq {
|
|
Freq::Unsupported => {
|
|
// The honest degradation: the first instance only.
|
|
if window.starts_within(parsed.start)
|
|
&& !suppress.contains(&parsed.start.unix_timestamp())
|
|
{
|
|
events.push(occurrence(parsed, parsed.start));
|
|
}
|
|
}
|
|
Freq::Daily | Freq::Weekly => {
|
|
let start_week = week_number(parsed.start.date());
|
|
let start_day = day_number(parsed.start.date());
|
|
let mut day = parsed.start.date();
|
|
loop {
|
|
let on_rule = match rule.freq {
|
|
Freq::Weekly => {
|
|
let weekday_ok =
|
|
rule.by_day.is_empty() || rule.by_day.contains(&day.weekday());
|
|
let week_ok =
|
|
(week_number(day) - start_week) % i64::from(rule.interval) == 0;
|
|
weekday_ok && week_ok
|
|
}
|
|
_ => (day_number(day) - start_day) % i64::from(rule.interval) == 0,
|
|
};
|
|
if !on_rule {
|
|
let Some(next) = day.next_day() else {
|
|
break;
|
|
};
|
|
day = next;
|
|
if day > window.to.date() {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
let start = day.with_time(parsed.start.time()).assume_utc();
|
|
if !candidate(start) {
|
|
break;
|
|
}
|
|
let Some(next) = day.next_day() else {
|
|
break;
|
|
};
|
|
day = next;
|
|
if day > window.to.date() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
Freq::Monthly => {
|
|
let days = if rule.by_month_day.is_empty() {
|
|
vec![parsed.start.day()]
|
|
} else {
|
|
let mut days = rule.by_month_day.clone();
|
|
days.sort_unstable();
|
|
days
|
|
};
|
|
let mut month = 0i64;
|
|
loop {
|
|
let (year, this_month) = add_months(
|
|
parsed.start.year(),
|
|
parsed.start.month(),
|
|
month * i64::from(rule.interval),
|
|
);
|
|
let Ok(first) = Date::from_calendar_date(year, this_month, 1) else {
|
|
break;
|
|
};
|
|
if first.with_time(Time::MIDNIGHT).assume_utc() > window.to {
|
|
break;
|
|
}
|
|
month += 1;
|
|
for day in &days {
|
|
if let Ok(date) = Date::from_calendar_date(year, this_month, *day) {
|
|
let start = date.with_time(parsed.start.time()).assume_utc();
|
|
if !candidate(start) {
|
|
return;
|
|
}
|
|
}
|
|
// A month without that day (a 31st in February)
|
|
// contributes no occurrence, by the RFC's own rule.
|
|
}
|
|
}
|
|
}
|
|
Freq::Yearly => {
|
|
let mut year = 0i64;
|
|
loop {
|
|
let this_year = parsed.start.year() + (year * i64::from(rule.interval)) as i32;
|
|
year += 1;
|
|
let Ok(january) = Date::from_calendar_date(this_year, Month::January, 1) else {
|
|
break;
|
|
};
|
|
if january.with_time(Time::MIDNIGHT).assume_utc() > window.to {
|
|
break;
|
|
}
|
|
// A year without that day (a February 29th) is
|
|
// skipped, not clamped.
|
|
if let Ok(date) =
|
|
Date::from_calendar_date(this_year, parsed.start.month(), parsed.start.day())
|
|
{
|
|
let start = date.with_time(parsed.start.time()).assume_utc();
|
|
if !candidate(start) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn window(start: &str, hours: i64) -> Window {
|
|
let from = OffsetDateTime::parse(start, &Rfc3339).expect("window start parses");
|
|
Window::new(from, from + time::Duration::hours(hours))
|
|
}
|
|
|
|
fn ids(events: &[CalendarEvent]) -> Vec<String> {
|
|
events.iter().map(|event| event.id.clone()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn webcal_urls_normalize_to_https() {
|
|
assert_eq!(
|
|
normalize_url("webcal://example.net/cal.ics".to_owned()),
|
|
"https://example.net/cal.ics"
|
|
);
|
|
assert_eq!(
|
|
normalize_url(" https://example.net/cal.ics ".to_owned()),
|
|
"https://example.net/cal.ics"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unfolding_joins_continuation_lines() {
|
|
let raw = "SUMMARY:a long\r\n title\r\nLOCATION:paris\n , france\nEND:VEVENT";
|
|
assert_eq!(
|
|
unfold(raw),
|
|
vec![
|
|
"SUMMARY:a longtitle".to_owned(),
|
|
"LOCATION:paris, france".to_owned(),
|
|
"END:VEVENT".to_owned(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_quoted_parameter_may_carry_a_colon() {
|
|
let line =
|
|
split_content_line(r#"ATTENDEE;CN="Smith: Boss";RSVP=TRUE:mailto:bob@example.net"#)
|
|
.expect("parses");
|
|
assert_eq!(line.name, "ATTENDEE");
|
|
assert_eq!(line.param("CN"), Some(r#"Smith: Boss"#));
|
|
assert_eq!(line.value, "mailto:bob@example.net");
|
|
}
|
|
|
|
#[test]
|
|
fn compact_datetimes_and_dates_read_as_utc() {
|
|
let zulu = parse_compact("20261020T140000Z").expect("zulu parses");
|
|
let floating = parse_compact("20261020T140000").expect("floating parses");
|
|
assert_eq!(zulu, floating);
|
|
let date = parse_date("20261020").expect("date parses");
|
|
assert_eq!(date, zulu.replace_time(Time::MIDNIGHT));
|
|
let rfc = parse_when(&ContentLine {
|
|
name: "DTSTART".into(),
|
|
params: Vec::new(),
|
|
value: "2026-10-20T14:00:00Z".into(),
|
|
})
|
|
.expect("rfc 3339 parses");
|
|
assert_eq!(rfc.0, zulu);
|
|
}
|
|
|
|
#[test]
|
|
fn durations_parse_in_every_unit() {
|
|
assert_eq!(parse_duration("PT1H30M"), Some(time::Duration::minutes(90)));
|
|
assert_eq!(parse_duration("P1D"), Some(time::Duration::days(1)));
|
|
assert_eq!(parse_duration("P2W"), Some(time::Duration::days(14)));
|
|
assert_eq!(parse_duration("PT15M"), Some(time::Duration::minutes(15)));
|
|
assert_eq!(parse_duration("-PT15M"), None, "negative reads as none");
|
|
assert_eq!(parse_duration("garbage"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_single_event_expands_to_itself() {
|
|
let raw = "BEGIN:VEVENT\nUID:a@x\nDTSTART:20261020T140000Z\nDTEND:20261020T150000Z\n\
|
|
SUMMARY:one-off\nEND:VEVENT\n";
|
|
let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands");
|
|
let at = OffsetDateTime::parse("2026-10-20T14:00:00Z", &Rfc3339).expect("parses");
|
|
assert_eq!(
|
|
ids(&events),
|
|
vec![format!("ics:a@x@{}", at.unix_timestamp())]
|
|
);
|
|
assert_eq!(events[0].title, "one-off");
|
|
assert_eq!(events[0].duration(), time::Duration::hours(1));
|
|
}
|
|
|
|
#[test]
|
|
fn text_values_unescape_and_addresses_strip_mailto() {
|
|
let raw = "BEGIN:VEVENT\nUID:a@x\nDTSTART:20261020T140000Z\n\
|
|
SUMMARY:quarterly\\, review\\n Agenda: TBA\n\
|
|
ORGANIZER;CN=Boss:MAILTO:boss@example.net\n\
|
|
ATTENDEE:mailto:alice@example.net\nATTENDEE:bob@example.net\n\
|
|
END:VEVENT\n";
|
|
let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands");
|
|
assert_eq!(events[0].title, "quarterly, review\n Agenda: TBA");
|
|
assert_eq!(events[0].organizer.as_deref(), Some("boss@example.net"));
|
|
assert_eq!(
|
|
events[0].attendees,
|
|
vec!["alice@example.net".to_owned(), "bob@example.net".to_owned()]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_multibyte_address_prefix_parses_without_panicking() {
|
|
// The first seven bytes of `üüüüüüüü:x` end inside a
|
|
// character, so a plain seven-byte slice would panic on a
|
|
// remote feed's deterministic value.
|
|
let raw = "BEGIN:VEVENT\nUID:a@x\nDTSTART:20261020T140000Z\n\
|
|
ORGANIZER:üüüüüüüü:x\n\
|
|
ATTENDEE:üüüüüüüü:x\n\
|
|
ATTENDEE:üüüx:alice@example.net\n\
|
|
ATTENDEE:mailto:alice@example.net\n\
|
|
ATTENDEE:MAILTO:Bob@Example.net\n\
|
|
END:VEVENT\n";
|
|
let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands");
|
|
assert_eq!(
|
|
events[0].organizer.as_deref(),
|
|
Some("üüüüüüüü:x"),
|
|
"no mailto: prefix reads verbatim"
|
|
);
|
|
assert_eq!(
|
|
events[0].attendees,
|
|
vec![
|
|
"üüüüüüüü:x".to_owned(),
|
|
"üüüx:alice@example.net".to_owned(),
|
|
"alice@example.net".to_owned(),
|
|
"Bob@Example.net".to_owned(),
|
|
],
|
|
"the mailto: strip is case-insensitive; anything else is kept whole"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_weekly_byday_rule_expands_across_the_window() {
|
|
let raw = "BEGIN:VEVENT\nUID:standup@x\nDTSTART:20261005T090000Z\nDTEND:20261005T093000Z\n\
|
|
RRULE:FREQ=WEEKLY;BYDAY=MO,WE\nEND:VEVENT\n";
|
|
let events = expand(raw, &window("2026-10-19T00:00:00Z", 24 * 7)).expect("expands");
|
|
// The window is Mon Oct 19 .. Sun Oct 25: the rule lands on
|
|
// Mon 19, Wed 21, Mon 26 is past `to`.
|
|
assert_eq!(ids(&events).len(), 2);
|
|
assert_eq!(
|
|
events[0].start,
|
|
OffsetDateTime::parse("2026-10-19T09:00:00Z", &Rfc3339).expect("parses")
|
|
);
|
|
assert_eq!(
|
|
events[1].start,
|
|
OffsetDateTime::parse("2026-10-21T09:00:00Z", &Rfc3339).expect("parses")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_biweekly_rule_counts_weeks_from_dtstart() {
|
|
// DTSTART is Mon Oct 5; interval 2 puts Oct 19 in, Oct 12 out.
|
|
let raw = "BEGIN:VEVENT\nUID:fortnight@x\nDTSTART:20261005T100000Z\n\
|
|
RRULE:FREQ=WEEKLY;INTERVAL=2\nEND:VEVENT\n";
|
|
let events = expand(raw, &window("2026-10-12T00:00:00Z", 24 * 9)).expect("expands");
|
|
let starts: Vec<String> = events
|
|
.iter()
|
|
.map(|event| event.start.format(&Rfc3339).expect("formats"))
|
|
.collect();
|
|
assert!(
|
|
starts
|
|
.iter()
|
|
.any(|start| start.contains("20261019") || start.contains("10-19"))
|
|
);
|
|
assert!(
|
|
!starts
|
|
.iter()
|
|
.any(|start| start.contains("10-12") || start.contains("20261012"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn count_bounds_the_expansion_even_past_the_window() {
|
|
let raw = "BEGIN:VEVENT\nUID:thrice@x\nDTSTART:20261001T080000Z\n\
|
|
RRULE:FREQ=DAILY;COUNT=3\nEND:VEVENT\n";
|
|
// The three occurrences are Oct 1..3; a mid-October window
|
|
// sees none of them.
|
|
let events = expand(raw, &window("2026-10-15T00:00:00Z", 24)).expect("expands");
|
|
assert!(events.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_monthly_rule_skips_months_without_the_day() {
|
|
let raw = "BEGIN:VEVENT\nUID:payday@x\nDTSTART:20260131T120000Z\n\
|
|
RRULE:FREQ=MONTHLY\nEND:VEVENT\n";
|
|
// February has no 31st; the walk lands on Mar 31.
|
|
let events = expand(raw, &window("2026-03-31T00:00:00Z", 24)).expect("expands");
|
|
assert_eq!(ids(&events).len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn until_stops_the_rule() {
|
|
let raw = "BEGIN:VEVENT\nUID:course@x\nDTSTART:20261001T180000Z\n\
|
|
RRULE:FREQ=DAILY;UNTIL=20261003T180000Z\nEND:VEVENT\n";
|
|
let events = expand(raw, &window("2026-10-02T00:00:00Z", 24 * 7)).expect("expands");
|
|
assert_eq!(ids(&events).len(), 2, "Oct 2 and Oct 3, never Oct 4");
|
|
}
|
|
|
|
#[test]
|
|
fn an_unsupported_rule_degrades_to_dtstart() {
|
|
let raw = "BEGIN:VEVENT\nUID:odd@x\nDTSTART:20261001T080000Z\n\
|
|
RRULE:FREQ=MONTHLY;BYDAY=1MO\nEND:VEVENT\n";
|
|
let events = expand(raw, &window("2026-10-01T00:00:00Z", 24)).expect("expands");
|
|
assert_eq!(ids(&events).len(), 1);
|
|
assert_eq!(events[0].start.day(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn an_override_replaces_its_occurrence_and_a_cancelled_one_removes_it() {
|
|
// Daily at 09:00; an override moves Oct 2's to 15:00, a
|
|
// cancelled one removes Oct 3's.
|
|
let raw = "BEGIN:VEVENT\nUID:week@x\nDTSTART:20261001T090000Z\n\
|
|
RRULE:FREQ=DAILY;COUNT=5\nEND:VEVENT\n\
|
|
BEGIN:VEVENT\nUID:week@x\nRECURRENCE-ID:20261002T090000Z\n\
|
|
DTSTART:20261002T150000Z\nSUMMARY:moved\nEND:VEVENT\n\
|
|
BEGIN:VEVENT\nUID:week@x\nRECURRENCE-ID:20261003T090000Z\n\
|
|
DTSTART:20261003T090000Z\nSTATUS:CANCELLED\nEND:VEVENT\n";
|
|
let events = expand(raw, &window("2026-10-01T00:00:00Z", 24 * 7)).expect("expands");
|
|
let starts: Vec<i64> = events
|
|
.iter()
|
|
.map(|event| event.start.unix_timestamp())
|
|
.collect();
|
|
assert_eq!(starts.len(), 4, "5 occurrences, one moved, one cancelled");
|
|
assert!(
|
|
!starts.contains(
|
|
&OffsetDateTime::parse("2026-10-02T09:00:00Z", &Rfc3339)
|
|
.expect("parses")
|
|
.unix_timestamp()
|
|
)
|
|
);
|
|
assert!(
|
|
starts.contains(
|
|
&OffsetDateTime::parse("2026-10-02T15:00:00Z", &Rfc3339)
|
|
.expect("parses")
|
|
.unix_timestamp()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_override_moving_an_occurrence_into_the_window_does_not_double() {
|
|
let raw = "BEGIN:VEVENT\nUID:move@x\nDTSTART:20261001T090000Z\n\
|
|
RRULE:FREQ=DAILY;COUNT=3\nEND:VEVENT\n\
|
|
BEGIN:VEVENT\nUID:move@x\nRECURRENCE-ID:20261001T090000Z\n\
|
|
DTSTART:20261020T150000Z\nEND:VEVENT\n";
|
|
// The window sees only the moved instance; the Oct 1 original
|
|
// it replaced is outside and must not resurrect.
|
|
let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands");
|
|
let moved = OffsetDateTime::parse("2026-10-20T15:00:00Z", &Rfc3339).expect("parses");
|
|
assert_eq!(
|
|
ids(&events),
|
|
vec![format!("ics:move@x@{}", moved.unix_timestamp())]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn exdate_drops_occurrences() {
|
|
let raw = "BEGIN:VEVENT\nUID:skip@x\nDTSTART:20261001T090000Z\n\
|
|
RRULE:FREQ=DAILY;COUNT=5\nEXDATE:20261002T090000Z\nEND:VEVENT\n";
|
|
let events = expand(raw, &window("2026-10-01T00:00:00Z", 24 * 7)).expect("expands");
|
|
assert_eq!(events.len(), 4);
|
|
}
|
|
|
|
#[test]
|
|
fn cancelled_masters_and_gtodos_are_ignored() {
|
|
let raw = "BEGIN:VTODO\nUID:task@x\nDTSTART:20261020T140000Z\nEND:VTODO\n\
|
|
BEGIN:VEVENT\nUID:gone@x\nDTSTART:20261020T140000Z\nSTATUS:CANCELLED\nEND:VEVENT\n";
|
|
let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands");
|
|
assert!(events.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn an_event_inside_a_valarm_is_not_mistaken_for_a_property() {
|
|
let raw = "BEGIN:VEVENT\nUID:alarm@x\nDTSTART:20261020T140000Z\nSUMMARY:real\n\
|
|
BEGIN:VALARM\nUID:fake@x\nDTSTART:20261020T134500Z\nSUMMARY:alarm\nEND:VALARM\n\
|
|
END:VEVENT\n";
|
|
let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands");
|
|
assert_eq!(events.len(), 1);
|
|
assert_eq!(events[0].title, "real");
|
|
}
|
|
|
|
#[test]
|
|
fn a_row_without_uid_or_dtstart_fails_the_feed_loudly() {
|
|
let no_uid = "BEGIN:VEVENT\nDTSTART:20261020T140000Z\nEND:VEVENT\n";
|
|
let err = expand(no_uid, &window("2026-10-20T00:00:00Z", 24)).expect_err("loud");
|
|
assert!(err.to_string().contains("UID"), "got: {err}");
|
|
|
|
let no_start = "BEGIN:VEVENT\nUID:a@x\nSUMMARY:when?\nEND:VEVENT\n";
|
|
let err = expand(no_start, &window("2026-10-20T00:00:00Z", 24)).expect_err("loud");
|
|
assert!(err.to_string().contains("DTSTART"), "got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn an_all_day_row_starts_at_midnight_and_says_so() {
|
|
let raw = "BEGIN:VEVENT\nUID:day@x\nDTSTART;VALUE=DATE:20261020\nSUMMARY:offsite\n\
|
|
DTEND;VALUE=DATE:20261021\nEND:VEVENT\n";
|
|
let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands");
|
|
assert_eq!(events.len(), 1);
|
|
assert!(events[0].all_day);
|
|
assert_eq!(events[0].duration(), time::Duration::days(1));
|
|
assert!(events[0].start.to_string().contains("00:00"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_tzid_parameter_rides_the_raw_row() {
|
|
let raw = "BEGIN:VEVENT\nUID:tz@x\nDTSTART;TZID=Europe/Paris:20261020T140000\nEND:VEVENT\n";
|
|
let events = expand(raw, &window("2026-10-20T00:00:00Z", 24)).expect("expands");
|
|
assert_eq!(
|
|
events[0].raw.get("DTSTART;TZID=Europe/Paris"),
|
|
Some(&Value::String("20261020T140000".to_owned()))
|
|
);
|
|
}
|
|
}
|