use std::env::args; use std::str::FromStr; use chrono::{DateTime, Local, NaiveTime, TimeDelta, TimeZone, Utc}; use chrono_tz::Tz; use icalendar::{Calendar, CalendarComponent, CalendarDateTime, Component, DatePerhapsTime, EventLike}; use rrule::RRuleSet; use walkdir::WalkDir; // convert icalendar's DatePerhapsTime to UTC, treating floating times as local fn to_utc(dpt: DatePerhapsTime) -> Option> { match dpt { DatePerhapsTime::DateTime(cdt) => match cdt { CalendarDateTime::Utc(dt) => Some(dt), CalendarDateTime::Floating(naive) => Local .from_local_datetime(&naive) .earliest() .map(|dt| dt.with_timezone(&Utc)), CalendarDateTime::WithTimezone { date_time, tzid } => { let tz = tzid.parse::().ok()?; tz.from_local_datetime(&date_time) .earliest() .map(|dt| dt.with_timezone(&Utc)) } }, DatePerhapsTime::Date(date) => { let naive = date.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap()); Local .from_local_datetime(&naive) .earliest() .map(|dt| dt.with_timezone(&Utc)) } } } // reconstruct a DTSTART iCalendar line for feeding into the rrule crate fn dpt_to_dtstart_string(dpt: &DatePerhapsTime) -> String { match dpt { DatePerhapsTime::DateTime(cdt) => match cdt { CalendarDateTime::Utc(dt) => format!("DTSTART:{}", dt.format("%Y%m%dT%H%M%SZ")), CalendarDateTime::Floating(naive) => format!("DTSTART:{}", naive.format("%Y%m%dT%H%M%S")), CalendarDateTime::WithTimezone { date_time, tzid } => { format!("DTSTART;TZID={}:{}", tzid, date_time.format("%Y%m%dT%H%M%S")) } }, DatePerhapsTime::Date(date) => format!("DTSTART;VALUE=DATE:{}", date.format("%Y%m%d")), } } fn is_imminent(td: TimeDelta) -> bool { td <= TimeDelta::hours(2) } fn is_soon(td: TimeDelta) -> bool { td <= TimeDelta::hours(6) } fn is_ending_imminent(td: TimeDelta) -> bool { td <= TimeDelta::minutes(30) } fn format_time_delta(td: TimeDelta) -> String { let total_mins = (td.num_seconds() + 59) / 60; let hours = total_mins / 60; let mins = total_mins % 60; if hours == 0 { format!("{}m", mins) } else { format!("{}h{}m", hours, mins) } } fn parenthesize_location(location: Option) -> String { location.map(|u| format!("({}) ", u)).unwrap_or_default() } // "some event at 13:15" fn show_distant_event(time: DateTime, summary: &str) -> String { format!("{} at {}", summary, time.format("%H:%M")) } // "some event in 1h7m" or "some event in 1h7m in Room 204" fn show_imminent(td: TimeDelta, summary: &str, location: Option) -> String { if let Some(loc) = location { format!("{} in {} in {}", summary, format_time_delta(td), loc) } else { format!("{} in {}", summary, format_time_delta(td)) } } // "Board meeting (Room 217) ending in 10m" fn show_imminent_ending(td: TimeDelta, summary: &str, location: Option) -> String { format!("{} {}ending in {}", summary, parenthesize_location(location), format_time_delta(td)) } // "in Board meeting (Room 217) until 13:00" fn show_distant_ending(end: DateTime, summary: &str, location: Option) -> String { format!("in {} {}until {}", summary, parenthesize_location(location), end.format("%H:%M")) } fn main() { let vdir = args().nth(1).expect("usage: calstr [withloc]"); let with_loc = args().nth(2) == Some("withloc".to_string()); let now = Utc::now(); let mut upcoming: Vec<(DateTime, DateTime, String, Option)> = Vec::new(); for entry in WalkDir::new(&vdir).follow_links(true).into_iter().filter_map(|e| e.ok()) { if !entry.file_name().to_string_lossy().ends_with(".ics") { continue; } let content = match std::fs::read_to_string(entry.path()) { Ok(c) => c, Err(_) => continue, }; let cal = match Calendar::from_str(&content) { Ok(c) => c, Err(_) => continue, }; for component in cal.components { let CalendarComponent::Event(event) = component else { continue }; let Some(start) = event.get_start() else { continue }; let Some(end) = event.get_end() else { continue }; let dtstart_str = dpt_to_dtstart_string(&start); let Some(dt) = to_utc(start) else { continue }; let Some(dte) = to_utc(end) else { continue }; let summary = event.get_summary().unwrap_or("(no title)").to_string(); let loc = if with_loc { event.get_location().map(|u| u.to_string()) } else { None }; if let Some(rrule_str) = event.property_value("RRULE") { // recurring event: build rrule input and expand occurrences let mut rrule_input = format!("{}\nRRULE:{}", dtstart_str, rrule_str); // include EXDATE so deleted single occurrences are skipped if let Some(exdate_prop) = event.properties().get("EXDATE") { rrule_input.push_str("\nEXDATE"); for (key, param) in exdate_prop.params() { rrule_input.push_str(&format!(";{}={}", key, param.value())); } rrule_input.push(':'); rrule_input.push_str(exdate_prop.value()); } if let Ok(rrule_set) = rrule_input.parse::() { let duration = dte - dt; // search from (now - duration) to also catch currently-ongoing occurrences let search_from = (now - duration).with_timezone(&rrule::Tz::UTC); for occ in rrule_set.after(search_from).all(5).dates { let occ_start: DateTime = occ.with_timezone(&Utc); let occ_end = occ_start + duration; if occ_end > now { upcoming.push((occ_start, occ_end, summary.clone(), loc.clone())); } } } } else if dte > now { upcoming.push((dt, dte, summary, loc)); } } } upcoming.sort_by_key(|(dt, _, _, _)| *dt); if upcoming.is_empty() { println!("nothing left today"); return; } let next = &upcoming[0]; let next_local = next.0.with_timezone(&Local); let time_until_next = next.0 - now; let midnight = Local::now().with_time(NaiveTime::from_hms_milli_opt(0, 0, 0, 0).unwrap()).unwrap() + TimeDelta::days(1); // nothing left today (with optional peek at tomorrow if soon) if next.0 > midnight && !is_imminent(time_until_next) { print!("nothing left today"); if is_soon(time_until_next) { print!(", {} tomorrow", show_distant_event(next_local, &next.2)); } println!(); return; } // event is ongoing if time_until_next < TimeDelta::zero() { let time_until_end = next.1 - now; let end_local = next.1.with_timezone(&Local); let second = upcoming.get(1); // does the next event overlap with the current one? let overlap = second.filter(|(b_start, _, _, _)| *b_start < next.1); let a_fmt = if is_ending_imminent(time_until_end) { show_imminent_ending(time_until_end, &next.2, next.3.clone()) } else { show_distant_ending(end_local, &next.2, next.3.clone()) }; if let Some((b_start, b_end, b_name, b_loc)) = overlap { if *b_start < now { // both ongoing — "and" let time_until_b_end = *b_end - now; let b_fmt = if is_ending_imminent(time_until_b_end) { show_imminent_ending(time_until_b_end, b_name, b_loc.clone()) } else { show_distant_ending(b_end.with_timezone(&Local), b_name, b_loc.clone()) }; println!("{}, and {}", a_fmt, b_fmt); } else if is_imminent(*b_start - now) { // B starting soon — "with" println!("{}, with {}", a_fmt, show_imminent(*b_start - now, b_name, b_loc.clone())); } else { println!("{}", a_fmt); } return; } // no overlap: show upcoming event only if current is ending imminently if is_ending_imminent(time_until_end) { if let Some((b_start, _, b_name, b_loc)) = second { let turnaround = *b_start - next.1; if is_soon(turnaround) { if is_imminent(turnaround) { println!("{}, then {}", a_fmt, show_imminent(turnaround, b_name, b_loc.clone())); } else { println!("{}, then {}", a_fmt, show_distant_event(b_start.with_timezone(&Local), b_name)); } return; } } } println!("{}", a_fmt); return; } // upcoming event if is_imminent(time_until_next) { println!("{}", show_imminent(time_until_next, &next.2, next.3.clone())); return; } println!("{}", show_distant_event(next_local, &next.2)); }