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 walkdir::WalkDir; 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)) } } } fn is_imminent(time_delta: TimeDelta) -> bool { time_delta <= TimeDelta::hours(2) } fn is_soon(time_delta: TimeDelta) -> bool { time_delta <= TimeDelta::hours(6) } fn show_distant_event(time: DateTime, summary: &str) -> String { format!("{} at {}", summary, time.format("%H:%M")) } fn format_time_delta(td: TimeDelta) -> String { if td.num_hours() == 0 { format!("{}m", td.num_minutes()) } else { format!("{}h{}m", td.num_hours(), td.num_minutes() % 60) } } fn show_imminent(time_delta: TimeDelta, summary: &str, location: Option) -> String { if let Some(loc) = location { format!("{} in {} in {}", summary, format_time_delta(time_delta), loc) } else { format!("{} in {}", summary, format_time_delta(time_delta)) } } fn parenthesize_location(location: Option) -> String { location.map(|u| format!("({}) ", u)).unwrap_or("".to_string()) } fn is_ending_imminent(time_delta: TimeDelta) -> bool { time_delta <= TimeDelta::minutes(30) } fn show_imminent_ending(time_delta: TimeDelta, summary: &str, location: Option) -> String { format!("{} {}ending in {}", summary, parenthesize_location(location), format_time_delta(time_delta)) } 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 { if let CalendarComponent::Event(event) = component { let Some(start) = event.get_start() else { continue; }; let Some(end) = event.get_end() else { continue; }; let Some(dt) = to_utc(start) else { continue }; let Some(dte) = to_utc(end) else { continue }; if dte > now { let summary = event.get_summary().unwrap_or("(no title)").to_string(); let location = event.get_location().map(|u| u.to_string()); upcoming.push((dt, dte, summary, if with_loc { location } else { None })); } } } } upcoming.sort_by_key(|(dt, _, _, _)| *dt); // write the message // "nothing left today" if next event is past midnight or there are no events if upcoming.is_empty() { println!("nothing left today"); return; } let next = &upcoming[0]; let next_local = next.0.with_timezone(&Local); let now = Utc::now(); let mut midnight = Utc::now().with_time(NaiveTime::from_hms_milli_opt(0, 0, 0, 0).unwrap()).unwrap(); midnight += TimeDelta::days(1); let time_until_next = next.0 - now; if next.0 > midnight && !is_imminent(time_until_next) { print!("nothing left today"); // exception: if event is soon (<6h), include it if is_soon(time_until_next) { print!(", {} tomorrow", show_distant_event(next_local, &next.2)) } println!(); return; } // has event started? if time_until_next < TimeDelta::zero() { let time_until_end = next.1 - now; let end_local = next.1.with_timezone(&Local); // is end imminent if is_ending_imminent(time_until_end) { let followup = upcoming.get(1); if let Some((following_start, _, following, following_loc)) = followup { print!("{}", show_imminent_ending(time_until_end, &next.2, next.3.clone())); let turnaround_time = *following_start - next.1; if !is_soon(turnaround_time) { println!(); return; } if is_imminent(turnaround_time) { println!(", then {}", show_imminent(turnaround_time, following, following_loc.clone())); return; } else { println!(", then {}", show_distant_event(following_start.with_timezone(&Local), following)); return; } } else { println!("{}", show_imminent_ending(time_until_end, &next.2, next.3.clone())); return; } } else { println!("{}", show_distant_ending(end_local, &next.2, next.3.clone())); return; } } // next event is today or imminent (or both) // is it imminent? if is_imminent(time_until_next) { println!("{}", show_imminent(time_until_next, &next.2, next.3.clone())); return; } // today, show distant println!("{}", show_distant_event(next_local, &next.2)); }