~core/calstr

ref: aec29833731ab7766a598800642acef5b719a00f calstr/src/main.rs -rw-r--r-- 9.3 KiB
aec29833 — core fix: incorrect time delta calculation a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
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));
}