@forcecalendar/core 1.1.0 → 2.1.0
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/events/Event.js +9 -8
- package/core/events/EventStore.js +3 -3
- package/core/events/RRuleParser.js +23 -5
- package/core/events/RecurrenceEngine.js +25 -6
- package/core/events/RecurrenceEngineV2.js +1 -1
- package/core/ics/ICSParser.js +8 -6
- package/core/search/EventSearch.js +1 -1
- package/core/state/StateManager.js +30 -2
- package/core/timezone/TimezoneManager.js +2 -2
- package/package.json +7 -2
package/core/events/Event.js
CHANGED
|
@@ -15,11 +15,11 @@ export class Event {
|
|
|
15
15
|
static normalize(data) {
|
|
16
16
|
const normalized = { ...data };
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
if (normalized.start
|
|
18
|
+
// Always clone Date objects to avoid mutating caller's data
|
|
19
|
+
if (normalized.start) {
|
|
20
20
|
normalized.start = new Date(normalized.start);
|
|
21
21
|
}
|
|
22
|
-
if (normalized.end
|
|
22
|
+
if (normalized.end) {
|
|
23
23
|
normalized.end = new Date(normalized.end);
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -29,6 +29,7 @@ export class Event {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
// For all-day events, normalize times to midnight
|
|
32
|
+
// (safe to mutate now since we cloned above)
|
|
32
33
|
if (normalized.allDay && normalized.start) {
|
|
33
34
|
normalized.start.setHours(0, 0, 0, 0);
|
|
34
35
|
if (normalized.end) {
|
|
@@ -373,7 +374,7 @@ export class Event {
|
|
|
373
374
|
* @returns {boolean} True if event spans multiple days
|
|
374
375
|
*/
|
|
375
376
|
get isMultiDay() {
|
|
376
|
-
if (!
|
|
377
|
+
if (!Object.prototype.hasOwnProperty.call(this._cache, 'isMultiDay')) {
|
|
377
378
|
const startDay = this.start.toDateString();
|
|
378
379
|
const endDay = this.end.toDateString();
|
|
379
380
|
this._cache.isMultiDay = startDay !== endDay;
|
|
@@ -416,10 +417,10 @@ export class Event {
|
|
|
416
417
|
dayEnd.setHours(23, 59, 59, 999);
|
|
417
418
|
|
|
418
419
|
return this.start <= dayEnd && this.end >= dayStart;
|
|
419
|
-
}
|
|
420
|
+
}
|
|
420
421
|
// Single day event: check if it's on the same day
|
|
421
422
|
return startString === dateString;
|
|
422
|
-
|
|
423
|
+
|
|
423
424
|
}
|
|
424
425
|
|
|
425
426
|
/**
|
|
@@ -435,9 +436,9 @@ export class Event {
|
|
|
435
436
|
} else if (otherEvent && otherEvent.start && otherEvent.end) {
|
|
436
437
|
// Allow checking against time ranges
|
|
437
438
|
return !(this.end <= otherEvent.start || this.start >= otherEvent.end);
|
|
438
|
-
}
|
|
439
|
+
}
|
|
439
440
|
throw new Error('Parameter must be an Event instance or have start/end properties');
|
|
440
|
-
|
|
441
|
+
|
|
441
442
|
}
|
|
442
443
|
|
|
443
444
|
/**
|
|
@@ -226,12 +226,12 @@ export class EventStore {
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
// Filter by all-day events
|
|
229
|
-
if (
|
|
229
|
+
if (Object.prototype.hasOwnProperty.call(filters, 'allDay')) {
|
|
230
230
|
results = results.filter(event => event.allDay === filters.allDay);
|
|
231
231
|
}
|
|
232
232
|
|
|
233
233
|
// Filter by recurring
|
|
234
|
-
if (
|
|
234
|
+
if (Object.prototype.hasOwnProperty.call(filters, 'recurring')) {
|
|
235
235
|
results = results.filter(event => event.recurring === filters.recurring);
|
|
236
236
|
}
|
|
237
237
|
|
|
@@ -250,7 +250,7 @@ export class EventStore {
|
|
|
250
250
|
}
|
|
251
251
|
|
|
252
252
|
// Filter by having attendees
|
|
253
|
-
if (
|
|
253
|
+
if (Object.prototype.hasOwnProperty.call(filters, 'hasAttendees')) {
|
|
254
254
|
results = results.filter(event => filters.hasAttendees ? event.hasAttendees : !event.hasAttendees);
|
|
255
255
|
}
|
|
256
256
|
|
|
@@ -265,6 +265,11 @@ export class RRuleParser {
|
|
|
265
265
|
// By* rules
|
|
266
266
|
if (rule.byDay && rule.byDay.length > 0) {
|
|
267
267
|
const dayStr = rule.byDay.map(d => {
|
|
268
|
+
// Handle both string format ('MO', '2TU', '-1FR') from parseByDay
|
|
269
|
+
// and object format ({nth: 2, weekday: 'MO'})
|
|
270
|
+
if (typeof d === 'string') {
|
|
271
|
+
return d;
|
|
272
|
+
}
|
|
268
273
|
return d.nth ? `${d.nth}${d.weekday}` : d.weekday;
|
|
269
274
|
}).join(',');
|
|
270
275
|
parts.push(`BYDAY=${dayStr}`);
|
|
@@ -374,17 +379,30 @@ export class RRuleParser {
|
|
|
374
379
|
description += 's';
|
|
375
380
|
}
|
|
376
381
|
|
|
377
|
-
// By day
|
|
382
|
+
// By day - handle both string format ('MO', '2TU') and object format ({nth, weekday})
|
|
378
383
|
if (rule.byDay && rule.byDay.length > 0) {
|
|
384
|
+
// Helper to extract weekday and nth from string or object
|
|
385
|
+
const parseDay = (d) => {
|
|
386
|
+
if (typeof d === 'string') {
|
|
387
|
+
const match = d.match(/^(-?\d+)?([A-Z]{2})$/);
|
|
388
|
+
if (match) {
|
|
389
|
+
return { nth: match[1] ? parseInt(match[1], 10) : null, weekday: match[2] };
|
|
390
|
+
}
|
|
391
|
+
return { nth: null, weekday: d };
|
|
392
|
+
}
|
|
393
|
+
return d;
|
|
394
|
+
};
|
|
395
|
+
|
|
379
396
|
if (rule.freq === 'WEEKLY') {
|
|
380
|
-
const days = rule.byDay.map(d => weekdayMap[d.weekday]).join(', ');
|
|
397
|
+
const days = rule.byDay.map(d => weekdayMap[parseDay(d).weekday]).join(', ');
|
|
381
398
|
description += ` on ${days}`;
|
|
382
399
|
} else if (rule.freq === 'MONTHLY' || rule.freq === 'YEARLY') {
|
|
383
400
|
const dayDescs = rule.byDay.map(d => {
|
|
384
|
-
|
|
385
|
-
|
|
401
|
+
const parsed = parseDay(d);
|
|
402
|
+
if (parsed.nth) {
|
|
403
|
+
return `the ${nthMap[parsed.nth] || parsed.nth} ${weekdayMap[parsed.weekday]}`;
|
|
386
404
|
}
|
|
387
|
-
return weekdayMap[
|
|
405
|
+
return weekdayMap[parsed.weekday];
|
|
388
406
|
}).join(', ');
|
|
389
407
|
description += ` on ${dayDescs}`;
|
|
390
408
|
}
|
|
@@ -39,6 +39,11 @@ export class RecurrenceEngine {
|
|
|
39
39
|
// Track DST transitions for proper timezone handling
|
|
40
40
|
let lastOffset = tzManager.getTimezoneOffset(currentDate, eventTimezone);
|
|
41
41
|
|
|
42
|
+
// Track last date to detect infinite loop (date not advancing)
|
|
43
|
+
const lastDateTimestamp = null;
|
|
44
|
+
let stuckCount = 0;
|
|
45
|
+
const maxStuckIterations = 3;
|
|
46
|
+
|
|
42
47
|
while (currentDate <= rangeEnd && count < maxOccurrences) {
|
|
43
48
|
// Check if this occurrence is within the range
|
|
44
49
|
if (currentDate >= rangeStart) {
|
|
@@ -68,9 +73,21 @@ export class RecurrenceEngine {
|
|
|
68
73
|
}
|
|
69
74
|
|
|
70
75
|
// Calculate next occurrence
|
|
76
|
+
const previousTimestamp = currentDate.getTime();
|
|
71
77
|
currentDate = this.getNextOccurrence(currentDate, rule, eventTimezone);
|
|
72
78
|
count++;
|
|
73
79
|
|
|
80
|
+
// Safeguard: detect if date is not advancing (infinite loop risk)
|
|
81
|
+
if (currentDate.getTime() === previousTimestamp) {
|
|
82
|
+
stuckCount++;
|
|
83
|
+
if (stuckCount >= maxStuckIterations) {
|
|
84
|
+
console.warn('RecurrenceEngine: Date not advancing, breaking to prevent infinite loop');
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
stuckCount = 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
// Check COUNT limit
|
|
75
92
|
if (rule.count && count >= rule.count) {
|
|
76
93
|
break;
|
|
@@ -111,15 +128,17 @@ export class RecurrenceEngine {
|
|
|
111
128
|
// Limit iterations to prevent infinite loop with malformed byDay
|
|
112
129
|
const maxIterations = 8; // 7 days + 1 for safety
|
|
113
130
|
let iterations = 0;
|
|
131
|
+
const originalDate = next.getDate();
|
|
114
132
|
next.setDate(next.getDate() + 1);
|
|
115
133
|
while (!this.matchesByDay(next, rule.byDay) && iterations < maxIterations) {
|
|
116
134
|
next.setDate(next.getDate() + 1);
|
|
117
135
|
iterations++;
|
|
118
136
|
}
|
|
119
|
-
// If no match found, fall back to simple weekly interval
|
|
137
|
+
// If no match found, fall back to simple weekly interval from original date
|
|
120
138
|
if (iterations >= maxIterations) {
|
|
121
139
|
console.warn('RecurrenceEngine: Invalid byDay rule, falling back to weekly interval');
|
|
122
|
-
|
|
140
|
+
// Reset to original and add weekly interval
|
|
141
|
+
next.setDate(originalDate + (7 * rule.interval));
|
|
123
142
|
}
|
|
124
143
|
} else {
|
|
125
144
|
// Simple weekly recurrence
|
|
@@ -248,11 +267,11 @@ export class RecurrenceEngine {
|
|
|
248
267
|
return Math.abs(exceptionDate.getTime() - dateTime) < 1000; // Within 1 second
|
|
249
268
|
}
|
|
250
269
|
return exceptionDate.toDateString() === dateStr;
|
|
251
|
-
}
|
|
270
|
+
}
|
|
252
271
|
// Simple date exception
|
|
253
272
|
const exceptionDate = exDate instanceof Date ? exDate : new Date(exDate);
|
|
254
273
|
return exceptionDate.toDateString() === dateStr;
|
|
255
|
-
|
|
274
|
+
|
|
256
275
|
});
|
|
257
276
|
}
|
|
258
277
|
|
|
@@ -308,9 +327,9 @@ export class RecurrenceEngine {
|
|
|
308
327
|
|
|
309
328
|
if (dateStr.endsWith('Z')) {
|
|
310
329
|
return new Date(Date.UTC(year, month, day, hour, minute, second));
|
|
311
|
-
}
|
|
330
|
+
}
|
|
312
331
|
return new Date(year, month, day, hour, minute, second);
|
|
313
|
-
|
|
332
|
+
|
|
314
333
|
}
|
|
315
334
|
|
|
316
335
|
// Fallback to standard date parsing
|
|
@@ -609,7 +609,7 @@ export class RecurrenceEngineV2 {
|
|
|
609
609
|
*/
|
|
610
610
|
clearEventCache(eventId) {
|
|
611
611
|
for (const key of this.occurrenceCache.keys()) {
|
|
612
|
-
if (key.startsWith(eventId
|
|
612
|
+
if (key.startsWith(`${eventId }_`)) {
|
|
613
613
|
this.occurrenceCache.delete(key);
|
|
614
614
|
}
|
|
615
615
|
}
|
package/core/ics/ICSParser.js
CHANGED
|
@@ -39,7 +39,7 @@ export class ICSParser {
|
|
|
39
39
|
let inEvent = false;
|
|
40
40
|
let inAlarm = false;
|
|
41
41
|
|
|
42
|
-
for (
|
|
42
|
+
for (const line of lines) {
|
|
43
43
|
// Skip empty lines
|
|
44
44
|
if (!line.trim()) continue;
|
|
45
45
|
|
|
@@ -248,7 +248,7 @@ export class ICSParser {
|
|
|
248
248
|
event.category = value.split(',')[0]; // Take first category
|
|
249
249
|
break;
|
|
250
250
|
|
|
251
|
-
case 'STATUS':
|
|
251
|
+
case 'STATUS': {
|
|
252
252
|
const statusMap = {
|
|
253
253
|
'TENTATIVE': 'tentative',
|
|
254
254
|
'CONFIRMED': 'confirmed',
|
|
@@ -256,6 +256,7 @@ export class ICSParser {
|
|
|
256
256
|
};
|
|
257
257
|
event.status = statusMap[value] || 'confirmed';
|
|
258
258
|
break;
|
|
259
|
+
}
|
|
259
260
|
|
|
260
261
|
case 'TRANSP':
|
|
261
262
|
event.showAs = value === 'TRANSPARENT' ? 'free' : 'busy';
|
|
@@ -265,7 +266,7 @@ export class ICSParser {
|
|
|
265
266
|
event.organizer = value.replace('mailto:', '');
|
|
266
267
|
break;
|
|
267
268
|
|
|
268
|
-
case 'ATTENDEE':
|
|
269
|
+
case 'ATTENDEE': {
|
|
269
270
|
if (!event.attendees) event.attendees = [];
|
|
270
271
|
const email = value.replace('mailto:', '');
|
|
271
272
|
event.attendees.push({
|
|
@@ -273,6 +274,7 @@ export class ICSParser {
|
|
|
273
274
|
name: email.split('@')[0] // Use email prefix as name
|
|
274
275
|
});
|
|
275
276
|
break;
|
|
277
|
+
}
|
|
276
278
|
|
|
277
279
|
case 'RRULE':
|
|
278
280
|
event.recurrence = value;
|
|
@@ -308,10 +310,10 @@ export class ICSParser {
|
|
|
308
310
|
if (dateString.endsWith('Z')) {
|
|
309
311
|
// UTC time
|
|
310
312
|
return new Date(Date.UTC(year, month, day, hour, minute, second));
|
|
311
|
-
}
|
|
313
|
+
}
|
|
312
314
|
// Local time
|
|
313
315
|
return new Date(year, month, day, hour, minute, second);
|
|
314
|
-
|
|
316
|
+
|
|
315
317
|
}
|
|
316
318
|
|
|
317
319
|
/**
|
|
@@ -365,7 +367,7 @@ export class ICSParser {
|
|
|
365
367
|
// Continuation lines (with space prefix)
|
|
366
368
|
while (remaining.length > 0) {
|
|
367
369
|
const chunk = remaining.substr(0, this.maxLineLength - 1);
|
|
368
|
-
folded.push(
|
|
370
|
+
folded.push(` ${ chunk}`);
|
|
369
371
|
remaining = remaining.substr(chunk.length);
|
|
370
372
|
}
|
|
371
373
|
|
|
@@ -288,7 +288,7 @@ export class EventSearch {
|
|
|
288
288
|
const events = this.eventStore.getAllEvents();
|
|
289
289
|
|
|
290
290
|
for (const event of events) {
|
|
291
|
-
const value = event[field] || (includeEmpty ?
|
|
291
|
+
const value = event[field] || (includeEmpty ? `(No ${ field })` : null);
|
|
292
292
|
if (value === null) continue;
|
|
293
293
|
|
|
294
294
|
if (!groups.has(value)) {
|
|
@@ -505,8 +505,10 @@ export class StateManager {
|
|
|
505
505
|
this.history = this.history.slice(0, this.historyIndex + 1);
|
|
506
506
|
}
|
|
507
507
|
|
|
508
|
-
//
|
|
509
|
-
|
|
508
|
+
// Deep clone state to prevent shared references in history
|
|
509
|
+
// (shallow copy would share nested objects like filters, businessHours, metadata)
|
|
510
|
+
const clonedState = this._deepClone(state);
|
|
511
|
+
this.history.push(clonedState);
|
|
510
512
|
this.historyIndex++;
|
|
511
513
|
|
|
512
514
|
// Limit history size
|
|
@@ -516,6 +518,32 @@ export class StateManager {
|
|
|
516
518
|
}
|
|
517
519
|
}
|
|
518
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Deep clone a value for history storage
|
|
523
|
+
* @private
|
|
524
|
+
*/
|
|
525
|
+
_deepClone(value) {
|
|
526
|
+
if (value === null || typeof value !== 'object') {
|
|
527
|
+
return value;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (value instanceof Date) {
|
|
531
|
+
return new Date(value);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (Array.isArray(value)) {
|
|
535
|
+
return value.map(item => this._deepClone(item));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const cloned = {};
|
|
539
|
+
for (const key in value) {
|
|
540
|
+
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
541
|
+
cloned[key] = this._deepClone(value[key]);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return cloned;
|
|
545
|
+
}
|
|
546
|
+
|
|
519
547
|
/**
|
|
520
548
|
* Notify listeners of state changes
|
|
521
549
|
* @private
|
|
@@ -343,13 +343,13 @@ export class TimezoneManager {
|
|
|
343
343
|
if (!tzString) return 'UTC';
|
|
344
344
|
|
|
345
345
|
// Check if it's already an IANA identifier
|
|
346
|
-
if (this.database.timezones
|
|
346
|
+
if (Object.prototype.hasOwnProperty.call(this.database.timezones, tzString)) {
|
|
347
347
|
return tzString;
|
|
348
348
|
}
|
|
349
349
|
|
|
350
350
|
// Check abbreviations
|
|
351
351
|
const upperTz = tzString.toUpperCase();
|
|
352
|
-
if (this.database.abbreviations && this.database.abbreviations
|
|
352
|
+
if (this.database.abbreviations && Object.prototype.hasOwnProperty.call(this.database.abbreviations, upperTz)) {
|
|
353
353
|
return this.database.abbreviations[upperTz];
|
|
354
354
|
}
|
|
355
355
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forcecalendar/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "A modern, lightweight, framework-agnostic calendar engine optimized for Salesforce",
|
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"test": "node tests/run-all.js",
|
|
13
13
|
"test:ics": "node tests/integration/test-ics.js",
|
|
14
|
-
"test:search": "node tests/integration/test-search.js"
|
|
14
|
+
"test:search": "node tests/integration/test-search.js",
|
|
15
|
+
"lint": "eslint core/ --ext .js",
|
|
16
|
+
"lint:fix": "eslint core/ --ext .js --fix",
|
|
17
|
+
"format": "prettier --write \"core/**/*.js\"",
|
|
18
|
+
"format:check": "prettier --check \"core/**/*.js\"",
|
|
19
|
+
"quality": "npm run lint && npm run format:check"
|
|
15
20
|
},
|
|
16
21
|
"exports": {
|
|
17
22
|
".": "./core/index.js",
|