@forcecalendar/core 2.1.0 → 2.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.
@@ -5,473 +5,471 @@
5
5
  */
6
6
 
7
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 (const 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
- }
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 (const 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 =
50
+ semicolonIndex > -1 && semicolonIndex < colonIndex ? semicolonIndex : colonIndex;
51
+
52
+ if (separatorIndex === -1) continue;
53
+
54
+ const property = line.substring(0, separatorIndex);
55
+ const value = line.substring(colonIndex + 1);
56
+
57
+ // Handle component boundaries
58
+ if (property === 'BEGIN') {
59
+ if (value === 'VEVENT') {
60
+ inEvent = true;
61
+ currentEvent = this.createEmptyEvent();
62
+ } else if (value === 'VALARM') {
63
+ inAlarm = true;
78
64
  }
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));
65
+ } else if (property === 'END') {
66
+ if (value === 'VEVENT' && currentEvent) {
67
+ events.push(this.normalizeEvent(currentEvent));
68
+ currentEvent = null;
69
+ inEvent = false;
70
+ } else if (value === 'VALARM') {
71
+ inAlarm = false;
102
72
  }
103
-
104
- // Calendar footer
105
- lines.push('END:VCALENDAR');
106
-
107
- // Fold long lines and join
108
- return this.foldLines(lines).join('\r\n');
73
+ } else if (inEvent && !inAlarm && currentEvent) {
74
+ // Parse event properties
75
+ this.parseProperty(property, value, currentEvent);
76
+ }
109
77
  }
110
78
 
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
- }
79
+ return events;
80
+ }
81
+
82
+ /**
83
+ * Export events to ICS format
84
+ * @param {Array} events - Array of event objects
85
+ * @param {string} calendarName - Name of the calendar
86
+ * @returns {string} ICS formatted string
87
+ */
88
+ export(events, calendarName = 'Lightning Calendar') {
89
+ const lines = [];
90
+
91
+ // Calendar header
92
+ lines.push('BEGIN:VCALENDAR');
93
+ lines.push('VERSION:2.0');
94
+ lines.push('PRODID:-//Force Calendar Core//EN');
95
+ lines.push(`X-WR-CALNAME:${calendarName}`);
96
+ lines.push('METHOD:PUBLISH');
97
+
98
+ // Add each event
99
+ for (const event of events) {
100
+ lines.push(...this.eventToICS(event));
101
+ }
200
102
 
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
- }
103
+ // Calendar footer
104
+ lines.push('END:VCALENDAR');
105
+
106
+ // Fold long lines and join
107
+ return this.foldLines(lines).join('\r\n');
108
+ }
109
+
110
+ /**
111
+ * Convert single event to ICS lines
112
+ * @private
113
+ */
114
+ eventToICS(event) {
115
+ const lines = [];
116
+ lines.push('BEGIN:VEVENT');
117
+
118
+ // UID (required)
119
+ lines.push(`UID:${event.id || this.generateUID()}`);
120
+
121
+ // Timestamps
122
+ lines.push(`DTSTAMP:${this.formatDate(new Date())}`);
123
+
124
+ // Start and end dates
125
+ if (event.allDay) {
126
+ lines.push(`DTSTART;VALUE=DATE:${this.formatDate(event.start, true)}`);
127
+ if (event.end) {
128
+ lines.push(`DTEND;VALUE=DATE:${this.formatDate(event.end, true)}`);
129
+ }
130
+ } else {
131
+ const tzid = event.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
132
+ lines.push(`DTSTART;TZID=${tzid}:${this.formatDate(event.start)}`);
133
+ if (event.end) {
134
+ lines.push(`DTEND;TZID=${tzid}:${this.formatDate(event.end)}`);
135
+ }
136
+ }
211
137
 
212
- lines.push('END:VEVENT');
213
- return lines;
138
+ // Basic properties
139
+ if (event.title) lines.push(`SUMMARY:${this.escapeText(event.title)}`);
140
+ if (event.description) lines.push(`DESCRIPTION:${this.escapeText(event.description)}`);
141
+ if (event.location) lines.push(`LOCATION:${this.escapeText(event.location)}`);
142
+
143
+ // Status
144
+ if (event.status) {
145
+ const statusMap = {
146
+ tentative: 'TENTATIVE',
147
+ confirmed: 'CONFIRMED',
148
+ cancelled: 'CANCELLED'
149
+ };
150
+ lines.push(`STATUS:${statusMap[event.status] || 'CONFIRMED'}`);
214
151
  }
215
152
 
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
-
261
- case 'TRANSP':
262
- event.showAs = value === 'TRANSPARENT' ? 'free' : 'busy';
263
- break;
264
-
265
- case 'ORGANIZER':
266
- event.organizer = value.replace('mailto:', '');
267
- break;
268
-
269
- case 'ATTENDEE': {
270
- if (!event.attendees) event.attendees = [];
271
- const email = value.replace('mailto:', '');
272
- event.attendees.push({
273
- email: email,
274
- name: email.split('@')[0] // Use email prefix as name
275
- });
276
- break;
277
- }
278
-
279
- case 'RRULE':
280
- event.recurrence = value;
281
- break;
282
- }
153
+ // Show as
154
+ if (event.showAs) {
155
+ const transpMap = {
156
+ busy: 'OPAQUE',
157
+ free: 'TRANSPARENT'
158
+ };
159
+ lines.push(`TRANSP:${transpMap[event.showAs] || 'OPAQUE'}`);
283
160
  }
284
161
 
285
- /**
286
- * Parse ICS date string
287
- * @private
288
- */
289
- parseDate(dateString, property = '') {
290
- // Remove timezone if present
291
- dateString = dateString.replace(/^TZID=[^:]+:/, '');
292
-
293
- // Check if it's a date-only value
294
- if (property.includes('VALUE=DATE') || dateString.length === 8) {
295
- // YYYYMMDD format
296
- const year = dateString.substr(0, 4);
297
- const month = dateString.substr(4, 2);
298
- const day = dateString.substr(6, 2);
299
- return new Date(year, month - 1, day);
300
- }
162
+ // Categories
163
+ if (event.category) {
164
+ lines.push(`CATEGORIES:${event.category}`);
165
+ }
301
166
 
302
- // Full datetime: YYYYMMDDTHHMMSS[Z]
303
- const year = parseInt(dateString.substr(0, 4));
304
- const month = parseInt(dateString.substr(4, 2)) - 1;
305
- const day = parseInt(dateString.substr(6, 2));
306
- const hour = parseInt(dateString.substr(9, 2)) || 0;
307
- const minute = parseInt(dateString.substr(11, 2)) || 0;
308
- const second = parseInt(dateString.substr(13, 2)) || 0;
309
-
310
- if (dateString.endsWith('Z')) {
311
- // UTC time
312
- return new Date(Date.UTC(year, month, day, hour, minute, second));
313
- }
314
- // Local time
315
- return new Date(year, month, day, hour, minute, second);
316
-
167
+ // Organizer
168
+ if (event.organizer) {
169
+ const org =
170
+ typeof event.organizer === 'string'
171
+ ? event.organizer
172
+ : event.organizer.email || event.organizer.name;
173
+ lines.push(`ORGANIZER:mailto:${org}`);
317
174
  }
318
175
 
319
- /**
320
- * Format date for ICS
321
- * @private
322
- */
323
- formatDate(date, dateOnly = false) {
324
- const year = date.getFullYear();
325
- const month = String(date.getMonth() + 1).padStart(2, '0');
326
- const day = String(date.getDate()).padStart(2, '0');
327
-
328
- if (dateOnly) {
329
- return `${year}${month}${day}`;
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}`);
330
182
  }
331
-
332
- const hour = String(date.getHours()).padStart(2, '0');
333
- const minute = String(date.getMinutes()).padStart(2, '0');
334
- const second = String(date.getSeconds()).padStart(2, '0');
335
-
336
- return `${year}${month}${day}T${hour}${minute}${second}`;
183
+ }
337
184
  }
338
185
 
339
- /**
340
- * Unfold ICS lines (reverse line folding)
341
- * @private
342
- */
343
- unfoldLines(icsString) {
344
- return icsString
345
- .replace(/\r\n /g, '')
346
- .replace(/\n /g, '')
347
- .split(/\r?\n/);
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
+ }
348
199
  }
349
200
 
350
- /**
351
- * Fold long lines per ICS spec
352
- * @private
353
- */
354
- foldLines(lines) {
355
- return lines.map(line => {
356
- if (line.length <= this.maxLineLength) {
357
- return line;
358
- }
359
-
360
- const folded = [];
361
- let remaining = line;
362
-
363
- // First line
364
- folded.push(remaining.substr(0, this.maxLineLength));
365
- remaining = remaining.substr(this.maxLineLength);
366
-
367
- // Continuation lines (with space prefix)
368
- while (remaining.length > 0) {
369
- const chunk = remaining.substr(0, this.maxLineLength - 1);
370
- folded.push(` ${ chunk}`);
371
- remaining = remaining.substr(chunk.length);
372
- }
373
-
374
- return folded.join('\r\n');
375
- }).flat();
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
+ }
376
210
  }
377
211
 
378
- /**
379
- * Escape special characters for ICS
380
- * @private
381
- */
382
- escapeText(text) {
383
- if (!text) return '';
384
- return text
385
- .replace(/\\/g, '\\\\')
386
- .replace(/;/g, '\\;')
387
- .replace(/,/g, '\\,')
388
- .replace(/\n/g, '\\n');
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
+
261
+ case 'TRANSP':
262
+ event.showAs = value === 'TRANSPARENT' ? 'free' : 'busy';
263
+ break;
264
+
265
+ case 'ORGANIZER':
266
+ event.organizer = value.replace('mailto:', '');
267
+ break;
268
+
269
+ case 'ATTENDEE': {
270
+ if (!event.attendees) event.attendees = [];
271
+ const email = value.replace('mailto:', '');
272
+ event.attendees.push({
273
+ email: email,
274
+ name: email.split('@')[0] // Use email prefix as name
275
+ });
276
+ break;
277
+ }
278
+
279
+ case 'RRULE':
280
+ event.recurrence = value;
281
+ break;
389
282
  }
390
-
391
- /**
392
- * Unescape ICS text
393
- * @private
394
- */
395
- unescapeText(text) {
396
- if (!text) return '';
397
- return text
398
- .replace(/\\n/g, '\n')
399
- .replace(/\\,/g, ',')
400
- .replace(/\\;/g, ';')
401
- .replace(/\\\\/g, '\\');
283
+ }
284
+
285
+ /**
286
+ * Parse ICS date string
287
+ * @private
288
+ */
289
+ parseDate(dateString, property = '') {
290
+ // Remove timezone if present
291
+ dateString = dateString.replace(/^TZID=[^:]+:/, '');
292
+
293
+ // Check if it's a date-only value
294
+ if (property.includes('VALUE=DATE') || dateString.length === 8) {
295
+ // YYYYMMDD format
296
+ const year = dateString.substr(0, 4);
297
+ const month = dateString.substr(4, 2);
298
+ const day = dateString.substr(6, 2);
299
+ return new Date(year, month - 1, day);
402
300
  }
403
301
 
404
- /**
405
- * Generate unique ID
406
- * @private
407
- */
408
- generateUID() {
409
- const timestamp = Date.now().toString(36);
410
- const random = Math.random().toString(36).substr(2, 9);
411
- return `${timestamp}-${random}@lightning-calendar`;
302
+ // Full datetime: YYYYMMDDTHHMMSS[Z]
303
+ const year = parseInt(dateString.substr(0, 4));
304
+ const month = parseInt(dateString.substr(4, 2)) - 1;
305
+ const day = parseInt(dateString.substr(6, 2));
306
+ const hour = parseInt(dateString.substr(9, 2)) || 0;
307
+ const minute = parseInt(dateString.substr(11, 2)) || 0;
308
+ const second = parseInt(dateString.substr(13, 2)) || 0;
309
+
310
+ if (dateString.endsWith('Z')) {
311
+ // UTC time
312
+ return new Date(Date.UTC(year, month, day, hour, minute, second));
412
313
  }
413
-
414
- /**
415
- * Create empty event object
416
- * @private
417
- */
418
- createEmptyEvent() {
419
- return {
420
- id: null,
421
- title: '',
422
- description: '',
423
- start: null,
424
- end: null,
425
- allDay: false,
426
- location: '',
427
- category: '',
428
- status: 'confirmed',
429
- showAs: 'busy',
430
- attendees: [],
431
- reminders: []
432
- };
314
+ // Local time
315
+ return new Date(year, month, day, hour, minute, second);
316
+ }
317
+
318
+ /**
319
+ * Format date for ICS
320
+ * @private
321
+ */
322
+ formatDate(date, dateOnly = false) {
323
+ const year = date.getFullYear();
324
+ const month = String(date.getMonth() + 1).padStart(2, '0');
325
+ const day = String(date.getDate()).padStart(2, '0');
326
+
327
+ if (dateOnly) {
328
+ return `${year}${month}${day}`;
433
329
  }
434
330
 
435
- /**
436
- * Normalize event object
437
- * @private
438
- */
439
- normalizeEvent(event) {
440
- // Ensure required fields
441
- if (!event.id) {
442
- event.id = this.generateUID();
331
+ const hour = String(date.getHours()).padStart(2, '0');
332
+ const minute = String(date.getMinutes()).padStart(2, '0');
333
+ const second = String(date.getSeconds()).padStart(2, '0');
334
+
335
+ return `${year}${month}${day}T${hour}${minute}${second}`;
336
+ }
337
+
338
+ /**
339
+ * Unfold ICS lines (reverse line folding)
340
+ * @private
341
+ */
342
+ unfoldLines(icsString) {
343
+ return icsString.replace(/\r\n /g, '').replace(/\n /g, '').split(/\r?\n/);
344
+ }
345
+
346
+ /**
347
+ * Fold long lines per ICS spec
348
+ * @private
349
+ */
350
+ foldLines(lines) {
351
+ return lines
352
+ .map(line => {
353
+ if (line.length <= this.maxLineLength) {
354
+ return line;
443
355
  }
444
356
 
445
- if (!event.title) {
446
- event.title = 'Untitled Event';
447
- }
357
+ const folded = [];
358
+ let remaining = line;
448
359
 
449
- // Convert dates to Date objects if needed
450
- if (event.start && !(event.start instanceof Date)) {
451
- event.start = new Date(event.start);
452
- }
360
+ // First line
361
+ folded.push(remaining.substr(0, this.maxLineLength));
362
+ remaining = remaining.substr(this.maxLineLength);
453
363
 
454
- if (event.end && !(event.end instanceof Date)) {
455
- event.end = new Date(event.end);
364
+ // Continuation lines (with space prefix)
365
+ while (remaining.length > 0) {
366
+ const chunk = remaining.substr(0, this.maxLineLength - 1);
367
+ folded.push(` ${chunk}`);
368
+ remaining = remaining.substr(chunk.length);
456
369
  }
457
370
 
458
- return event;
371
+ return folded.join('\r\n');
372
+ })
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();
459
441
  }
460
442
 
461
- /**
462
- * Convert recurrence object to RRULE string
463
- * @private
464
- */
465
- objectToRRule(recurrence) {
466
- const parts = [];
467
-
468
- if (recurrence.freq) parts.push(`FREQ=${recurrence.freq.toUpperCase()}`);
469
- if (recurrence.interval) parts.push(`INTERVAL=${recurrence.interval}`);
470
- if (recurrence.count) parts.push(`COUNT=${recurrence.count}`);
471
- if (recurrence.until) parts.push(`UNTIL=${this.formatDate(recurrence.until)}`);
472
- if (recurrence.byDay) parts.push(`BYDAY=${recurrence.byDay.join(',')}`);
473
- if (recurrence.byMonth) parts.push(`BYMONTH=${recurrence.byMonth.join(',')}`);
474
-
475
- return parts.join(';');
443
+ if (!event.title) {
444
+ event.title = 'Untitled Event';
476
445
  }
477
- }
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
+ }