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<DateTime<Utc>> {
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::<Tz>().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>) -> String {
location.map(|u| format!("({}) ", u)).unwrap_or_default()
}
// "some event at 13:15"
fn show_distant_event(time: DateTime<Local>, 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>) -> 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>) -> 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<Local>, summary: &str, location: Option<String>) -> String {
format!("in {} {}until {}", summary, parenthesize_location(location), end.format("%H:%M"))
}
fn main() {
let vdir = args().nth(1).expect("usage: calstr <vdir> [withloc]");
let with_loc = args().nth(2) == Some("withloc".to_string());
let now = Utc::now();
let mut upcoming: Vec<(DateTime<Utc>, DateTime<Utc>, String, Option<String>)> = 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::<RRuleSet>() {
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<Utc> = 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));
}