WIP: llm-based prototype #1

Draft
thermokarst wants to merge 7 commits from thermokarst_jj_yttmtqskpotw into main
5 changed files with 266 additions and 18 deletions
Showing only changes of commit 56abab7fb4 - Show all commits

49
Cargo.lock generated
View file

@ -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",

View file

@ -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
View 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())
}

View file

@ -14,6 +14,7 @@ pub struct Config {
pub location: String,
pub timezone: String,
pub pollen_zip: String,
pub calendar_url: String,
}
impl Config {

View file

@ -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);
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() {
println!("{}. {}", i + 1, event);
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![