From 56abab7fb498cfe24e83797a126cf7fca91be078 Mon Sep 17 00:00:00 2001 From: Matthew Ryan Dillon Date: Sat, 10 May 2025 20:45:15 -0400 Subject: [PATCH] roughing in calendar --- Cargo.lock | 49 ++++++++++++- Cargo.toml | 2 + src/calendar.rs | 186 ++++++++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 1 + src/main.rs | 46 +++++++----- 5 files changed, 266 insertions(+), 18 deletions(-) create mode 100644 src/calendar.rs diff --git a/Cargo.lock b/Cargo.lock index ab73076..3b5cfcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -153,7 +153,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" dependencies = [ "chrono", - "chrono-tz-build", + "chrono-tz-build 0.2.1", + "phf 0.11.3", +] + +[[package]] +name = "chrono-tz" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efdce149c370f133a071ca8ef6ea340b7b88748ab0810097a9e2976eaa34b4f3" +dependencies = [ + "chrono", + "chrono-tz-build 0.4.1", "phf 0.11.3", ] @@ -168,6 +179,16 @@ dependencies = [ "phf_codegen 0.11.3", ] +[[package]] +name = "chrono-tz-build" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f10f8c9340e31fc120ff885fcdb54a0b48e474bbd77cab557f0c30a3e569402" +dependencies = [ + "parse-zoneinfo", + "phf_codegen 0.11.3", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -607,6 +628,15 @@ dependencies = [ "cc", ] +[[package]] +name = "ical" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "356d82bd58997815d55ea6f9081bd4cac149e50ca943f7a4f7c050fec7271c1f" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "icu_collections" version = "2.0.0" @@ -1327,6 +1357,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rrule" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "720acfb4980b9d8a6a430f6d7a11933e701ebbeba5eee39cc9d8c5f932aaff74" +dependencies = [ + "chrono", + "chrono-tz 0.10.3", + "log", + "regex", + "thiserror 2.0.12", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -1938,9 +1981,11 @@ name = "trmnl" version = "0.1.0" dependencies = [ "chrono", - "chrono-tz", + "chrono-tz 0.8.6", "dirs", + "ical", "reqwest", + "rrule", "scraper", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 36d625c..2d61a0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,5 @@ serde_json = "1.0" chrono-tz = "0.8" tokio = { version = "1.37", features = ["full"] } scraper = "0.18" +ical = "0.8" +rrule = "0.14.0" diff --git a/src/calendar.rs b/src/calendar.rs new file mode 100644 index 0000000..e7a168b --- /dev/null +++ b/src/calendar.rs @@ -0,0 +1,186 @@ +use chrono::{DateTime, FixedOffset, NaiveDate, Offset, TimeZone, Utc}; +use ical::IcalParser; +use rrule::{RRule, RRuleSet, Unvalidated}; +use std::error::Error; + +pub struct CalendarEventSummary { + pub start: Option>, + pub summary: String, + pub all_day: bool, +} + +pub async fn fetch_next_events( + calendar_url: &str, + max_events: usize, + tz_str: &str, +) -> Result, Box> { + let resp = reqwest::get(calendar_url).await?; + let body = resp.bytes().await?; + let ical_str = String::from_utf8_lossy(&body); + + let parser = IcalParser::new(ical_str.as_bytes()); + let mut events: Vec = Vec::new(); + + let tz: Option = tz_str.parse().ok(); + for calendar in parser { + for evt in calendar?.events { + let mut summary = None; + let mut dtstart = None; + let mut all_day = false; + let mut rrule_str: Option = None; + let mut raw_dtstart: Option = None; + let mut dt_params: Option)>> = None; + for prop in &evt.properties { + match prop.name.as_str() { + "SUMMARY" => summary = prop.value.clone(), + "DTSTART" => { + raw_dtstart = prop.value.clone(); + dt_params = prop.params.clone(); + if let Some(val) = &prop.value { + // -------- Existing DTSTART parsing logic goes below ------ + // All-day check + let is_all_day = prop + .params + .as_ref() + .and_then(|params| params.iter().find(|(k, _)| k == "VALUE")) + .and_then(|(_, v)| v.first()) + .is_some_and(|v| v == "DATE"); + + if is_all_day { + if let Ok(date) = NaiveDate::parse_from_str(val, "%Y%m%d") { + if let Some(tz) = tz { + if let Some(dt) = tz + .from_local_datetime( + &date.and_hms_opt(0, 0, 0).unwrap(), + ) + .single() + { + dtstart = Some(dt.with_timezone(&dt.offset().fix())); + } + } + all_day = true; + } + } else if let Some(params) = &prop.params { + // Check and handle TZID param! + if let Some((_, tz_vec)) = params.iter().find(|(k, _)| k == "TZID") + { + let tz_id = &tz_vec[0]; + if let Ok(parsed_tz) = tz_id.parse::() { + if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str( + val, + "%Y%m%dT%H%M%S", + ) { + let local_dt = + parsed_tz.from_local_datetime(&naive_dt).single(); + if let Some(dt) = local_dt { + dtstart = + Some(dt.with_timezone(&dt.offset().fix())); + } + } + } + } else if let Ok(dt) = DateTime::parse_from_rfc3339(val) { + dtstart = Some(dt.with_timezone(dt.offset())); + } else if let Ok(dt) = + DateTime::parse_from_str(val, "%Y%m%dT%H%M%SZ") + { + dtstart = Some(dt.with_timezone(&Utc.fix())); + } + } else if val.ends_with('Z') && val.len() == 16 { + // e.g. "20250522T181500Z" not RFC3339, convert to RFC3339 + let iso = format!( + "{}-{}-{}T{}:{}:{}Z", + &val[0..4], + &val[4..6], + &val[6..8], + &val[9..11], + &val[11..13], + &val[13..15] + ); + if let Ok(dt) = DateTime::parse_from_rfc3339(&iso) { + dtstart = Some(dt.with_timezone(&Utc.fix())); + } + } else if let Ok(dt) = DateTime::parse_from_rfc3339(val) { + dtstart = Some(dt.with_timezone(dt.offset())); + } else if let Ok(dt) = DateTime::parse_from_str(val, "%Y%m%dT%H%M%S") { + // No Z/zone, treat as in configured tz + if let Some(tz) = tz { + if let Some(dt2) = + tz.from_local_datetime(&dt.naive_local()).single() + { + dtstart = Some(dt2.with_timezone(&dt2.offset().fix())); + } + } + } else if let Ok(date) = NaiveDate::parse_from_str(val, "%Y%m%d") { + // As a fallback, treat as all-day + if let Some(tz) = tz { + if let Some(dt2) = tz + .from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap()) + .single() + { + dtstart = Some(dt2.with_timezone(&dt2.offset().fix())); + } + } + all_day = true; + } + } + } + "RRULE" => rrule_str = prop.value.clone(), + _ => {} + } + } + // ----------- RRULE recurring event expansion block ----------- + if let (Some(ref s), Some(dt), Some(ref rrule_val), Some(_val)) = ( + summary.clone(), + dtstart, + rrule_str.as_ref(), + raw_dtstart.as_ref(), + ) { + // dtstart is FixedOffset, convert to Utc for rrule + let dtstart_rrule = dt.with_timezone(&rrule::Tz::UTC); + if let Ok(unvalid) = rrule_val.parse::>() { + if let Ok(rrule) = unvalid.validate(dtstart_rrule) { + let set = RRuleSet::new(dtstart_rrule).rrule(rrule); + // Expand up to the next 20 future instances for each recurring event + let now = Utc::now(); + let instances = set.all(1000); + let occur_iter = instances + .dates + .iter() + .filter(|t| **t > now) + .take(max_events); + for occ in occur_iter { + let occ_fixed: DateTime = + occ.with_timezone(&dt.offset().fix()); + events.push(CalendarEventSummary { + start: Some(occ_fixed), + summary: s.clone(), + all_day, + }); + } + } else if dtstart_rrule > Utc::now() { + eprintln!("[ERROR] Failed to validate RRULE: {:?}", rrule_val); + } + // Otherwise, ignore and continue + } else { + eprintln!("[ERROR] Failed to parse RRULE: {:?}", rrule_val); + } + continue; + } + + // Non-recurring event + if let (Some(s), Some(dt)) = (summary.clone(), dtstart) { + events.push(CalendarEventSummary { + start: Some(dt), + summary: s, + all_day, + }); + } + } + } + // Filter to only future events + let now = Utc::now(); + events.retain(|e| e.start.map(|s| s > now).unwrap_or(false)); + // Sort by time ascending, then take first max_events + events.sort_by_key(|e| e.start); + Ok(events.into_iter().take(max_events).collect()) +} diff --git a/src/config.rs b/src/config.rs index 7b4cd9d..2cba554 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,7 @@ pub struct Config { pub location: String, pub timezone: String, pub pollen_zip: String, + pub calendar_url: String, } impl Config { diff --git a/src/main.rs b/src/main.rs index 0303101..fe3ffdc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,10 @@ +mod calendar; mod config; mod pollen; mod weather; -use chrono::{TimeZone, Utc, DateTime}; +use calendar::fetch_next_events; +use chrono::{DateTime, TimeZone, Utc}; use chrono_tz::Tz; use config::Config; use pollen::fetch_pollen_api; @@ -66,7 +68,6 @@ async fn main() { match fetch_pollen_api(&config.pollen_zip).await { Ok(p) => { - // Format pollen forecast date to match weather, in config.timezone if let Ok(dt) = DateTime::parse_from_rfc3339(&p.forecast_date) { match config.timezone.parse::() { Ok(tz) => { @@ -111,9 +112,31 @@ async fn main() { println!(); println!("Upcoming calendar events:"); - let events = get_calendar_events(&config, config.calendar_event_count); - for (i, event) in events.iter().enumerate() { - println!("{}. {}", i + 1, event); + + match fetch_next_events( + &config.calendar_url, + config.calendar_event_count, + &config.timezone, + ) + .await + { + Ok(events) if !events.is_empty() => { + for (i, event) in events.iter().enumerate() { + let day = event + .start + .map(|dt| dt.format("%a %Y-%m-%d").to_string()) + .unwrap_or_else(|| "".to_string()); + let time = if event.all_day { + "all day".to_string() + } else if let Some(dt) = event.start { + dt.format("%H:%M").to_string() + } else { + "".to_string() + }; + println!("{}. {} {:>8} {}", i + 1, day, time, event.summary); + } + } + _ => println!("No upcoming calendar events found."), } println!(); @@ -124,17 +147,8 @@ async fn main() { } } -// Placeholder stubs -fn get_calendar_events(_config: &Config, n: usize) -> Vec { - vec![ - "Team meeting at 10:00 AM".to_string(), - "Lunch with Alex at 12:30 PM".to_string(), - "Dentist appointment at 3:00 PM".to_string(), - ] - .into_iter() - .take(n) - .collect() -} +// No longer needed (handled above) +// fn get_calendar_events(_config: &Config, n: usize) -> Vec { ... } fn get_shopify_packages(_config: &Config) -> Vec { vec![