@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/dist/calendar.js +2777 -2010
- package/package.json +7 -1
- package/src/ActiveCalendarStore.ts +88 -88
- package/src/CalDAVConfig.ts +611 -514
- package/src/CalDAVSource.ts +561 -433
- package/src/CalendarIntegration.ts +64 -47
- package/src/CalendarInternal.ts +645 -613
- package/src/CalendarLayer.ts +1 -0
- package/src/CalendarStorage.ts +51 -48
- package/src/CalendarView.ts +1085 -505
- package/src/Color.ts +48 -54
- package/src/DescriptionSanitizer.ts +10 -0
- package/src/GoogleCalendarSource.ts +758 -661
- package/src/ICal.ts +420 -343
- package/src/InMemorySource.ts +56 -48
- package/src/IndexedDBStorage.ts +444 -395
- package/src/InhouseBookingSource.ts +614 -522
- 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 +301 -115
- 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,163 +1,193 @@
|
|
|
1
|
-
import type { CalendarCredentials } from
|
|
2
|
-
import type {
|
|
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
|
-
|
|
6
|
-
|
|
11
|
+
events: CalendarEvent[];
|
|
12
|
+
notifications: Map<string, NotificationConfig[]>;
|
|
7
13
|
}
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
147
|
-
|
|
174
|
+
// Parse ISO 8601 duration format: -PT15M, -PT1H, -P1D, etc.
|
|
175
|
+
// Negative means before the event
|
|
148
176
|
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
187
|
+
const totalMinutes = days * 24 * 60 + hours * 60 + minutes;
|
|
158
188
|
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
+
return text.replace(/(\r\n|\r|\n)[ \t]/g, "");
|
|
313
390
|
}
|
|
314
391
|
|
|
315
392
|
export function formatICalDate(date: Date): string {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
410
|
+
const colonIdx = line.indexOf(":");
|
|
411
|
+
const keyPart = line.slice(0, colonIdx);
|
|
412
|
+
const value = line.slice(colonIdx + 1);
|
|
336
413
|
|
|
337
|
-
|
|
338
|
-
|
|
414
|
+
const emailMatch =
|
|
415
|
+
value.match(/mailto:([^\s]+)/i) || value.match(/([^\s@]+@[^\s]+)/);
|
|
416
|
+
const email = emailMatch ? emailMatch[1] : value;
|
|
339
417
|
|
|
340
|
-
|
|
341
|
-
|
|
418
|
+
const cnMatch = keyPart.match(/CN=([^;:]+)/i);
|
|
419
|
+
const name = cnMatch ? cnMatch[1].replace(/^"(.*)"$/, "$1") : undefined;
|
|
342
420
|
|
|
343
|
-
|
|
421
|
+
return { email, name };
|
|
344
422
|
}
|
|
345
423
|
|
|
346
424
|
function parseICalAttendee(line: string): Attendee {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
425
|
+
const colonIdx = line.indexOf(":");
|
|
426
|
+
const keyPart = line.slice(0, colonIdx);
|
|
427
|
+
const value = line.slice(colonIdx + 1);
|
|
350
428
|
|
|
351
|
-
|
|
352
|
-
|
|
429
|
+
const emailMatch =
|
|
430
|
+
value.match(/mailto:([^\s]+)/i) || value.match(/([^\s@]+@[^\s]+)/);
|
|
431
|
+
const email = emailMatch ? emailMatch[1] : value;
|
|
353
432
|
|
|
354
|
-
|
|
355
|
-
|
|
433
|
+
const cnMatch = keyPart.match(/CN=([^;:]+)/i);
|
|
434
|
+
const name = cnMatch ? cnMatch[1].replace(/^"(.*)"$/, "$1") : undefined;
|
|
356
435
|
|
|
357
|
-
|
|
358
|
-
|
|
436
|
+
const roleMatch = keyPart.match(/ROLE=([^;:]+)/i);
|
|
437
|
+
const role = roleMatch ? (roleMatch[1] as Attendee["role"]) : undefined;
|
|
359
438
|
|
|
360
|
-
|
|
361
|
-
|
|
439
|
+
const partstatMatch = keyPart.match(/PARTSTAT=([^;:]+)/i);
|
|
440
|
+
const status = partstatMatch
|
|
441
|
+
? (partstatMatch[1] as Attendee["status"])
|
|
442
|
+
: undefined;
|
|
362
443
|
|
|
363
|
-
|
|
444
|
+
return { email, name, role, status };
|
|
364
445
|
}
|
|
365
446
|
|
|
366
447
|
export function serializeEventsToICal(events: CalendarEvent[]): string {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
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
|
}
|