@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,914 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event class - represents a calendar event with timezone support
|
|
3
|
+
* Pure JavaScript, no DOM dependencies
|
|
4
|
+
* Locker Service compatible
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { TimezoneManager } from '../timezone/TimezoneManager.js';
|
|
8
|
+
|
|
9
|
+
export class Event {
|
|
10
|
+
/**
|
|
11
|
+
* Normalize event data
|
|
12
|
+
* @param {import('../../types.js').EventData} data - Raw event data
|
|
13
|
+
* @returns {import('../../types.js').EventData} Normalized event data
|
|
14
|
+
*/
|
|
15
|
+
static normalize(data) {
|
|
16
|
+
const normalized = { ...data };
|
|
17
|
+
|
|
18
|
+
// Ensure dates are Date objects
|
|
19
|
+
if (normalized.start && !(normalized.start instanceof Date)) {
|
|
20
|
+
normalized.start = new Date(normalized.start);
|
|
21
|
+
}
|
|
22
|
+
if (normalized.end && !(normalized.end instanceof Date)) {
|
|
23
|
+
normalized.end = new Date(normalized.end);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// If no end date, set it to start date
|
|
27
|
+
if (!normalized.end) {
|
|
28
|
+
normalized.end = normalized.start ? new Date(normalized.start) : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// For all-day events, normalize times to midnight
|
|
32
|
+
if (normalized.allDay && normalized.start) {
|
|
33
|
+
normalized.start.setHours(0, 0, 0, 0);
|
|
34
|
+
if (normalized.end) {
|
|
35
|
+
normalized.end.setHours(23, 59, 59, 999);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Normalize string fields
|
|
40
|
+
normalized.id = String(normalized.id || '').trim();
|
|
41
|
+
normalized.title = String(normalized.title || '').trim();
|
|
42
|
+
normalized.description = String(normalized.description || '').trim();
|
|
43
|
+
normalized.location = String(normalized.location || '').trim();
|
|
44
|
+
|
|
45
|
+
// Normalize arrays
|
|
46
|
+
normalized.attendees = Array.isArray(normalized.attendees) ? normalized.attendees : [];
|
|
47
|
+
normalized.reminders = Array.isArray(normalized.reminders) ? normalized.reminders : [];
|
|
48
|
+
|
|
49
|
+
// Handle both 'category' (singular) and 'categories' (plural)
|
|
50
|
+
if (data.category && !data.categories) {
|
|
51
|
+
// If single category is provided, convert to array
|
|
52
|
+
normalized.categories = [data.category];
|
|
53
|
+
} else if (normalized.categories) {
|
|
54
|
+
normalized.categories = Array.isArray(normalized.categories) ? normalized.categories : [];
|
|
55
|
+
} else {
|
|
56
|
+
normalized.categories = [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
normalized.attachments = Array.isArray(normalized.attachments) ? normalized.attachments : [];
|
|
60
|
+
|
|
61
|
+
// Normalize status and visibility
|
|
62
|
+
const validStatuses = ['confirmed', 'tentative', 'cancelled'];
|
|
63
|
+
if (!validStatuses.includes(normalized.status)) {
|
|
64
|
+
normalized.status = 'confirmed';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const validVisibilities = ['public', 'private', 'confidential'];
|
|
68
|
+
if (!validVisibilities.includes(normalized.visibility)) {
|
|
69
|
+
normalized.visibility = 'public';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Normalize colors
|
|
73
|
+
if (normalized.color && !normalized.backgroundColor) {
|
|
74
|
+
normalized.backgroundColor = normalized.color;
|
|
75
|
+
}
|
|
76
|
+
if (normalized.color && !normalized.borderColor) {
|
|
77
|
+
normalized.borderColor = normalized.color;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Validate event data
|
|
85
|
+
* @param {import('../../types.js').EventData} data - Normalized event data
|
|
86
|
+
* @throws {Error} If validation fails
|
|
87
|
+
*/
|
|
88
|
+
static validate(data) {
|
|
89
|
+
// Required fields
|
|
90
|
+
if (!data.id) {
|
|
91
|
+
throw new Error('Event must have an id');
|
|
92
|
+
}
|
|
93
|
+
if (!data.title) {
|
|
94
|
+
throw new Error('Event must have a title');
|
|
95
|
+
}
|
|
96
|
+
if (!data.start) {
|
|
97
|
+
throw new Error('Event must have a start date');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Validate dates
|
|
101
|
+
if (!(data.start instanceof Date) || isNaN(data.start.getTime())) {
|
|
102
|
+
throw new Error('Invalid start date');
|
|
103
|
+
}
|
|
104
|
+
if (data.end && (!(data.end instanceof Date) || isNaN(data.end.getTime()))) {
|
|
105
|
+
throw new Error('Invalid end date');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Validate date order
|
|
109
|
+
if (data.end && data.start && data.end < data.start) {
|
|
110
|
+
throw new Error('Event end time cannot be before start time');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Validate recurrence
|
|
114
|
+
if (data.recurring && !data.recurrenceRule) {
|
|
115
|
+
throw new Error('Recurring events must have a recurrence rule');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Validate attendees
|
|
119
|
+
if (data.attendees && data.attendees.length > 0) {
|
|
120
|
+
data.attendees.forEach((attendee, index) => {
|
|
121
|
+
if (!attendee.email || !attendee.name) {
|
|
122
|
+
throw new Error(`Attendee at index ${index} must have email and name`);
|
|
123
|
+
}
|
|
124
|
+
// Validate email format
|
|
125
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
126
|
+
if (!emailRegex.test(attendee.email)) {
|
|
127
|
+
throw new Error(`Invalid email for attendee: ${attendee.email}`);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Validate reminders
|
|
133
|
+
if (data.reminders && data.reminders.length > 0) {
|
|
134
|
+
data.reminders.forEach((reminder, index) => {
|
|
135
|
+
if (!reminder.method || reminder.minutesBefore == null) {
|
|
136
|
+
throw new Error(`Reminder at index ${index} must have method and minutesBefore`);
|
|
137
|
+
}
|
|
138
|
+
if (reminder.minutesBefore < 0) {
|
|
139
|
+
throw new Error('Reminder minutesBefore must be non-negative');
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Validate timezone if provided
|
|
145
|
+
if (data.timeZone) {
|
|
146
|
+
try {
|
|
147
|
+
// Test if timezone is valid by trying to use it
|
|
148
|
+
new Intl.DateTimeFormat('en-US', { timeZone: data.timeZone });
|
|
149
|
+
} catch (e) {
|
|
150
|
+
throw new Error(`Invalid timezone: ${data.timeZone}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Create a new Event instance
|
|
157
|
+
* @param {import('../../types.js').EventData} eventData - Event data object
|
|
158
|
+
* @throws {Error} If required fields are missing or invalid
|
|
159
|
+
*/
|
|
160
|
+
constructor({
|
|
161
|
+
id,
|
|
162
|
+
title,
|
|
163
|
+
start,
|
|
164
|
+
end,
|
|
165
|
+
allDay = false,
|
|
166
|
+
description = '',
|
|
167
|
+
location = '',
|
|
168
|
+
color = null,
|
|
169
|
+
backgroundColor = null,
|
|
170
|
+
borderColor = null,
|
|
171
|
+
textColor = null,
|
|
172
|
+
recurring = false,
|
|
173
|
+
recurrenceRule = null,
|
|
174
|
+
timeZone = null,
|
|
175
|
+
endTimeZone = null,
|
|
176
|
+
status = 'confirmed',
|
|
177
|
+
visibility = 'public',
|
|
178
|
+
organizer = null,
|
|
179
|
+
attendees = [],
|
|
180
|
+
reminders = [],
|
|
181
|
+
categories = [],
|
|
182
|
+
attachments = [],
|
|
183
|
+
conferenceData = null,
|
|
184
|
+
metadata = {}
|
|
185
|
+
}) {
|
|
186
|
+
// Normalize and validate input
|
|
187
|
+
const normalized = Event.normalize({
|
|
188
|
+
id,
|
|
189
|
+
title,
|
|
190
|
+
start,
|
|
191
|
+
end,
|
|
192
|
+
allDay,
|
|
193
|
+
description,
|
|
194
|
+
location,
|
|
195
|
+
color,
|
|
196
|
+
backgroundColor,
|
|
197
|
+
borderColor,
|
|
198
|
+
textColor,
|
|
199
|
+
recurring,
|
|
200
|
+
recurrenceRule,
|
|
201
|
+
timeZone,
|
|
202
|
+
endTimeZone,
|
|
203
|
+
status,
|
|
204
|
+
visibility,
|
|
205
|
+
organizer,
|
|
206
|
+
attendees,
|
|
207
|
+
reminders,
|
|
208
|
+
categories,
|
|
209
|
+
attachments,
|
|
210
|
+
conferenceData,
|
|
211
|
+
metadata
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Validate normalized data
|
|
215
|
+
Event.validate(normalized);
|
|
216
|
+
|
|
217
|
+
this.id = normalized.id;
|
|
218
|
+
this.title = normalized.title;
|
|
219
|
+
|
|
220
|
+
// Initialize timezone manager
|
|
221
|
+
this._timezoneManager = new TimezoneManager();
|
|
222
|
+
|
|
223
|
+
// Timezone handling
|
|
224
|
+
// Store the timezone the event was created in (wall-clock time)
|
|
225
|
+
this.timeZone = normalized.timeZone || this._timezoneManager.getSystemTimezone();
|
|
226
|
+
this.endTimeZone = normalized.endTimeZone || this.timeZone; // Different end timezone for flights etc.
|
|
227
|
+
|
|
228
|
+
// Store dates as provided (wall-clock time in event timezone)
|
|
229
|
+
this.start = normalized.start;
|
|
230
|
+
this.end = normalized.end;
|
|
231
|
+
|
|
232
|
+
// Store UTC versions for efficient querying and comparison
|
|
233
|
+
this.startUTC = this._timezoneManager.toUTC(this.start, this.timeZone);
|
|
234
|
+
this.endUTC = this._timezoneManager.toUTC(this.end, this.endTimeZone);
|
|
235
|
+
|
|
236
|
+
this.allDay = normalized.allDay;
|
|
237
|
+
this.description = normalized.description;
|
|
238
|
+
this.location = normalized.location;
|
|
239
|
+
|
|
240
|
+
// Styling
|
|
241
|
+
this.color = normalized.color;
|
|
242
|
+
this.backgroundColor = normalized.backgroundColor;
|
|
243
|
+
this.borderColor = normalized.borderColor;
|
|
244
|
+
this.textColor = normalized.textColor;
|
|
245
|
+
|
|
246
|
+
// Recurrence
|
|
247
|
+
this.recurring = normalized.recurring;
|
|
248
|
+
this.recurrenceRule = normalized.recurrenceRule;
|
|
249
|
+
|
|
250
|
+
// Store original timezone from system if not provided
|
|
251
|
+
this._originalTimeZone = normalized.timeZone || null;
|
|
252
|
+
|
|
253
|
+
// Event status and visibility
|
|
254
|
+
this.status = normalized.status;
|
|
255
|
+
this.visibility = normalized.visibility;
|
|
256
|
+
|
|
257
|
+
// People
|
|
258
|
+
this.organizer = normalized.organizer;
|
|
259
|
+
this.attendees = [...normalized.attendees];
|
|
260
|
+
|
|
261
|
+
// Reminders
|
|
262
|
+
this.reminders = [...normalized.reminders];
|
|
263
|
+
|
|
264
|
+
// Categories/Tags
|
|
265
|
+
this.categories = [...normalized.categories];
|
|
266
|
+
|
|
267
|
+
// Attachments
|
|
268
|
+
this.attachments = [...normalized.attachments];
|
|
269
|
+
|
|
270
|
+
// Conference/Virtual meeting
|
|
271
|
+
this.conferenceData = normalized.conferenceData;
|
|
272
|
+
|
|
273
|
+
// Custom metadata for extensibility
|
|
274
|
+
this.metadata = { ...normalized.metadata };
|
|
275
|
+
|
|
276
|
+
// Computed properties cache
|
|
277
|
+
this._cache = {};
|
|
278
|
+
|
|
279
|
+
// Validate complex properties
|
|
280
|
+
this._validateAttendees();
|
|
281
|
+
this._validateReminders();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get event duration in milliseconds
|
|
286
|
+
* @returns {number} Duration in milliseconds
|
|
287
|
+
*/
|
|
288
|
+
get duration() {
|
|
289
|
+
if (!this._cache.duration) {
|
|
290
|
+
// Use UTC times for accurate duration calculation
|
|
291
|
+
this._cache.duration = this.endUTC.getTime() - this.startUTC.getTime();
|
|
292
|
+
}
|
|
293
|
+
return this._cache.duration;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Get start date in a specific timezone
|
|
298
|
+
* @param {string} timezone - Target timezone
|
|
299
|
+
* @returns {Date} Start date in specified timezone
|
|
300
|
+
*/
|
|
301
|
+
getStartInTimezone(timezone) {
|
|
302
|
+
if (timezone === this.timeZone) {
|
|
303
|
+
return new Date(this.start);
|
|
304
|
+
}
|
|
305
|
+
return this._timezoneManager.fromUTC(this.startUTC, timezone);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Get end date in a specific timezone
|
|
310
|
+
* @param {string} timezone - Target timezone
|
|
311
|
+
* @returns {Date} End date in specified timezone
|
|
312
|
+
*/
|
|
313
|
+
getEndInTimezone(timezone) {
|
|
314
|
+
if (timezone === this.endTimeZone) {
|
|
315
|
+
return new Date(this.end);
|
|
316
|
+
}
|
|
317
|
+
return this._timezoneManager.fromUTC(this.endUTC, timezone);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Update event times preserving the timezone
|
|
322
|
+
* @param {Date} start - New start date
|
|
323
|
+
* @param {Date} end - New end date
|
|
324
|
+
* @param {string} [timezone] - Timezone for the new dates
|
|
325
|
+
*/
|
|
326
|
+
updateTimes(start, end, timezone) {
|
|
327
|
+
const tz = timezone || this.timeZone;
|
|
328
|
+
|
|
329
|
+
this.start = start instanceof Date ? start : new Date(start);
|
|
330
|
+
this.end = end instanceof Date ? end : new Date(end);
|
|
331
|
+
|
|
332
|
+
if (timezone) {
|
|
333
|
+
this.timeZone = timezone;
|
|
334
|
+
this.endTimeZone = timezone;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Update UTC versions
|
|
338
|
+
this.startUTC = this._timezoneManager.toUTC(this.start, this.timeZone);
|
|
339
|
+
this.endUTC = this._timezoneManager.toUTC(this.end, this.endTimeZone);
|
|
340
|
+
|
|
341
|
+
// Clear cache
|
|
342
|
+
this._cache = {};
|
|
343
|
+
|
|
344
|
+
// Validate
|
|
345
|
+
if (this.endUTC < this.startUTC) {
|
|
346
|
+
throw new Error('Event end time cannot be before start time');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Get event duration in minutes
|
|
352
|
+
* @returns {number} Duration in minutes
|
|
353
|
+
*/
|
|
354
|
+
get durationMinutes() {
|
|
355
|
+
return Math.floor(this.duration / (1000 * 60));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Get event duration in hours
|
|
360
|
+
* @returns {number} Duration in hours
|
|
361
|
+
*/
|
|
362
|
+
get durationHours() {
|
|
363
|
+
return this.duration / (1000 * 60 * 60);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Check if this is a multi-day event
|
|
368
|
+
* @returns {boolean} True if event spans multiple days
|
|
369
|
+
*/
|
|
370
|
+
get isMultiDay() {
|
|
371
|
+
if (!this._cache.hasOwnProperty('isMultiDay')) {
|
|
372
|
+
const startDay = this.start.toDateString();
|
|
373
|
+
const endDay = this.end.toDateString();
|
|
374
|
+
this._cache.isMultiDay = startDay !== endDay;
|
|
375
|
+
}
|
|
376
|
+
return this._cache.isMultiDay;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Check if event is recurring
|
|
381
|
+
* @returns {boolean} True if event is recurring
|
|
382
|
+
*/
|
|
383
|
+
isRecurring() {
|
|
384
|
+
return this.recurring && this.recurrenceRule !== null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Check if event occurs on a specific date
|
|
389
|
+
* @param {Date|string} date - The date to check
|
|
390
|
+
* @returns {boolean} True if event occurs on the given date
|
|
391
|
+
*/
|
|
392
|
+
occursOn(date) {
|
|
393
|
+
if (!(date instanceof Date)) {
|
|
394
|
+
date = new Date(date);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const dateString = date.toDateString();
|
|
398
|
+
const startString = this.start.toDateString();
|
|
399
|
+
const endString = this.end.toDateString();
|
|
400
|
+
|
|
401
|
+
// For all-day events, check if date falls within range
|
|
402
|
+
if (this.allDay) {
|
|
403
|
+
return date >= new Date(startString) && date <= new Date(endString);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// For timed events, check if any part of the event occurs on this date
|
|
407
|
+
if (this.isMultiDay) {
|
|
408
|
+
// Multi-day event: check if date is within range
|
|
409
|
+
const dayStart = new Date(dateString);
|
|
410
|
+
const dayEnd = new Date(dateString);
|
|
411
|
+
dayEnd.setHours(23, 59, 59, 999);
|
|
412
|
+
|
|
413
|
+
return this.start <= dayEnd && this.end >= dayStart;
|
|
414
|
+
} else {
|
|
415
|
+
// Single day event: check if it's on the same day
|
|
416
|
+
return startString === dateString;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Check if this event overlaps with another event
|
|
422
|
+
* @param {Event|{start: Date, end: Date}} otherEvent - The other event or time range to check
|
|
423
|
+
* @returns {boolean} True if events overlap
|
|
424
|
+
* @throws {Error} If otherEvent is not an Event instance or doesn't have start/end
|
|
425
|
+
*/
|
|
426
|
+
overlaps(otherEvent) {
|
|
427
|
+
if (otherEvent instanceof Event) {
|
|
428
|
+
// Events don't overlap if one ends before the other starts
|
|
429
|
+
return !(this.end <= otherEvent.start || this.start >= otherEvent.end);
|
|
430
|
+
} else if (otherEvent && otherEvent.start && otherEvent.end) {
|
|
431
|
+
// Allow checking against time ranges
|
|
432
|
+
return !(this.end <= otherEvent.start || this.start >= otherEvent.end);
|
|
433
|
+
} else {
|
|
434
|
+
throw new Error('Parameter must be an Event instance or have start/end properties');
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Check if event contains a specific datetime
|
|
440
|
+
* @param {Date|string} datetime - The datetime to check
|
|
441
|
+
* @returns {boolean} True if the datetime falls within the event
|
|
442
|
+
*/
|
|
443
|
+
contains(datetime) {
|
|
444
|
+
if (!(datetime instanceof Date)) {
|
|
445
|
+
datetime = new Date(datetime);
|
|
446
|
+
}
|
|
447
|
+
return datetime >= this.start && datetime <= this.end;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Clone the event with optional updates
|
|
452
|
+
* @param {Partial<import('../../types.js').EventData>} [updates={}] - Properties to update in the clone
|
|
453
|
+
* @returns {Event} New Event instance with updated properties
|
|
454
|
+
*/
|
|
455
|
+
clone(updates = {}) {
|
|
456
|
+
return new Event({
|
|
457
|
+
id: this.id,
|
|
458
|
+
title: this.title,
|
|
459
|
+
start: new Date(this.start),
|
|
460
|
+
end: new Date(this.end),
|
|
461
|
+
allDay: this.allDay,
|
|
462
|
+
description: this.description,
|
|
463
|
+
location: this.location,
|
|
464
|
+
color: this.color,
|
|
465
|
+
backgroundColor: this.backgroundColor,
|
|
466
|
+
borderColor: this.borderColor,
|
|
467
|
+
textColor: this.textColor,
|
|
468
|
+
recurring: this.recurring,
|
|
469
|
+
recurrenceRule: this.recurrenceRule,
|
|
470
|
+
timeZone: this.timeZone,
|
|
471
|
+
status: this.status,
|
|
472
|
+
visibility: this.visibility,
|
|
473
|
+
organizer: this.organizer ? { ...this.organizer } : null,
|
|
474
|
+
attendees: this.attendees.map(a => ({ ...a })),
|
|
475
|
+
reminders: this.reminders.map(r => ({ ...r })),
|
|
476
|
+
categories: [...this.categories],
|
|
477
|
+
attachments: this.attachments.map(a => ({ ...a })),
|
|
478
|
+
conferenceData: this.conferenceData ? { ...this.conferenceData } : null,
|
|
479
|
+
metadata: { ...this.metadata },
|
|
480
|
+
...updates
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Convert event to plain object
|
|
486
|
+
* @returns {import('../../types.js').EventData} Plain object representation of the event
|
|
487
|
+
*/
|
|
488
|
+
toObject() {
|
|
489
|
+
return {
|
|
490
|
+
id: this.id,
|
|
491
|
+
title: this.title,
|
|
492
|
+
start: this.start.toISOString(),
|
|
493
|
+
end: this.end.toISOString(),
|
|
494
|
+
allDay: this.allDay,
|
|
495
|
+
description: this.description,
|
|
496
|
+
location: this.location,
|
|
497
|
+
color: this.color,
|
|
498
|
+
backgroundColor: this.backgroundColor,
|
|
499
|
+
borderColor: this.borderColor,
|
|
500
|
+
textColor: this.textColor,
|
|
501
|
+
recurring: this.recurring,
|
|
502
|
+
recurrenceRule: this.recurrenceRule,
|
|
503
|
+
timeZone: this.timeZone,
|
|
504
|
+
status: this.status,
|
|
505
|
+
visibility: this.visibility,
|
|
506
|
+
organizer: this.organizer,
|
|
507
|
+
attendees: this.attendees,
|
|
508
|
+
reminders: this.reminders,
|
|
509
|
+
categories: this.categories,
|
|
510
|
+
attachments: this.attachments,
|
|
511
|
+
conferenceData: this.conferenceData,
|
|
512
|
+
metadata: { ...this.metadata }
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Create Event from plain object
|
|
518
|
+
* @param {import('../../types.js').EventData} obj - Plain object with event properties
|
|
519
|
+
* @returns {Event} New Event instance
|
|
520
|
+
*/
|
|
521
|
+
static fromObject(obj) {
|
|
522
|
+
return new Event(obj);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Compare events for equality
|
|
527
|
+
* @param {Event} other - The other event
|
|
528
|
+
* @returns {boolean} True if events are equal
|
|
529
|
+
*/
|
|
530
|
+
equals(other) {
|
|
531
|
+
if (!(other instanceof Event)) return false;
|
|
532
|
+
|
|
533
|
+
return (
|
|
534
|
+
this.id === other.id &&
|
|
535
|
+
this.title === other.title &&
|
|
536
|
+
this.start.getTime() === other.start.getTime() &&
|
|
537
|
+
this.end.getTime() === other.end.getTime() &&
|
|
538
|
+
this.allDay === other.allDay &&
|
|
539
|
+
this.description === other.description &&
|
|
540
|
+
this.location === other.location &&
|
|
541
|
+
this.recurring === other.recurring &&
|
|
542
|
+
this.recurrenceRule === other.recurrenceRule &&
|
|
543
|
+
this.status === other.status
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ============ Attendee Management Methods ============
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Add an attendee to the event
|
|
551
|
+
* @param {import('../../types.js').Attendee} attendee - Attendee to add
|
|
552
|
+
* @returns {boolean} True if attendee was added, false if already exists
|
|
553
|
+
*/
|
|
554
|
+
addAttendee(attendee) {
|
|
555
|
+
if (!attendee || !attendee.email) {
|
|
556
|
+
throw new Error('Attendee must have an email');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Check if attendee already exists
|
|
560
|
+
if (this.hasAttendee(attendee.email)) {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Generate ID if not provided
|
|
565
|
+
if (!attendee.id) {
|
|
566
|
+
attendee.id = `attendee_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Set defaults
|
|
570
|
+
attendee.responseStatus = attendee.responseStatus || 'needs-action';
|
|
571
|
+
attendee.role = attendee.role || 'required';
|
|
572
|
+
|
|
573
|
+
this.attendees.push(attendee);
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Remove an attendee from the event
|
|
579
|
+
* @param {string} emailOrId - Email or ID of the attendee to remove
|
|
580
|
+
* @returns {boolean} True if attendee was removed
|
|
581
|
+
*/
|
|
582
|
+
removeAttendee(emailOrId) {
|
|
583
|
+
const index = this.attendees.findIndex(
|
|
584
|
+
a => a.email === emailOrId || a.id === emailOrId
|
|
585
|
+
);
|
|
586
|
+
|
|
587
|
+
if (index !== -1) {
|
|
588
|
+
this.attendees.splice(index, 1);
|
|
589
|
+
return true;
|
|
590
|
+
}
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Update an attendee's response status
|
|
596
|
+
* @param {string} email - Attendee's email
|
|
597
|
+
* @param {import('../../types.js').AttendeeResponseStatus} responseStatus - New response status
|
|
598
|
+
* @returns {boolean} True if attendee was updated
|
|
599
|
+
*/
|
|
600
|
+
updateAttendeeResponse(email, responseStatus) {
|
|
601
|
+
const attendee = this.getAttendee(email);
|
|
602
|
+
if (attendee) {
|
|
603
|
+
attendee.responseStatus = responseStatus;
|
|
604
|
+
attendee.responseTime = new Date();
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Get an attendee by email
|
|
612
|
+
* @param {string} email - Attendee's email
|
|
613
|
+
* @returns {import('../../types.js').Attendee|null} The attendee or null
|
|
614
|
+
*/
|
|
615
|
+
getAttendee(email) {
|
|
616
|
+
return this.attendees.find(a => a.email === email) || null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Check if an attendee exists
|
|
621
|
+
* @param {string} email - Attendee's email
|
|
622
|
+
* @returns {boolean} True if attendee exists
|
|
623
|
+
*/
|
|
624
|
+
hasAttendee(email) {
|
|
625
|
+
return this.attendees.some(a => a.email === email);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Get attendees by response status
|
|
630
|
+
* @param {import('../../types.js').AttendeeResponseStatus} status - Response status to filter by
|
|
631
|
+
* @returns {import('../../types.js').Attendee[]} Filtered attendees
|
|
632
|
+
*/
|
|
633
|
+
getAttendeesByStatus(status) {
|
|
634
|
+
return this.attendees.filter(a => a.responseStatus === status);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Get count of attendees by response status
|
|
639
|
+
* @returns {Object.<string, number>} Count by status
|
|
640
|
+
*/
|
|
641
|
+
getAttendeeCounts() {
|
|
642
|
+
return this.attendees.reduce((counts, attendee) => {
|
|
643
|
+
const status = attendee.responseStatus || 'needs-action';
|
|
644
|
+
counts[status] = (counts[status] || 0) + 1;
|
|
645
|
+
return counts;
|
|
646
|
+
}, {});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ============ Reminder Management Methods ============
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Add a reminder to the event
|
|
653
|
+
* @param {import('../../types.js').Reminder} reminder - Reminder to add
|
|
654
|
+
* @returns {boolean} True if reminder was added
|
|
655
|
+
*/
|
|
656
|
+
addReminder(reminder) {
|
|
657
|
+
if (!reminder || typeof reminder.minutesBefore !== 'number') {
|
|
658
|
+
throw new Error('Reminder must have minutesBefore property');
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// Generate ID if not provided
|
|
662
|
+
if (!reminder.id) {
|
|
663
|
+
reminder.id = `reminder_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Set defaults
|
|
667
|
+
reminder.method = reminder.method || 'popup';
|
|
668
|
+
reminder.enabled = reminder.enabled !== false;
|
|
669
|
+
|
|
670
|
+
// Check for duplicate
|
|
671
|
+
const duplicate = this.reminders.some(
|
|
672
|
+
r => r.method === reminder.method && r.minutesBefore === reminder.minutesBefore
|
|
673
|
+
);
|
|
674
|
+
|
|
675
|
+
if (duplicate) {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
this.reminders.push(reminder);
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Remove a reminder from the event
|
|
685
|
+
* @param {string} reminderId - ID of the reminder to remove
|
|
686
|
+
* @returns {boolean} True if reminder was removed
|
|
687
|
+
*/
|
|
688
|
+
removeReminder(reminderId) {
|
|
689
|
+
const index = this.reminders.findIndex(r => r.id === reminderId);
|
|
690
|
+
if (index !== -1) {
|
|
691
|
+
this.reminders.splice(index, 1);
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Get active reminders
|
|
699
|
+
* @returns {import('../../types.js').Reminder[]} Active reminders
|
|
700
|
+
*/
|
|
701
|
+
getActiveReminders() {
|
|
702
|
+
return this.reminders.filter(r => r.enabled !== false);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Get reminder trigger times
|
|
707
|
+
* @returns {Date[]} Array of dates when reminders should trigger
|
|
708
|
+
*/
|
|
709
|
+
getReminderTriggerTimes() {
|
|
710
|
+
return this.getActiveReminders().map(reminder => {
|
|
711
|
+
const triggerTime = new Date(this.start);
|
|
712
|
+
triggerTime.setMinutes(triggerTime.getMinutes() - reminder.minutesBefore);
|
|
713
|
+
return triggerTime;
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ============ Category Management Methods ============
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Add a category to the event
|
|
721
|
+
* @param {string} category - Category to add
|
|
722
|
+
* @returns {boolean} True if category was added
|
|
723
|
+
*/
|
|
724
|
+
addCategory(category) {
|
|
725
|
+
if (!category || typeof category !== 'string') {
|
|
726
|
+
throw new Error('Category must be a non-empty string');
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const normalizedCategory = category.trim().toLowerCase();
|
|
730
|
+
if (!this.hasCategory(normalizedCategory)) {
|
|
731
|
+
this.categories.push(normalizedCategory);
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
734
|
+
return false;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Remove a category from the event
|
|
739
|
+
* @param {string} category - Category to remove
|
|
740
|
+
* @returns {boolean} True if category was removed
|
|
741
|
+
*/
|
|
742
|
+
removeCategory(category) {
|
|
743
|
+
const normalizedCategory = category.trim().toLowerCase();
|
|
744
|
+
const index = this.categories.findIndex(
|
|
745
|
+
c => c.toLowerCase() === normalizedCategory
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
if (index !== -1) {
|
|
749
|
+
this.categories.splice(index, 1);
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Get primary category (first in array) for backward compatibility
|
|
757
|
+
* @returns {string|null} Primary category or null
|
|
758
|
+
*/
|
|
759
|
+
get category() {
|
|
760
|
+
return this.categories && this.categories.length > 0 ? this.categories[0] : null;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Check if event has a specific category
|
|
765
|
+
* @param {string} category - Category to check
|
|
766
|
+
* @returns {boolean} True if event has the category
|
|
767
|
+
*/
|
|
768
|
+
hasCategory(category) {
|
|
769
|
+
const normalizedCategory = category.trim().toLowerCase();
|
|
770
|
+
return this.categories.some(c => c.toLowerCase() === normalizedCategory);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Check if event has any of the specified categories
|
|
775
|
+
* @param {string[]} categories - Categories to check
|
|
776
|
+
* @returns {boolean} True if event has any of the categories
|
|
777
|
+
*/
|
|
778
|
+
hasAnyCategory(categories) {
|
|
779
|
+
return categories.some(category => this.hasCategory(category));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Check if event has all of the specified categories
|
|
784
|
+
* @param {string[]} categories - Categories to check
|
|
785
|
+
* @returns {boolean} True if event has all of the categories
|
|
786
|
+
*/
|
|
787
|
+
hasAllCategories(categories) {
|
|
788
|
+
return categories.every(category => this.hasCategory(category));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ============ Validation Methods ============
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Validate attendees
|
|
795
|
+
* @private
|
|
796
|
+
* @throws {Error} If attendees are invalid
|
|
797
|
+
*/
|
|
798
|
+
_validateAttendees() {
|
|
799
|
+
for (const attendee of this.attendees) {
|
|
800
|
+
if (!attendee.email) {
|
|
801
|
+
throw new Error('All attendees must have an email address');
|
|
802
|
+
}
|
|
803
|
+
if (!attendee.name) {
|
|
804
|
+
attendee.name = attendee.email; // Use email as fallback name
|
|
805
|
+
}
|
|
806
|
+
if (!this._isValidEmail(attendee.email)) {
|
|
807
|
+
throw new Error(`Invalid attendee email: ${attendee.email}`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Validate reminders
|
|
814
|
+
* @private
|
|
815
|
+
* @throws {Error} If reminders are invalid
|
|
816
|
+
*/
|
|
817
|
+
_validateReminders() {
|
|
818
|
+
for (const reminder of this.reminders) {
|
|
819
|
+
if (typeof reminder.minutesBefore !== 'number' || reminder.minutesBefore < 0) {
|
|
820
|
+
throw new Error('Reminder minutesBefore must be a positive number');
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const validMethods = ['email', 'popup', 'sms'];
|
|
824
|
+
if (!validMethods.includes(reminder.method)) {
|
|
825
|
+
throw new Error(`Invalid reminder method: ${reminder.method}`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Validate email address
|
|
832
|
+
* @private
|
|
833
|
+
* @param {string} email - Email to validate
|
|
834
|
+
* @returns {boolean} True if email is valid
|
|
835
|
+
*/
|
|
836
|
+
_isValidEmail(email) {
|
|
837
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
838
|
+
return emailRegex.test(email);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ============ Enhanced Getters ============
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Check if the event is cancelled
|
|
845
|
+
* @returns {boolean} True if event is cancelled
|
|
846
|
+
*/
|
|
847
|
+
get isCancelled() {
|
|
848
|
+
return this.status === 'cancelled';
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Check if the event is tentative
|
|
853
|
+
* @returns {boolean} True if event is tentative
|
|
854
|
+
*/
|
|
855
|
+
get isTentative() {
|
|
856
|
+
return this.status === 'tentative';
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Check if the event is confirmed
|
|
861
|
+
* @returns {boolean} True if event is confirmed
|
|
862
|
+
*/
|
|
863
|
+
get isConfirmed() {
|
|
864
|
+
return this.status === 'confirmed';
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Check if the event is private
|
|
869
|
+
* @returns {boolean} True if event is private
|
|
870
|
+
*/
|
|
871
|
+
get isPrivate() {
|
|
872
|
+
return this.visibility === 'private';
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Check if the event is public
|
|
877
|
+
* @returns {boolean} True if event is public
|
|
878
|
+
*/
|
|
879
|
+
get isPublic() {
|
|
880
|
+
return this.visibility === 'public';
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Check if the event has attendees
|
|
885
|
+
* @returns {boolean} True if event has attendees
|
|
886
|
+
*/
|
|
887
|
+
get hasAttendees() {
|
|
888
|
+
return this.attendees.length > 0;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Check if the event has reminders
|
|
893
|
+
* @returns {boolean} True if event has reminders
|
|
894
|
+
*/
|
|
895
|
+
get hasReminders() {
|
|
896
|
+
return this.reminders.length > 0;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* Check if the event is a meeting (has attendees or conference data)
|
|
901
|
+
* @returns {boolean} True if event is a meeting
|
|
902
|
+
*/
|
|
903
|
+
get isMeeting() {
|
|
904
|
+
return this.hasAttendees || this.conferenceData !== null;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Check if the event is virtual (has conference data)
|
|
909
|
+
* @returns {boolean} True if event is virtual
|
|
910
|
+
*/
|
|
911
|
+
get isVirtual() {
|
|
912
|
+
return this.conferenceData !== null;
|
|
913
|
+
}
|
|
914
|
+
}
|