@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.
- package/core/calendar/Calendar.js +715 -0
- package/core/calendar/DateUtils.js +553 -0
- package/core/conflicts/ConflictDetector.js +517 -0
- package/core/events/Event.js +914 -0
- package/core/events/EventStore.js +1198 -0
- package/core/events/RRuleParser.js +420 -0
- package/core/events/RecurrenceEngine.js +382 -0
- package/core/ics/ICSHandler.js +389 -0
- package/core/ics/ICSParser.js +475 -0
- package/core/performance/AdaptiveMemoryManager.js +333 -0
- package/core/performance/LRUCache.js +118 -0
- package/core/performance/PerformanceOptimizer.js +523 -0
- package/core/search/EventSearch.js +476 -0
- package/core/state/StateManager.js +546 -0
- package/core/timezone/TimezoneDatabase.js +294 -0
- package/core/timezone/TimezoneManager.js +419 -0
- package/core/types.js +366 -0
- package/package.json +11 -9
|
@@ -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
|
+
}
|