@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/dist/calendar.js +2344 -2061
- package/package.json +7 -1
- package/src/ActiveCalendarStore.ts +88 -88
- package/src/CalDAVConfig.ts +611 -514
- package/src/CalDAVSource.ts +561 -466
- package/src/CalendarIntegration.ts +64 -47
- package/src/CalendarInternal.ts +645 -614
- package/src/CalendarLayer.ts +1 -0
- package/src/CalendarStorage.ts +51 -48
- package/src/CalendarView.ts +883 -507
- package/src/Color.ts +48 -54
- package/src/GoogleCalendarSource.ts +758 -662
- package/src/ICal.ts +420 -348
- package/src/InMemorySource.ts +56 -48
- package/src/IndexedDBStorage.ts +444 -398
- package/src/InhouseBookingSource.ts +614 -523
- package/src/Keybinds.ts +6 -1
- package/src/NotificationScheduler.ts +11 -8
- package/src/StatusBar.ts +12 -8
- package/src/StatusMessage.ts +2 -2
- package/src/Theme.ts +21 -7
- package/src/TimeseriesJson.ts +98 -98
- package/src/app.ts +153 -78
- package/src/layers/EventsLayer.ts +530 -400
- package/src/layers/GridLayer.ts +45 -125
- package/src/layers/TimeseriesHeatmapLayer.ts +123 -120
- package/src/service-worker.js +3 -2
package/src/ICal.ts
CHANGED
|
@@ -1,166 +1,193 @@
|
|
|
1
|
-
import type { CalendarCredentials } from
|
|
2
|
-
import type {
|
|
3
|
-
|
|
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
|
-
|
|
7
|
-
|
|
11
|
+
events: CalendarEvent[];
|
|
12
|
+
notifications: Map<string, NotificationConfig[]>;
|
|
8
13
|
}
|
|
9
14
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
|
|
174
|
+
// Parse ISO 8601 duration format: -PT15M, -PT1H, -P1D, etc.
|
|
175
|
+
// Negative means before the event
|
|
151
176
|
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
187
|
+
const totalMinutes = days * 24 * 60 + hours * 60 + minutes;
|
|
161
188
|
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
389
|
+
return text.replace(/(\r\n|\r|\n)[ \t]/g, "");
|
|
318
390
|
}
|
|
319
391
|
|
|
320
392
|
export function formatICalDate(date: Date): string {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
410
|
+
const colonIdx = line.indexOf(":");
|
|
411
|
+
const keyPart = line.slice(0, colonIdx);
|
|
412
|
+
const value = line.slice(colonIdx + 1);
|
|
341
413
|
|
|
342
|
-
|
|
343
|
-
|
|
414
|
+
const emailMatch =
|
|
415
|
+
value.match(/mailto:([^\s]+)/i) || value.match(/([^\s@]+@[^\s]+)/);
|
|
416
|
+
const email = emailMatch ? emailMatch[1] : value;
|
|
344
417
|
|
|
345
|
-
|
|
346
|
-
|
|
418
|
+
const cnMatch = keyPart.match(/CN=([^;:]+)/i);
|
|
419
|
+
const name = cnMatch ? cnMatch[1].replace(/^"(.*)"$/, "$1") : undefined;
|
|
347
420
|
|
|
348
|
-
|
|
421
|
+
return { email, name };
|
|
349
422
|
}
|
|
350
423
|
|
|
351
424
|
function parseICalAttendee(line: string): Attendee {
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
425
|
+
const colonIdx = line.indexOf(":");
|
|
426
|
+
const keyPart = line.slice(0, colonIdx);
|
|
427
|
+
const value = line.slice(colonIdx + 1);
|
|
355
428
|
|
|
356
|
-
|
|
357
|
-
|
|
429
|
+
const emailMatch =
|
|
430
|
+
value.match(/mailto:([^\s]+)/i) || value.match(/([^\s@]+@[^\s]+)/);
|
|
431
|
+
const email = emailMatch ? emailMatch[1] : value;
|
|
358
432
|
|
|
359
|
-
|
|
360
|
-
|
|
433
|
+
const cnMatch = keyPart.match(/CN=([^;:]+)/i);
|
|
434
|
+
const name = cnMatch ? cnMatch[1].replace(/^"(.*)"$/, "$1") : undefined;
|
|
361
435
|
|
|
362
|
-
|
|
363
|
-
|
|
436
|
+
const roleMatch = keyPart.match(/ROLE=([^;:]+)/i);
|
|
437
|
+
const role = roleMatch ? (roleMatch[1] as Attendee["role"]) : undefined;
|
|
364
438
|
|
|
365
|
-
|
|
366
|
-
|
|
439
|
+
const partstatMatch = keyPart.match(/PARTSTAT=([^;:]+)/i);
|
|
440
|
+
const status = partstatMatch
|
|
441
|
+
? (partstatMatch[1] as Attendee["status"])
|
|
442
|
+
: undefined;
|
|
367
443
|
|
|
368
|
-
|
|
444
|
+
return { email, name, role, status };
|
|
369
445
|
}
|
|
370
446
|
|
|
371
447
|
export function serializeEventsToICal(events: CalendarEvent[]): string {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
}
|