@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.
@@ -7,383 +7,383 @@ import { ICSParser } from './ICSParser.js';
7
7
  import { Event } from '../events/Event.js';
8
8
 
9
9
  export class ICSHandler {
10
- constructor(calendar) {
11
- this.calendar = calendar;
12
- this.parser = new ICSParser();
13
- }
14
-
15
- /**
16
- * Import events from ICS file or string
17
- * @param {string|File|Blob} input - ICS data source
18
- * @param {Object} options - Import options
19
- * @returns {Promise<Object>} Import results
20
- */
21
- async import(input, options = {}) {
22
- const {
23
- merge = true, // Merge with existing events
24
- updateExisting = false, // Update events with matching IDs
25
- skipDuplicates = true, // Skip if event already exists
26
- dateRange = null, // Only import events in range
27
- categories = null // Only import specific categories
28
- } = options;
29
-
10
+ constructor(calendar) {
11
+ this.calendar = calendar;
12
+ this.parser = new ICSParser();
13
+ }
14
+
15
+ /**
16
+ * Import events from ICS file or string
17
+ * @param {string|File|Blob} input - ICS data source
18
+ * @param {Object} options - Import options
19
+ * @returns {Promise<Object>} Import results
20
+ */
21
+ async import(input, options = {}) {
22
+ const {
23
+ merge = true, // Merge with existing events
24
+ updateExisting = false, // Update events with matching IDs
25
+ skipDuplicates = true, // Skip if event already exists
26
+ dateRange = null, // Only import events in range
27
+ categories = null // Only import specific categories
28
+ } = options;
29
+
30
+ try {
31
+ // Get ICS string from input
32
+ const icsString = await this.getICSString(input);
33
+
34
+ // Parse ICS to events
35
+ const parsedEvents = this.parser.parse(icsString);
36
+
37
+ // Process each event
38
+ const results = {
39
+ imported: [],
40
+ skipped: [],
41
+ updated: [],
42
+ errors: []
43
+ };
44
+
45
+ for (const eventData of parsedEvents) {
30
46
  try {
31
- // Get ICS string from input
32
- const icsString = await this.getICSString(input);
33
-
34
- // Parse ICS to events
35
- const parsedEvents = this.parser.parse(icsString);
36
-
37
- // Process each event
38
- const results = {
39
- imported: [],
40
- skipped: [],
41
- updated: [],
42
- errors: []
43
- };
44
-
45
- for (const eventData of parsedEvents) {
46
- try {
47
- // Apply filters
48
- if (dateRange && !this.isInDateRange(eventData, dateRange)) {
49
- results.skipped.push({ event: eventData, reason: 'out_of_range' });
50
- continue;
51
- }
52
-
53
- if (categories && !categories.includes(eventData.category)) {
54
- results.skipped.push({ event: eventData, reason: 'category_filtered' });
55
- continue;
56
- }
57
-
58
- // Check for existing event
59
- const existingEvent = this.calendar.getEvent(eventData.id);
60
-
61
- if (existingEvent) {
62
- if (updateExisting) {
63
- // Update existing event
64
- this.calendar.updateEvent(eventData.id, eventData);
65
- results.updated.push(eventData);
66
- } else if (skipDuplicates) {
67
- results.skipped.push({ event: eventData, reason: 'duplicate' });
68
- } else {
69
- // Create new event with different ID
70
- eventData.id = this.generateNewId(eventData.id);
71
- this.calendar.addEvent(eventData);
72
- results.imported.push(eventData);
73
- }
74
- } else {
75
- // Add new event
76
- this.calendar.addEvent(eventData);
77
- results.imported.push(eventData);
78
- }
79
- } catch (error) {
80
- results.errors.push({
81
- event: eventData,
82
- error: error.message
83
- });
84
- }
47
+ // Apply filters
48
+ if (dateRange && !this.isInDateRange(eventData, dateRange)) {
49
+ results.skipped.push({ event: eventData, reason: 'out_of_range' });
50
+ continue;
51
+ }
52
+
53
+ if (categories && !categories.includes(eventData.category)) {
54
+ results.skipped.push({ event: eventData, reason: 'category_filtered' });
55
+ continue;
56
+ }
57
+
58
+ // Check for existing event
59
+ const existingEvent = this.calendar.getEvent(eventData.id);
60
+
61
+ if (existingEvent) {
62
+ if (updateExisting) {
63
+ // Update existing event
64
+ this.calendar.updateEvent(eventData.id, eventData);
65
+ results.updated.push(eventData);
66
+ } else if (skipDuplicates) {
67
+ results.skipped.push({ event: eventData, reason: 'duplicate' });
68
+ } else {
69
+ // Create new event with different ID
70
+ eventData.id = this.generateNewId(eventData.id);
71
+ this.calendar.addEvent(eventData);
72
+ results.imported.push(eventData);
85
73
  }
86
-
87
- // Clear and replace if not merging
88
- if (!merge) {
89
- // Remove existing events not in import
90
- const importedIds = new Set(parsedEvents.map(e => e.id));
91
- const existingEvents = this.calendar.getEvents();
92
-
93
- for (const event of existingEvents) {
94
- if (!importedIds.has(event.id)) {
95
- this.calendar.removeEvent(event.id);
96
- }
97
- }
98
- }
99
-
100
- return results;
101
-
74
+ } else {
75
+ // Add new event
76
+ this.calendar.addEvent(eventData);
77
+ results.imported.push(eventData);
78
+ }
102
79
  } catch (error) {
103
- throw new Error(`ICS import failed: ${error.message}`);
104
- }
105
- }
106
-
107
- /**
108
- * Export calendar events to ICS format
109
- * @param {Object} options - Export options
110
- * @returns {string} ICS formatted string
111
- */
112
- export(options = {}) {
113
- const {
114
- dateRange = null, // Only export events in range
115
- categories = null, // Only export specific categories
116
- calendarName = 'Lightning Calendar Export',
117
- includeRecurring = true,
118
- expandRecurring = false // Expand recurring events to instances
119
- } = options;
120
-
121
- // Get events to export
122
- let events = this.calendar.getEvents();
123
-
124
- // Apply filters
125
- if (dateRange) {
126
- events = events.filter(event => this.isInDateRange(event, dateRange));
80
+ results.errors.push({
81
+ event: eventData,
82
+ error: error.message
83
+ });
127
84
  }
128
-
129
- if (categories) {
130
- events = events.filter(event => categories.includes(event.category));
85
+ }
86
+
87
+ // Clear and replace if not merging
88
+ if (!merge) {
89
+ // Remove existing events not in import
90
+ const importedIds = new Set(parsedEvents.map(e => e.id));
91
+ const existingEvents = this.calendar.getEvents();
92
+
93
+ for (const event of existingEvents) {
94
+ if (!importedIds.has(event.id)) {
95
+ this.calendar.removeEvent(event.id);
96
+ }
131
97
  }
98
+ }
132
99
 
133
- // Handle recurring events
134
- if (expandRecurring) {
135
- events = this.expandRecurringEvents(events, dateRange);
136
- } else if (!includeRecurring) {
137
- events = events.filter(event => !event.recurrence);
138
- }
100
+ return results;
101
+ } catch (error) {
102
+ throw new Error(`ICS import failed: ${error.message}`);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Export calendar events to ICS format
108
+ * @param {Object} options - Export options
109
+ * @returns {string} ICS formatted string
110
+ */
111
+ export(options = {}) {
112
+ const {
113
+ dateRange = null, // Only export events in range
114
+ categories = null, // Only export specific categories
115
+ calendarName = 'Lightning Calendar Export',
116
+ includeRecurring = true,
117
+ expandRecurring = false // Expand recurring events to instances
118
+ } = options;
119
+
120
+ // Get events to export
121
+ let events = this.calendar.getEvents();
122
+
123
+ // Apply filters
124
+ if (dateRange) {
125
+ events = events.filter(event => this.isInDateRange(event, dateRange));
126
+ }
139
127
 
140
- // Generate ICS
141
- return this.parser.export(events, calendarName);
128
+ if (categories) {
129
+ events = events.filter(event => categories.includes(event.category));
142
130
  }
143
131
 
144
- /**
145
- * Export and download as file
146
- * @param {string} filename - Name for the downloaded file
147
- * @param {Object} options - Export options
148
- */
149
- downloadAsFile(filename = 'calendar.ics', options = {}) {
150
- const icsContent = this.export(options);
151
- const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' });
152
-
153
- // Create download link
154
- const link = document.createElement('a');
155
- link.href = URL.createObjectURL(blob);
156
- link.download = filename;
157
-
158
- // Trigger download
159
- document.body.appendChild(link);
160
- link.click();
161
- document.body.removeChild(link);
162
-
163
- // Clean up
164
- URL.revokeObjectURL(link.href);
132
+ // Handle recurring events
133
+ if (expandRecurring) {
134
+ events = this.expandRecurringEvents(events, dateRange);
135
+ } else if (!includeRecurring) {
136
+ events = events.filter(event => !event.recurrence);
165
137
  }
166
138
 
167
- /**
168
- * Import from URL
169
- * @param {string} url - URL to ICS file
170
- * @param {Object} options - Import options
171
- * @returns {Promise<Object>} Import results
172
- */
173
- async importFromURL(url, options = {}) {
139
+ // Generate ICS
140
+ return this.parser.export(events, calendarName);
141
+ }
142
+
143
+ /**
144
+ * Export and download as file
145
+ * @param {string} filename - Name for the downloaded file
146
+ * @param {Object} options - Export options
147
+ */
148
+ downloadAsFile(filename = 'calendar.ics', options = {}) {
149
+ const icsContent = this.export(options);
150
+ const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' });
151
+
152
+ // Create download link
153
+ const link = document.createElement('a');
154
+ link.href = URL.createObjectURL(blob);
155
+ link.download = filename;
156
+
157
+ // Trigger download
158
+ document.body.appendChild(link);
159
+ link.click();
160
+ document.body.removeChild(link);
161
+
162
+ // Clean up
163
+ URL.revokeObjectURL(link.href);
164
+ }
165
+
166
+ /**
167
+ * Import from URL
168
+ * @param {string} url - URL to ICS file
169
+ * @param {Object} options - Import options
170
+ * @returns {Promise<Object>} Import results
171
+ */
172
+ async importFromURL(url, options = {}) {
173
+ try {
174
+ const response = await fetch(url);
175
+ if (!response.ok) {
176
+ throw new Error(`Failed to fetch ICS: ${response.statusText}`);
177
+ }
178
+ const icsString = await response.text();
179
+ return this.import(icsString, options);
180
+ } catch (error) {
181
+ throw new Error(`Failed to import from URL: ${error.message}`);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Subscribe to calendar feed
187
+ * @param {string} url - URL to ICS feed
188
+ * @param {Object} options - Subscription options
189
+ * @returns {Object} Subscription object
190
+ */
191
+ subscribe(url, options = {}) {
192
+ const {
193
+ refreshInterval = 3600000, // 1 hour default
194
+ autoRefresh = true,
195
+ ...importOptions
196
+ } = options;
197
+
198
+ const subscription = {
199
+ url,
200
+ lastRefresh: null,
201
+ intervalId: null,
202
+ status: 'active',
203
+
204
+ refresh: async () => {
174
205
  try {
175
- const response = await fetch(url);
176
- if (!response.ok) {
177
- throw new Error(`Failed to fetch ICS: ${response.statusText}`);
178
- }
179
- const icsString = await response.text();
180
- return this.import(icsString, options);
206
+ const results = await this.importFromURL(url, importOptions);
207
+ subscription.lastRefresh = new Date();
208
+ return results;
181
209
  } catch (error) {
182
- throw new Error(`Failed to import from URL: ${error.message}`);
210
+ subscription.status = 'error';
211
+ throw error;
183
212
  }
184
- }
185
-
186
- /**
187
- * Subscribe to calendar feed
188
- * @param {string} url - URL to ICS feed
189
- * @param {Object} options - Subscription options
190
- * @returns {Object} Subscription object
191
- */
192
- subscribe(url, options = {}) {
193
- const {
194
- refreshInterval = 3600000, // 1 hour default
195
- autoRefresh = true,
196
- ...importOptions
197
- } = options;
198
-
199
- const subscription = {
200
- url,
201
- lastRefresh: null,
202
- intervalId: null,
203
- status: 'active',
204
-
205
- refresh: async () => {
206
- try {
207
- const results = await this.importFromURL(url, importOptions);
208
- subscription.lastRefresh = new Date();
209
- return results;
210
- } catch (error) {
211
- subscription.status = 'error';
212
- throw error;
213
- }
214
- },
215
-
216
- stop: () => {
217
- if (subscription.intervalId) {
218
- clearInterval(subscription.intervalId);
219
- subscription.intervalId = null;
220
- }
221
- subscription.status = 'stopped';
222
- },
223
-
224
- start: () => {
225
- subscription.stop();
226
- if (autoRefresh) {
227
- subscription.intervalId = setInterval(() => {
228
- subscription.refresh().catch(console.error);
229
- }, refreshInterval);
230
- }
231
- subscription.status = 'active';
232
- }
233
- };
234
-
235
- // Initial import
236
- subscription.refresh().catch(console.error);
213
+ },
237
214
 
238
- // Start auto-refresh if enabled
239
- if (autoRefresh) {
240
- subscription.start();
215
+ stop: () => {
216
+ if (subscription.intervalId) {
217
+ clearInterval(subscription.intervalId);
218
+ subscription.intervalId = null;
241
219
  }
220
+ subscription.status = 'stopped';
221
+ },
242
222
 
243
- return subscription;
244
- }
245
-
246
- /**
247
- * Get ICS string from various input types
248
- * @private
249
- */
250
- async getICSString(input) {
251
- if (typeof input === 'string') {
252
- return input;
223
+ start: () => {
224
+ subscription.stop();
225
+ if (autoRefresh) {
226
+ subscription.intervalId = setInterval(() => {
227
+ subscription.refresh().catch(console.error);
228
+ }, refreshInterval);
253
229
  }
230
+ subscription.status = 'active';
231
+ }
232
+ };
254
233
 
255
- if (input instanceof File || input instanceof Blob) {
256
- return new Promise((resolve, reject) => {
257
- const reader = new FileReader();
258
- reader.onload = () => resolve(reader.result);
259
- reader.onerror = reject;
260
- reader.readAsText(input);
261
- });
262
- }
234
+ // Initial import
235
+ subscription.refresh().catch(console.error);
263
236
 
264
- throw new Error('Invalid input type. Expected string, File, or Blob.');
237
+ // Start auto-refresh if enabled
238
+ if (autoRefresh) {
239
+ subscription.start();
265
240
  }
266
241
 
267
- /**
268
- * Check if event is in date range
269
- * @private
270
- */
271
- isInDateRange(event, dateRange) {
272
- if (!dateRange) return true;
273
-
274
- const { start, end } = dateRange;
275
- const eventStart = event.start instanceof Date ? event.start : new Date(event.start);
276
- const eventEnd = event.end instanceof Date ? event.end : new Date(event.end || event.start);
277
-
278
- return (
279
- (eventStart >= start && eventStart <= end) ||
280
- (eventEnd >= start && eventEnd <= end) ||
281
- (eventStart <= start && eventEnd >= end)
282
- );
283
- }
242
+ return subscription;
243
+ }
284
244
 
285
- /**
286
- * Generate new ID for duplicate event
287
- * @private
288
- */
289
- generateNewId(originalId) {
290
- const timestamp = Date.now().toString(36);
291
- return `${originalId}-copy-${timestamp}`;
245
+ /**
246
+ * Get ICS string from various input types
247
+ * @private
248
+ */
249
+ async getICSString(input) {
250
+ if (typeof input === 'string') {
251
+ return input;
292
252
  }
293
253
 
294
- /**
295
- * Expand recurring events into individual instances
296
- * @private
297
- */
298
- expandRecurringEvents(events, dateRange) {
299
- const expanded = [];
300
- const rangeStart = dateRange?.start || new Date();
301
- const rangeEnd = dateRange?.end || new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
302
-
303
- for (const event of events) {
304
- if (!event.recurrence) {
305
- expanded.push(event);
306
- continue;
307
- }
254
+ if (input instanceof File || input instanceof Blob) {
255
+ return new Promise((resolve, reject) => {
256
+ const reader = new FileReader();
257
+ reader.onload = () => resolve(reader.result);
258
+ reader.onerror = reject;
259
+ reader.readAsText(input);
260
+ });
261
+ }
308
262
 
309
- // TODO: Get instances from calendar's recurrence engine when implemented
310
- // For now, just include the base event
311
- const instances = [{
312
- start: event.start,
313
- end: event.end
314
- }];
315
-
316
- // Add each instance as a separate event
317
- for (const instance of instances) {
318
- expanded.push({
319
- ...event,
320
- id: `${event.id}-${instance.start.getTime()}`,
321
- start: instance.start,
322
- end: instance.end,
323
- recurrence: null, // Remove recurrence from instances
324
- parentId: event.id // Reference to original
325
- });
326
- }
263
+ throw new Error('Invalid input type. Expected string, File, or Blob.');
264
+ }
265
+
266
+ /**
267
+ * Check if event is in date range
268
+ * @private
269
+ */
270
+ isInDateRange(event, dateRange) {
271
+ if (!dateRange) return true;
272
+
273
+ const { start, end } = dateRange;
274
+ const eventStart = event.start instanceof Date ? event.start : new Date(event.start);
275
+ const eventEnd = event.end instanceof Date ? event.end : new Date(event.end || event.start);
276
+
277
+ return (
278
+ (eventStart >= start && eventStart <= end) ||
279
+ (eventEnd >= start && eventEnd <= end) ||
280
+ (eventStart <= start && eventEnd >= end)
281
+ );
282
+ }
283
+
284
+ /**
285
+ * Generate new ID for duplicate event
286
+ * @private
287
+ */
288
+ generateNewId(originalId) {
289
+ const timestamp = Date.now().toString(36);
290
+ return `${originalId}-copy-${timestamp}`;
291
+ }
292
+
293
+ /**
294
+ * Expand recurring events into individual instances
295
+ * @private
296
+ */
297
+ expandRecurringEvents(events, dateRange) {
298
+ const expanded = [];
299
+ const rangeStart = dateRange?.start || new Date();
300
+ const rangeEnd = dateRange?.end || new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
301
+
302
+ for (const event of events) {
303
+ if (!event.recurrence) {
304
+ expanded.push(event);
305
+ continue;
306
+ }
307
+
308
+ // TODO: Get instances from calendar's recurrence engine when implemented
309
+ // For now, just include the base event
310
+ const instances = [
311
+ {
312
+ start: event.start,
313
+ end: event.end
327
314
  }
328
-
329
- return expanded;
315
+ ];
316
+
317
+ // Add each instance as a separate event
318
+ for (const instance of instances) {
319
+ expanded.push({
320
+ ...event,
321
+ id: `${event.id}-${instance.start.getTime()}`,
322
+ start: instance.start,
323
+ end: instance.end,
324
+ recurrence: null, // Remove recurrence from instances
325
+ parentId: event.id // Reference to original
326
+ });
327
+ }
330
328
  }
331
329
 
332
- /**
333
- * Validate ICS string
334
- * @param {string} icsString - ICS content to validate
335
- * @returns {Object} Validation results
336
- */
337
- validate(icsString) {
338
- const results = {
339
- valid: true,
340
- errors: [],
341
- warnings: []
342
- };
343
-
344
- try {
345
- // Check basic structure
346
- if (!icsString.includes('BEGIN:VCALENDAR')) {
347
- results.errors.push('Missing BEGIN:VCALENDAR');
348
- results.valid = false;
349
- }
350
-
351
- if (!icsString.includes('END:VCALENDAR')) {
352
- results.errors.push('Missing END:VCALENDAR');
353
- results.valid = false;
354
- }
355
-
356
- if (!icsString.includes('VERSION:')) {
357
- results.warnings.push('Missing VERSION property');
358
- }
359
-
360
- // Try to parse
361
- const events = this.parser.parse(icsString);
362
-
363
- // Check events
364
- if (events.length === 0) {
365
- results.warnings.push('No events found in calendar');
366
- }
367
-
368
- // Validate each event
369
- for (let i = 0; i < events.length; i++) {
370
- const event = events[i];
371
-
372
- if (!event.start) {
373
- results.errors.push(`Event ${i + 1}: Missing start date`);
374
- results.valid = false;
375
- }
376
-
377
- if (!event.title && !event.description) {
378
- results.warnings.push(`Event ${i + 1}: No title or description`);
379
- }
380
- }
381
-
382
- } catch (error) {
383
- results.errors.push(`Parse error: ${error.message}`);
384
- results.valid = false;
330
+ return expanded;
331
+ }
332
+
333
+ /**
334
+ * Validate ICS string
335
+ * @param {string} icsString - ICS content to validate
336
+ * @returns {Object} Validation results
337
+ */
338
+ validate(icsString) {
339
+ const results = {
340
+ valid: true,
341
+ errors: [],
342
+ warnings: []
343
+ };
344
+
345
+ try {
346
+ // Check basic structure
347
+ if (!icsString.includes('BEGIN:VCALENDAR')) {
348
+ results.errors.push('Missing BEGIN:VCALENDAR');
349
+ results.valid = false;
350
+ }
351
+
352
+ if (!icsString.includes('END:VCALENDAR')) {
353
+ results.errors.push('Missing END:VCALENDAR');
354
+ results.valid = false;
355
+ }
356
+
357
+ if (!icsString.includes('VERSION:')) {
358
+ results.warnings.push('Missing VERSION property');
359
+ }
360
+
361
+ // Try to parse
362
+ const events = this.parser.parse(icsString);
363
+
364
+ // Check events
365
+ if (events.length === 0) {
366
+ results.warnings.push('No events found in calendar');
367
+ }
368
+
369
+ // Validate each event
370
+ for (let i = 0; i < events.length; i++) {
371
+ const event = events[i];
372
+
373
+ if (!event.start) {
374
+ results.errors.push(`Event ${i + 1}: Missing start date`);
375
+ results.valid = false;
385
376
  }
386
377
 
387
- return results;
378
+ if (!event.title && !event.description) {
379
+ results.warnings.push(`Event ${i + 1}: No title or description`);
380
+ }
381
+ }
382
+ } catch (error) {
383
+ results.errors.push(`Parse error: ${error.message}`);
384
+ results.valid = false;
388
385
  }
389
- }
386
+
387
+ return results;
388
+ }
389
+ }