roughing in calendar
This commit is contained in:
parent
024956a12b
commit
56abab7fb4
5 changed files with 266 additions and 18 deletions
186
src/calendar.rs
Normal file
186
src/calendar.rs
Normal file
|
@ -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<DateTime<FixedOffset>>,
|
||||
pub summary: String,
|
||||
pub all_day: bool,
|
||||
}
|
||||
|
||||
pub async fn fetch_next_events(
|
||||
calendar_url: &str,
|
||||
max_events: usize,
|
||||
tz_str: &str,
|
||||
) -> Result<Vec<CalendarEventSummary>, Box<dyn Error>> {
|
||||
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<CalendarEventSummary> = Vec::new();
|
||||
|
||||
let tz: Option<chrono_tz::Tz> = 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<String> = None;
|
||||
let mut raw_dtstart: Option<String> = None;
|
||||
let mut dt_params: Option<Vec<(String, Vec<String>)>> = 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::<chrono_tz::Tz>() {
|
||||
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::<RRule<Unvalidated>>() {
|
||||
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<FixedOffset> =
|
||||
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())
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue