//! 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, ¶ms) {
ev.start = Some(dt);
ev.all_day = all_day;
}
}
"DTEND" => ev.end = parse_dt(value, ¶ms).map(|(dt, _)| dt),
"RECURRENCE-ID" => ev.recurrence_id = parse_dt(value, ¶ms).map(|(dt, _)| dt),
"EXDATE" | "RDATE" => {
let dates = value
.split(',')
.filter_map(|v| parse_dt(v, ¶ms).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")),
};
// Where it is first: it's short, and descriptions can run long
let location = ev
.location
.as_deref()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| format!("📍 {}", l));
let text = [
location.as_deref(),
ev.description.as_deref().map(str::trim),
]
.into_iter()
.flatten()
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join("\n\n");
out.push(Holiday {
date,
name,
calendar: cal,
details: Some(Details {
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 trip = got.iter().find(|(_, n, _)| n.starts_with("Trip")).unwrap();
assert_eq!(trip.2, "");
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");
}
}
⭐
Log in to mark this repository.
17.1 KiB · text
History
d31eadb