@forcecalendar/core 2.0.0 → 2.1.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.
@@ -5,471 +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 (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
- }
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
- }
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
+ }
175
102
 
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
- }
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
+ }
185
137
 
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
- }
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'}`);
151
+ }
200
152
 
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
- }
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'}`);
160
+ }
211
161
 
212
- lines.push('END:VEVENT');
213
- return lines;
162
+ // Categories
163
+ if (event.category) {
164
+ lines.push(`CATEGORIES:${event.category}`);
214
165
  }
215
166
 
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
- }
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}`);
281
174
  }
282
175
 
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);
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}`);
298
182
  }
183
+ }
184
+ }
299
185
 
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));
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);
311
192
  } else {
312
- // Local time
313
- return new Date(year, month, day, hour, minute, second);
193
+ lines.push(`RRULE:${event.recurrence}`);
314
194
  }
195
+ } else if (typeof event.recurrence === 'object') {
196
+ // Convert object to RRULE
197
+ lines.push(`RRULE:${this.objectToRRule(event.recurrence)}`);
198
+ }
315
199
  }
316
200
 
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}`;
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
+ }
335
210
  }
336
211
 
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/);
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;
346
282
  }
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();
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);
374
300
  }
375
301
 
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');
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));
387
313
  }
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, '\\');
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}`;
400
329
  }
401
330
 
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
- }
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;
355
+ }
411
356
 
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
- }
357
+ const folded = [];
358
+ let remaining = line;
432
359
 
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
- }
360
+ // First line
361
+ folded.push(remaining.substr(0, this.maxLineLength));
362
+ remaining = remaining.substr(this.maxLineLength);
442
363
 
443
- if (!event.title) {
444
- event.title = 'Untitled Event';
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);
445
369
  }
446
370
 
447
- // Convert dates to Date objects if needed
448
- if (event.start && !(event.start instanceof Date)) {
449
- event.start = new Date(event.start);
450
- }
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();
441
+ }
451
442
 
452
- if (event.end && !(event.end instanceof Date)) {
453
- event.end = new Date(event.end);
454
- }
443
+ if (!event.title) {
444
+ event.title = 'Untitled Event';
445
+ }
455
446
 
456
- return event;
447
+ // Convert dates to Date objects if needed
448
+ if (event.start && !(event.start instanceof Date)) {
449
+ event.start = new Date(event.start);
457
450
  }
458
451
 
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(';');
452
+ if (event.end && !(event.end instanceof Date)) {
453
+ event.end = new Date(event.end);
474
454
  }
475
- }
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
+ }