16.9 KiB · text History 2c2327e
//! Subscribed iCal calendars: fetching them (with a cache, so the motd stays quick),
//! reading their events and expanding repeating ones into a year's dates.

use crate::holidays::{CalendarDef, Details, Holiday};
use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, NaiveDateTime, TimeZone, Utc};
use log::debug;
use rrule::{RRule, RRuleSet, Tz, Unvalidated};
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::SystemTime;

/// How long a fetched calendar is used before it's fetched again
const REFRESH: std::time::Duration = std::time::Duration::from_secs(6 * 60 * 60);
/// Long enough for a slow server, short enough not to hold up a new shell
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);

/// A file path, or a URL to fetch
fn is_remote(source: &str) -> bool {
    let s = source.to_ascii_lowercase();
    s.starts_with("http://") || s.starts_with("https://") || s.starts_with("webcal://")
}

fn local_path(source: &str) -> PathBuf {
    let path = source.strip_prefix("file://").unwrap_or(source);
    match path.strip_prefix("~/") {
        Some(rest) => std::env::var_os("HOME")
            .map(|h| PathBuf::from(h).join(rest))
            .unwrap_or_else(|| PathBuf::from(path)),
        None => PathBuf::from(path),
    }
}

fn cache_path(key: &str) -> Option<PathBuf> {
    xdg::BaseDirectories::with_prefix("pplapp")
        .ok()?
        .place_cache_file(format!("ical/{}.ics", key))
        .ok()
}

fn fetch(url: &str) -> io::Result<String> {
    // webcal:// is http(s) by another name
    let url = match url.get(..9) {
        Some(p) if p.eq_ignore_ascii_case("webcal://") => format!("https://{}", &url[9..]),
        _ => url.to_string(),
    };
    let agent = ureq::Agent::new_with_config(
        ureq::Agent::config_builder()
            .timeout_global(Some(TIMEOUT))
            .build(),
    );
    agent
        .get(&url)
        .call()
        .and_then(|mut r| r.body_mut().with_config().limit(50 << 20).read_to_string())
        .map_err(|e| io::Error::other(e.to_string()))
}

/// Fetch or read a calendar now, and check it is one. Remote ones are saved to the cache.
pub fn sync(key: &str, source: &str) -> io::Result<usize> {
    let text = match is_remote(source) {
        true => fetch(source)?,
        false => fs::read_to_string(local_path(source))?,
    };
    if !text.contains("BEGIN:VCALENDAR") {
        return Err(io::Error::new(
            io::ErrorKind::InvalidData,
            "that doesn't look like an iCal (.ics) calendar",
        ));
    }
    if is_remote(source) {
        if let Some(path) = cache_path(key) {
            fs::write(path, &text)?;
        }
    }
    Ok(parse(&text).len())
}

/// Fetch remote calendars whose cache is missing or older than REFRESH. A failure keeps
/// the old copy, so a laptop offline still sees what it had.
pub fn refresh_stale(subscriptions: &[(String, String)]) {
    for (key, source) in subscriptions.iter().filter(|(_, s)| is_remote(s)) {
        let fresh = cache_path(key)
            .and_then(|p| fs::metadata(p).ok())
            .and_then(|m| m.modified().ok())
            .and_then(|t| SystemTime::now().duration_since(t).ok())
            .is_some_and(|age| age < REFRESH);
        if !fresh {
            if let Err(e) = sync(key, source) {
                debug!("DEBUG: Couldn't refresh calendar '{}': {}", key, e);
                // Keep using the old copy, and don't try again (and wait) until it's stale again
                if let Some(file) =
                    cache_path(key).and_then(|p| fs::File::options().append(true).open(p).ok())
                {
                    let _ = file.set_modified(SystemTime::now());
                }
            }
        }
    }
}

pub fn remove_cache(key: &str) {
    if let Some(path) = cache_path(key) {
        let _ = fs::remove_file(path);
    }
}

fn read(cal: &CalendarDef) -> Option<String> {
    let source = cal.note?;
    match is_remote(source) {
        true => fs::read_to_string(cache_path(cal.key)?).ok(),
        false => fs::read_to_string(local_path(source)).ok(),
    }
}

#[derive(Debug, Default)]
struct Event {
    uid: String,
    summary: String,
    description: Option<String>,
    location: Option<String>,
    url: Option<String>,
    start: Option<DateTime<Tz>>,
    all_day: bool,
    end: Option<DateTime<Tz>>,
    rrule: Option<String>,
    rdates: Vec<DateTime<Tz>>,
    exdates: Vec<DateTime<Tz>>,
    recurrence_id: Option<DateTime<Tz>>,
    cancelled: bool,
}

/// Undo iCal text escaping
fn unescape(v: &str) -> String {
    let mut out = String::new();
    let mut chars = v.chars();
    while let Some(c) = chars.next() {
        match (c, c == '\\') {
            (_, true) => match chars.next() {
                Some('n') | Some('N') => out.push('\n'),
                Some(other) => out.push(other),
                None => {}
            },
            (c, false) => out.push(c),
        }
    }
    out
}

/// A DATE or DATE-TIME value: UTC with a Z, in its TZID, or floating (local time).
/// An unknown TZID, like Outlook's Windows zone names, is read as local time too.
fn parse_dt(value: &str, params: &HashMap<String, String>) -> Option<(DateTime<Tz>, bool)> {
    let value = value.trim();
    let local = |naive: NaiveDateTime| Tz::LOCAL.from_local_datetime(&naive).earliest();
    if value.len() == 8 || params.get("VALUE").is_some_and(|v| v == "DATE") {
        let date = NaiveDate::parse_from_str(value.get(..8)?, "%Y%m%d").ok()?;
        return Some((local(date.and_hms_opt(0, 0, 0)?)?, true));
    }
    let naive = NaiveDateTime::parse_from_str(value.trim_end_matches('Z'), "%Y%m%dT%H%M%S").ok()?;
    if value.ends_with('Z') {
        return Some((Utc.from_utc_datetime(&naive).with_timezone(&Tz::UTC), false));
    }
    let tz = params
        .get("TZID")
        .and_then(|t| chrono_tz::Tz::from_str(t.trim_matches('"')).ok());
    let dt = match tz {
        Some(tz) => Tz::Tz(tz).from_local_datetime(&naive).earliest(),
        None => local(naive),
    };
    Some((dt?, false))
}

/// Every VEVENT in a calendar, with long lines unfolded
fn parse(text: &str) -> Vec<Event> {
    let mut lines: Vec<String> = vec![];
    for line in text.lines() {
        match (
            line.strip_prefix(' ').or(line.strip_prefix('\t')),
            lines.last_mut(),
        ) {
            (Some(rest), Some(prev)) => prev.push_str(rest),
            _ => lines.push(line.to_string()),
        }
    }

    let mut events = vec![];
    let mut current: Option<Event> = None;
    // Alarms and the like sit inside events; only the event's own properties count
    let mut depth = 0;
    for line in lines {
        // The value starts at the first colon outside a quoted parameter
        let mut quoted = false;
        let Some(split) = line.char_indices().find_map(|(i, c)| {
            if c == '"' {
                quoted = !quoted;
            }
            (c == ':' && !quoted).then_some(i)
        }) else {
            continue;
        };
        let (head, value) = (&line[..split], &line[split + 1..]);
        let mut parts = head.split(';');
        let name = parts.next().unwrap_or("").to_ascii_uppercase();
        let params = parts
            .filter_map(|p| p.split_once('='))
            .map(|(k, v)| (k.to_ascii_uppercase(), v.to_string()))
            .collect::<HashMap<_, _>>();

        match (name.as_str(), value.trim()) {
            ("BEGIN", "VEVENT") => {
                current = Some(Event::default());
                depth = 0;
                continue;
            }
            ("END", "VEVENT") => {
                events.extend(current.take());
                continue;
            }
            ("BEGIN", _) if current.is_some() => depth += 1,
            ("END", _) if current.is_some() => depth -= 1,
            _ => {}
        }
        let Some(ev) = current.as_mut().filter(|_| depth == 0) else {
            continue;
        };
        match name.as_str() {
            "UID" => ev.uid = value.to_string(),
            "SUMMARY" => ev.summary = unescape(value),
            "DESCRIPTION" => ev.description = Some(unescape(value)),
            "LOCATION" => ev.location = Some(unescape(value)),
            "URL" => ev.url = Some(value.to_string()),
            "STATUS" => ev.cancelled = value.eq_ignore_ascii_case("CANCELLED"),
            "RRULE" => ev.rrule = Some(value.to_string()),
            "DTSTART" => {
                if let Some((dt, all_day)) = parse_dt(value, &params) {
                    ev.start = Some(dt);
                    ev.all_day = all_day;
                }
            }
            "DTEND" => ev.end = parse_dt(value, &params).map(|(dt, _)| dt),
            "RECURRENCE-ID" => ev.recurrence_id = parse_dt(value, &params).map(|(dt, _)| dt),
            "EXDATE" | "RDATE" => {
                let dates = value
                    .split(',')
                    .filter_map(|v| parse_dt(v, &params).map(|(dt, _)| dt));
                match name.as_str() {
                    "EXDATE" => ev.exdates.extend(dates),
                    _ => ev.rdates.extend(dates),
                }
            }
            _ => {}
        }
    }
    events
}

/// When a repeating event happens between two times
fn expand(
    ev: &Event,
    start: DateTime<Tz>,
    from: DateTime<Tz>,
    to: DateTime<Tz>,
) -> Vec<DateTime<Tz>> {
    let Some(rule) = &ev.rrule else {
        let mut all = vec![start];
        all.extend(ev.rdates.iter().copied());
        return all;
    };
    // rrule is strict about UNTIL's time zone matching DTSTART's, which calendars often
    // aren't, so leave it out and stop at its date ourselves
    let mut until: Option<DateTime<Tz>> = None;
    let rule = rule
        .split(';')
        .filter(|part| match part.split_once('=') {
            Some((k, v)) if k.eq_ignore_ascii_case("UNTIL") => {
                until = until_time(v, start);
                false
            }
            _ => true,
        })
        .collect::<Vec<_>>()
        .join(";");
    let set = rule
        .parse::<RRule<Unvalidated>>()
        .and_then(|r| r.build(start));
    let mut set: RRuleSet = match set {
        Ok(set) => set,
        Err(e) => {
            debug!("DEBUG: Couldn't expand '{}' ({}): {}", ev.summary, rule, e);
            return vec![start];
        }
    };
    for d in &ev.rdates {
        set = set.rdate(*d);
    }
    for d in &ev.exdates {
        set = set.exdate(*d);
    }
    set.after(from)
        .before(to)
        .all(5000)
        .dates
        .into_iter()
        .filter(|d| until.is_none_or(|u| *d <= u))
        .collect()
}

/// UNTIL as a time: UTC with a Z, the end of the day for a date, otherwise in DTSTART's zone
fn until_time(v: &str, start: DateTime<Tz>) -> Option<DateTime<Tz>> {
    let tz = start.timezone();
    if v.len() == 8 {
        let date = NaiveDate::parse_from_str(v, "%Y%m%d").ok()?;
        return tz
            .from_local_datetime(&date.and_hms_opt(23, 59, 59)?)
            .earliest();
    }
    let naive = NaiveDateTime::parse_from_str(v.trim_end_matches('Z'), "%Y%m%dT%H%M%S").ok()?;
    match v.ends_with('Z') {
        true => Some(Utc.from_utc_datetime(&naive).with_timezone(&tz)),
        false => tz.from_local_datetime(&naive).earliest(),
    }
}

/// A subscribed calendar's events in a year, as holidays
pub fn holidays_in(cal: &'static CalendarDef, year: i32) -> Vec<Holiday> {
    let Some(text) = read(cal) else {
        return vec![];
    };
    let (Some(first), Some(last)) = (
        NaiveDate::from_ymd_opt(year, 1, 1).and_then(|d| d.and_hms_opt(0, 0, 0)),
        NaiveDate::from_ymd_opt(year + 1, 1, 1).and_then(|d| d.and_hms_opt(0, 0, 0)),
    ) else {
        return vec![];
    };
    let (Some(from), Some(to)) = (
        Tz::LOCAL
            .from_local_datetime(&(first - Duration::days(1)))
            .earliest(),
        Tz::LOCAL
            .from_local_datetime(&(last + Duration::days(1)))
            .earliest(),
    ) else {
        return vec![];
    };

    let events = parse(&text);
    // Moved or cancelled single occurrences of a repeating event, by UID
    let mut changed: HashMap<&str, Vec<i64>> = HashMap::new();
    for ev in events.iter() {
        if let Some(rid) = ev.recurrence_id {
            changed.entry(&ev.uid).or_default().push(rid.timestamp());
        }
    }

    let mut out = vec![];
    for ev in &events {
        let Some(start) = ev.start else { continue };
        if ev.cancelled {
            continue;
        }
        let length = ev.end.map(|e| e - start);
        for occurrence in expand(ev, start, from, to) {
            if ev.recurrence_id.is_none()
                && changed
                    .get(ev.uid.as_str())
                    .is_some_and(|ts| ts.contains(&occurrence.timestamp()))
            {
                continue;
            }
            // All-day dates are just dates; timed events show in local time
            let local = occurrence.with_timezone(&Local);
            let date = match ev.all_day {
                true => occurrence.naive_local().date(),
                false => local.date_naive(),
            };
            if date.year() != year {
                continue;
            }
            let summary = match ev.summary.trim() {
                "" => "(no title)",
                s => s,
            };
            let name = match (ev.all_day, length) {
                (true, Some(len)) if len.num_days() > 1 => {
                    let through = date + Duration::days(len.num_days() - 1);
                    format!("{} (through {})", summary, through.format("%b %-d"))
                }
                (true, _) => summary.to_string(),
                (false, _) => format!("{} {}", summary, local.format("%H:%M")),
            };
            let text = [ev.description.as_deref(), ev.location.as_deref()]
                .into_iter()
                .flatten()
                .map(str::trim)
                .filter(|t| !t.is_empty())
                .collect::<Vec<_>>()
                .join("\n\n");
            out.push(Holiday {
                date,
                name,
                calendar: cal,
                details: Some(Details {
                    text: match text.is_empty() {
                        true => format!("From your {} calendar.", cal.key),
                        false => text,
                    },
                    url: ev.url.clone(),
                }),
            });
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    const ICS: &str = "BEGIN:VCALENDAR\r
VERSION:2.0\r
X-WR-CALNAME:Test\r
BEGIN:VEVENT\r
UID:standup\r
SUMMARY:Standup\r
DTSTART;TZID=America/New_York:20261005T093000\r
DTEND;TZID=America/New_York:20261005T094500\r
RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20261102T000000Z\r
EXDATE;TZID=America/New_York:20261012T093000\r
BEGIN:VALARM\r
DESCRIPTION:not the event's\r
END:VALARM\r
END:VEVENT\r
BEGIN:VEVENT\r
UID:standup\r
RECURRENCE-ID;TZID=America/New_York:20261019T093000\r
SUMMARY:Standup (moved)\r
DTSTART;TZID=America/New_York:20261020T100000\r
END:VEVENT\r
BEGIN:VEVENT\r
UID:bday\r
SUMMARY:Sam's birthday\r
DESCRIPTION:Bring cake\\, candles\\nand a card\r
DTSTART;VALUE=DATE:19900314\r
RRULE:FREQ=YEARLY\r
END:VEVENT\r
BEGIN:VEVENT\r
UID:trip\r
SUMMARY:Trip to the\r
  coast\r
DTSTART;VALUE=DATE:20260801\r
DTEND;VALUE=DATE:20260805\r
END:VEVENT\r
BEGIN:VEVENT\r
UID:gone\r
SUMMARY:Cancelled thing\r
STATUS:CANCELLED\r
DTSTART:20260601T120000Z\r
END:VEVENT\r
END:VCALENDAR\r
";

    #[test]
    fn events() {
        let dir = std::env::temp_dir().join(format!("ppl-ical-{}", std::process::id()));
        fs::create_dir_all(&dir).unwrap();
        let file = dir.join("test.ics");
        fs::write(&file, ICS).unwrap();
        let cal = crate::holidays::register_subscription("test-ical", file.to_str().unwrap());
        let mut got = holidays_in(cal, 2026)
            .into_iter()
            .map(|h| (h.date.to_string(), h.name, h.details.unwrap().text))
            .collect::<Vec<_>>();
        got.sort();
        let names = got
            .iter()
            .map(|(d, n, _)| format!("{} {}", d, n))
            .collect::<Vec<_>>();
        // Mondays from Oct 5 until midnight UTC on Nov 2 (the evening of Nov 1 in New York),
        // less the 12th, with the 19th moved to the 20th
        assert!(names
            .iter()
            .any(|n| n.starts_with("2026-03-14 Sam's birthday")));
        assert!(names
            .iter()
            .any(|n| n == "2026-08-01 Trip to the coast (through Aug 4)"));
        assert!(names
            .iter()
            .any(|n| n.starts_with("2026-10-20 Standup (moved)")));
        let standups = names.iter().filter(|n| n.contains("Standup")).count();
        assert_eq!(standups, 3, "{:#?}", names);
        assert!(!names.iter().any(|n| n.contains("2026-10-12")));
        assert!(!names.iter().any(|n| n.contains("Cancelled")));
        let cake = got.iter().find(|(_, n, _)| n == "Sam's birthday").unwrap();
        assert_eq!(cake.2, "Bring cake, candles\nand a card");
        fs::remove_dir_all(dir).unwrap();
        crate::holidays::unregister_subscription("test-ical");
    }
}