WIP: llm-based prototype #1
5 changed files with 266 additions and 18 deletions
49
Cargo.lock
generated
49
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
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())
|
||||
}
|
|
@ -14,6 +14,7 @@ pub struct Config {
|
|||
pub location: String,
|
||||
pub timezone: String,
|
||||
pub pollen_zip: String,
|
||||
pub calendar_url: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
46
src/main.rs
46
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::<Tz>() {
|
||||
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<String> {
|
||||
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<String> { ... }
|
||||
|
||||
fn get_shopify_packages(_config: &Config) -> Vec<String> {
|
||||
vec![
|
||||
|
|
Loading…
Add table
Reference in a new issue