use crate::utils::lock::LockRecover;
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";
const REFRESH_DURATION: &str = "PT1H";
const EVENT_DURATION: &str = "PT15M";
const DEFAULT_CAL_NAME: &str = "Moadim Routines";
fn escape_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\\' => out.push_str("\\\\"),
';' => out.push_str("\\;"),
',' => out.push_str("\\,"),
'\n' => out.push_str("\\n"),
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
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()
}
fn format_local(dt: DateTime<Local>) -> String {
dt.format("%Y%m%dT%H%M%S").to_string()
}
fn format_utc_offset(offset: chrono::FixedOffset) -> String {
let total = offset.local_minus_utc();
let sign = if total < 0 { '-' } else { '+' };
let total = total.unsigned_abs();
let (hours, minutes, seconds) = (total / 3600, (total % 3600) / 60, total % 60);
if seconds == 0 {
format!("{sign}{hours:02}{minutes:02}")
} else {
format!("{sign}{hours:02}{minutes:02}{seconds:02}")
}
}
fn vtimezone_lines(tzid: &str, offset: chrono::FixedOffset) -> Vec<String> {
let offset_str = format_utc_offset(offset);
vec![
"BEGIN:VTIMEZONE".to_string(),
format!("TZID:{}", escape_text(tzid)),
"BEGIN:STANDARD".to_string(),
"DTSTART:19700101T000000".to_string(),
format!("TZOFFSETFROM:{offset_str}"),
format!("TZOFFSETTO:{offset_str}"),
"END:STANDARD".to_string(),
"END:VTIMEZONE".to_string(),
]
}
const DESCRIPTION_PROMPT_MAX: usize = 120;
fn prompt_summary(prompt: &str) -> String {
let non_empty = || prompt.lines().filter(|line| !line.trim().is_empty());
let first_line = non_empty().next().unwrap_or("").trim();
let has_more_lines = non_empty().count() > 1;
let truncated: String = first_line.chars().take(DESCRIPTION_PROMPT_MAX).collect();
if has_more_lines || truncated.chars().count() < first_line.chars().count() {
format!("{truncated}…")
} else {
truncated
}
}
const FOLD_LIMIT: usize = 75;
fn fold_line(line: &str) -> String {
if line.len() <= FOLD_LIMIT {
return line.to_string();
}
let mut out = String::with_capacity(line.len() + line.len() / FOLD_LIMIT + 1);
let mut budget = FOLD_LIMIT;
for ch in line.chars() {
let char_len = ch.len_utf8();
if char_len > budget {
out.push_str("\r\n ");
budget = FOLD_LIMIT - 1;
}
out.push(ch);
budget -= char_len;
}
out
}
pub fn build_ical(routines: &[Routine], now: DateTime<Local>) -> String {
build_ical_named(routines, now, DEFAULT_CAL_NAME)
}
fn build_ical_named(routines: &[Routine], now: DateTime<Local>, cal_name: &str) -> String {
build_ical_core(routines, now, cal_name, MAX_EVENTS_PER_ROUTINE)
}
fn build_ical_core(
routines: &[Routine],
now: DateTime<Local>,
cal_name: &str,
max_events: usize,
) -> String {
let host_tz = super::model::local_timezone().map(|tzid| (tzid, *now.offset()));
build_ical_core_with_tz(routines, now, cal_name, max_events, host_tz.as_ref())
}
fn build_ical_core_with_tz(
routines: &[Routine],
now: DateTime<Local>,
cal_name: &str,
max_events: usize,
host_tz: Option<&(String, chrono::FixedOffset)>,
) -> 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(),
format!("X-WR-CALNAME:{}", escape_text(cal_name)),
format!("REFRESH-INTERVAL;VALUE=DURATION:{REFRESH_DURATION}"),
format!("X-PUBLISHED-TTL:{REFRESH_DURATION}"),
];
if let Some((tzid, offset)) = &host_tz {
lines.extend(vtimezone_lines(tzid, *offset));
}
let dtstart_line = |local: DateTime<Local>, utc_stamp: &str| match &host_tz {
Some((tzid, _)) => format!("DTSTART;TZID={tzid}:{}", format_local(local)),
None => format!("DTSTART:{utc_stamp}"),
};
let globally_locked = crate::global_lock::is_globally_locked();
for routine in routines {
if !routine.enabled || globally_locked || routine.power_saving {
continue;
}
let Ok(cron) = routine.schedule.parse::<Cron>() else {
continue;
};
let summary = escape_text(&routine.title);
let description = escape_text(&format!(
"{} (agent: {})",
prompt_summary(&routine.prompt),
routine.agent
));
let snoozed_until = routine.snoozed_until;
let skip_runs = routine.skip_runs.unwrap_or(0) as usize;
let mut fires = cron
.iter_after(now)
.take_while(|dt| *dt <= horizon)
.filter(move |dt| {
snoozed_until
.is_none_or(|until| u64::try_from(dt.timestamp()).is_ok_and(|ts| ts >= until))
})
.skip(skip_runs);
let mut emitted = 0_usize;
for fire in fires.by_ref().take(max_events) {
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(dtstart_line(fire, &stamp));
lines.push(format!("DURATION:{EVENT_DURATION}"));
lines.push("TRANSP:TRANSPARENT".to_string());
lines.push("X-MICROSOFT-CDO-BUSYSTATUS:FREE".to_string());
lines.push(format!("SUMMARY:{summary}"));
lines.push(format!("DESCRIPTION:{description}"));
lines.push("END:VEVENT".to_string());
emitted += 1;
}
if emitted == max_events {
if let Some(next) = fires.next() {
let stamp = format_utc(next.with_timezone(&Utc));
let note = escape_text(&format!(
"{}: schedule truncated — only the first {} of more upcoming runs through {} \
are listed. Subscribe to the daemon directly for the full schedule.",
routine.title,
max_events,
horizon.format("%Y-%m-%d")
));
lines.push("BEGIN:VEVENT".to_string());
lines.push(format!("UID:{}-truncated@moadim", routine.id));
lines.push(format!("DTSTAMP:{dtstamp}"));
lines.push(dtstart_line(next, &stamp));
lines.push(format!("DURATION:{EVENT_DURATION}"));
lines.push("TRANSP:TRANSPARENT".to_string());
lines.push("X-MICROSOFT-CDO-BUSYSTATUS:FREE".to_string());
lines.push(format!("SUMMARY:⚠ {summary} (schedule truncated)"));
lines.push(format!("DESCRIPTION:{note}"));
lines.push("END:VEVENT".to_string());
}
}
}
lines.push("END:VCALENDAR".to_string());
let mut out = lines
.iter()
.map(|line| fold_line(line))
.collect::<Vec<_>>()
.join("\r\n");
out.push_str("\r\n");
out
}
#[cfg(test)]
pub(crate) fn build_ical_with_cap(
routines: &[Routine],
now: DateTime<Local>,
max_events: usize,
) -> String {
build_ical_core(routines, now, DEFAULT_CAL_NAME, max_events)
}
#[cfg(test)]
#[allow(
clippy::needless_pass_by_value,
reason = "owned Option reads cleanest at test call sites; internally borrowed once"
)]
pub(crate) fn build_ical_with_tz(
routines: &[Routine],
now: DateTime<Local>,
max_events: usize,
host_tz: Option<(String, chrono::FixedOffset)>,
) -> String {
build_ical_core_with_tz(
routines,
now,
DEFAULT_CAL_NAME,
max_events,
host_tz.as_ref(),
)
}
pub fn svc_ical(store: &RoutineStore, dir: &std::path::Path) -> String {
crate::routine_storage::reload_store_from_dir(store, dir);
let routines: Vec<Routine> = store.lock_recover().values().cloned().collect();
build_ical(&routines, Local::now())
}
pub fn svc_ical_routine(store: &RoutineStore, dir: &std::path::Path, id: &str) -> String {
crate::routine_storage::reload_store_from_dir(store, dir);
let routine = store.lock_recover().get(id).cloned();
match routine {
Some(routine) => {
let cal_name = routine.title.clone();
build_ical_named(std::slice::from_ref(&routine), Local::now(), &cal_name)
}
None => build_ical_named(&[], Local::now(), DEFAULT_CAL_NAME),
}
}
#[cfg(test)]
#[path = "ical_tests.rs"]
mod ical_tests;