use chrono::{DateTime, Duration, Local, Utc};
use croner::Cron;
use super::model::{Routine, RoutineStore};
const HORIZON_DAYS: i64 = 30;
const MAX_EVENTS_PER_ROUTINE: usize = 100;
const PRODID: &str = "-//moadim//routines//EN";
fn escape_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'\\' => out.push_str("\\\\"),
';' => out.push_str("\\;"),
',' => out.push_str("\\,"),
'\n' => out.push_str("\\n"),
_ => out.push(ch),
}
}
out
}
fn format_utc(dt: DateTime<Utc>) -> String {
dt.format("%Y%m%dT%H%M%SZ").to_string()
}
pub fn build_ical(routines: &[Routine], now: DateTime<Local>) -> String {
let dtstamp = format_utc(now.with_timezone(&Utc));
let horizon = now + Duration::days(HORIZON_DAYS);
let mut lines = vec![
"BEGIN:VCALENDAR".to_string(),
"VERSION:2.0".to_string(),
format!("PRODID:{PRODID}"),
"CALSCALE:GREGORIAN".to_string(),
"X-WR-CALNAME:Moadim Routines".to_string(),
];
for routine in routines {
if !routine.enabled {
continue;
}
let Ok(cron) = routine.schedule.parse::<Cron>() else {
continue;
};
let summary = escape_text(&routine.title);
let description = escape_text(&format!("{} (agent: {})", routine.prompt, routine.agent));
for fire in cron
.iter_after(now)
.take_while(|dt| *dt <= horizon)
.take(MAX_EVENTS_PER_ROUTINE)
{
let stamp = format_utc(fire.with_timezone(&Utc));
lines.push("BEGIN:VEVENT".to_string());
lines.push(format!("UID:{}-{}@moadim", routine.id, stamp));
lines.push(format!("DTSTAMP:{dtstamp}"));
lines.push(format!("DTSTART:{stamp}"));
lines.push(format!("SUMMARY:{summary}"));
lines.push(format!("DESCRIPTION:{description}"));
lines.push("END:VEVENT".to_string());
}
}
lines.push("END:VCALENDAR".to_string());
let mut out = lines.join("\r\n");
out.push_str("\r\n");
out
}
pub fn svc_ical(store: &RoutineStore) -> String {
let routines: Vec<Routine> = store.lock().unwrap().values().cloned().collect();
build_ical(&routines, Local::now())
}
#[cfg(test)]
#[path = "ical_tests.rs"]
mod ical_tests;