1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
#![allow(
clippy::wildcard_imports,
clippy::too_many_arguments,
clippy::needless_pass_by_value,
dead_code,
reason = "split-out module preserves existing code while keeping files under the linecheck limit"
)]
use super::*;
/// Like [`build_ical_core`] but with the host `VTIMEZONE` info (`(TZID, UTC offset)`) passed in
/// explicitly, so tests can exercise both the `TZID`-qualified and UTC-fallback `DTSTART` forms
/// deterministically instead of depending on the test machine's own resolvable timezone.
pub(crate) 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)),
// RFC 7986 §5.7 standard hint plus the widely-honored Microsoft/Google
// X-PUBLISHED-TTL fallback, so subscribers poll often enough to pick up
// routine changes promptly instead of using their slow built-in default.
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));
}
// `TZID` param-values never need quoting here: IANA zone names (e.g. `Asia/Jerusalem`) never
// contain the `:`/`;`/`,` characters RFC 5545 §3.2 would require escaping.
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 schedules = routine.effective_schedules();
let summary = escape_text(&routine.title);
let description = escape_text(&format!(
"{} (agent: {})",
prompt_summary(&routine.prompt),
routine.agent
));
// Fire times within the horizon, in order. Kept as a stateful iterator so that after the
// per-routine cap is spent we can peek whether more fires remain inside the horizon and, if
// so, surface the truncation rather than letting the feed silently stop short of 30 days.
//
// `snoozed_until`/`skip_runs` are mutually exclusive (enforced by `svc_snooze`): a fire is
// dropped either because it falls before the snooze deadline, or because it's among the
// next `skip_runs` fires that `svc_trigger_scheduled` will skip and decrement past. Both
// mirror the exact skip performed there so the feed matches what will actually run.
let snoozed_until = routine.snoozed_until;
let skip_runs = routine.skip_runs.unwrap_or(0) as usize;
let mut all_fires: Vec<DateTime<Local>> = schedules
.iter()
.filter_map(|schedule| schedule.parse::<Cron>().ok())
.flat_map(|cron| cron.iter_after(now).take_while(|dt| *dt <= horizon))
.collect();
all_fires.sort();
all_fires.dedup();
let mut fires = all_fires
.into_iter()
.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}"));
// The feed is purely informational ("when will my loops fire?"), so a
// fire must not consume the subscriber's free/busy time. RFC 5545
// §3.8.2.7 defaults `TRANSP` to `OPAQUE` (counts as busy); mark each
// event `TRANSPARENT` so it never blocks availability. The legacy
// `X-MICROSOFT-CDO-BUSYSTATUS:FREE` carries the same intent to Outlook
// clients that honor the Microsoft property instead of `TRANSP`.
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;
}
// Cap reached with fires still pending inside the horizon: append a marker VEVENT at the
// first omitted fire so subscribers see the projection was truncated and where it stops.
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));
// Mirror the regular fire VEVENT's DURATION/TRANSP/BUSYSTATUS (see the comments
// on EVENT_DURATION and on the regular-fire VEVENT above): without a DURATION
// this marker is a zero-length instant, which most calendar UIs render as an
// invisible sliver — defeating its one job of telling subscribers the feed was
// truncated.
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());
// RFC 5545 mandates CRLF line endings, including a trailing CRLF after the final
// line. Each content line is folded (§3.1) so no physical line exceeds 75 octets.
let mut out = lines
.iter()
.map(|line| fold_line(line))
.collect::<Vec<_>>()
.join("\r\n");
out.push_str("\r\n");
out
}
/// Test-only entry point: build the iCalendar feed with a custom per-routine event cap so tests
/// can exercise the truncation-marker path without needing a cron schedule that fires exactly
/// [`MAX_EVENTS_PER_ROUTINE`] times in the 30-day horizon.
#[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)
}
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(),
)
}
/// Build the iCalendar feed for every routine currently in `store`.
///
/// Refreshes the store from `dir` first so the feed reflects routines pulled or edited on disk under
/// a running daemon without a restart (disk is the source of truth).
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())
}
/// Build the iCalendar feed for a single routine by `id` (issue #263).
///
/// The calendar is named after the routine so a subscribed feed reads as that routine
/// rather than the generic all-routines name. An unknown id yields a well-formed empty
/// calendar (named [`DEFAULT_CAL_NAME`]) rather than an error, mirroring how a disabled
/// routine already contributes no events.
///
/// Refreshes the store from `dir` first so the feed reflects a routine pulled or edited on disk
/// under a running daemon without a restart (disk is the source of truth).
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),
}
}