@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,382 @@
|
|
|
1
|
+
import { DateUtils } from '../calendar/DateUtils.js';
|
|
2
|
+
import { TimezoneManager } from '../timezone/TimezoneManager.js';
|
|
3
|
+
import { RRuleParser } from './RRuleParser.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* RecurrenceEngine - Handles expansion of recurring events
|
|
7
|
+
* Full support for RFC 5545 (iCalendar) RRULE specification
|
|
8
|
+
*/
|
|
9
|
+
export class RecurrenceEngine {
|
|
10
|
+
/**
|
|
11
|
+
* Expand a recurring event into individual occurrences
|
|
12
|
+
* @param {import('./Event.js').Event} event - The recurring event
|
|
13
|
+
* @param {Date} rangeStart - Start of the expansion range
|
|
14
|
+
* @param {Date} rangeEnd - End of the expansion range
|
|
15
|
+
* @param {number} [maxOccurrences=365] - Maximum number of occurrences to generate
|
|
16
|
+
* @param {string} [timezone] - Timezone for expansion (important for DST)
|
|
17
|
+
* @returns {import('../../types.js').EventOccurrence[]} Array of occurrence objects with start/end dates
|
|
18
|
+
*/
|
|
19
|
+
static expandEvent(event, rangeStart, rangeEnd, maxOccurrences = 365, timezone = null) {
|
|
20
|
+
if (!event.recurring || !event.recurrenceRule) {
|
|
21
|
+
return [{ start: event.start, end: event.end, timezone: event.timeZone }];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rule = this.parseRule(event.recurrenceRule);
|
|
25
|
+
const occurrences = [];
|
|
26
|
+
const duration = event.end - event.start;
|
|
27
|
+
const eventTimezone = timezone || event.timeZone || 'UTC';
|
|
28
|
+
const tzManager = new TimezoneManager();
|
|
29
|
+
|
|
30
|
+
// Work in event's timezone for accurate recurrence calculation
|
|
31
|
+
let currentDate = new Date(event.start);
|
|
32
|
+
let count = 0;
|
|
33
|
+
|
|
34
|
+
// If UNTIL is specified, use it as the range end
|
|
35
|
+
if (rule.until && rule.until < rangeEnd) {
|
|
36
|
+
rangeEnd = rule.until;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Track DST transitions for proper timezone handling
|
|
40
|
+
let lastOffset = tzManager.getTimezoneOffset(currentDate, eventTimezone);
|
|
41
|
+
|
|
42
|
+
while (currentDate <= rangeEnd && count < maxOccurrences) {
|
|
43
|
+
// Check if this occurrence is within the range
|
|
44
|
+
if (currentDate >= rangeStart) {
|
|
45
|
+
const occurrenceStart = new Date(currentDate);
|
|
46
|
+
const occurrenceEnd = new Date(currentDate.getTime() + duration);
|
|
47
|
+
|
|
48
|
+
// Handle DST transitions
|
|
49
|
+
const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
|
|
50
|
+
if (currentOffset !== lastOffset) {
|
|
51
|
+
// Adjust for DST change
|
|
52
|
+
const offsetDiff = lastOffset - currentOffset;
|
|
53
|
+
occurrenceStart.setMinutes(occurrenceStart.getMinutes() + offsetDiff);
|
|
54
|
+
occurrenceEnd.setMinutes(occurrenceEnd.getMinutes() + offsetDiff);
|
|
55
|
+
}
|
|
56
|
+
lastOffset = currentOffset;
|
|
57
|
+
|
|
58
|
+
// Apply exceptions if any
|
|
59
|
+
if (!this.isException(occurrenceStart, rule, event.id)) {
|
|
60
|
+
occurrences.push({
|
|
61
|
+
start: occurrenceStart,
|
|
62
|
+
end: occurrenceEnd,
|
|
63
|
+
recurringEventId: event.id,
|
|
64
|
+
timezone: eventTimezone,
|
|
65
|
+
originalStart: event.start
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Calculate next occurrence
|
|
71
|
+
currentDate = this.getNextOccurrence(currentDate, rule, eventTimezone);
|
|
72
|
+
count++;
|
|
73
|
+
|
|
74
|
+
// Check COUNT limit
|
|
75
|
+
if (rule.count && count >= rule.count) {
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return occurrences;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Parse an RRULE string into a rule object
|
|
85
|
+
* @param {string|import('../../types.js').RecurrenceRule} ruleString - RRULE string (e.g., "FREQ=DAILY;INTERVAL=1;COUNT=10") or rule object
|
|
86
|
+
* @returns {import('../../types.js').RecurrenceRule} Parsed rule object
|
|
87
|
+
*/
|
|
88
|
+
static parseRule(ruleString) {
|
|
89
|
+
// Use the new comprehensive parser
|
|
90
|
+
return RRuleParser.parse(ruleString);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Calculate the next occurrence based on the rule
|
|
95
|
+
* @param {Date} currentDate - Current occurrence date
|
|
96
|
+
* @param {Object} rule - Recurrence rule object
|
|
97
|
+
* @param {string} [timezone] - Timezone for calculation
|
|
98
|
+
* @returns {Date} Next occurrence date
|
|
99
|
+
*/
|
|
100
|
+
static getNextOccurrence(currentDate, rule, timezone = 'UTC') {
|
|
101
|
+
const next = new Date(currentDate);
|
|
102
|
+
|
|
103
|
+
switch (rule.freq) {
|
|
104
|
+
case 'DAILY':
|
|
105
|
+
next.setDate(next.getDate() + rule.interval);
|
|
106
|
+
break;
|
|
107
|
+
|
|
108
|
+
case 'WEEKLY':
|
|
109
|
+
if (rule.byDay && rule.byDay.length > 0) {
|
|
110
|
+
// Find next day that matches byDay
|
|
111
|
+
next.setDate(next.getDate() + 1);
|
|
112
|
+
while (!this.matchesByDay(next, rule.byDay)) {
|
|
113
|
+
next.setDate(next.getDate() + 1);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
// Simple weekly recurrence
|
|
117
|
+
next.setDate(next.getDate() + (7 * rule.interval));
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
|
|
121
|
+
case 'MONTHLY':
|
|
122
|
+
if (rule.byMonthDay && rule.byMonthDay.length > 0) {
|
|
123
|
+
// Specific day(s) of month
|
|
124
|
+
const currentMonth = next.getMonth();
|
|
125
|
+
next.setMonth(currentMonth + rule.interval);
|
|
126
|
+
next.setDate(rule.byMonthDay[0]); // Use first specified day
|
|
127
|
+
} else if (rule.byDay && rule.byDay.length > 0) {
|
|
128
|
+
// Specific weekday of month (e.g., "2nd Tuesday")
|
|
129
|
+
next.setMonth(next.getMonth() + rule.interval);
|
|
130
|
+
this.setToWeekdayOfMonth(next, rule.byDay[0], rule.bySetPos[0] || 1);
|
|
131
|
+
} else {
|
|
132
|
+
// Same day of month
|
|
133
|
+
next.setMonth(next.getMonth() + rule.interval);
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
136
|
+
|
|
137
|
+
case 'YEARLY':
|
|
138
|
+
if (rule.byMonth && rule.byMonth.length > 0) {
|
|
139
|
+
next.setFullYear(next.getFullYear() + rule.interval);
|
|
140
|
+
next.setMonth(rule.byMonth[0] - 1); // Months are 0-indexed
|
|
141
|
+
} else {
|
|
142
|
+
next.setFullYear(next.getFullYear() + rule.interval);
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
|
|
146
|
+
default:
|
|
147
|
+
// Unsupported frequency
|
|
148
|
+
next.setTime(next.getTime() + (24 * 60 * 60 * 1000)); // Daily fallback
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return next;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Check if a date matches the BYDAY rule
|
|
156
|
+
* @param {Date} date - Date to check
|
|
157
|
+
* @param {Array<string>} byDay - Array of day codes (e.g., ['MO', 'WE', 'FR'])
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
static matchesByDay(date, byDay) {
|
|
161
|
+
const dayMap = {
|
|
162
|
+
'SU': 0, 'MO': 1, 'TU': 2, 'WE': 3,
|
|
163
|
+
'TH': 4, 'FR': 5, 'SA': 6
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const dayOfWeek = date.getDay();
|
|
167
|
+
return byDay.some(day => {
|
|
168
|
+
// Handle numbered weekdays (e.g., "2MO" for 2nd Monday)
|
|
169
|
+
const match = day.match(/^(-?\d+)?([A-Z]{2})$/);
|
|
170
|
+
if (match) {
|
|
171
|
+
const weekdayCode = match[2];
|
|
172
|
+
return dayMap[weekdayCode] === dayOfWeek;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Set date to specific weekday of month
|
|
180
|
+
* @param {Date} date - Date to modify
|
|
181
|
+
* @param {string} weekday - Weekday code (e.g., 'MO', 'TU')
|
|
182
|
+
* @param {number} position - Position in month (1-5, or -1 for last)
|
|
183
|
+
*/
|
|
184
|
+
static setToWeekdayOfMonth(date, weekday, position = 1) {
|
|
185
|
+
const dayMap = {
|
|
186
|
+
'SU': 0, 'MO': 1, 'TU': 2, 'WE': 3,
|
|
187
|
+
'TH': 4, 'FR': 5, 'SA': 6
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Extract weekday code if it has a number prefix
|
|
191
|
+
const match = weekday.match(/^(-?\d+)?([A-Z]{2})$/);
|
|
192
|
+
const weekdayCode = match ? match[2] : weekday;
|
|
193
|
+
const targetDay = dayMap[weekdayCode];
|
|
194
|
+
|
|
195
|
+
date.setDate(1); // Start at first of month
|
|
196
|
+
|
|
197
|
+
// Find first occurrence of the weekday
|
|
198
|
+
while (date.getDay() !== targetDay) {
|
|
199
|
+
date.setDate(date.getDate() + 1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Move to the nth occurrence
|
|
203
|
+
if (position > 1) {
|
|
204
|
+
date.setDate(date.getDate() + (7 * (position - 1)));
|
|
205
|
+
} else if (position === -1) {
|
|
206
|
+
// Last occurrence of the month
|
|
207
|
+
const nextMonth = new Date(date);
|
|
208
|
+
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
|
209
|
+
nextMonth.setDate(0); // Last day of current month
|
|
210
|
+
|
|
211
|
+
while (nextMonth.getDay() !== targetDay) {
|
|
212
|
+
nextMonth.setDate(nextMonth.getDate() - 1);
|
|
213
|
+
}
|
|
214
|
+
date.setTime(nextMonth.getTime());
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Check if a date is an exception
|
|
220
|
+
* @param {Date} date - Date to check
|
|
221
|
+
* @param {Object} rule - Rule object with exceptions
|
|
222
|
+
* @param {string} [eventId] - Event ID for better exception tracking
|
|
223
|
+
* @returns {boolean}
|
|
224
|
+
*/
|
|
225
|
+
static isException(date, rule, eventId = null) {
|
|
226
|
+
if (!rule.exceptions || rule.exceptions.length === 0) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Support both date-only and date-time exceptions
|
|
231
|
+
const dateStr = date.toDateString();
|
|
232
|
+
const dateTime = date.getTime();
|
|
233
|
+
|
|
234
|
+
return rule.exceptions.some(exDate => {
|
|
235
|
+
if (typeof exDate === 'object' && exDate.date) {
|
|
236
|
+
// Enhanced exception format with reason
|
|
237
|
+
const exceptionDate = exDate.date instanceof Date ? exDate.date : new Date(exDate.date);
|
|
238
|
+
if (exDate.matchTime) {
|
|
239
|
+
return Math.abs(exceptionDate.getTime() - dateTime) < 1000; // Within 1 second
|
|
240
|
+
}
|
|
241
|
+
return exceptionDate.toDateString() === dateStr;
|
|
242
|
+
} else {
|
|
243
|
+
// Simple date exception
|
|
244
|
+
const exceptionDate = exDate instanceof Date ? exDate : new Date(exDate);
|
|
245
|
+
return exceptionDate.toDateString() === dateStr;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Add exception dates to a recurrence rule
|
|
252
|
+
* @param {Object} rule - Recurrence rule
|
|
253
|
+
* @param {Date|Date[]} exceptions - Exception date(s) to add
|
|
254
|
+
* @param {Object} [options] - Options for exception
|
|
255
|
+
* @returns {Object} Updated rule
|
|
256
|
+
*/
|
|
257
|
+
static addExceptions(rule, exceptions, options = {}) {
|
|
258
|
+
if (!rule.exceptions) {
|
|
259
|
+
rule.exceptions = [];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const exceptionArray = Array.isArray(exceptions) ? exceptions : [exceptions];
|
|
263
|
+
|
|
264
|
+
exceptionArray.forEach(date => {
|
|
265
|
+
if (options.reason || options.matchTime) {
|
|
266
|
+
rule.exceptions.push({
|
|
267
|
+
date: date,
|
|
268
|
+
reason: options.reason,
|
|
269
|
+
matchTime: options.matchTime || false
|
|
270
|
+
});
|
|
271
|
+
} else {
|
|
272
|
+
rule.exceptions.push(date);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return rule;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Parse date from RRULE format (YYYYMMDDTHHMMSSZ)
|
|
281
|
+
* @param {string} dateStr - Date string in RRULE format
|
|
282
|
+
* @returns {Date}
|
|
283
|
+
*/
|
|
284
|
+
static parseDate(dateStr) {
|
|
285
|
+
if (dateStr.length === 8) {
|
|
286
|
+
// YYYYMMDD
|
|
287
|
+
const year = parseInt(dateStr.substr(0, 4), 10);
|
|
288
|
+
const month = parseInt(dateStr.substr(4, 2), 10) - 1;
|
|
289
|
+
const day = parseInt(dateStr.substr(6, 2), 10);
|
|
290
|
+
return new Date(year, month, day);
|
|
291
|
+
} else if (dateStr.length === 15 || dateStr.length === 16) {
|
|
292
|
+
// YYYYMMDDTHHMMSS[Z]
|
|
293
|
+
const year = parseInt(dateStr.substr(0, 4), 10);
|
|
294
|
+
const month = parseInt(dateStr.substr(4, 2), 10) - 1;
|
|
295
|
+
const day = parseInt(dateStr.substr(6, 2), 10);
|
|
296
|
+
const hour = parseInt(dateStr.substr(9, 2), 10);
|
|
297
|
+
const minute = parseInt(dateStr.substr(11, 2), 10);
|
|
298
|
+
const second = parseInt(dateStr.substr(13, 2), 10);
|
|
299
|
+
|
|
300
|
+
if (dateStr.endsWith('Z')) {
|
|
301
|
+
return new Date(Date.UTC(year, month, day, hour, minute, second));
|
|
302
|
+
} else {
|
|
303
|
+
return new Date(year, month, day, hour, minute, second);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Fallback to standard date parsing
|
|
308
|
+
return new Date(dateStr);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Generate a human-readable description of the recurrence rule
|
|
313
|
+
* @param {Object|string} rule - Recurrence rule
|
|
314
|
+
* @returns {string} Human-readable description
|
|
315
|
+
*/
|
|
316
|
+
static getDescription(rule) {
|
|
317
|
+
if (typeof rule === 'string') {
|
|
318
|
+
rule = this.parseRule(rule);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let description = '';
|
|
322
|
+
const interval = rule.interval || 1;
|
|
323
|
+
|
|
324
|
+
switch (rule.freq) {
|
|
325
|
+
case 'DAILY':
|
|
326
|
+
description = interval === 1 ? 'Daily' : `Every ${interval} days`;
|
|
327
|
+
break;
|
|
328
|
+
case 'WEEKLY':
|
|
329
|
+
description = interval === 1 ? 'Weekly' : `Every ${interval} weeks`;
|
|
330
|
+
if (rule.byDay && rule.byDay.length > 0) {
|
|
331
|
+
const days = rule.byDay.map(d => this.getDayName(d)).join(', ');
|
|
332
|
+
description += ` on ${days}`;
|
|
333
|
+
}
|
|
334
|
+
break;
|
|
335
|
+
case 'MONTHLY':
|
|
336
|
+
description = interval === 1 ? 'Monthly' : `Every ${interval} months`;
|
|
337
|
+
if (rule.byMonthDay && rule.byMonthDay.length > 0) {
|
|
338
|
+
description += ` on day ${rule.byMonthDay.join(', ')}`;
|
|
339
|
+
}
|
|
340
|
+
break;
|
|
341
|
+
case 'YEARLY':
|
|
342
|
+
description = interval === 1 ? 'Yearly' : `Every ${interval} years`;
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (rule.count) {
|
|
347
|
+
description += `, ${rule.count} times`;
|
|
348
|
+
} else if (rule.until) {
|
|
349
|
+
description += `, until ${rule.until.toLocaleDateString()}`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return description;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Get day name from RRULE day code
|
|
357
|
+
* @param {string} dayCode - Day code (e.g., 'MO', '2TU')
|
|
358
|
+
* @returns {string} Day name
|
|
359
|
+
*/
|
|
360
|
+
static getDayName(dayCode) {
|
|
361
|
+
const dayNames = {
|
|
362
|
+
'SU': 'Sunday', 'MO': 'Monday', 'TU': 'Tuesday',
|
|
363
|
+
'WE': 'Wednesday', 'TH': 'Thursday', 'FR': 'Friday',
|
|
364
|
+
'SA': 'Saturday'
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
// Extract day code if it has a number prefix
|
|
368
|
+
const match = dayCode.match(/^(-?\d+)?([A-Z]{2})$/);
|
|
369
|
+
const code = match ? match[2] : dayCode;
|
|
370
|
+
const position = match && match[1] ? parseInt(match[1], 10) : null;
|
|
371
|
+
|
|
372
|
+
let name = dayNames[code] || dayCode;
|
|
373
|
+
|
|
374
|
+
if (position) {
|
|
375
|
+
const ordinals = ['', '1st', '2nd', '3rd', '4th', '5th'];
|
|
376
|
+
const ordinal = position === -1 ? 'Last' : (ordinals[position] || `${position}th`);
|
|
377
|
+
name = `${ordinal} ${name}`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return name;
|
|
381
|
+
}
|
|
382
|
+
}
|