@luckydye/calendar 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ICal.ts CHANGED
@@ -1,163 +1,193 @@
1
- import type { CalendarCredentials } from './CalendarIntegration.js';
2
- import type { CalendarEvent, Attendee, Organizer, NotificationConfig } from './CalendarInternal.js';
1
+ import type { CalendarCredentials } from "./CalendarIntegration.js";
2
+ import type {
3
+ Attendee,
4
+ CalendarEvent,
5
+ NotificationConfig,
6
+ Organizer,
7
+ } from "./CalendarInternal.js";
8
+ import { sanitizeEventDescription } from "./DescriptionSanitizer.js";
3
9
 
4
10
  export interface ParseResult {
5
- events: CalendarEvent[];
6
- notifications: Map<string, NotificationConfig[]>;
11
+ events: CalendarEvent[];
12
+ notifications: Map<string, NotificationConfig[]>;
7
13
  }
8
14
 
9
-
10
- export function parseICalEvents(icalText: string, color: string, calendar: string): CalendarEvent[] {
11
- const result = parseICalEventsWithNotifications(icalText, color, calendar);
12
- return result.events;
15
+ export function parseICalEvents(
16
+ icalText: string,
17
+ color: string,
18
+ calendar: string,
19
+ ): CalendarEvent[] {
20
+ const result = parseICalEventsWithNotifications(icalText, color, calendar);
21
+ return result.events;
13
22
  }
14
23
 
15
- export function parseICalEventsWithNotifications(icalText: string, color: string, calendar: string): ParseResult {
16
- const unfolded = unfoldICalLines(icalText);
17
- const lines = unfolded.split(/\r?\n/);
18
- const events: CalendarEvent[] = [];
19
- const notifications = new Map<string, NotificationConfig[]>();
20
- let currentEvent: Record<string, unknown> = {};
21
- let currentAlarms: string[] = [];
22
- let inAlarm = false;
23
- let currentAlarmText = "";
24
-
25
- for (const line of lines) {
26
- const trimmed = line.trim();
27
-
28
- if (trimmed === "BEGIN:VALARM") {
29
- inAlarm = true;
30
- currentAlarmText = "";
31
- } else if (trimmed === "END:VALARM") {
32
- inAlarm = false;
33
- if (currentAlarmText) {
34
- currentAlarms.push(currentAlarmText);
35
- }
36
- currentAlarmText = "";
37
- } else if (inAlarm) {
38
- currentAlarmText += trimmed + "\n";
39
- } else if (trimmed === "BEGIN:VEVENT") {
40
- currentEvent = {};
41
- currentAlarms = [];
42
- } else if (trimmed === "END:VEVENT") {
43
- if (currentEvent.status !== 'CANCELLED' && currentEvent.start && currentEvent.start instanceof Date) {
44
- const eventId = String(currentEvent.uid || Math.random());
45
- const parsedReminders = currentAlarms.length > 0 ? parseVAlarms(eventId, currentAlarms) : undefined;
46
- const event: CalendarEvent = {
47
- id: eventId,
48
- title: String(currentEvent.summary || "Untitled Event"),
49
- start: currentEvent.start,
50
- end: (currentEvent.end instanceof Date ? currentEvent.end : currentEvent.start) as Date,
51
- color,
52
- calendar,
53
- description: currentEvent.description ? String(currentEvent.description) : undefined,
54
- location: currentEvent.location ? String(currentEvent.location) : undefined,
55
- url: currentEvent.url ? String(currentEvent.url) : undefined,
56
- organizer: currentEvent.organizer as Organizer | undefined,
57
- attendees: currentEvent.attendees as Attendee[] | undefined,
58
- readOnly: true,
59
- isAllDay: currentEvent.isAllDay as boolean | undefined,
60
- reminders: parsedReminders?.length ? parsedReminders : undefined,
61
- };
62
- events.push(event);
63
-
64
- if (parsedReminders?.length) {
65
- notifications.set(eventId, parsedReminders);
66
- }
67
- }
68
- currentEvent = {};
69
- currentAlarms = [];
70
- } else {
71
- const colonIdx = trimmed.indexOf(":");
72
- if (colonIdx === -1) continue;
73
-
74
- const keyPart = trimmed.slice(0, colonIdx);
75
- const value = trimmed.slice(colonIdx + 1);
76
- const key = keyPart.split(";")[0];
77
-
78
- switch (key) {
79
- case "SUMMARY":
80
- currentEvent.summary = value;
81
- break;
82
- case "DTSTART":
83
- currentEvent.start = parseICalDate(value, keyPart);
84
- if (keyPart.includes("VALUE=DATE")) {
85
- currentEvent.isAllDay = true;
86
- }
87
- break;
88
- case "DTEND":
89
- currentEvent.end = parseICalDate(value, keyPart);
90
- break;
91
- case "UID":
92
- currentEvent.uid = value;
93
- break;
94
- case "DESCRIPTION":
95
- currentEvent.description = value;
96
- break;
97
- case "LOCATION":
98
- currentEvent.location = value;
99
- break;
100
- case "URL":
101
- currentEvent.url = value;
102
- break;
103
- case "STATUS":
104
- currentEvent.status = value;
105
- break;
106
- case "ORGANIZER":
107
- currentEvent.organizer = parseICalPerson(trimmed);
108
- break;
109
- case "ATTENDEE":
110
- if (!currentEvent.attendees) {
111
- currentEvent.attendees = [];
112
- }
113
- (currentEvent.attendees as Attendee[]).push(parseICalAttendee(trimmed));
114
- break;
115
- }
116
- }
117
- }
118
- return { events, notifications };
24
+ export function parseICalEventsWithNotifications(
25
+ icalText: string,
26
+ color: string,
27
+ calendar: string,
28
+ ): ParseResult {
29
+ const unfolded = unfoldICalLines(icalText);
30
+ const lines = unfolded.split(/\r?\n/);
31
+ const events: CalendarEvent[] = [];
32
+ const notifications = new Map<string, NotificationConfig[]>();
33
+ let currentEvent: Record<string, unknown> = {};
34
+ let currentAlarms: string[] = [];
35
+ let inAlarm = false;
36
+ let currentAlarmText = "";
37
+
38
+ for (const line of lines) {
39
+ const trimmed = line.trim();
40
+
41
+ if (trimmed === "BEGIN:VALARM") {
42
+ inAlarm = true;
43
+ currentAlarmText = "";
44
+ } else if (trimmed === "END:VALARM") {
45
+ inAlarm = false;
46
+ if (currentAlarmText) {
47
+ currentAlarms.push(currentAlarmText);
48
+ }
49
+ currentAlarmText = "";
50
+ } else if (inAlarm) {
51
+ currentAlarmText += `${trimmed}\n`;
52
+ } else if (trimmed === "BEGIN:VEVENT") {
53
+ currentEvent = {};
54
+ currentAlarms = [];
55
+ } else if (trimmed === "END:VEVENT") {
56
+ if (
57
+ currentEvent.status !== "CANCELLED" &&
58
+ currentEvent.start &&
59
+ currentEvent.start instanceof Date
60
+ ) {
61
+ const eventId = String(currentEvent.uid || Math.random());
62
+ const parsedReminders =
63
+ currentAlarms.length > 0
64
+ ? parseVAlarms(eventId, currentAlarms)
65
+ : undefined;
66
+ const event: CalendarEvent = {
67
+ id: eventId,
68
+ title: String(currentEvent.summary || "Untitled Event"),
69
+ start: currentEvent.start,
70
+ end: (currentEvent.end instanceof Date
71
+ ? currentEvent.end
72
+ : currentEvent.start) as Date,
73
+ color,
74
+ calendar,
75
+ description: currentEvent.description
76
+ ? sanitizeEventDescription(String(currentEvent.description))
77
+ : undefined,
78
+ location: currentEvent.location
79
+ ? String(currentEvent.location)
80
+ : undefined,
81
+ url: currentEvent.url ? String(currentEvent.url) : undefined,
82
+ organizer: currentEvent.organizer as Organizer | undefined,
83
+ attendees: currentEvent.attendees as Attendee[] | undefined,
84
+ readOnly: true,
85
+ isAllDay: currentEvent.isAllDay as boolean | undefined,
86
+ reminders: parsedReminders?.length ? parsedReminders : undefined,
87
+ };
88
+ events.push(event);
89
+
90
+ if (parsedReminders?.length) {
91
+ notifications.set(eventId, parsedReminders);
92
+ }
93
+ }
94
+ currentEvent = {};
95
+ currentAlarms = [];
96
+ } else {
97
+ const colonIdx = trimmed.indexOf(":");
98
+ if (colonIdx === -1) continue;
99
+
100
+ const keyPart = trimmed.slice(0, colonIdx);
101
+ const value = trimmed.slice(colonIdx + 1);
102
+ const key = keyPart.split(";")[0];
103
+
104
+ switch (key) {
105
+ case "SUMMARY":
106
+ currentEvent.summary = value;
107
+ break;
108
+ case "DTSTART":
109
+ currentEvent.start = parseICalDate(value, keyPart);
110
+ if (keyPart.includes("VALUE=DATE")) {
111
+ currentEvent.isAllDay = true;
112
+ }
113
+ break;
114
+ case "DTEND":
115
+ currentEvent.end = parseICalDate(value, keyPart);
116
+ break;
117
+ case "UID":
118
+ currentEvent.uid = value;
119
+ break;
120
+ case "DESCRIPTION":
121
+ currentEvent.description = value;
122
+ break;
123
+ case "LOCATION":
124
+ currentEvent.location = value;
125
+ break;
126
+ case "URL":
127
+ currentEvent.url = value;
128
+ break;
129
+ case "STATUS":
130
+ currentEvent.status = value;
131
+ break;
132
+ case "ORGANIZER":
133
+ currentEvent.organizer = parseICalPerson(trimmed);
134
+ break;
135
+ case "ATTENDEE":
136
+ if (!currentEvent.attendees) {
137
+ currentEvent.attendees = [];
138
+ }
139
+ (currentEvent.attendees as Attendee[]).push(
140
+ parseICalAttendee(trimmed),
141
+ );
142
+ break;
143
+ }
144
+ }
145
+ }
146
+ return { events, notifications };
119
147
  }
120
148
 
121
149
  function parseVAlarms(eventId: string, alarms: string[]): NotificationConfig[] {
122
- const notifications: NotificationConfig[] = [];
123
-
124
- for (let i = 0; i < alarms.length; i++) {
125
- const alarm = alarms[i];
126
- const triggerMatch = alarm.match(/TRIGGER[^:]*:([^\n]+)/);
127
-
128
- if (triggerMatch) {
129
- const trigger = triggerMatch[1].trim();
130
- const offsetMinutes = parseTriggerDuration(trigger);
131
-
132
- if (offsetMinutes !== null) {
133
- notifications.push({
134
- id: `${eventId}-valarm-${i}`,
135
- triggerOffset: offsetMinutes,
136
- enabled: true,
137
- });
138
- }
139
- }
140
- }
141
-
142
- return notifications;
150
+ const notifications: NotificationConfig[] = [];
151
+
152
+ for (let i = 0; i < alarms.length; i++) {
153
+ const alarm = alarms[i];
154
+ const triggerMatch = alarm.match(/TRIGGER[^:]*:([^\n]+)/);
155
+
156
+ if (triggerMatch) {
157
+ const trigger = triggerMatch[1].trim();
158
+ const offsetMinutes = parseTriggerDuration(trigger);
159
+
160
+ if (offsetMinutes !== null) {
161
+ notifications.push({
162
+ id: `${eventId}-valarm-${i}`,
163
+ triggerOffset: offsetMinutes,
164
+ enabled: true,
165
+ });
166
+ }
167
+ }
168
+ }
169
+
170
+ return notifications;
143
171
  }
144
172
 
145
173
  function parseTriggerDuration(trigger: string): number | null {
146
- // Parse ISO 8601 duration format: -PT15M, -PT1H, -P1D, etc.
147
- // Negative means before the event
174
+ // Parse ISO 8601 duration format: -PT15M, -PT1H, -P1D, etc.
175
+ // Negative means before the event
148
176
 
149
- const match = trigger.match(/^(-?)P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
150
- if (!match) return null;
177
+ const match = trigger.match(
178
+ /^(-?)P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/,
179
+ );
180
+ if (!match) return null;
151
181
 
152
- const isNegative = match[1] === '-';
153
- const days = parseInt(match[2] || '0', 10);
154
- const hours = parseInt(match[3] || '0', 10);
155
- const minutes = parseInt(match[4] || '0', 10);
182
+ const isNegative = match[1] === "-";
183
+ const days = Number.parseInt(match[2] || "0", 10);
184
+ const hours = Number.parseInt(match[3] || "0", 10);
185
+ const minutes = Number.parseInt(match[4] || "0", 10);
156
186
 
157
- const totalMinutes = days * 24 * 60 + hours * 60 + minutes;
187
+ const totalMinutes = days * 24 * 60 + hours * 60 + minutes;
158
188
 
159
- // Only support "before" triggers (negative)
160
- return isNegative ? totalMinutes : null;
189
+ // Only support "before" triggers (negative)
190
+ return isNegative ? totalMinutes : null;
161
191
  }
162
192
 
163
193
  /**
@@ -165,236 +195,283 @@ function parseTriggerDuration(trigger: string): number | null {
165
195
  * Used by CalDAV sources where each event arrives as its own iCal string.
166
196
  */
167
197
  export function parseSingleICalEvent(icalText: string): Partial<CalendarEvent> {
168
- const unfolded = unfoldICalLines(icalText);
169
- const lines = unfolded.split(/\r?\n|\r/);
170
- const raw: Record<string, unknown> = {};
171
- const alarms: string[] = [];
172
- let inEvent = false;
173
- let inAlarm = false;
174
- let currentAlarmText = "";
175
-
176
- for (const line of lines) {
177
- const trimmed = line.trim();
178
-
179
- if (trimmed === "BEGIN:VEVENT") { inEvent = true; continue; }
180
- if (trimmed === "END:VEVENT") break;
181
- if (!inEvent) continue;
182
-
183
- if (trimmed === "BEGIN:VALARM") { inAlarm = true; currentAlarmText = ""; continue; }
184
- if (trimmed === "END:VALARM") {
185
- inAlarm = false;
186
- if (currentAlarmText) alarms.push(currentAlarmText);
187
- currentAlarmText = "";
188
- continue;
189
- }
190
- if (inAlarm) { currentAlarmText += trimmed + "\n"; continue; }
191
-
192
- const colonIdx = trimmed.indexOf(":");
193
- if (colonIdx === -1) continue;
194
- const keyPart = trimmed.slice(0, colonIdx);
195
- const value = trimmed.slice(colonIdx + 1);
196
- const key = keyPart.split(";")[0];
197
-
198
- switch (key) {
199
- case "SUMMARY": raw.title = value; break;
200
- case "UID": raw.id = value; break;
201
- case "DTSTART":
202
- raw.start = parseICalDate(value, keyPart);
203
- if (keyPart.includes("VALUE=DATE")) raw.isAllDay = true;
204
- break;
205
- case "DTEND": raw.end = parseICalDate(value, keyPart); break;
206
- case "DESCRIPTION": raw.description = value.replace(/\\n/g, "\n"); break;
207
- case "LOCATION": raw.location = value; break;
208
- case "URL": raw.url = value; break;
209
- case "RRULE": raw.rrule = value.trim(); break;
210
- case "ORGANIZER": raw.organizer = parseICalPerson(trimmed); break;
211
- case "ATTENDEE":
212
- if (!raw.attendees) raw.attendees = [];
213
- (raw.attendees as Attendee[]).push(parseICalAttendee(trimmed));
214
- break;
215
- }
216
- }
217
-
218
- const id = raw.id as string | undefined;
219
- const reminders = id && alarms.length > 0 ? parseVAlarms(id, alarms) : undefined;
220
-
221
- return {
222
- ...raw,
223
- reminders: reminders?.length ? reminders : undefined,
224
- } as Partial<CalendarEvent>;
198
+ const unfolded = unfoldICalLines(icalText);
199
+ const lines = unfolded.split(/\r?\n|\r/);
200
+ const raw: Record<string, unknown> = {};
201
+ const alarms: string[] = [];
202
+ let inEvent = false;
203
+ let inAlarm = false;
204
+ let currentAlarmText = "";
205
+
206
+ for (const line of lines) {
207
+ const trimmed = line.trim();
208
+
209
+ if (trimmed === "BEGIN:VEVENT") {
210
+ inEvent = true;
211
+ continue;
212
+ }
213
+ if (trimmed === "END:VEVENT") break;
214
+ if (!inEvent) continue;
215
+
216
+ if (trimmed === "BEGIN:VALARM") {
217
+ inAlarm = true;
218
+ currentAlarmText = "";
219
+ continue;
220
+ }
221
+ if (trimmed === "END:VALARM") {
222
+ inAlarm = false;
223
+ if (currentAlarmText) alarms.push(currentAlarmText);
224
+ currentAlarmText = "";
225
+ continue;
226
+ }
227
+ if (inAlarm) {
228
+ currentAlarmText += `${trimmed}\n`;
229
+ continue;
230
+ }
231
+
232
+ const colonIdx = trimmed.indexOf(":");
233
+ if (colonIdx === -1) continue;
234
+ const keyPart = trimmed.slice(0, colonIdx);
235
+ const value = trimmed.slice(colonIdx + 1);
236
+ const key = keyPart.split(";")[0];
237
+
238
+ switch (key) {
239
+ case "SUMMARY":
240
+ raw.title = value;
241
+ break;
242
+ case "UID":
243
+ raw.id = value;
244
+ break;
245
+ case "DTSTART":
246
+ raw.start = parseICalDate(value, keyPart);
247
+ if (keyPart.includes("VALUE=DATE")) raw.isAllDay = true;
248
+ break;
249
+ case "DTEND":
250
+ raw.end = parseICalDate(value, keyPart);
251
+ break;
252
+ case "DESCRIPTION":
253
+ raw.description = sanitizeEventDescription(value.replace(/\\n/g, "\n"));
254
+ break;
255
+ case "LOCATION":
256
+ raw.location = value;
257
+ break;
258
+ case "URL":
259
+ raw.url = value;
260
+ break;
261
+ case "RRULE":
262
+ raw.rrule = value.trim();
263
+ break;
264
+ case "ORGANIZER":
265
+ raw.organizer = parseICalPerson(trimmed);
266
+ break;
267
+ case "ATTENDEE":
268
+ if (!raw.attendees) raw.attendees = [];
269
+ (raw.attendees as Attendee[]).push(parseICalAttendee(trimmed));
270
+ break;
271
+ }
272
+ }
273
+
274
+ const id = raw.id as string | undefined;
275
+ const reminders =
276
+ id && alarms.length > 0 ? parseVAlarms(id, alarms) : undefined;
277
+
278
+ return {
279
+ ...raw,
280
+ reminders: reminders?.length ? reminders : undefined,
281
+ } as Partial<CalendarEvent>;
225
282
  }
226
283
 
227
- export async function fetchICalEvents(credentials: CalendarCredentials, color: string, name: string): Promise<CalendarEvent[]> {
228
- const url = credentials.url;
229
- const isExternal = url.startsWith('http://') || url.startsWith('https://');
230
-
231
- const fetchUrl = isExternal
232
- ? `/ical-proxy?url=${encodeURIComponent(url)}`
233
- : url;
234
-
235
- const response = await fetch(fetchUrl);
236
- if (!response.ok) {
237
- throw new Error(`Failed to fetch iCal: ${response.status}`);
238
- }
239
- const text = await response.text();
240
- return parseICalEvents(text, color, name);
284
+ export async function fetchICalEvents(
285
+ credentials: CalendarCredentials,
286
+ color: string,
287
+ name: string,
288
+ ): Promise<CalendarEvent[]> {
289
+ const url = credentials.url;
290
+ const isExternal = url.startsWith("http://") || url.startsWith("https://");
291
+
292
+ const fetchUrl = isExternal
293
+ ? `/ical-proxy?url=${encodeURIComponent(url)}`
294
+ : url;
295
+
296
+ const response = await fetch(fetchUrl);
297
+ if (!response.ok) {
298
+ throw new Error(`Failed to fetch iCal: ${response.status}`);
299
+ }
300
+ const text = await response.text();
301
+ return parseICalEvents(text, color, name);
241
302
  }
242
303
 
243
- export async function fetchICalEventsWithNotifications(credentials: CalendarCredentials, color: string, name: string): Promise<ParseResult> {
244
- const url = credentials.url;
245
- const isExternal = url.startsWith('http://') || url.startsWith('https://');
246
-
247
- const fetchUrl = isExternal
248
- ? `/ical-proxy?url=${encodeURIComponent(url)}`
249
- : url;
250
-
251
- const response = await fetch(fetchUrl);
252
- if (!response.ok) {
253
- throw new Error(`Failed to fetch iCal: ${response.status}`);
254
- }
255
- const text = await response.text();
256
- return parseICalEventsWithNotifications(text, color, name);
304
+ export async function fetchICalEventsWithNotifications(
305
+ credentials: CalendarCredentials,
306
+ color: string,
307
+ name: string,
308
+ ): Promise<ParseResult> {
309
+ const url = credentials.url;
310
+ const isExternal = url.startsWith("http://") || url.startsWith("https://");
311
+
312
+ const fetchUrl = isExternal
313
+ ? `/ical-proxy?url=${encodeURIComponent(url)}`
314
+ : url;
315
+
316
+ const response = await fetch(fetchUrl);
317
+ if (!response.ok) {
318
+ throw new Error(`Failed to fetch iCal: ${response.status}`);
319
+ }
320
+ const text = await response.text();
321
+ return parseICalEventsWithNotifications(text, color, name);
257
322
  }
258
323
 
259
324
  export function parseICalDate(dateStr: string, keyPart?: string): Date | null {
260
- if (!dateStr) return null;
261
- const clean = dateStr.replace(/[^0-9T]/g, "");
262
- const year = Number.parseInt(clean.slice(0, 4), 10);
263
- const month = Number.parseInt(clean.slice(4, 6), 10) - 1;
264
- const day = Number.parseInt(clean.slice(6, 8), 10);
265
-
266
- if (clean.includes("T")) {
267
- const hour = Number.parseInt(clean.slice(9, 11), 10);
268
- const minute = Number.parseInt(clean.slice(11, 13), 10);
269
- const second = Number.parseInt(clean.slice(13, 15), 10) || 0;
270
-
271
- // Check for UTC
272
- if (dateStr.endsWith("Z")) {
273
- return new Date(Date.UTC(year, month, day, hour, minute, second));
274
- }
275
-
276
- // Check for TZID in keyPart
277
- if (keyPart) {
278
- const tzidMatch = keyPart.match(/TZID=([^;:]+)/);
279
- if (tzidMatch) {
280
- const tzid = tzidMatch[1];
281
- // Use Intl to determine the correct UTC offset for the given timezone and date,
282
- // which correctly accounts for DST transitions.
283
- try {
284
- // Classic "reverse offset" trick using Intl:
285
- // 1. Treat the wall-clock digits as if they were UTC (utcGuess)
286
- // 2. Format utcGuess in the target tz → reveals what UTC+offset looks like
287
- // 3. Compute offsetMs = utcGuess − that formatted time (also expressed as UTC ms)
288
- // 4. actualUTC = utcGuess + offsetMs (subtracts the tz offset)
289
- const utcGuess = Date.UTC(year, month, day, hour, minute, second);
290
- const parts = new Intl.DateTimeFormat("en-US", {
291
- timeZone: tzid,
292
- year: "numeric", month: "2-digit", day: "2-digit",
293
- hour: "2-digit", minute: "2-digit", second: "2-digit",
294
- hour12: false,
295
- }).formatToParts(new Date(utcGuess));
296
- const get = (type: string) => Number(parts.find(p => p.type === type)?.value ?? "0");
297
- const tzAsUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour") % 24, get("minute"), get("second"));
298
- const offsetMs = utcGuess - tzAsUtc;
299
- return new Date(utcGuess + offsetMs);
300
- } catch {
301
- // Unknown timezone fall through to local time interpretation
302
- }
303
- }
304
- }
305
-
306
- return new Date(year, month, day, hour, minute, second);
307
- }
308
- return new Date(year, month, day);
325
+ if (!dateStr) return null;
326
+ const clean = dateStr.replace(/[^0-9T]/g, "");
327
+ const year = Number.parseInt(clean.slice(0, 4), 10);
328
+ const month = Number.parseInt(clean.slice(4, 6), 10) - 1;
329
+ const day = Number.parseInt(clean.slice(6, 8), 10);
330
+
331
+ if (clean.includes("T")) {
332
+ const hour = Number.parseInt(clean.slice(9, 11), 10);
333
+ const minute = Number.parseInt(clean.slice(11, 13), 10);
334
+ const second = Number.parseInt(clean.slice(13, 15), 10) || 0;
335
+
336
+ // Check for UTC
337
+ if (dateStr.endsWith("Z")) {
338
+ return new Date(Date.UTC(year, month, day, hour, minute, second));
339
+ }
340
+
341
+ // Check for TZID in keyPart
342
+ if (keyPart) {
343
+ const tzidMatch = keyPart.match(/TZID=([^;:]+)/);
344
+ if (tzidMatch) {
345
+ const tzid = tzidMatch[1];
346
+ // Use Intl to determine the correct UTC offset for the given timezone and date,
347
+ // which correctly accounts for DST transitions.
348
+ try {
349
+ // Classic "reverse offset" trick using Intl:
350
+ // 1. Treat the wall-clock digits as if they were UTC (utcGuess)
351
+ // 2. Format utcGuess in the target tz → reveals what UTC+offset looks like
352
+ // 3. Compute offsetMs = utcGuess − that formatted time (also expressed as UTC ms)
353
+ // 4. actualUTC = utcGuess + offsetMs (subtracts the tz offset)
354
+ const utcGuess = Date.UTC(year, month, day, hour, minute, second);
355
+ const parts = new Intl.DateTimeFormat("en-US", {
356
+ timeZone: tzid,
357
+ year: "numeric",
358
+ month: "2-digit",
359
+ day: "2-digit",
360
+ hour: "2-digit",
361
+ minute: "2-digit",
362
+ second: "2-digit",
363
+ hour12: false,
364
+ }).formatToParts(new Date(utcGuess));
365
+ const get = (type: string) =>
366
+ Number(parts.find((p) => p.type === type)?.value ?? "0");
367
+ const tzAsUtc = Date.UTC(
368
+ get("year"),
369
+ get("month") - 1,
370
+ get("day"),
371
+ get("hour") % 24,
372
+ get("minute"),
373
+ get("second"),
374
+ );
375
+ const offsetMs = utcGuess - tzAsUtc;
376
+ return new Date(utcGuess + offsetMs);
377
+ } catch {
378
+ // Unknown timezone — fall through to local time interpretation
379
+ }
380
+ }
381
+ }
382
+
383
+ return new Date(year, month, day, hour, minute, second);
384
+ }
385
+ return new Date(year, month, day);
309
386
  }
310
387
 
311
388
  export function unfoldICalLines(text: string): string {
312
- return text.replace(/(\r\n|\r|\n)[ \t]/g, "");
389
+ return text.replace(/(\r\n|\r|\n)[ \t]/g, "");
313
390
  }
314
391
 
315
392
  export function formatICalDate(date: Date): string {
316
- const year = date.getFullYear();
317
- const month = String(date.getMonth() + 1).padStart(2, "0");
318
- const day = String(date.getDate()).padStart(2, "0");
319
- const hours = String(date.getHours()).padStart(2, "0");
320
- const minutes = String(date.getMinutes()).padStart(2, "0");
321
- const seconds = String(date.getSeconds()).padStart(2, "0");
322
- return `${year}${month}${day}T${hours}${minutes}${seconds}`;
393
+ const year = date.getFullYear();
394
+ const month = String(date.getMonth() + 1).padStart(2, "0");
395
+ const day = String(date.getDate()).padStart(2, "0");
396
+ const hours = String(date.getHours()).padStart(2, "0");
397
+ const minutes = String(date.getMinutes()).padStart(2, "0");
398
+ const seconds = String(date.getSeconds()).padStart(2, "0");
399
+ return `${year}${month}${day}T${hours}${minutes}${seconds}`;
323
400
  }
324
401
 
325
402
  export function formatICalDateOnly(date: Date): string {
326
- const year = date.getFullYear();
327
- const month = String(date.getMonth() + 1).padStart(2, "0");
328
- const day = String(date.getDate()).padStart(2, "0");
329
- return `${year}${month}${day}`;
403
+ const year = date.getFullYear();
404
+ const month = String(date.getMonth() + 1).padStart(2, "0");
405
+ const day = String(date.getDate()).padStart(2, "0");
406
+ return `${year}${month}${day}`;
330
407
  }
331
408
 
332
409
  function parseICalPerson(line: string): Organizer {
333
- const colonIdx = line.indexOf(":");
334
- const keyPart = line.slice(0, colonIdx);
335
- const value = line.slice(colonIdx + 1);
410
+ const colonIdx = line.indexOf(":");
411
+ const keyPart = line.slice(0, colonIdx);
412
+ const value = line.slice(colonIdx + 1);
336
413
 
337
- const emailMatch = value.match(/mailto:([^\s]+)/i) || value.match(/([^\s@]+@[^\s]+)/);
338
- const email = emailMatch ? emailMatch[1] : value;
414
+ const emailMatch =
415
+ value.match(/mailto:([^\s]+)/i) || value.match(/([^\s@]+@[^\s]+)/);
416
+ const email = emailMatch ? emailMatch[1] : value;
339
417
 
340
- const cnMatch = keyPart.match(/CN=([^;:]+)/i);
341
- const name = cnMatch ? cnMatch[1].replace(/^"(.*)"$/, '$1') : undefined;
418
+ const cnMatch = keyPart.match(/CN=([^;:]+)/i);
419
+ const name = cnMatch ? cnMatch[1].replace(/^"(.*)"$/, "$1") : undefined;
342
420
 
343
- return { email, name };
421
+ return { email, name };
344
422
  }
345
423
 
346
424
  function parseICalAttendee(line: string): Attendee {
347
- const colonIdx = line.indexOf(":");
348
- const keyPart = line.slice(0, colonIdx);
349
- const value = line.slice(colonIdx + 1);
425
+ const colonIdx = line.indexOf(":");
426
+ const keyPart = line.slice(0, colonIdx);
427
+ const value = line.slice(colonIdx + 1);
350
428
 
351
- const emailMatch = value.match(/mailto:([^\s]+)/i) || value.match(/([^\s@]+@[^\s]+)/);
352
- const email = emailMatch ? emailMatch[1] : value;
429
+ const emailMatch =
430
+ value.match(/mailto:([^\s]+)/i) || value.match(/([^\s@]+@[^\s]+)/);
431
+ const email = emailMatch ? emailMatch[1] : value;
353
432
 
354
- const cnMatch = keyPart.match(/CN=([^;:]+)/i);
355
- const name = cnMatch ? cnMatch[1].replace(/^"(.*)"$/, '$1') : undefined;
433
+ const cnMatch = keyPart.match(/CN=([^;:]+)/i);
434
+ const name = cnMatch ? cnMatch[1].replace(/^"(.*)"$/, "$1") : undefined;
356
435
 
357
- const roleMatch = keyPart.match(/ROLE=([^;:]+)/i);
358
- const role = roleMatch ? roleMatch[1] as Attendee['role'] : undefined;
436
+ const roleMatch = keyPart.match(/ROLE=([^;:]+)/i);
437
+ const role = roleMatch ? (roleMatch[1] as Attendee["role"]) : undefined;
359
438
 
360
- const partstatMatch = keyPart.match(/PARTSTAT=([^;:]+)/i);
361
- const status = partstatMatch ? partstatMatch[1] as Attendee['status'] : undefined;
439
+ const partstatMatch = keyPart.match(/PARTSTAT=([^;:]+)/i);
440
+ const status = partstatMatch
441
+ ? (partstatMatch[1] as Attendee["status"])
442
+ : undefined;
362
443
 
363
- return { email, name, role, status };
444
+ return { email, name, role, status };
364
445
  }
365
446
 
366
447
  export function serializeEventsToICal(events: CalendarEvent[]): string {
367
- const lines = [
368
- "BEGIN:VCALENDAR",
369
- "VERSION:2.0",
370
- "PRODID:-//Calendar//EN",
371
- ];
372
-
373
- for (const event of events) {
374
- lines.push("BEGIN:VEVENT");
375
- lines.push(`UID:${event.id}`);
376
- lines.push(`SUMMARY:${event.title}`);
377
- if (event.isAllDay) {
378
- lines.push(`DTSTART;VALUE=DATE:${formatICalDateOnly(event.start)}`);
379
- lines.push(`DTEND;VALUE=DATE:${formatICalDateOnly(event.end)}`);
380
- } else {
381
- lines.push(`DTSTART:${formatICalDate(event.start)}`);
382
- lines.push(`DTEND:${formatICalDate(event.end)}`);
383
- }
384
-
385
- if (event.description) {
386
- lines.push(`DESCRIPTION:${event.description}`);
387
- }
388
- if (event.location) {
389
- lines.push(`LOCATION:${event.location}`);
390
- }
391
- if (event.url) {
392
- lines.push(`URL:${event.url}`);
393
- }
394
-
395
- lines.push("END:VEVENT");
396
- }
397
-
398
- lines.push("END:VCALENDAR");
399
- return lines.join("\r\n");
448
+ const lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Calendar//EN"];
449
+
450
+ for (const event of events) {
451
+ lines.push("BEGIN:VEVENT");
452
+ lines.push(`UID:${event.id}`);
453
+ lines.push(`SUMMARY:${event.title}`);
454
+ if (event.isAllDay) {
455
+ lines.push(`DTSTART;VALUE=DATE:${formatICalDateOnly(event.start)}`);
456
+ lines.push(`DTEND;VALUE=DATE:${formatICalDateOnly(event.end)}`);
457
+ } else {
458
+ lines.push(`DTSTART:${formatICalDate(event.start)}`);
459
+ lines.push(`DTEND:${formatICalDate(event.end)}`);
460
+ }
461
+
462
+ if (event.description) {
463
+ lines.push(`DESCRIPTION:${event.description}`);
464
+ }
465
+ if (event.location) {
466
+ lines.push(`LOCATION:${event.location}`);
467
+ }
468
+ if (event.url) {
469
+ lines.push(`URL:${event.url}`);
470
+ }
471
+
472
+ lines.push("END:VEVENT");
473
+ }
474
+
475
+ lines.push("END:VCALENDAR");
476
+ return lines.join("\r\n");
400
477
  }