@luckydye/calendar 1.1.1 → 1.1.2
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/package.json +4 -3
- package/src/ActiveCalendarStore.ts +96 -0
- package/src/CalDAVConfig.ts +1000 -0
- package/src/CalDAVSource.ts +506 -0
- package/src/CalendarIntegration.ts +68 -0
- package/src/CalendarInternal.ts +609 -0
- package/src/CalendarStorage.ts +54 -0
- package/src/CalendarView.ts +5290 -0
- package/src/Color.ts +64 -0
- package/src/GoogleCalendarSource.ts +717 -0
- package/src/ICal.ts +400 -0
- package/src/InMemorySource.ts +89 -0
- package/src/IndexedDBStorage.ts +393 -0
- package/src/InhouseBookingSource.ts +237 -0
- package/src/NotificationScheduler.ts +91 -0
- package/src/StatusBar.ts +128 -0
- package/src/StatusMessage.ts +122 -0
- package/src/Theme.ts +228 -0
- package/src/app.css +4 -0
- package/src/app.ts +932 -0
- package/src/lib.ts +4 -0
- package/src/service-worker.js +177 -0
package/src/ICal.ts
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import type { CalendarCredentials } from './CalendarIntegration.js';
|
|
2
|
+
import type { CalendarEvent, Attendee, Organizer, NotificationConfig } from './CalendarInternal.js';
|
|
3
|
+
|
|
4
|
+
export interface ParseResult {
|
|
5
|
+
events: CalendarEvent[];
|
|
6
|
+
notifications: Map<string, NotificationConfig[]>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
export function parseICalEvents(icalText: string, color: string, calendar: string): CalendarEvent[] {
|
|
11
|
+
const result = parseICalEventsWithNotifications(icalText, color, calendar);
|
|
12
|
+
return result.events;
|
|
13
|
+
}
|
|
14
|
+
|
|
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 };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
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;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseTriggerDuration(trigger: string): number | null {
|
|
146
|
+
// Parse ISO 8601 duration format: -PT15M, -PT1H, -P1D, etc.
|
|
147
|
+
// Negative means before the event
|
|
148
|
+
|
|
149
|
+
const match = trigger.match(/^(-?)P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/);
|
|
150
|
+
if (!match) return null;
|
|
151
|
+
|
|
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);
|
|
156
|
+
|
|
157
|
+
const totalMinutes = days * 24 * 60 + hours * 60 + minutes;
|
|
158
|
+
|
|
159
|
+
// Only support "before" triggers (negative)
|
|
160
|
+
return isNegative ? totalMinutes : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Parses a single VCALENDAR/VEVENT block into a partial CalendarEvent.
|
|
165
|
+
* Used by CalDAV sources where each event arrives as its own iCal string.
|
|
166
|
+
*/
|
|
167
|
+
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>;
|
|
225
|
+
}
|
|
226
|
+
|
|
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);
|
|
241
|
+
}
|
|
242
|
+
|
|
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);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
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);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function unfoldICalLines(text: string): string {
|
|
312
|
+
return text.replace(/(\r\n|\r|\n)[ \t]/g, "");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
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}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
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}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function parseICalPerson(line: string): Organizer {
|
|
333
|
+
const colonIdx = line.indexOf(":");
|
|
334
|
+
const keyPart = line.slice(0, colonIdx);
|
|
335
|
+
const value = line.slice(colonIdx + 1);
|
|
336
|
+
|
|
337
|
+
const emailMatch = value.match(/mailto:([^\s]+)/i) || value.match(/([^\s@]+@[^\s]+)/);
|
|
338
|
+
const email = emailMatch ? emailMatch[1] : value;
|
|
339
|
+
|
|
340
|
+
const cnMatch = keyPart.match(/CN=([^;:]+)/i);
|
|
341
|
+
const name = cnMatch ? cnMatch[1].replace(/^"(.*)"$/, '$1') : undefined;
|
|
342
|
+
|
|
343
|
+
return { email, name };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function parseICalAttendee(line: string): Attendee {
|
|
347
|
+
const colonIdx = line.indexOf(":");
|
|
348
|
+
const keyPart = line.slice(0, colonIdx);
|
|
349
|
+
const value = line.slice(colonIdx + 1);
|
|
350
|
+
|
|
351
|
+
const emailMatch = value.match(/mailto:([^\s]+)/i) || value.match(/([^\s@]+@[^\s]+)/);
|
|
352
|
+
const email = emailMatch ? emailMatch[1] : value;
|
|
353
|
+
|
|
354
|
+
const cnMatch = keyPart.match(/CN=([^;:]+)/i);
|
|
355
|
+
const name = cnMatch ? cnMatch[1].replace(/^"(.*)"$/, '$1') : undefined;
|
|
356
|
+
|
|
357
|
+
const roleMatch = keyPart.match(/ROLE=([^;:]+)/i);
|
|
358
|
+
const role = roleMatch ? roleMatch[1] as Attendee['role'] : undefined;
|
|
359
|
+
|
|
360
|
+
const partstatMatch = keyPart.match(/PARTSTAT=([^;:]+)/i);
|
|
361
|
+
const status = partstatMatch ? partstatMatch[1] as Attendee['status'] : undefined;
|
|
362
|
+
|
|
363
|
+
return { email, name, role, status };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
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");
|
|
400
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { CalendarEvent } from "./CalendarInternal.js";
|
|
2
|
+
import type { CalendarSource, CalendarCredentials } from "./CalendarIntegration.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-memory calendar source for testing event creation and moving.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const source = new InMemorySource('test', 'Test Calendar', '#3b82f6');
|
|
9
|
+
* await source.createEvent({
|
|
10
|
+
* id: 'event-1',
|
|
11
|
+
* title: 'Test Event',
|
|
12
|
+
* start: new Date('2026-02-15T10:00:00'),
|
|
13
|
+
* end: new Date('2026-02-15T11:00:00'),
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* // Use with CalendarIntegration
|
|
17
|
+
* await integration.sync(source);
|
|
18
|
+
*/
|
|
19
|
+
export class InMemorySource implements CalendarSource {
|
|
20
|
+
readonly type = 'in-memory';
|
|
21
|
+
readonly credentials: CalendarCredentials = {};
|
|
22
|
+
events: Map<string, CalendarEvent> = new Map();
|
|
23
|
+
|
|
24
|
+
constructor(
|
|
25
|
+
public id: string,
|
|
26
|
+
public name: string,
|
|
27
|
+
public color: string = '#3b82f6',
|
|
28
|
+
public enabled: boolean = true
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
async fetchEvents(): Promise<CalendarEvent[]> {
|
|
32
|
+
return Array.from(this.events.values());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async createEvent(event: Omit<CalendarEvent, 'calendar' | 'color'>): Promise<CalendarEvent> {
|
|
36
|
+
const newEvent: CalendarEvent = {
|
|
37
|
+
...event,
|
|
38
|
+
calendar: this.name,
|
|
39
|
+
calendarId: this.id,
|
|
40
|
+
sourceId: this.id,
|
|
41
|
+
color: this.color,
|
|
42
|
+
};
|
|
43
|
+
this.events.set(newEvent.id, newEvent);
|
|
44
|
+
return newEvent;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async updateEvent(id: string, updates: Partial<CalendarEvent>): Promise<CalendarEvent> {
|
|
48
|
+
const event = this.events.get(id);
|
|
49
|
+
if (!event) {
|
|
50
|
+
throw new Error(`Event ${id} not found`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const updated = {
|
|
54
|
+
...event,
|
|
55
|
+
...updates,
|
|
56
|
+
};
|
|
57
|
+
this.events.set(id, updated);
|
|
58
|
+
return updated;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async moveEvent(id: string, newStart: Date, newEnd: Date): Promise<CalendarEvent> {
|
|
62
|
+
const event = this.events.get(id);
|
|
63
|
+
if (!event) {
|
|
64
|
+
throw new Error(`Event ${id} not found`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const moved = {
|
|
68
|
+
...event,
|
|
69
|
+
start: newStart,
|
|
70
|
+
end: newEnd,
|
|
71
|
+
};
|
|
72
|
+
this.events.set(id, moved);
|
|
73
|
+
return moved;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async deleteEvent(id: string): Promise<void> {
|
|
77
|
+
const deleted = this.events.delete(id);
|
|
78
|
+
if (!deleted) {
|
|
79
|
+
throw new Error(`Event ${id} not found`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Clears all events.
|
|
85
|
+
*/
|
|
86
|
+
clear(): void {
|
|
87
|
+
this.events.clear();
|
|
88
|
+
}
|
|
89
|
+
}
|