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
//! Calendar export artifacts for natural-language scheduling (issue #404).
//!
//! A parsed [`ScheduledEvent`] is rendered into two portable, login-free forms:
//! a universal RFC 5545 `.ics` VEVENT document any calendar app can import, and
//! a Google Calendar "render" template URL that pre-fills the event in a browser
//! with no API token or server. The CLI, HTTP server, and browser worker each
//! surface whichever is simplest for the user.
use super::calendar::days_to_date;
use std::time::{SystemTime, UNIX_EPOCH};
/// A fully-parsed scheduling request, ready to be exported to any calendar.
/// Times are wall-clock times in `time_zone` (a floating local time paired with
/// an IANA zone id), which is exactly what both iCalendar `TZID` and the Google
/// Calendar `ctz` parameter expect.
pub(super) struct ScheduledEvent {
pub(super) title: String,
pub(super) year: i32,
pub(super) month: u32,
pub(super) day: u32,
pub(super) hour: u32,
pub(super) minute: u32,
pub(super) time_zone: &'static str,
pub(super) duration_minutes: u32,
}
impl ScheduledEvent {
pub(super) fn iso_date(&self) -> String {
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
}
/// Wall-clock start as the basic iCalendar date-time form `YYYYMMDDTHHMMSS`.
fn start_stamp(&self) -> String {
format!(
"{:04}{:02}{:02}T{:02}{:02}00",
self.year, self.month, self.day, self.hour, self.minute
)
}
/// Wall-clock end, derived from the start plus the duration, rolling over
/// across the hour/day/month/year boundaries as needed.
fn end_stamp(&self) -> String {
let (year, month, day, hour, minute) = add_minutes(
self.year,
self.month,
self.day,
self.hour,
self.minute,
self.duration_minutes,
);
format!("{year:04}{month:02}{day:02}T{hour:02}{minute:02}00")
}
/// A stable, content-derived UID so re-importing the same proposal updates
/// the existing entry instead of duplicating it.
fn uid(&self) -> String {
format!("{}-{}@formal-ai", self.start_stamp(), self.time_zone)
}
/// Build an RFC 5545 VEVENT calendar document. CRLF line endings keep it
/// spec-compliant so it imports cleanly into Apple Calendar, Outlook,
/// Google Calendar, Thunderbird, and any other iCalendar client.
pub(super) fn to_ics(&self) -> String {
let lines = [
"BEGIN:VCALENDAR".to_owned(),
"VERSION:2.0".to_owned(),
"PRODID:-//formal-ai//calendar//EN".to_owned(),
"CALSCALE:GREGORIAN".to_owned(),
"METHOD:PUBLISH".to_owned(),
"BEGIN:VEVENT".to_owned(),
format!("UID:{}", self.uid()),
format!("DTSTAMP:{}", ics_dtstamp()),
format!("DTSTART;TZID={}:{}", self.time_zone, self.start_stamp()),
format!("DTEND;TZID={}:{}", self.time_zone, self.end_stamp()),
format!("SUMMARY:{}", ics_escape(&self.title)),
"END:VEVENT".to_owned(),
"END:VCALENDAR".to_owned(),
];
let mut out = lines.join("\r\n");
out.push_str("\r\n");
out
}
/// Build a Google Calendar "render" template URL. Opening it pre-fills a new
/// event in the user's logged-in calendar with no API token or server — the
/// simplest possible path in a browser environment.
pub(super) fn to_google_calendar_url(&self) -> String {
format!(
"https://calendar.google.com/calendar/render?action=TEMPLATE&text={}&dates={}/{}&ctz={}",
percent_encode(&self.title),
self.start_stamp(),
self.end_stamp(),
percent_encode(self.time_zone),
)
}
}
/// Add `minutes` to a wall-clock date-time, rolling over hour/day/month/year.
const fn add_minutes(
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
minutes: u32,
) -> (i32, u32, u32, u32, u32) {
let total = hour * 60 + minute + minutes;
let mut day_carry = total / (24 * 60);
let new_minute = total % 60;
let new_hour = (total / 60) % 24;
let mut y = year;
let mut m = month;
let mut d = day;
while day_carry > 0 {
let max_day = days_in_month(y, m);
if d < max_day {
d += 1;
} else {
d = 1;
m += 1;
if m > 12 {
m = 1;
y += 1;
}
}
day_carry -= 1;
}
(y, m, d, new_hour, new_minute)
}
const fn days_in_month(year: i32, month: u32) -> u32 {
match month {
2 => {
if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
29
} else {
28
}
}
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
/// Current UTC instant as an iCalendar UTC stamp `YYYYMMDDTHHMMSSZ` for DTSTAMP.
fn ics_dtstamp() -> String {
let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else {
return "19700101T000000Z".to_owned();
};
let secs = i64::try_from(now.as_secs()).unwrap_or(0);
let days = secs.div_euclid(86_400);
let sod = secs.rem_euclid(86_400);
let (y, m, d) = days_to_date(days);
format!(
"{:04}{:02}{:02}T{:02}{:02}{:02}Z",
y,
m,
d,
sod / 3_600,
(sod % 3_600) / 60,
sod % 60,
)
}
/// Escape a text value for an iCalendar property (RFC 5545 §3.3.11).
fn ics_escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'\\' => out.push_str("\\\\"),
';' => out.push_str("\\;"),
',' => out.push_str("\\,"),
'\n' => out.push_str("\\n"),
_ => out.push(ch),
}
}
out
}
/// Percent-encode a string for use in a URL query value (RFC 3986 unreserved
/// set kept literal; everything else encoded as UTF-8 `%XX`).
fn percent_encode(value: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => {
out.push('%');
out.push(HEX[(byte >> 4) as usize] as char);
out.push(HEX[(byte & 0x0F) as usize] as char);
}
}
}
out
}