@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.
- package/core/calendar/Calendar.js +715 -0
- package/core/calendar/DateUtils.js +553 -0
- package/core/conflicts/ConflictDetector.js +517 -0
- package/core/events/Event.js +914 -0
- package/core/events/EventStore.js +1198 -0
- package/core/events/RRuleParser.js +420 -0
- package/core/events/RecurrenceEngine.js +382 -0
- package/core/ics/ICSHandler.js +389 -0
- package/core/ics/ICSParser.js +475 -0
- package/core/performance/AdaptiveMemoryManager.js +333 -0
- package/core/performance/LRUCache.js +118 -0
- package/core/performance/PerformanceOptimizer.js +523 -0
- package/core/search/EventSearch.js +476 -0
- package/core/state/StateManager.js +546 -0
- package/core/timezone/TimezoneDatabase.js +294 -0
- package/core/timezone/TimezoneManager.js +419 -0
- package/core/types.js +366 -0
- package/package.json +11 -9
|
@@ -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
|
+
}
|