@@ 1,11 1,13 @@
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 {
@@ 31,13 33,12 @@ fn to_utc(dpt: DatePerhapsTime) -> Option<DateTime<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::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"))
}
@@ 46,16 47,9 @@ fn dpt_to_dtstart_string(dpt: &DatePerhapsTime) -> String {
}
}
-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<Local>, summary: &str) -> String {
- format!("{} at {}", summary, time.format("%H:%M"))
-}
+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 {
if td.num_hours() == 0 {
@@ 65,41 59,42 @@ fn format_time_delta(td: TimeDelta) -> String {
}
}
-fn show_imminent(time_delta: TimeDelta, summary: &str, location: Option<String>) -> String {
+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(time_delta), loc)
+ format!("{} in {} in {}", summary, format_time_delta(td), loc)
} else {
- format!("{} in {}", summary, format_time_delta(time_delta))
+ format!("{} in {}", summary, format_time_delta(td))
}
}
-fn parenthesize_location(location: Option<String>) -> String {
- location.map(|u| format!("({}) ", u)).unwrap_or("".to_string())
+// "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))
}
-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>) -> String {
- format!("{} {}ending in {}", summary, parenthesize_location(location), format_time_delta(time_delta))
-}
+// "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())
- {
+ 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;
}
@@ 112,87 107,77 @@ fn main() {
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 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 location = event.get_location().map(|u| u.to_string());
- let loc = if with_loc { location } else { None };
-
- if let Some(rrule_str) = event.property_value("RRULE") {
- let mut rrule_input = format!("{}\nRRULE:{}", dtstart_str, rrule_str);
- 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());
+ 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()));
}
- if let Ok(rrule_set) = rrule_input.parse::<RRuleSet>() {
- let duration = dte - dt;
- let search_from = (now - duration).with_timezone(&rrule::Tz::UTC);
- let result = rrule_set.after(search_from).all(5);
- for occ in result.dates {
- let occ_utc: DateTime<Utc> = occ.with_timezone(&Utc);
- let occ_end = occ_utc + duration;
- if occ_end > now {
- upcoming.push((occ_utc, occ_end, summary.clone(), loc.clone()));
- }
+ 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));
- }
}
+ } else if dte > now {
+ upcoming.push((dt, dte, summary, loc));
}
}
}
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 = Local::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;
+ 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");
- // exception: if event is soon (<6h), include it
if is_soon(time_until_next) {
- print!(", {} tomorrow", show_distant_event(next_local, &next.2))
+ print!(", {} tomorrow", show_distant_event(next_local, &next.2));
}
println!();
return;
}
- // has event started?
+ // 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) {
@@ 202,8 187,8 @@ fn main() {
};
if let Some((b_start, b_end, b_name, b_loc)) = overlap {
- let time_until_b = *b_start - now;
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())
@@ 211,40 196,37 @@ fn main() {
show_distant_ending(b_end.with_timezone(&Local), b_name, b_loc.clone())
};
println!("{}, and {}", a_fmt, b_fmt);
- } else if is_imminent(time_until_b) {
- println!("{}, with {}", a_fmt, show_imminent(time_until_b, b_name, b_loc.clone()));
+ } 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_time = *b_start - next.1;
- if !is_soon(turnaround_time) {
- println!("{}", a_fmt);
+ 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;
}
- if is_imminent(turnaround_time) {
- println!("{}, then {}", a_fmt, show_imminent(turnaround_time, 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;
}
- // next event is today or imminent (or both)
- // is it imminent?
+ // upcoming event
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));
}