~core/calstr

3c309e6088e1112b8aeea362704facac99b4d740 — core 3 months ago cad8652
chore: code cleanup
1 files changed, 79 insertions(+), 97 deletions(-)

M src/main.rs
M src/main.rs => src/main.rs +79 -97
@@ 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));
}