@forcecalendar/core 0.2.0 → 0.2.1

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.
@@ -0,0 +1,475 @@
1
+ /**
2
+ * ICS (iCalendar) Parser
3
+ * Converts between ICS format and Calendar events
4
+ * RFC 5545 compliant
5
+ */
6
+
7
+ export class ICSParser {
8
+ constructor() {
9
+ // ICS line folding max width
10
+ this.maxLineLength = 75;
11
+
12
+ // Property mappings
13
+ this.propertyMap = {
14
+ 'SUMMARY': 'title',
15
+ 'DESCRIPTION': 'description',
16
+ 'LOCATION': 'location',
17
+ 'DTSTART': 'start',
18
+ 'DTEND': 'end',
19
+ 'UID': 'id',
20
+ 'CATEGORIES': 'category',
21
+ 'STATUS': 'status',
22
+ 'TRANSP': 'showAs',
23
+ 'ORGANIZER': 'organizer',
24
+ 'ATTENDEE': 'attendees',
25
+ 'RRULE': 'recurrence',
26
+ 'EXDATE': 'excludeDates'
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Parse ICS string into events
32
+ * @param {string} icsString - The ICS formatted string
33
+ * @returns {Array} Array of event objects
34
+ */
35
+ parse(icsString) {
36
+ const events = [];
37
+ const lines = this.unfoldLines(icsString);
38
+ let currentEvent = null;
39
+ let inEvent = false;
40
+ let inAlarm = false;
41
+
42
+ for (let line of lines) {
43
+ // Skip empty lines
44
+ if (!line.trim()) continue;
45
+
46
+ // Parse property and value
47
+ const colonIndex = line.indexOf(':');
48
+ const semicolonIndex = line.indexOf(';');
49
+ const separatorIndex = semicolonIndex > -1 && semicolonIndex < colonIndex
50
+ ? semicolonIndex
51
+ : colonIndex;
52
+
53
+ if (separatorIndex === -1) continue;
54
+
55
+ const property = line.substring(0, separatorIndex);
56
+ const value = line.substring(colonIndex + 1);
57
+
58
+ // Handle component boundaries
59
+ if (property === 'BEGIN') {
60
+ if (value === 'VEVENT') {
61
+ inEvent = true;
62
+ currentEvent = this.createEmptyEvent();
63
+ } else if (value === 'VALARM') {
64
+ inAlarm = true;
65
+ }
66
+ } else if (property === 'END') {
67
+ if (value === 'VEVENT' && currentEvent) {
68
+ events.push(this.normalizeEvent(currentEvent));
69
+ currentEvent = null;
70
+ inEvent = false;
71
+ } else if (value === 'VALARM') {
72
+ inAlarm = false;
73
+ }
74
+ } else if (inEvent && !inAlarm && currentEvent) {
75
+ // Parse event properties
76
+ this.parseProperty(property, value, currentEvent);
77
+ }
78
+ }
79
+
80
+ return events;
81
+ }
82
+
83
+ /**
84
+ * Export events to ICS format
85
+ * @param {Array} events - Array of event objects
86
+ * @param {string} calendarName - Name of the calendar
87
+ * @returns {string} ICS formatted string
88
+ */
89
+ export(events, calendarName = 'Lightning Calendar') {
90
+ const lines = [];
91
+
92
+ // Calendar header
93
+ lines.push('BEGIN:VCALENDAR');
94
+ lines.push('VERSION:2.0');
95
+ lines.push('PRODID:-//Force Calendar Core//EN');
96
+ lines.push(`X-WR-CALNAME:${calendarName}`);
97
+ lines.push('METHOD:PUBLISH');
98
+
99
+ // Add each event
100
+ for (const event of events) {
101
+ lines.push(...this.eventToICS(event));
102
+ }
103
+
104
+ // Calendar footer
105
+ lines.push('END:VCALENDAR');
106
+
107
+ // Fold long lines and join
108
+ return this.foldLines(lines).join('\r\n');
109
+ }
110
+
111
+ /**
112
+ * Convert single event to ICS lines
113
+ * @private
114
+ */
115
+ eventToICS(event) {
116
+ const lines = [];
117
+ lines.push('BEGIN:VEVENT');
118
+
119
+ // UID (required)
120
+ lines.push(`UID:${event.id || this.generateUID()}`);
121
+
122
+ // Timestamps
123
+ lines.push(`DTSTAMP:${this.formatDate(new Date())}`);
124
+
125
+ // Start and end dates
126
+ if (event.allDay) {
127
+ lines.push(`DTSTART;VALUE=DATE:${this.formatDate(event.start, true)}`);
128
+ if (event.end) {
129
+ lines.push(`DTEND;VALUE=DATE:${this.formatDate(event.end, true)}`);
130
+ }
131
+ } else {
132
+ const tzid = event.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
133
+ lines.push(`DTSTART;TZID=${tzid}:${this.formatDate(event.start)}`);
134
+ if (event.end) {
135
+ lines.push(`DTEND;TZID=${tzid}:${this.formatDate(event.end)}`);
136
+ }
137
+ }
138
+
139
+ // Basic properties
140
+ if (event.title) lines.push(`SUMMARY:${this.escapeText(event.title)}`);
141
+ if (event.description) lines.push(`DESCRIPTION:${this.escapeText(event.description)}`);
142
+ if (event.location) lines.push(`LOCATION:${this.escapeText(event.location)}`);
143
+
144
+ // Status
145
+ if (event.status) {
146
+ const statusMap = {
147
+ 'tentative': 'TENTATIVE',
148
+ 'confirmed': 'CONFIRMED',
149
+ 'cancelled': 'CANCELLED'
150
+ };
151
+ lines.push(`STATUS:${statusMap[event.status] || 'CONFIRMED'}`);
152
+ }
153
+
154
+ // Show as
155
+ if (event.showAs) {
156
+ const transpMap = {
157
+ 'busy': 'OPAQUE',
158
+ 'free': 'TRANSPARENT'
159
+ };
160
+ lines.push(`TRANSP:${transpMap[event.showAs] || 'OPAQUE'}`);
161
+ }
162
+
163
+ // Categories
164
+ if (event.category) {
165
+ lines.push(`CATEGORIES:${event.category}`);
166
+ }
167
+
168
+ // Organizer
169
+ if (event.organizer) {
170
+ const org = typeof event.organizer === 'string'
171
+ ? event.organizer
172
+ : event.organizer.email || event.organizer.name;
173
+ lines.push(`ORGANIZER:mailto:${org}`);
174
+ }
175
+
176
+ // Attendees
177
+ if (event.attendees && event.attendees.length > 0) {
178
+ for (const attendee of event.attendees) {
179
+ const email = attendee.email || attendee;
180
+ if (email) {
181
+ lines.push(`ATTENDEE:mailto:${email}`);
182
+ }
183
+ }
184
+ }
185
+
186
+ // Recurrence
187
+ if (event.recurrence) {
188
+ if (typeof event.recurrence === 'string') {
189
+ // Already in RRULE format
190
+ if (event.recurrence.startsWith('RRULE:')) {
191
+ lines.push(event.recurrence);
192
+ } else {
193
+ lines.push(`RRULE:${event.recurrence}`);
194
+ }
195
+ } else if (typeof event.recurrence === 'object') {
196
+ // Convert object to RRULE
197
+ lines.push(`RRULE:${this.objectToRRule(event.recurrence)}`);
198
+ }
199
+ }
200
+
201
+ // Reminders/Alarms
202
+ if (event.reminders && event.reminders.length > 0) {
203
+ for (const reminder of event.reminders) {
204
+ lines.push('BEGIN:VALARM');
205
+ lines.push('ACTION:DISPLAY');
206
+ lines.push(`TRIGGER:-PT${reminder.minutes || 15}M`);
207
+ lines.push(`DESCRIPTION:${event.title || 'Reminder'}`);
208
+ lines.push('END:VALARM');
209
+ }
210
+ }
211
+
212
+ lines.push('END:VEVENT');
213
+ return lines;
214
+ }
215
+
216
+ /**
217
+ * Parse ICS property into event object
218
+ * @private
219
+ */
220
+ parseProperty(property, value, event) {
221
+ // Extract actual property name (before parameters)
222
+ const propName = property.split(';')[0];
223
+
224
+ // Map to event property
225
+ const eventProp = this.propertyMap[propName];
226
+ if (!eventProp) return;
227
+
228
+ switch (propName) {
229
+ case 'DTSTART':
230
+ case 'DTEND':
231
+ event[eventProp] = this.parseDate(value, property);
232
+ if (property.includes('VALUE=DATE')) {
233
+ event.allDay = true;
234
+ }
235
+ break;
236
+
237
+ case 'SUMMARY':
238
+ case 'DESCRIPTION':
239
+ case 'LOCATION':
240
+ event[eventProp] = this.unescapeText(value);
241
+ break;
242
+
243
+ case 'UID':
244
+ event.id = value;
245
+ break;
246
+
247
+ case 'CATEGORIES':
248
+ event.category = value.split(',')[0]; // Take first category
249
+ break;
250
+
251
+ case 'STATUS':
252
+ const statusMap = {
253
+ 'TENTATIVE': 'tentative',
254
+ 'CONFIRMED': 'confirmed',
255
+ 'CANCELLED': 'cancelled'
256
+ };
257
+ event.status = statusMap[value] || 'confirmed';
258
+ break;
259
+
260
+ case 'TRANSP':
261
+ event.showAs = value === 'TRANSPARENT' ? 'free' : 'busy';
262
+ break;
263
+
264
+ case 'ORGANIZER':
265
+ event.organizer = value.replace('mailto:', '');
266
+ break;
267
+
268
+ case 'ATTENDEE':
269
+ if (!event.attendees) event.attendees = [];
270
+ const email = value.replace('mailto:', '');
271
+ event.attendees.push({
272
+ email: email,
273
+ name: email.split('@')[0] // Use email prefix as name
274
+ });
275
+ break;
276
+
277
+ case 'RRULE':
278
+ event.recurrence = value;
279
+ break;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Parse ICS date string
285
+ * @private
286
+ */
287
+ parseDate(dateString, property = '') {
288
+ // Remove timezone if present
289
+ dateString = dateString.replace(/^TZID=[^:]+:/, '');
290
+
291
+ // Check if it's a date-only value
292
+ if (property.includes('VALUE=DATE') || dateString.length === 8) {
293
+ // YYYYMMDD format
294
+ const year = dateString.substr(0, 4);
295
+ const month = dateString.substr(4, 2);
296
+ const day = dateString.substr(6, 2);
297
+ return new Date(year, month - 1, day);
298
+ }
299
+
300
+ // Full datetime: YYYYMMDDTHHMMSS[Z]
301
+ const year = parseInt(dateString.substr(0, 4));
302
+ const month = parseInt(dateString.substr(4, 2)) - 1;
303
+ const day = parseInt(dateString.substr(6, 2));
304
+ const hour = parseInt(dateString.substr(9, 2)) || 0;
305
+ const minute = parseInt(dateString.substr(11, 2)) || 0;
306
+ const second = parseInt(dateString.substr(13, 2)) || 0;
307
+
308
+ if (dateString.endsWith('Z')) {
309
+ // UTC time
310
+ return new Date(Date.UTC(year, month, day, hour, minute, second));
311
+ } else {
312
+ // Local time
313
+ return new Date(year, month, day, hour, minute, second);
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Format date for ICS
319
+ * @private
320
+ */
321
+ formatDate(date, dateOnly = false) {
322
+ const year = date.getFullYear();
323
+ const month = String(date.getMonth() + 1).padStart(2, '0');
324
+ const day = String(date.getDate()).padStart(2, '0');
325
+
326
+ if (dateOnly) {
327
+ return `${year}${month}${day}`;
328
+ }
329
+
330
+ const hour = String(date.getHours()).padStart(2, '0');
331
+ const minute = String(date.getMinutes()).padStart(2, '0');
332
+ const second = String(date.getSeconds()).padStart(2, '0');
333
+
334
+ return `${year}${month}${day}T${hour}${minute}${second}`;
335
+ }
336
+
337
+ /**
338
+ * Unfold ICS lines (reverse line folding)
339
+ * @private
340
+ */
341
+ unfoldLines(icsString) {
342
+ return icsString
343
+ .replace(/\r\n /g, '')
344
+ .replace(/\n /g, '')
345
+ .split(/\r?\n/);
346
+ }
347
+
348
+ /**
349
+ * Fold long lines per ICS spec
350
+ * @private
351
+ */
352
+ foldLines(lines) {
353
+ return lines.map(line => {
354
+ if (line.length <= this.maxLineLength) {
355
+ return line;
356
+ }
357
+
358
+ const folded = [];
359
+ let remaining = line;
360
+
361
+ // First line
362
+ folded.push(remaining.substr(0, this.maxLineLength));
363
+ remaining = remaining.substr(this.maxLineLength);
364
+
365
+ // Continuation lines (with space prefix)
366
+ while (remaining.length > 0) {
367
+ const chunk = remaining.substr(0, this.maxLineLength - 1);
368
+ folded.push(' ' + chunk);
369
+ remaining = remaining.substr(chunk.length);
370
+ }
371
+
372
+ return folded.join('\r\n');
373
+ }).flat();
374
+ }
375
+
376
+ /**
377
+ * Escape special characters for ICS
378
+ * @private
379
+ */
380
+ escapeText(text) {
381
+ if (!text) return '';
382
+ return text
383
+ .replace(/\\/g, '\\\\')
384
+ .replace(/;/g, '\\;')
385
+ .replace(/,/g, '\\,')
386
+ .replace(/\n/g, '\\n');
387
+ }
388
+
389
+ /**
390
+ * Unescape ICS text
391
+ * @private
392
+ */
393
+ unescapeText(text) {
394
+ if (!text) return '';
395
+ return text
396
+ .replace(/\\n/g, '\n')
397
+ .replace(/\\,/g, ',')
398
+ .replace(/\\;/g, ';')
399
+ .replace(/\\\\/g, '\\');
400
+ }
401
+
402
+ /**
403
+ * Generate unique ID
404
+ * @private
405
+ */
406
+ generateUID() {
407
+ const timestamp = Date.now().toString(36);
408
+ const random = Math.random().toString(36).substr(2, 9);
409
+ return `${timestamp}-${random}@lightning-calendar`;
410
+ }
411
+
412
+ /**
413
+ * Create empty event object
414
+ * @private
415
+ */
416
+ createEmptyEvent() {
417
+ return {
418
+ id: null,
419
+ title: '',
420
+ description: '',
421
+ start: null,
422
+ end: null,
423
+ allDay: false,
424
+ location: '',
425
+ category: '',
426
+ status: 'confirmed',
427
+ showAs: 'busy',
428
+ attendees: [],
429
+ reminders: []
430
+ };
431
+ }
432
+
433
+ /**
434
+ * Normalize event object
435
+ * @private
436
+ */
437
+ normalizeEvent(event) {
438
+ // Ensure required fields
439
+ if (!event.id) {
440
+ event.id = this.generateUID();
441
+ }
442
+
443
+ if (!event.title) {
444
+ event.title = 'Untitled Event';
445
+ }
446
+
447
+ // Convert dates to Date objects if needed
448
+ if (event.start && !(event.start instanceof Date)) {
449
+ event.start = new Date(event.start);
450
+ }
451
+
452
+ if (event.end && !(event.end instanceof Date)) {
453
+ event.end = new Date(event.end);
454
+ }
455
+
456
+ return event;
457
+ }
458
+
459
+ /**
460
+ * Convert recurrence object to RRULE string
461
+ * @private
462
+ */
463
+ objectToRRule(recurrence) {
464
+ const parts = [];
465
+
466
+ if (recurrence.freq) parts.push(`FREQ=${recurrence.freq.toUpperCase()}`);
467
+ if (recurrence.interval) parts.push(`INTERVAL=${recurrence.interval}`);
468
+ if (recurrence.count) parts.push(`COUNT=${recurrence.count}`);
469
+ if (recurrence.until) parts.push(`UNTIL=${this.formatDate(recurrence.until)}`);
470
+ if (recurrence.byDay) parts.push(`BYDAY=${recurrence.byDay.join(',')}`);
471
+ if (recurrence.byMonth) parts.push(`BYMONTH=${recurrence.byMonth.join(',')}`);
472
+
473
+ return parts.join(';');
474
+ }
475
+ }