@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.
- package/core/calendar/Calendar.js +7 -9
- package/core/calendar/DateUtils.js +10 -9
- package/core/conflicts/ConflictDetector.js +24 -24
- package/core/events/Event.js +14 -20
- package/core/events/EventStore.js +70 -19
- package/core/events/RRuleParser.js +423 -394
- package/core/events/RecurrenceEngine.js +33 -21
- package/core/events/RecurrenceEngineV2.js +536 -562
- package/core/ics/ICSHandler.js +348 -348
- package/core/ics/ICSParser.js +433 -435
- package/core/index.js +1 -1
- package/core/integration/EnhancedCalendar.js +363 -398
- package/core/performance/AdaptiveMemoryManager.js +310 -308
- package/core/performance/LRUCache.js +3 -4
- package/core/performance/PerformanceOptimizer.js +4 -6
- package/core/search/EventSearch.js +409 -417
- package/core/search/SearchWorkerManager.js +338 -338
- package/core/state/StateManager.js +4 -2
- package/core/timezone/TimezoneDatabase.js +574 -271
- package/core/timezone/TimezoneManager.js +422 -402
- package/core/types.js +1 -1
- package/package.json +1 -1
package/core/ics/ICSParser.js
CHANGED
|
@@ -5,473 +5,471 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export class ICSParser {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
//
|
|
105
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
213
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
}
|
|
357
|
+
const folded = [];
|
|
358
|
+
let remaining = line;
|
|
448
359
|
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
360
|
+
// First line
|
|
361
|
+
folded.push(remaining.substr(0, this.maxLineLength));
|
|
362
|
+
remaining = remaining.substr(this.maxLineLength);
|
|
453
363
|
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|