@forcecalendar/core 2.2.0 → 2.4.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/calendar/Calendar.js +96 -4
- package/core/events/Event.js +112 -0
- package/core/events/EventStore.js +176 -39
- package/core/events/RecurrenceEngine.js +442 -12
- package/core/events/RecurrenceEngineV2.js +103 -3
- package/core/index.js +1 -1
- package/core/timezone/TimezoneManager.js +79 -1
- package/core/types.js +43 -1
- package/package.json +1 -1
- package/types/calendar/Calendar.d.ts +60 -3
- package/types/events/Event.d.ts +36 -0
- package/types/events/EventStore.d.ts +50 -1
- package/types/events/RecurrenceEngine.d.ts +99 -1
- package/types/events/RecurrenceEngineV2.d.ts +45 -2
- package/types/index.d.ts +1 -1
- package/types/timezone/TimezoneManager.d.ts +20 -0
- package/types/types.d.ts +121 -2
|
@@ -12,6 +12,11 @@ export class RecurrenceEngine {
|
|
|
12
12
|
// Hard limit to prevent resource exhaustion regardless of caller input
|
|
13
13
|
static MAX_OCCURRENCES_HARD_LIMIT = 10000;
|
|
14
14
|
|
|
15
|
+
// Hard limit on expansion loop iterations (every occurrence stepped
|
|
16
|
+
// through, inside the range or not) so a rule that cannot be seeked
|
|
17
|
+
// arithmetically still terminates in bounded time
|
|
18
|
+
static MAX_ITERATIONS_HARD_LIMIT = 100000;
|
|
19
|
+
|
|
15
20
|
// expandEvent is typically called many times with the same RRULE string
|
|
16
21
|
// (every view render), so parsed rules are cached by their source string
|
|
17
22
|
static _ruleCache = new Map();
|
|
@@ -19,10 +24,18 @@ export class RecurrenceEngine {
|
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Expand a recurring event into individual occurrences
|
|
27
|
+
*
|
|
28
|
+
* Occurrences before rangeStart are skipped without being generated:
|
|
29
|
+
* daily, weekly and sub-daily rules seek straight to the range, so the
|
|
30
|
+
* cost of a query does not grow with the age of the series, and a series
|
|
31
|
+
* that started years before the queried window is still expanded.
|
|
32
|
+
*
|
|
22
33
|
* @param {import('./Event.js').Event} event - The recurring event
|
|
23
34
|
* @param {Date} rangeStart - Start of the expansion range
|
|
24
35
|
* @param {Date} rangeEnd - End of the expansion range
|
|
25
|
-
* @param {number} [maxOccurrences=365] - Maximum number of occurrences to
|
|
36
|
+
* @param {number} [maxOccurrences=365] - Maximum number of occurrences to return.
|
|
37
|
+
* Only occurrences inside the range count towards this limit; occurrences
|
|
38
|
+
* between the series start and rangeStart do not consume it.
|
|
26
39
|
* @param {string} [timezone] - Timezone for expansion (important for DST)
|
|
27
40
|
* @returns {import('../types.js').EventOccurrence[]} Array of occurrence objects with start/end dates
|
|
28
41
|
*/
|
|
@@ -34,20 +47,74 @@ export class RecurrenceEngine {
|
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
const rule = this._getParsedRule(event.recurrenceRule);
|
|
37
|
-
const occurrences = [];
|
|
38
50
|
const duration = event.end - event.start;
|
|
39
51
|
const eventTimezone = timezone || event.timeZone || 'UTC';
|
|
40
52
|
const tzManager = TimezoneManager.getInstance();
|
|
41
53
|
|
|
42
|
-
// Work in event's timezone for accurate recurrence calculation
|
|
43
|
-
const currentDate = new Date(event.start);
|
|
44
|
-
let count = 0;
|
|
45
|
-
|
|
46
54
|
// If UNTIL is specified, use it as the range end
|
|
47
55
|
if (rule.until && rule.until < rangeEnd) {
|
|
48
56
|
rangeEnd = rule.until;
|
|
49
57
|
}
|
|
50
58
|
|
|
59
|
+
// DAILY and WEEKLY series iterate on numeric timestamps (no Date
|
|
60
|
+
// arithmetic per step); other frequencies use the general loop
|
|
61
|
+
let occurrences = null;
|
|
62
|
+
if (rule.freq === 'DAILY' || rule.freq === 'WEEKLY') {
|
|
63
|
+
occurrences = this._expandFast(
|
|
64
|
+
event,
|
|
65
|
+
rule,
|
|
66
|
+
rangeStart.getTime(),
|
|
67
|
+
rangeEnd.getTime(),
|
|
68
|
+
maxOccurrences,
|
|
69
|
+
eventTimezone,
|
|
70
|
+
tzManager,
|
|
71
|
+
duration
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (!occurrences) {
|
|
75
|
+
occurrences = this._expandGeneral(
|
|
76
|
+
event,
|
|
77
|
+
rule,
|
|
78
|
+
rangeStart.getTime(),
|
|
79
|
+
rangeEnd.getTime(),
|
|
80
|
+
maxOccurrences,
|
|
81
|
+
eventTimezone,
|
|
82
|
+
tzManager,
|
|
83
|
+
duration
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Apply BYSETPOS filtering if present and not already handled by MONTHLY+byDay
|
|
88
|
+
if (rule.bySetPos && rule.bySetPos.length > 0 && rule.freq !== 'MONTHLY') {
|
|
89
|
+
return this._applyBySetPos(occurrences, rule);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return occurrences;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* General expansion loop: advances a Date cursor per step. Handles every
|
|
97
|
+
* frequency and degenerate rules (non-advancing dates, invalid intervals).
|
|
98
|
+
* @private
|
|
99
|
+
*/
|
|
100
|
+
static _expandGeneral(
|
|
101
|
+
event,
|
|
102
|
+
rule,
|
|
103
|
+
rangeStartMs,
|
|
104
|
+
rangeEndMs,
|
|
105
|
+
maxOccurrences,
|
|
106
|
+
eventTimezone,
|
|
107
|
+
tzManager,
|
|
108
|
+
duration
|
|
109
|
+
) {
|
|
110
|
+
const occurrences = [];
|
|
111
|
+
|
|
112
|
+
// Work in event's timezone for accurate recurrence calculation
|
|
113
|
+
const currentDate = new Date(event.start);
|
|
114
|
+
// Steps taken from DTSTART: RFC 5545 COUNT is measured from there,
|
|
115
|
+
// independent of how many occurrences fall inside the range
|
|
116
|
+
let count = 0;
|
|
117
|
+
|
|
51
118
|
// Track DST transitions for proper timezone handling
|
|
52
119
|
let lastOffset = tzManager.getTimezoneOffset(currentDate, eventTimezone);
|
|
53
120
|
|
|
@@ -57,12 +124,34 @@ export class RecurrenceEngine {
|
|
|
57
124
|
|
|
58
125
|
// Compare on numeric timestamps in the loop — Date-object comparisons
|
|
59
126
|
// re-coerce through valueOf on every check
|
|
60
|
-
const rangeStartMs = rangeStart.getTime();
|
|
61
|
-
const rangeEndMs = rangeEnd.getTime();
|
|
62
127
|
const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
|
|
63
128
|
let currentMs = currentDate.getTime();
|
|
64
129
|
|
|
65
|
-
|
|
130
|
+
// Sub-daily rules step a fixed number of milliseconds, so the span
|
|
131
|
+
// before the range is skipped arithmetically instead of one step at a
|
|
132
|
+
// time; other frequencies take few enough steps per year to just walk
|
|
133
|
+
const stepMs = this._fixedStepMs(rule);
|
|
134
|
+
if (stepMs > 0 && currentMs < rangeStartMs) {
|
|
135
|
+
const seek = this._seekFixedStep(
|
|
136
|
+
currentMs,
|
|
137
|
+
rangeStartMs,
|
|
138
|
+
rangeEndMs,
|
|
139
|
+
stepMs,
|
|
140
|
+
rule.count ? rule.count - 1 : Infinity,
|
|
141
|
+
cursor => this._advanceInPlace(cursor, rule)
|
|
142
|
+
);
|
|
143
|
+
currentMs = seek.ms;
|
|
144
|
+
count = seek.steps;
|
|
145
|
+
currentDate.setTime(currentMs);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let iterations = 0;
|
|
149
|
+
while (
|
|
150
|
+
currentMs <= rangeEndMs &&
|
|
151
|
+
occurrences.length < maxOccurrences &&
|
|
152
|
+
iterations < RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT
|
|
153
|
+
) {
|
|
154
|
+
iterations++;
|
|
66
155
|
// Check if this occurrence is within the range
|
|
67
156
|
if (currentMs >= rangeStartMs) {
|
|
68
157
|
const occurrenceStart = new Date(currentMs);
|
|
@@ -113,14 +202,355 @@ export class RecurrenceEngine {
|
|
|
113
202
|
}
|
|
114
203
|
}
|
|
115
204
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
205
|
+
return occurrences;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Numeric expansion loop for DAILY and WEEKLY rules.
|
|
210
|
+
*
|
|
211
|
+
* Between DST transitions a wall-clock-preserving day step is a constant
|
|
212
|
+
* number of milliseconds, so the loop is pure numeric addition. Transition
|
|
213
|
+
* instants — in the system timezone (which defines Date arithmetic) and in
|
|
214
|
+
* the event's timezone (which drives occurrence adjustment) — are
|
|
215
|
+
* discovered by binary search and cached, so only the one step that
|
|
216
|
+
* crosses a transition falls back to Date arithmetic, and only the first
|
|
217
|
+
* occurrence after a transition queries a timezone offset.
|
|
218
|
+
*
|
|
219
|
+
* Produces output identical to _expandGeneral for the rules it accepts;
|
|
220
|
+
* returns null to delegate anything it cannot handle exactly.
|
|
221
|
+
* @private
|
|
222
|
+
*/
|
|
223
|
+
static _expandFast(
|
|
224
|
+
event,
|
|
225
|
+
rule,
|
|
226
|
+
rangeStartMs,
|
|
227
|
+
rangeEndMs,
|
|
228
|
+
maxOccurrences,
|
|
229
|
+
eventTimezone,
|
|
230
|
+
tzManager,
|
|
231
|
+
duration
|
|
232
|
+
) {
|
|
233
|
+
const DAY = 86400000;
|
|
234
|
+
let dayDeltas = null;
|
|
235
|
+
let stepDays = 0;
|
|
236
|
+
let weekday = 0;
|
|
237
|
+
|
|
238
|
+
const startDate = new Date(event.start);
|
|
239
|
+
if (rule.freq === 'WEEKLY' && rule.byDay && rule.byDay.length > 0) {
|
|
240
|
+
const daySet = rule._byDaySet || (rule._byDaySet = this._buildByDaySet(rule.byDay));
|
|
241
|
+
if (daySet.size === 0) {
|
|
242
|
+
return null; // invalid byDay — general loop handles the fallback warning
|
|
243
|
+
}
|
|
244
|
+
dayDeltas = rule._byDayDeltas || (rule._byDayDeltas = this._buildByDayDeltas(daySet));
|
|
245
|
+
weekday = startDate.getDay();
|
|
246
|
+
} else {
|
|
247
|
+
stepDays = (rule.freq === 'DAILY' ? 1 : 7) * rule.interval;
|
|
248
|
+
if (!Number.isInteger(stepDays) || stepDays <= 0) {
|
|
249
|
+
return null; // degenerate interval — general loop's stuck detection applies
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const occurrences = [];
|
|
254
|
+
let currentMs = startDate.getTime();
|
|
255
|
+
if (Number.isNaN(currentMs)) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
let count = 0;
|
|
259
|
+
const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
|
|
260
|
+
|
|
261
|
+
let lastOffset = tzManager.getTimezoneOffset(startDate, eventTimezone);
|
|
262
|
+
let nextEventTzTransition = tzManager.getNextTransition(eventTimezone, currentMs, rangeEndMs);
|
|
263
|
+
let nextSystemTransition = this._nextSystemTransition(currentMs, rangeEndMs);
|
|
264
|
+
|
|
265
|
+
// Seek to the last occurrence before the range. nextEventTzTransition is
|
|
266
|
+
// deliberately left as computed from DTSTART: if the seek passed an
|
|
267
|
+
// event-timezone transition, the first in-range occurrence must still
|
|
268
|
+
// compare its offset with lastOffset, exactly as the general loop does.
|
|
269
|
+
if (currentMs < rangeStartMs) {
|
|
270
|
+
const maxSteps = rule.count ? rule.count - 1 : Infinity;
|
|
271
|
+
const seek = dayDeltas
|
|
272
|
+
? this._seekWeekCycle(currentMs, weekday, rangeStartMs, rangeEndMs, rule, maxSteps)
|
|
273
|
+
: this._seekFixedStep(
|
|
274
|
+
currentMs,
|
|
275
|
+
rangeStartMs,
|
|
276
|
+
rangeEndMs,
|
|
277
|
+
stepDays * DAY,
|
|
278
|
+
maxSteps,
|
|
279
|
+
cursor => cursor.setDate(cursor.getDate() + stepDays)
|
|
280
|
+
);
|
|
281
|
+
currentMs = seek.ms;
|
|
282
|
+
count = seek.steps;
|
|
283
|
+
nextSystemTransition = seek.nextSystemTransition;
|
|
284
|
+
if (dayDeltas) {
|
|
285
|
+
weekday = seek.weekday;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
let iterations = 0;
|
|
290
|
+
while (
|
|
291
|
+
currentMs <= rangeEndMs &&
|
|
292
|
+
occurrences.length < maxOccurrences &&
|
|
293
|
+
iterations < RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT
|
|
294
|
+
) {
|
|
295
|
+
iterations++;
|
|
296
|
+
if (currentMs >= rangeStartMs) {
|
|
297
|
+
const occurrenceStart = new Date(currentMs);
|
|
298
|
+
const occurrenceEnd = new Date(currentMs + duration);
|
|
299
|
+
|
|
300
|
+
// Only the first occurrence past a transition needs an offset check
|
|
301
|
+
if (currentMs >= nextEventTzTransition) {
|
|
302
|
+
const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
|
|
303
|
+
if (currentOffset !== lastOffset) {
|
|
304
|
+
const offsetDiff = lastOffset - currentOffset;
|
|
305
|
+
occurrenceStart.setMinutes(occurrenceStart.getMinutes() + offsetDiff);
|
|
306
|
+
occurrenceEnd.setMinutes(occurrenceEnd.getMinutes() + offsetDiff);
|
|
307
|
+
lastOffset = currentOffset;
|
|
308
|
+
}
|
|
309
|
+
nextEventTzTransition = tzManager.getNextTransition(eventTimezone, currentMs, rangeEndMs);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!hasExceptions || !this.isException(occurrenceStart, rule, event.id)) {
|
|
313
|
+
occurrences.push({
|
|
314
|
+
start: occurrenceStart,
|
|
315
|
+
end: occurrenceEnd,
|
|
316
|
+
recurringEventId: event.id,
|
|
317
|
+
timezone: eventTimezone,
|
|
318
|
+
originalStart: event.start
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Advance: pure addition unless the step crosses a system-timezone
|
|
324
|
+
// transition, where Date arithmetic reproduces wall-clock semantics
|
|
325
|
+
const days = dayDeltas ? dayDeltas[weekday] : stepDays;
|
|
326
|
+
if (dayDeltas) {
|
|
327
|
+
weekday = (weekday + days) % 7;
|
|
328
|
+
}
|
|
329
|
+
const naiveMs = currentMs + days * DAY;
|
|
330
|
+
if (naiveMs >= nextSystemTransition) {
|
|
331
|
+
const cursor = new Date(currentMs);
|
|
332
|
+
cursor.setDate(cursor.getDate() + days);
|
|
333
|
+
currentMs = cursor.getTime();
|
|
334
|
+
nextSystemTransition = this._nextSystemTransition(currentMs, rangeEndMs);
|
|
335
|
+
} else {
|
|
336
|
+
currentMs = naiveMs;
|
|
337
|
+
}
|
|
338
|
+
count++;
|
|
339
|
+
|
|
340
|
+
if (rule.count && count >= rule.count) {
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
119
343
|
}
|
|
120
344
|
|
|
121
345
|
return occurrences;
|
|
122
346
|
}
|
|
123
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Milliseconds per step for rules whose step is a fixed duration while
|
|
350
|
+
* the system UTC offset is constant. Only the sub-daily frequencies are
|
|
351
|
+
* reported here: DAILY and WEEKLY have their own numeric loop, and the
|
|
352
|
+
* calendar-based frequencies take too few steps per year to need seeking.
|
|
353
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
354
|
+
* @returns {number} Step length in milliseconds, or 0 when not fixed
|
|
355
|
+
* @private
|
|
356
|
+
*/
|
|
357
|
+
static _fixedStepMs(rule) {
|
|
358
|
+
const interval = rule.interval;
|
|
359
|
+
if (!Number.isInteger(interval) || interval <= 0) {
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
switch (rule.freq) {
|
|
363
|
+
case 'SECONDLY':
|
|
364
|
+
return interval * 1000;
|
|
365
|
+
case 'MINUTELY':
|
|
366
|
+
return interval * 60000;
|
|
367
|
+
case 'HOURLY':
|
|
368
|
+
return interval * 3600000;
|
|
369
|
+
default:
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Skip the occurrences of a fixed-step rule that fall before
|
|
376
|
+
* rangeStartMs without visiting each one.
|
|
377
|
+
*
|
|
378
|
+
* While the system UTC offset is constant, a wall-clock step of the
|
|
379
|
+
* cursor is a constant number of milliseconds, so a whole run of steps
|
|
380
|
+
* collapses into one multiplication. The single step that crosses a
|
|
381
|
+
* system-timezone transition is taken with `advance` instead, so the
|
|
382
|
+
* cursor ends up exactly where stepping every occurrence would have put
|
|
383
|
+
* it. Stops at the last occurrence before rangeStartMs; the caller's loop
|
|
384
|
+
* takes the step into the range.
|
|
385
|
+
*
|
|
386
|
+
* @param {number} fromMs - Cursor position (an occurrence instant)
|
|
387
|
+
* @param {number} rangeStartMs - Seek target
|
|
388
|
+
* @param {number} rangeEndMs - Upper bound for transition lookup
|
|
389
|
+
* @param {number} stepMs - Step length while the UTC offset is constant
|
|
390
|
+
* @param {number} maxSteps - Steps still permitted under COUNT (Infinity if unbounded)
|
|
391
|
+
* @param {(cursor: Date) => void} advance - Wall-clock step, mutating the cursor
|
|
392
|
+
* @returns {{ ms: number, steps: number, nextSystemTransition: number }}
|
|
393
|
+
* Cursor position, steps taken and the next system transition after it
|
|
394
|
+
* @private
|
|
395
|
+
*/
|
|
396
|
+
static _seekFixedStep(fromMs, rangeStartMs, rangeEndMs, stepMs, maxSteps, advance) {
|
|
397
|
+
let ms = fromMs;
|
|
398
|
+
let steps = 0;
|
|
399
|
+
let nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
|
|
400
|
+
if (!Number.isFinite(rangeStartMs) || !(stepMs > 0)) {
|
|
401
|
+
return { ms, steps, nextSystemTransition };
|
|
402
|
+
}
|
|
403
|
+
// Comparisons are written so an invalid (NaN) cursor ends the seek
|
|
404
|
+
while (ms < rangeStartMs && steps < maxSteps) {
|
|
405
|
+
const limit = Math.min(rangeStartMs, nextSystemTransition);
|
|
406
|
+
// Largest k with ms + k * stepMs < limit
|
|
407
|
+
let k = Math.ceil((limit - ms) / stepMs) - 1;
|
|
408
|
+
if (ms + k * stepMs >= limit) {
|
|
409
|
+
k--; // division rounded up
|
|
410
|
+
}
|
|
411
|
+
k = Math.min(k, maxSteps - steps);
|
|
412
|
+
if (k > 0) {
|
|
413
|
+
ms += k * stepMs;
|
|
414
|
+
steps += k;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (ms + stepMs >= rangeStartMs) {
|
|
418
|
+
break; // next step lands in the range
|
|
419
|
+
}
|
|
420
|
+
// Next step crosses a system-timezone transition
|
|
421
|
+
const cursor = new Date(ms);
|
|
422
|
+
advance(cursor);
|
|
423
|
+
ms = cursor.getTime();
|
|
424
|
+
steps++;
|
|
425
|
+
nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
|
|
426
|
+
}
|
|
427
|
+
return { ms, steps, nextSystemTransition };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Seek for WEEKLY BYDAY rules, whose step pattern repeats every week:
|
|
432
|
+
* whole weeks are skipped arithmetically from any weekday in the BYDAY
|
|
433
|
+
* set, and single steps (identical to the expansion loop's) are only
|
|
434
|
+
* taken to reach the set, around system-timezone transitions and in the
|
|
435
|
+
* last week before the range.
|
|
436
|
+
*
|
|
437
|
+
* @param {number} fromMs - Cursor position (an occurrence instant)
|
|
438
|
+
* @param {number} weekday - Weekday of the cursor (Date#getDay)
|
|
439
|
+
* @param {number} rangeStartMs - Seek target
|
|
440
|
+
* @param {number} rangeEndMs - Upper bound for transition lookup
|
|
441
|
+
* @param {Object} rule - Parsed rule with compiled _byDaySet/_byDayDeltas
|
|
442
|
+
* @param {number} maxSteps - Steps still permitted under COUNT (Infinity if unbounded)
|
|
443
|
+
* @returns {{ ms: number, steps: number, weekday: number, nextSystemTransition: number }}
|
|
444
|
+
* @private
|
|
445
|
+
*/
|
|
446
|
+
static _seekWeekCycle(fromMs, weekday, rangeStartMs, rangeEndMs, rule, maxSteps) {
|
|
447
|
+
const DAY = 86400000;
|
|
448
|
+
const WEEK = 7 * DAY;
|
|
449
|
+
const daySet = rule._byDaySet;
|
|
450
|
+
const dayDeltas = rule._byDayDeltas;
|
|
451
|
+
const stepsPerWeek = daySet.size;
|
|
452
|
+
let ms = fromMs;
|
|
453
|
+
let steps = 0;
|
|
454
|
+
let nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
|
|
455
|
+
if (!Number.isFinite(rangeStartMs)) {
|
|
456
|
+
return { ms, steps, weekday, nextSystemTransition };
|
|
457
|
+
}
|
|
458
|
+
while (ms < rangeStartMs && steps < maxSteps) {
|
|
459
|
+
// A week from a weekday in the set is exactly stepsPerWeek steps and
|
|
460
|
+
// returns to the same weekday
|
|
461
|
+
if (daySet.has(weekday)) {
|
|
462
|
+
const limit = Math.min(rangeStartMs, nextSystemTransition);
|
|
463
|
+
let weeks = Math.ceil((limit - ms) / WEEK) - 1;
|
|
464
|
+
if (ms + weeks * WEEK >= limit) {
|
|
465
|
+
weeks--;
|
|
466
|
+
}
|
|
467
|
+
weeks = Math.min(weeks, Math.floor((maxSteps - steps) / stepsPerWeek));
|
|
468
|
+
if (weeks > 0) {
|
|
469
|
+
ms += weeks * WEEK;
|
|
470
|
+
steps += weeks * stepsPerWeek;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
// Single step, identical to the expansion loop
|
|
475
|
+
const days = dayDeltas[weekday];
|
|
476
|
+
const naiveMs = ms + days * DAY;
|
|
477
|
+
if (naiveMs >= rangeStartMs) {
|
|
478
|
+
break; // next step lands in the range
|
|
479
|
+
}
|
|
480
|
+
weekday = (weekday + days) % 7;
|
|
481
|
+
if (naiveMs >= nextSystemTransition) {
|
|
482
|
+
const cursor = new Date(ms);
|
|
483
|
+
cursor.setDate(cursor.getDate() + days);
|
|
484
|
+
ms = cursor.getTime();
|
|
485
|
+
nextSystemTransition = this._nextSystemTransition(ms, rangeEndMs);
|
|
486
|
+
} else {
|
|
487
|
+
ms = naiveMs;
|
|
488
|
+
}
|
|
489
|
+
steps++;
|
|
490
|
+
}
|
|
491
|
+
return { ms, steps, weekday, nextSystemTransition };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Find the next system-timezone offset transition after fromMs.
|
|
496
|
+
* Cached module-wide: the system timezone is fixed for the process.
|
|
497
|
+
* @param {number} fromMs - Search from this timestamp (exclusive)
|
|
498
|
+
* @param {number} toMs - Extend cache coverage at least this far
|
|
499
|
+
* @returns {number} Transition timestamp, or Infinity if none within coverage
|
|
500
|
+
* @private
|
|
501
|
+
*/
|
|
502
|
+
static _nextSystemTransition(fromMs, toMs) {
|
|
503
|
+
if (fromMs >= toMs) {
|
|
504
|
+
return Infinity;
|
|
505
|
+
}
|
|
506
|
+
let cache = this._systemTransitions;
|
|
507
|
+
if (!cache || fromMs < cache.from || toMs > cache.to) {
|
|
508
|
+
const from = Math.min(fromMs, cache ? cache.from : fromMs);
|
|
509
|
+
const to = Math.max(toMs, cache ? cache.to : toMs);
|
|
510
|
+
cache = { from, to, transitions: this._scanSystemTransitions(from, to) };
|
|
511
|
+
this._systemTransitions = cache;
|
|
512
|
+
}
|
|
513
|
+
for (const t of cache.transitions) {
|
|
514
|
+
if (t > fromMs) {
|
|
515
|
+
return t;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return Infinity;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Scan for system-timezone offset transitions via Date#getTimezoneOffset.
|
|
523
|
+
* Probes weekly (shorter than any real-world gap between transitions)
|
|
524
|
+
* and binary-searches each change to the exact millisecond.
|
|
525
|
+
* @private
|
|
526
|
+
*/
|
|
527
|
+
static _scanSystemTransitions(fromMs, toMs) {
|
|
528
|
+
const WEEK = 7 * 86400000;
|
|
529
|
+
const transitions = [];
|
|
530
|
+
let lo = fromMs;
|
|
531
|
+
let loOffset = new Date(lo).getTimezoneOffset();
|
|
532
|
+
while (lo < toMs) {
|
|
533
|
+
const hi = Math.min(lo + WEEK, toMs);
|
|
534
|
+
const hiOffset = new Date(hi).getTimezoneOffset();
|
|
535
|
+
if (hiOffset !== loOffset) {
|
|
536
|
+
let a = lo;
|
|
537
|
+
let b = hi;
|
|
538
|
+
while (b - a > 1) {
|
|
539
|
+
const mid = Math.floor((a + b) / 2);
|
|
540
|
+
if (new Date(mid).getTimezoneOffset() === loOffset) {
|
|
541
|
+
a = mid;
|
|
542
|
+
} else {
|
|
543
|
+
b = mid;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
transitions.push(b);
|
|
547
|
+
loOffset = hiOffset;
|
|
548
|
+
}
|
|
549
|
+
lo = hi;
|
|
550
|
+
}
|
|
551
|
+
return transitions;
|
|
552
|
+
}
|
|
553
|
+
|
|
124
554
|
/**
|
|
125
555
|
* Apply BYSETPOS to filter occurrences within each frequency period
|
|
126
556
|
* @param {Array} occurrences - Generated occurrences
|
|
@@ -4,12 +4,20 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { TimezoneManager } from '../timezone/TimezoneManager.js';
|
|
7
|
+
import { RecurrenceEngine } from './RecurrenceEngine.js';
|
|
7
8
|
import { RRuleParser } from './RRuleParser.js';
|
|
8
9
|
|
|
10
|
+
const DAY = 86400000;
|
|
11
|
+
|
|
9
12
|
export class RecurrenceEngineV2 {
|
|
10
13
|
// Hard limit to prevent resource exhaustion regardless of caller input
|
|
11
14
|
static MAX_OCCURRENCES_HARD_LIMIT = 10000;
|
|
12
15
|
|
|
16
|
+
// Hard limit on expansion loop iterations (every occurrence stepped
|
|
17
|
+
// through, inside the range or not) so a rule that cannot be seeked
|
|
18
|
+
// arithmetically still terminates in bounded time
|
|
19
|
+
static MAX_ITERATIONS_HARD_LIMIT = 100000;
|
|
20
|
+
|
|
13
21
|
constructor() {
|
|
14
22
|
// Use singleton to share cache across all components
|
|
15
23
|
this.tzManager = TimezoneManager.getInstance();
|
|
@@ -27,10 +35,22 @@ export class RecurrenceEngineV2 {
|
|
|
27
35
|
|
|
28
36
|
/**
|
|
29
37
|
* Expand recurring event with advanced handling
|
|
30
|
-
*
|
|
38
|
+
*
|
|
39
|
+
* Occurrences before rangeStart are skipped without being generated:
|
|
40
|
+
* daily, weekly, hourly and minutely rules seek straight to the range, so
|
|
41
|
+
* a series that started years before the queried window is expanded at
|
|
42
|
+
* the same cost as one that started yesterday.
|
|
43
|
+
*
|
|
44
|
+
* @param {import('./Event.js').Event} event - Recurring event
|
|
31
45
|
* @param {Date} rangeStart - Start of expansion range
|
|
32
46
|
* @param {Date} rangeEnd - End of expansion range
|
|
33
47
|
* @param {Object} options - Expansion options
|
|
48
|
+
* @param {number} [options.maxOccurrences=365] - Maximum number of occurrences to
|
|
49
|
+
* return. Only occurrences inside the range count towards this limit.
|
|
50
|
+
* @param {boolean} [options.includeModified=true] - Apply stored instance modifications
|
|
51
|
+
* @param {boolean} [options.includeCancelled=false] - Return exception dates as cancelled occurrences
|
|
52
|
+
* @param {string} [options.timezone] - Timezone for expansion (defaults to the event's)
|
|
53
|
+
* @param {boolean} [options.handleDST=true] - Adjust occurrences across DST transitions
|
|
34
54
|
* @returns {Array} Expanded occurrences
|
|
35
55
|
*/
|
|
36
56
|
expandEvent(event, rangeStart, rangeEnd, options = {}) {
|
|
@@ -59,7 +79,8 @@ export class RecurrenceEngineV2 {
|
|
|
59
79
|
const occurrences = [];
|
|
60
80
|
const duration = event.end - event.start;
|
|
61
81
|
|
|
62
|
-
// Initialize expansion state
|
|
82
|
+
// Initialize expansion state. `count` is the number of steps taken from
|
|
83
|
+
// DTSTART, which is what RFC 5545 COUNT measures.
|
|
63
84
|
const state = {
|
|
64
85
|
currentDate: new Date(event.start),
|
|
65
86
|
count: 0,
|
|
@@ -73,8 +94,16 @@ export class RecurrenceEngineV2 {
|
|
|
73
94
|
state.dstTransitions = this.findDSTTransitions(rangeStart, rangeEnd, timezone);
|
|
74
95
|
}
|
|
75
96
|
|
|
97
|
+
this.seekToRange(state, rule, rangeStart, rangeEnd, timezone);
|
|
98
|
+
|
|
76
99
|
// Expand occurrences
|
|
77
|
-
|
|
100
|
+
let iterations = 0;
|
|
101
|
+
while (
|
|
102
|
+
state.currentDate <= rangeEnd &&
|
|
103
|
+
occurrences.length < maxOccurrences &&
|
|
104
|
+
iterations < RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT
|
|
105
|
+
) {
|
|
106
|
+
iterations++;
|
|
78
107
|
if (state.currentDate >= rangeStart) {
|
|
79
108
|
const occurrence = this.generateOccurrence(
|
|
80
109
|
event,
|
|
@@ -144,6 +173,77 @@ export class RecurrenceEngineV2 {
|
|
|
144
173
|
return this.cloneOccurrences(occurrences);
|
|
145
174
|
}
|
|
146
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Move the expansion cursor to the last occurrence before the range
|
|
178
|
+
* without stepping through every occurrence in between.
|
|
179
|
+
*
|
|
180
|
+
* Applies to rules whose step is a fixed duration between system-timezone
|
|
181
|
+
* transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY); the step that
|
|
182
|
+
* crosses a transition is taken with getNextDate so the result is exactly
|
|
183
|
+
* what stepping from DTSTART would produce. Never seeks past UNTIL, and
|
|
184
|
+
* counts skipped steps against COUNT.
|
|
185
|
+
*
|
|
186
|
+
* @param {Object} state - Expansion state (currentDate and count are updated)
|
|
187
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
188
|
+
* @param {Date} rangeStart - Start of expansion range
|
|
189
|
+
* @param {Date} rangeEnd - End of expansion range
|
|
190
|
+
* @param {string} timezone - Expansion timezone
|
|
191
|
+
*/
|
|
192
|
+
seekToRange(state, rule, rangeStart, rangeEnd, timezone) {
|
|
193
|
+
const stepMs = this.getFixedStepMs(rule);
|
|
194
|
+
if (stepMs <= 0) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
let targetMs = rangeStart.getTime();
|
|
198
|
+
if (rule.until) {
|
|
199
|
+
// Rule objects may carry UNTIL as a string; an unparseable value
|
|
200
|
+
// compares false and leaves the target alone
|
|
201
|
+
const untilMs = new Date(rule.until).getTime();
|
|
202
|
+
if (untilMs < targetMs) {
|
|
203
|
+
targetMs = untilMs;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const fromMs = state.currentDate.getTime();
|
|
207
|
+
if (!(fromMs < targetMs)) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const seek = RecurrenceEngine._seekFixedStep(
|
|
211
|
+
fromMs,
|
|
212
|
+
targetMs,
|
|
213
|
+
rangeEnd.getTime(),
|
|
214
|
+
stepMs,
|
|
215
|
+
rule.count ? rule.count - 1 : Infinity,
|
|
216
|
+
cursor => cursor.setTime(this.getNextDate(cursor, rule, timezone, state).getTime())
|
|
217
|
+
);
|
|
218
|
+
state.currentDate = new Date(seek.ms);
|
|
219
|
+
state.count = seek.steps;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Milliseconds per step for rules getNextDate advances by a fixed
|
|
224
|
+
* duration while the system UTC offset is constant
|
|
225
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
226
|
+
* @returns {number} Step length in milliseconds, or 0 when not fixed
|
|
227
|
+
*/
|
|
228
|
+
getFixedStepMs(rule) {
|
|
229
|
+
const interval = rule.interval;
|
|
230
|
+
if (!Number.isInteger(interval) || interval <= 0) {
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
switch (rule.freq) {
|
|
234
|
+
case 'DAILY':
|
|
235
|
+
return rule.byHour && rule.byHour.length > 0 ? 0 : interval * DAY;
|
|
236
|
+
case 'WEEKLY':
|
|
237
|
+
return rule.byDay && rule.byDay.length > 0 ? 0 : 7 * interval * DAY;
|
|
238
|
+
case 'HOURLY':
|
|
239
|
+
return interval * 3600000;
|
|
240
|
+
case 'MINUTELY':
|
|
241
|
+
return interval * 60000;
|
|
242
|
+
default:
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
147
247
|
/**
|
|
148
248
|
* Generate a single occurrence with timezone handling
|
|
149
249
|
*/
|
package/core/index.js
CHANGED
|
@@ -32,7 +32,7 @@ export { ConflictDetector } from './conflicts/ConflictDetector.js';
|
|
|
32
32
|
export { EnhancedCalendar } from './integration/EnhancedCalendar.js';
|
|
33
33
|
|
|
34
34
|
// Version — keep in sync with package.json
|
|
35
|
-
export const VERSION = '2.
|
|
35
|
+
export const VERSION = '2.4.0';
|
|
36
36
|
|
|
37
37
|
// Default export
|
|
38
38
|
export { Calendar as default } from './calendar/Calendar.js';
|