@forcecalendar/core 2.5.0 → 2.5.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 +93 -17
- package/core/events/Event.js +46 -15
- package/core/events/EventStore.js +184 -20
- package/core/events/RRuleParser.js +19 -3
- package/core/events/RecurrenceEngine.js +63 -18
- package/core/events/RecurrenceEngineV2.js +219 -43
- package/core/index.js +1 -1
- package/core/integration/EnhancedCalendar.js +38 -0
- package/core/timezone/TimezoneManager.js +45 -9
- package/core/types.js +9 -0
- package/package.json +1 -1
- package/types/calendar/Calendar.d.ts +50 -6
- package/types/events/Event.d.ts +11 -0
- package/types/events/EventStore.d.ts +76 -12
- package/types/events/RRuleParser.d.ts +10 -1
- package/types/events/RecurrenceEngine.d.ts +4 -2
- package/types/events/RecurrenceEngineV2.d.ts +68 -9
- package/types/index.d.ts +1 -1
- package/types/integration/EnhancedCalendar.d.ts +7 -0
- package/types/types.d.ts +26 -0
|
@@ -216,10 +216,26 @@ export class RRuleParser {
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
/**
|
|
219
|
-
* Validate and normalize rule
|
|
219
|
+
* Validate and normalize a rule.
|
|
220
|
+
*
|
|
221
|
+
* Works on a copy: rule objects handed in are typically the stored
|
|
222
|
+
* recurrenceRule of an Event, and normalising them in place would make
|
|
223
|
+
* the stored event differ from the data it was created from (a spurious
|
|
224
|
+
* update on the next reconcile) and let the engines' per-rule caches leak
|
|
225
|
+
* into it. The copy is shallow except for the array fields, which are
|
|
226
|
+
* copied as well.
|
|
227
|
+
* @param {Object} rule - Rule object (not modified)
|
|
228
|
+
* @returns {Object} Normalised copy
|
|
220
229
|
* @private
|
|
221
230
|
*/
|
|
222
231
|
static validateRule(rule) {
|
|
232
|
+
rule = { ...rule };
|
|
233
|
+
for (const field of ['byDay', 'bySetPos', 'exceptions']) {
|
|
234
|
+
if (Array.isArray(rule[field])) {
|
|
235
|
+
rule[field] = [...rule[field]];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
223
239
|
// Ensure frequency is set
|
|
224
240
|
if (!rule.freq) {
|
|
225
241
|
rule.freq = 'DAILY';
|
|
@@ -230,8 +246,8 @@ export class RRuleParser {
|
|
|
230
246
|
throw new Error('RRULE cannot have both COUNT and UNTIL');
|
|
231
247
|
}
|
|
232
248
|
|
|
233
|
-
// Validate interval
|
|
234
|
-
if (rule.interval < 1) {
|
|
249
|
+
// Validate interval (RFC 5545 default is 1; rule objects may omit it)
|
|
250
|
+
if (!Number.isInteger(rule.interval) || rule.interval < 1) {
|
|
235
251
|
rule.interval = 1;
|
|
236
252
|
}
|
|
237
253
|
|
|
@@ -4,6 +4,11 @@ import { RRuleParser } from './RRuleParser.js';
|
|
|
4
4
|
|
|
5
5
|
const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
6
6
|
|
|
7
|
+
// Timezone databases know only local mean time before the 19th century, so
|
|
8
|
+
// a span that ends before this instant is checked at its ends rather than
|
|
9
|
+
// probed week by week
|
|
10
|
+
const PRE_TZDATA_FLOOR_MS = Date.UTC(1800, 0, 1);
|
|
11
|
+
|
|
7
12
|
/**
|
|
8
13
|
* RecurrenceEngine - Handles expansion of recurring events
|
|
9
14
|
* Full support for RFC 5545 (iCalendar) RRULE specification
|
|
@@ -196,12 +201,12 @@ export class RecurrenceEngine {
|
|
|
196
201
|
* const nextFive = RecurrenceEngine.takeOccurrences(event, 5, { after: new Date() });
|
|
197
202
|
*
|
|
198
203
|
* @param {import('./Event.js').Event} event - The event to query
|
|
199
|
-
* @param {number} count - Maximum number of occurrences to return
|
|
204
|
+
* @param {number} count - Maximum number of occurrences to return (fractions are floored)
|
|
200
205
|
* @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Window and timezone
|
|
201
206
|
* @returns {import('../types.js').EventOccurrence[]} Up to `count` occurrences in chronological order
|
|
202
207
|
*/
|
|
203
208
|
static takeOccurrences(event, count, options = {}) {
|
|
204
|
-
const limit = Math.min(count, RecurrenceEngine.MAX_OCCURRENCES_HARD_LIMIT);
|
|
209
|
+
const limit = Math.floor(Math.min(count, RecurrenceEngine.MAX_OCCURRENCES_HARD_LIMIT));
|
|
205
210
|
const taken = [];
|
|
206
211
|
if (!(limit > 0)) {
|
|
207
212
|
return taken;
|
|
@@ -822,7 +827,9 @@ export class RecurrenceEngine {
|
|
|
822
827
|
|
|
823
828
|
/**
|
|
824
829
|
* Find the next system-timezone offset transition after fromMs.
|
|
825
|
-
* Cached module-wide: the system timezone is fixed for the process.
|
|
830
|
+
* Cached module-wide: the system timezone is fixed for the process. The
|
|
831
|
+
* cache grows incrementally, so extending coverage (a view navigating
|
|
832
|
+
* forward, a series with a far-past DTSTART) scans only the new span.
|
|
826
833
|
* @param {number} fromMs - Search from this timestamp (exclusive)
|
|
827
834
|
* @param {number} toMs - Extend cache coverage at least this far
|
|
828
835
|
* @returns {number} Transition timestamp, or Infinity if none within coverage
|
|
@@ -833,18 +840,34 @@ export class RecurrenceEngine {
|
|
|
833
840
|
return Infinity;
|
|
834
841
|
}
|
|
835
842
|
let cache = this._systemTransitions;
|
|
836
|
-
if (!cache
|
|
837
|
-
|
|
838
|
-
const to = Math.max(toMs, cache ? cache.to : toMs);
|
|
839
|
-
cache = { from, to, transitions: this._scanSystemTransitions(from, to) };
|
|
843
|
+
if (!cache) {
|
|
844
|
+
cache = { from: fromMs, to: toMs, transitions: this._scanSystemTransitions(fromMs, toMs) };
|
|
840
845
|
this._systemTransitions = cache;
|
|
846
|
+
} else {
|
|
847
|
+
if (fromMs < cache.from) {
|
|
848
|
+
cache.transitions = this._scanSystemTransitions(fromMs, cache.from).concat(
|
|
849
|
+
cache.transitions
|
|
850
|
+
);
|
|
851
|
+
cache.from = fromMs;
|
|
852
|
+
}
|
|
853
|
+
if (toMs > cache.to) {
|
|
854
|
+
cache.transitions = cache.transitions.concat(this._scanSystemTransitions(cache.to, toMs));
|
|
855
|
+
cache.to = toMs;
|
|
856
|
+
}
|
|
841
857
|
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
858
|
+
// First transition after fromMs; the list is sorted
|
|
859
|
+
const transitions = cache.transitions;
|
|
860
|
+
let lo = 0;
|
|
861
|
+
let hi = transitions.length;
|
|
862
|
+
while (lo < hi) {
|
|
863
|
+
const mid = (lo + hi) >>> 1;
|
|
864
|
+
if (transitions[mid] > fromMs) {
|
|
865
|
+
hi = mid;
|
|
866
|
+
} else {
|
|
867
|
+
lo = mid + 1;
|
|
845
868
|
}
|
|
846
869
|
}
|
|
847
|
-
return Infinity;
|
|
870
|
+
return lo < transitions.length ? transitions[lo] : Infinity;
|
|
848
871
|
}
|
|
849
872
|
|
|
850
873
|
/**
|
|
@@ -858,6 +881,12 @@ export class RecurrenceEngine {
|
|
|
858
881
|
const transitions = [];
|
|
859
882
|
let lo = fromMs;
|
|
860
883
|
let loOffset = new Date(lo).getTimezoneOffset();
|
|
884
|
+
// The pre-tzdata era has a single offset (local mean time) and is
|
|
885
|
+
// skipped in one step; probing it week by week could take minutes
|
|
886
|
+
if (lo < PRE_TZDATA_FLOOR_MS) {
|
|
887
|
+
lo = Math.min(toMs, PRE_TZDATA_FLOOR_MS);
|
|
888
|
+
loOffset = new Date(lo).getTimezoneOffset();
|
|
889
|
+
}
|
|
861
890
|
while (lo < toMs) {
|
|
862
891
|
const hi = Math.min(lo + WEEK, toMs);
|
|
863
892
|
const hiOffset = new Date(hi).getTimezoneOffset();
|
|
@@ -918,8 +947,14 @@ export class RecurrenceEngine {
|
|
|
918
947
|
switch (rule.freq) {
|
|
919
948
|
case 'YEARLY':
|
|
920
949
|
return occurrence.start.getFullYear();
|
|
921
|
-
case 'WEEKLY':
|
|
922
|
-
|
|
950
|
+
case 'WEEKLY': {
|
|
951
|
+
// ISO week-year: the week's Thursday decides the year, so the days
|
|
952
|
+
// of a week that straddles New Year share one period key
|
|
953
|
+
const thursday = new Date(occurrence.start);
|
|
954
|
+
thursday.setHours(0, 0, 0, 0);
|
|
955
|
+
thursday.setDate(thursday.getDate() + 4 - (thursday.getDay() || 7));
|
|
956
|
+
return `${thursday.getFullYear()}-W${DateUtils.getWeekNumber(occurrence.start)}`;
|
|
957
|
+
}
|
|
923
958
|
default:
|
|
924
959
|
return `${occurrence.start.getFullYear()}-${occurrence.start.getMonth()}`;
|
|
925
960
|
}
|
|
@@ -1037,11 +1072,21 @@ export class RecurrenceEngine {
|
|
|
1037
1072
|
case 'MONTHLY':
|
|
1038
1073
|
if (rule.byMonthDay && rule.byMonthDay.length > 0) {
|
|
1039
1074
|
// Specific day(s) of month
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1075
|
+
const monthDay = rule.byMonthDay[0];
|
|
1076
|
+
if (monthDay < 0) {
|
|
1077
|
+
// Counted from the end of the month (-1 is the last day). Move
|
|
1078
|
+
// to the first so the month step cannot overflow from a 31st.
|
|
1079
|
+
next.setDate(1);
|
|
1080
|
+
next.setMonth(next.getMonth() + rule.interval);
|
|
1081
|
+
const daysInMonth = this._daysInMonth(next.getFullYear(), next.getMonth());
|
|
1082
|
+
next.setDate(Math.max(1, daysInMonth + monthDay + 1));
|
|
1083
|
+
} else {
|
|
1084
|
+
const currentMonth = next.getMonth();
|
|
1085
|
+
next.setMonth(currentMonth + rule.interval);
|
|
1086
|
+
// Clamp to last day of month if day doesn't exist
|
|
1087
|
+
const daysInMonth = this._daysInMonth(next.getFullYear(), next.getMonth());
|
|
1088
|
+
next.setDate(Math.min(monthDay, daysInMonth));
|
|
1089
|
+
}
|
|
1045
1090
|
} else if (rule.byDay && rule.byDay.length > 0) {
|
|
1046
1091
|
// Specific weekday of month (e.g., "2nd Tuesday")
|
|
1047
1092
|
next.setMonth(next.getMonth() + rule.interval);
|
|
@@ -12,6 +12,21 @@ const DAY = 86400000;
|
|
|
12
12
|
// How far ahead of the iteration cursor DST transitions are scanned at a time
|
|
13
13
|
const DST_SCAN_CHUNK = 100 * DAY;
|
|
14
14
|
|
|
15
|
+
const WEEKDAYS = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 };
|
|
16
|
+
|
|
17
|
+
// An expansion cut short by MAX_ITERATIONS_HARD_LIMIT is reported once per
|
|
18
|
+
// process rather than on every render
|
|
19
|
+
let iterationLimitWarned = false;
|
|
20
|
+
function warnIterationLimit(eventId) {
|
|
21
|
+
if (iterationLimitWarned) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
iterationLimitWarned = true;
|
|
25
|
+
console.warn(
|
|
26
|
+
`RecurrenceEngineV2: expansion of event ${eventId} stopped after ${RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT} steps without reaching the end of the range; results are truncated`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
export class RecurrenceEngineV2 {
|
|
16
31
|
// Hard limit to prevent resource exhaustion regardless of caller input
|
|
17
32
|
static MAX_OCCURRENCES_HARD_LIMIT = 10000;
|
|
@@ -39,10 +54,15 @@ export class RecurrenceEngineV2 {
|
|
|
39
54
|
/**
|
|
40
55
|
* Expand recurring event with advanced handling
|
|
41
56
|
*
|
|
42
|
-
* Occurrences before rangeStart are skipped without being generated
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* the same cost as one that
|
|
57
|
+
* Occurrences before rangeStart are skipped without being generated for
|
|
58
|
+
* the rules seekToRange can seek: DAILY (without BYHOUR), WEEKLY (with or
|
|
59
|
+
* without BYDAY), HOURLY and MINUTELY. Such a series that started years
|
|
60
|
+
* before the queried window is expanded at the same cost as one that
|
|
61
|
+
* started yesterday. MONTHLY and YEARLY rules, and DAILY with BYHOUR,
|
|
62
|
+
* are stepped from DTSTART; they take few enough steps per year that
|
|
63
|
+
* this is cheap, but an expansion that would need more than
|
|
64
|
+
* MAX_ITERATIONS_HARD_LIMIT steps is truncated (with one console.warn
|
|
65
|
+
* per process).
|
|
46
66
|
*
|
|
47
67
|
* @param {import('./Event.js').Event} event - Recurring event
|
|
48
68
|
* @param {Date} rangeStart - Start of expansion range
|
|
@@ -69,7 +89,7 @@ export class RecurrenceEngineV2 {
|
|
|
69
89
|
const maxOccurrences = Math.min(requestedMax, RecurrenceEngineV2.MAX_OCCURRENCES_HARD_LIMIT);
|
|
70
90
|
|
|
71
91
|
// Check cache
|
|
72
|
-
const cacheKey = this.getCacheKey(event
|
|
92
|
+
const cacheKey = this.getCacheKey(event, rangeStart, rangeEnd, options);
|
|
73
93
|
if (this.occurrenceCache.has(cacheKey)) {
|
|
74
94
|
return this.cloneOccurrences(this.occurrenceCache.get(cacheKey));
|
|
75
95
|
}
|
|
@@ -145,6 +165,14 @@ export class RecurrenceEngineV2 {
|
|
|
145
165
|
}
|
|
146
166
|
}
|
|
147
167
|
|
|
168
|
+
if (
|
|
169
|
+
iterations >= RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT &&
|
|
170
|
+
state.currentDate <= rangeEnd &&
|
|
171
|
+
occurrences.length < maxOccurrences
|
|
172
|
+
) {
|
|
173
|
+
warnIterationLimit(event.id);
|
|
174
|
+
}
|
|
175
|
+
|
|
148
176
|
// Cache results
|
|
149
177
|
this.cacheOccurrences(cacheKey, occurrences);
|
|
150
178
|
|
|
@@ -158,7 +186,7 @@ export class RecurrenceEngineV2 {
|
|
|
158
186
|
* time and without the expansion cache: stored instance modifications
|
|
159
187
|
* and exceptions are applied as each occurrence is produced, so changes
|
|
160
188
|
* made through addModifiedInstance or addException are visible on the
|
|
161
|
-
* next pull. Rules seekToRange can seek (
|
|
189
|
+
* next pull. Rules seekToRange can seek (daily, weekly, hourly,
|
|
162
190
|
* minutely) jump straight to `after`, and DST transitions are scanned
|
|
163
191
|
* lazily ahead of the cursor instead of for the whole window up front.
|
|
164
192
|
*
|
|
@@ -234,12 +262,12 @@ export class RecurrenceEngineV2 {
|
|
|
234
262
|
* const nextFive = engine.takeOccurrences(event, 5, { after: new Date() });
|
|
235
263
|
*
|
|
236
264
|
* @param {import('./Event.js').Event} event - The event to query
|
|
237
|
-
* @param {number} count - Maximum number of occurrences to return
|
|
265
|
+
* @param {number} count - Maximum number of occurrences to return (fractions are floored)
|
|
238
266
|
* @param {import('../types.js').ExpandedOccurrenceIteratorOptions} [options={}] - Window and expansion options
|
|
239
267
|
* @returns {import('../types.js').ExpandedOccurrence[]} Up to `count` occurrences in chronological order
|
|
240
268
|
*/
|
|
241
269
|
takeOccurrences(event, count, options = {}) {
|
|
242
|
-
const limit = Math.min(count, RecurrenceEngineV2.MAX_OCCURRENCES_HARD_LIMIT);
|
|
270
|
+
const limit = Math.floor(Math.min(count, RecurrenceEngineV2.MAX_OCCURRENCES_HARD_LIMIT));
|
|
243
271
|
const taken = [];
|
|
244
272
|
if (!(limit > 0)) {
|
|
245
273
|
return taken;
|
|
@@ -350,6 +378,7 @@ export class RecurrenceEngineV2 {
|
|
|
350
378
|
}
|
|
351
379
|
idleSteps++;
|
|
352
380
|
if (idleSteps >= RecurrenceEngineV2.MAX_ITERATIONS_HARD_LIMIT) {
|
|
381
|
+
warnIterationLimit(event.id);
|
|
353
382
|
return;
|
|
354
383
|
}
|
|
355
384
|
}
|
|
@@ -392,8 +421,9 @@ export class RecurrenceEngineV2 {
|
|
|
392
421
|
* without stepping through every occurrence in between.
|
|
393
422
|
*
|
|
394
423
|
* Applies to rules whose step is a fixed duration between system-timezone
|
|
395
|
-
* transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY)
|
|
396
|
-
*
|
|
424
|
+
* transitions (plain DAILY and WEEKLY, HOURLY, MINUTELY) and to WEEKLY
|
|
425
|
+
* rules with BYDAY, whose steps repeat in a weekly cycle; the steps that
|
|
426
|
+
* cross a transition are taken with getNextDate so the result is exactly
|
|
397
427
|
* what stepping from DTSTART would produce. Never seeks past UNTIL, and
|
|
398
428
|
* counts skipped steps against COUNT.
|
|
399
429
|
*
|
|
@@ -404,6 +434,10 @@ export class RecurrenceEngineV2 {
|
|
|
404
434
|
* @param {string} timezone - Expansion timezone
|
|
405
435
|
*/
|
|
406
436
|
seekToRange(state, rule, rangeStart, rangeEnd, timezone) {
|
|
437
|
+
if (rule.freq === 'WEEKLY' && rule.byDay && rule.byDay.length > 0) {
|
|
438
|
+
this._seekWeekCycle(state, rule, rangeStart, rangeEnd, timezone);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
407
441
|
const stepMs = this.getFixedStepMs(rule);
|
|
408
442
|
if (stepMs <= 0) {
|
|
409
443
|
return;
|
|
@@ -433,6 +467,127 @@ export class RecurrenceEngineV2 {
|
|
|
433
467
|
state.count = seek.steps;
|
|
434
468
|
}
|
|
435
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Seek for WEEKLY rules with BYDAY. getNextWeekly picks the next weekday
|
|
472
|
+
* from the BYDAY list (in list order), so the step from each weekday is
|
|
473
|
+
* fixed and the walk from DTSTART settles into a cycle of weekdays that
|
|
474
|
+
* repeats every whole number of weeks. The cursor is stepped one
|
|
475
|
+
* occurrence at a time until it is on that cycle (at most six steps),
|
|
476
|
+
* then whole cycles are skipped arithmetically between system-timezone
|
|
477
|
+
* transitions, exactly as seekToRange does for fixed steps.
|
|
478
|
+
* @param {Object} state - Expansion state (currentDate and count are updated)
|
|
479
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
480
|
+
* @param {Date} rangeStart - Start of expansion range
|
|
481
|
+
* @param {Date} rangeEnd - End of expansion range
|
|
482
|
+
* @param {string} timezone - Expansion timezone
|
|
483
|
+
* @private
|
|
484
|
+
*/
|
|
485
|
+
_seekWeekCycle(state, rule, rangeStart, rangeEnd, timezone) {
|
|
486
|
+
const deltas = this._weekdayDeltas(rule);
|
|
487
|
+
if (!deltas) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
let targetMs = rangeStart.getTime();
|
|
491
|
+
if (rule.until) {
|
|
492
|
+
const untilMs = new Date(rule.until).getTime();
|
|
493
|
+
if (untilMs < targetMs) {
|
|
494
|
+
targetMs = untilMs;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (!(state.currentDate.getTime() < targetMs)) {
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const maxSteps = rule.count ? rule.count - 1 : Infinity;
|
|
501
|
+
|
|
502
|
+
// Follow the weekday graph from the cursor until a weekday repeats:
|
|
503
|
+
// the steps before the repeat lead in to the cycle
|
|
504
|
+
const path = [];
|
|
505
|
+
const seen = new Map();
|
|
506
|
+
let weekday = state.currentDate.getDay();
|
|
507
|
+
while (!seen.has(weekday)) {
|
|
508
|
+
seen.set(weekday, path.length);
|
|
509
|
+
path.push(weekday);
|
|
510
|
+
weekday = (weekday + deltas[weekday]) % 7;
|
|
511
|
+
}
|
|
512
|
+
const leadIn = seen.get(weekday);
|
|
513
|
+
const cycleSteps = path.length - leadIn;
|
|
514
|
+
let cycleDays = 0;
|
|
515
|
+
for (let i = leadIn; i < path.length; i++) {
|
|
516
|
+
cycleDays += deltas[path[i]];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Lead-in: single steps, identical to the expansion loop's
|
|
520
|
+
for (let i = 0; i < leadIn; i++) {
|
|
521
|
+
if (state.count >= maxSteps) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const next = this.getNextDate(state.currentDate, rule, timezone, state);
|
|
525
|
+
if (!(next.getTime() < targetMs)) {
|
|
526
|
+
return; // the next step lands in the range; the loop takes it
|
|
527
|
+
}
|
|
528
|
+
state.currentDate = next;
|
|
529
|
+
state.count++;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const seek = RecurrenceEngine._seekFixedStep(
|
|
533
|
+
state.currentDate.getTime(),
|
|
534
|
+
targetMs,
|
|
535
|
+
rangeEnd.getTime(),
|
|
536
|
+
cycleDays * DAY,
|
|
537
|
+
Math.floor((maxSteps - state.count) / cycleSteps),
|
|
538
|
+
cursor => {
|
|
539
|
+
for (let i = 0; i < cycleSteps; i++) {
|
|
540
|
+
cursor.setTime(this.getNextDate(cursor, rule, timezone, state).getTime());
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
);
|
|
544
|
+
state.currentDate = new Date(seek.ms);
|
|
545
|
+
state.count += seek.steps * cycleSteps;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Days getNextWeekly adds from each weekday (index 0-6) for a WEEKLY
|
|
550
|
+
* rule with BYDAY, or null when the rule cannot be seeked (an invalid
|
|
551
|
+
* interval or day code, which the expansion loop handles as before)
|
|
552
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
553
|
+
* @returns {number[]|null} Delta table indexed by Date#getDay()
|
|
554
|
+
* @private
|
|
555
|
+
*/
|
|
556
|
+
_weekdayDeltas(rule) {
|
|
557
|
+
const interval = rule.interval;
|
|
558
|
+
if (!Number.isInteger(interval) || interval <= 0) {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
const targets = this._weekdayTargets(rule);
|
|
562
|
+
if (targets.some(target => target === undefined)) {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
const deltas = [];
|
|
566
|
+
for (let weekday = 0; weekday < 7; weekday++) {
|
|
567
|
+
const next = targets.find(target => target > weekday);
|
|
568
|
+
deltas[weekday] =
|
|
569
|
+
next !== undefined ? next - weekday : 7 - weekday + targets[0] + 7 * (interval - 1);
|
|
570
|
+
}
|
|
571
|
+
return deltas;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Weekday numbers (Date#getDay) of a rule's BYDAY entries in ascending
|
|
576
|
+
* order, computed once per parsed rule. Invalid day codes map to
|
|
577
|
+
* undefined and sort last.
|
|
578
|
+
* @param {Object} rule - Parsed recurrence rule with byDay
|
|
579
|
+
* @returns {number[]} Sorted weekday numbers
|
|
580
|
+
* @private
|
|
581
|
+
*/
|
|
582
|
+
_weekdayTargets(rule) {
|
|
583
|
+
if (!rule._weekdayTargets) {
|
|
584
|
+
rule._weekdayTargets = rule.byDay
|
|
585
|
+
.map(byDay => WEEKDAYS[byDay.weekday || byDay])
|
|
586
|
+
.sort((a, b) => (a === undefined) - (b === undefined) || a - b);
|
|
587
|
+
}
|
|
588
|
+
return rule._weekdayTargets;
|
|
589
|
+
}
|
|
590
|
+
|
|
436
591
|
/**
|
|
437
592
|
* Milliseconds per step for rules getNextDate advances by a fixed
|
|
438
593
|
* duration while the system UTC offset is constant
|
|
@@ -556,33 +711,16 @@ export class RecurrenceEngineV2 {
|
|
|
556
711
|
const next = new Date(date);
|
|
557
712
|
|
|
558
713
|
if (rule.byDay && rule.byDay.length > 0) {
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
MO: 1,
|
|
563
|
-
TU: 2,
|
|
564
|
-
WE: 3,
|
|
565
|
-
TH: 4,
|
|
566
|
-
FR: 5,
|
|
567
|
-
SA: 6
|
|
568
|
-
};
|
|
569
|
-
|
|
714
|
+
// BYDAY is a set: the next weekday in it after the current one, or the
|
|
715
|
+
// earliest one INTERVAL weeks on when the week has none left
|
|
716
|
+
const targets = this._weekdayTargets(rule);
|
|
570
717
|
const currentDay = next.getDay();
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
daysToAdd = targetDay - currentDay;
|
|
578
|
-
break;
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// If no day found in current week, go to next week
|
|
583
|
-
if (daysToAdd === null) {
|
|
584
|
-
const firstDay = dayMap[rule.byDay[0].weekday || rule.byDay[0]];
|
|
585
|
-
daysToAdd = 7 - currentDay + firstDay;
|
|
718
|
+
const nextDay = targets.find(target => target > currentDay);
|
|
719
|
+
let daysToAdd;
|
|
720
|
+
if (nextDay !== undefined) {
|
|
721
|
+
daysToAdd = nextDay - currentDay;
|
|
722
|
+
} else {
|
|
723
|
+
daysToAdd = 7 - currentDay + targets[0];
|
|
586
724
|
|
|
587
725
|
// Apply interval for weekly recurrence
|
|
588
726
|
if (rule.interval > 1) {
|
|
@@ -615,15 +753,17 @@ export class RecurrenceEngineV2 {
|
|
|
615
753
|
// Found a day in current month
|
|
616
754
|
next.setDate(targetDay);
|
|
617
755
|
} else {
|
|
618
|
-
// Move to next month
|
|
619
|
-
next.setMonth(next.getMonth() + rule.interval);
|
|
620
|
-
|
|
621
|
-
// Handle negative days (from end of month)
|
|
622
756
|
targetDay = targetDays[0];
|
|
623
757
|
if (targetDay < 0) {
|
|
758
|
+
// Counted from the end of the month (-1 is the last day). Move to
|
|
759
|
+
// the first so the month step cannot overflow from a 31st.
|
|
760
|
+
next.setDate(1);
|
|
761
|
+
next.setMonth(next.getMonth() + rule.interval);
|
|
624
762
|
const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate();
|
|
625
|
-
next.setDate(lastDay + targetDay + 1);
|
|
763
|
+
next.setDate(Math.max(1, lastDay + targetDay + 1));
|
|
626
764
|
} else {
|
|
765
|
+
// Move to next month
|
|
766
|
+
next.setMonth(next.getMonth() + rule.interval);
|
|
627
767
|
next.setDate(targetDay);
|
|
628
768
|
}
|
|
629
769
|
}
|
|
@@ -944,9 +1084,45 @@ export class RecurrenceEngineV2 {
|
|
|
944
1084
|
|
|
945
1085
|
/**
|
|
946
1086
|
* Create cache key
|
|
1087
|
+
*
|
|
1088
|
+
* When given the event itself the key also covers everything the
|
|
1089
|
+
* expansion depends on (DTSTART, end, recurrence rule), so a series that
|
|
1090
|
+
* is updated, replaced or re-added under the same id can never be served
|
|
1091
|
+
* a stale expansion. Keys always start with `<eventId>_`, which is what
|
|
1092
|
+
* {@link RecurrenceEngineV2#clearEventCache} matches on.
|
|
1093
|
+
* @param {import('./Event.js').Event|string} event - Recurring event, or just its id
|
|
1094
|
+
* @param {Date} start - Start of expansion range
|
|
1095
|
+
* @param {Date} end - End of expansion range
|
|
1096
|
+
* @param {Object} options - Expansion options
|
|
1097
|
+
* @returns {string} Cache key
|
|
1098
|
+
*/
|
|
1099
|
+
getCacheKey(event, start, end, options) {
|
|
1100
|
+
const eventId = typeof event === 'string' ? event : event.id;
|
|
1101
|
+
const key = `${eventId}_${start.getTime()}_${end.getTime()}_${JSON.stringify(options)}`;
|
|
1102
|
+
if (typeof event === 'string') {
|
|
1103
|
+
return key;
|
|
1104
|
+
}
|
|
1105
|
+
const startMs = new Date(event.start).getTime();
|
|
1106
|
+
const endMs = new Date(event.end).getTime();
|
|
1107
|
+
return `${key}|${startMs}|${endMs}|${this._ruleFingerprint(event.recurrenceRule)}`;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Stable text form of a recurrence rule for cache keys. A rule that
|
|
1112
|
+
* cannot be serialised gets a unique fingerprint, i.e. is never cached.
|
|
1113
|
+
* @param {string|Object} rule - RRULE string or rule object
|
|
1114
|
+
* @returns {string} Fingerprint
|
|
1115
|
+
* @private
|
|
947
1116
|
*/
|
|
948
|
-
|
|
949
|
-
|
|
1117
|
+
_ruleFingerprint(rule) {
|
|
1118
|
+
if (typeof rule === 'string') {
|
|
1119
|
+
return rule;
|
|
1120
|
+
}
|
|
1121
|
+
try {
|
|
1122
|
+
return JSON.stringify(rule);
|
|
1123
|
+
} catch {
|
|
1124
|
+
return `uncacheable:${Date.now()}:${Math.random()}`;
|
|
1125
|
+
}
|
|
950
1126
|
}
|
|
951
1127
|
|
|
952
1128
|
/**
|
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.5.
|
|
35
|
+
export const VERSION = '2.5.1';
|
|
36
36
|
|
|
37
37
|
// Default export
|
|
38
38
|
export { Calendar as default } from './calendar/Calendar.js';
|
|
@@ -24,6 +24,40 @@ export class EnhancedCalendar extends Calendar {
|
|
|
24
24
|
|
|
25
25
|
// Setup event listeners for real-time indexing
|
|
26
26
|
this.setupRealtimeIndexing();
|
|
27
|
+
|
|
28
|
+
// The enhanced engine keeps its own expansion cache, so drop the entries
|
|
29
|
+
// of every series the store changes
|
|
30
|
+
this._unsubscribeCacheInvalidation = this.eventStore.subscribe(change =>
|
|
31
|
+
this._invalidateOccurrenceCache(change)
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Invalidate the enhanced engine's cached expansions for a store change
|
|
37
|
+
* @param {import('../types.js').EventStoreChange} change - Store change
|
|
38
|
+
* @private
|
|
39
|
+
*/
|
|
40
|
+
_invalidateOccurrenceCache(change) {
|
|
41
|
+
const engine = this.recurrenceEngine;
|
|
42
|
+
if (!engine || !change) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
switch (change.type) {
|
|
46
|
+
case 'add':
|
|
47
|
+
case 'update':
|
|
48
|
+
case 'remove':
|
|
49
|
+
if (change.event) {
|
|
50
|
+
engine.clearEventCache(change.event.id);
|
|
51
|
+
}
|
|
52
|
+
break;
|
|
53
|
+
case 'batch':
|
|
54
|
+
for (const entry of change.changes || []) {
|
|
55
|
+
this._invalidateOccurrenceCache(entry);
|
|
56
|
+
}
|
|
57
|
+
break;
|
|
58
|
+
default:
|
|
59
|
+
engine.occurrenceCache.clear();
|
|
60
|
+
}
|
|
27
61
|
}
|
|
28
62
|
|
|
29
63
|
/**
|
|
@@ -440,6 +474,10 @@ export class EnhancedCalendar extends Calendar {
|
|
|
440
474
|
this._clearReindexTimeout();
|
|
441
475
|
this._clearReindexTimeout = null;
|
|
442
476
|
}
|
|
477
|
+
if (typeof this._unsubscribeCacheInvalidation === 'function') {
|
|
478
|
+
this._unsubscribeCacheInvalidation();
|
|
479
|
+
this._unsubscribeCacheInvalidation = null;
|
|
480
|
+
}
|
|
443
481
|
|
|
444
482
|
// Clean up worker
|
|
445
483
|
if (this.searchManager) {
|
|
@@ -10,6 +10,11 @@ import { TimezoneDatabase } from './TimezoneDatabase.js';
|
|
|
10
10
|
// Singleton instance for shared use across the application
|
|
11
11
|
let sharedInstance = null;
|
|
12
12
|
|
|
13
|
+
// Timezone databases know only local mean time before the 19th century, so
|
|
14
|
+
// transition scans check a span that ends before this instant at its ends
|
|
15
|
+
// rather than probing it week by week
|
|
16
|
+
const PRE_TZDATA_FLOOR_MS = Date.UTC(1800, 0, 1);
|
|
17
|
+
|
|
13
18
|
export class TimezoneManager {
|
|
14
19
|
/**
|
|
15
20
|
* Get the shared singleton instance of TimezoneManager
|
|
@@ -215,19 +220,43 @@ export class TimezoneManager {
|
|
|
215
220
|
}
|
|
216
221
|
timezone = this.database.resolveAlias(timezone);
|
|
217
222
|
let cached = this.transitionCache.get(timezone);
|
|
218
|
-
if (!cached
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
223
|
+
if (!cached) {
|
|
224
|
+
cached = {
|
|
225
|
+
from: fromMs,
|
|
226
|
+
to: toMs,
|
|
227
|
+
transitions: this._scanTransitions(timezone, fromMs, toMs)
|
|
228
|
+
};
|
|
224
229
|
this.transitionCache.set(timezone, cached);
|
|
230
|
+
} else {
|
|
231
|
+
// Extend coverage incrementally so only the uncovered span is scanned
|
|
232
|
+
if (fromMs < cached.from) {
|
|
233
|
+
cached.transitions = this._scanTransitions(timezone, fromMs, cached.from).concat(
|
|
234
|
+
cached.transitions
|
|
235
|
+
);
|
|
236
|
+
cached.from = fromMs;
|
|
237
|
+
}
|
|
238
|
+
if (toMs > cached.to) {
|
|
239
|
+
cached.transitions = cached.transitions.concat(
|
|
240
|
+
this._scanTransitions(timezone, cached.to, toMs)
|
|
241
|
+
);
|
|
242
|
+
cached.to = toMs;
|
|
243
|
+
}
|
|
225
244
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
245
|
+
// First transition after fromMs; the list is sorted
|
|
246
|
+
const transitions = cached.transitions;
|
|
247
|
+
let lo = 0;
|
|
248
|
+
let hi = transitions.length;
|
|
249
|
+
while (lo < hi) {
|
|
250
|
+
const mid = (lo + hi) >>> 1;
|
|
251
|
+
if (transitions[mid] > fromMs) {
|
|
252
|
+
hi = mid;
|
|
253
|
+
} else {
|
|
254
|
+
lo = mid + 1;
|
|
229
255
|
}
|
|
230
256
|
}
|
|
257
|
+
if (lo < transitions.length && transitions[lo] <= toMs) {
|
|
258
|
+
return transitions[lo];
|
|
259
|
+
}
|
|
231
260
|
return Infinity;
|
|
232
261
|
}
|
|
233
262
|
|
|
@@ -247,6 +276,13 @@ export class TimezoneManager {
|
|
|
247
276
|
const offsetAt = ms => this.getTimezoneOffset(new Date(ms), timezone);
|
|
248
277
|
let lo = fromMs;
|
|
249
278
|
let loOffset = offsetAt(lo);
|
|
279
|
+
// Timezone databases know only local mean time before the 19th century:
|
|
280
|
+
// that era has a single offset and is skipped in one step (probing it
|
|
281
|
+
// week by week could take minutes for a very old DTSTART)
|
|
282
|
+
if (lo < PRE_TZDATA_FLOOR_MS) {
|
|
283
|
+
lo = Math.min(toMs, PRE_TZDATA_FLOOR_MS);
|
|
284
|
+
loOffset = offsetAt(lo);
|
|
285
|
+
}
|
|
250
286
|
while (lo < toMs) {
|
|
251
287
|
const hi = Math.min(lo + WEEK, toMs);
|
|
252
288
|
const hiOffset = offsetAt(hi);
|