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