@forcecalendar/core 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,389 @@
1
+ /**
2
+ * ICS Import/Export Handler
3
+ * High-level API for calendar data interchange
4
+ */
5
+
6
+ import { ICSParser } from './ICSParser.js';
7
+ import { Event } from '../events/Event.js';
8
+
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
+
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) {
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
+ }
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
+ }
97
+ }
98
+ }
99
+
100
+ return results;
101
+
102
+ } 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));
127
+ }
128
+
129
+ if (categories) {
130
+ events = events.filter(event => categories.includes(event.category));
131
+ }
132
+
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
+ }
139
+
140
+ // Generate ICS
141
+ return this.parser.export(events, calendarName);
142
+ }
143
+
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);
165
+ }
166
+
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 = {}) {
174
+ 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);
181
+ } catch (error) {
182
+ throw new Error(`Failed to import from URL: ${error.message}`);
183
+ }
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);
237
+
238
+ // Start auto-refresh if enabled
239
+ if (autoRefresh) {
240
+ subscription.start();
241
+ }
242
+
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;
253
+ }
254
+
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
+ }
263
+
264
+ throw new Error('Invalid input type. Expected string, File, or Blob.');
265
+ }
266
+
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
+ }
284
+
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}`;
292
+ }
293
+
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
+ }
308
+
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
+ }
327
+ }
328
+
329
+ return expanded;
330
+ }
331
+
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;
385
+ }
386
+
387
+ return results;
388
+ }
389
+ }