@forcecalendar/core 2.4.0 → 2.5.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 +102 -10
- package/core/events/Event.js +61 -0
- package/core/events/EventStore.js +308 -53
- package/core/events/RecurrenceEngine.js +368 -17
- package/core/events/RecurrenceEngineV2.js +281 -48
- package/core/index.js +1 -1
- package/core/integration/EnhancedCalendar.js +88 -14
- package/core/types.js +52 -0
- package/package.json +1 -1
- package/types/calendar/Calendar.d.ts +77 -7
- package/types/events/Event.d.ts +39 -0
- package/types/events/EventStore.d.ts +163 -5
- package/types/events/RecurrenceEngine.d.ts +156 -0
- package/types/events/RecurrenceEngineV2.d.ts +112 -8
- package/types/index.d.ts +1 -1
- package/types/integration/EnhancedCalendar.d.ts +66 -1
- package/types/types.d.ts +175 -0
|
@@ -92,6 +92,335 @@ export class RecurrenceEngine {
|
|
|
92
92
|
return occurrences;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Lazily iterate the occurrences of an event in chronological order.
|
|
97
|
+
*
|
|
98
|
+
* Yields the same occurrence objects, in the same order, that expandEvent
|
|
99
|
+
* returns for the window — but one at a time, so a caller can stop after
|
|
100
|
+
* any number of them without the rest of the series being generated.
|
|
101
|
+
* Daily, weekly and sub-daily rules are seeked to `after` arithmetically,
|
|
102
|
+
* so finding the first occurrence after a far-away instant does not step
|
|
103
|
+
* through the series from its start.
|
|
104
|
+
*
|
|
105
|
+
* Both bounds are exclusive unless `inclusive` is set: an occurrence that
|
|
106
|
+
* starts exactly at `after` or `before` is skipped by default, which lets
|
|
107
|
+
* `iterateOccurrences(event, { after: previous.start })` continue a series
|
|
108
|
+
* without repeating `previous`. With `inclusive: true` the window is
|
|
109
|
+
* closed on both ends, exactly like expandEvent's range. An omitted bound
|
|
110
|
+
* leaves that end of the window open.
|
|
111
|
+
*
|
|
112
|
+
* COUNT, UNTIL, INTERVAL, BYDAY/BYMONTHDAY/BYSETPOS and exception dates
|
|
113
|
+
* are honoured as in expandEvent; BYSETPOS rules are yielded one period at
|
|
114
|
+
* a time, since the set positions of a period are only known once it is
|
|
115
|
+
* complete. A non-recurring event yields its single occurrence when it
|
|
116
|
+
* falls inside the window. Iteration ends at COUNT or UNTIL, at `before`,
|
|
117
|
+
* or — as a guard for rules that produce no occurrences — after
|
|
118
|
+
* MAX_ITERATIONS_HARD_LIMIT consecutive steps without one.
|
|
119
|
+
*
|
|
120
|
+
* The generator is single-use; call this method again for a fresh one.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* for (const occurrence of RecurrenceEngine.iterateOccurrences(event, { after: new Date() })) {
|
|
124
|
+
* if (occurrence.start > deadline) break;
|
|
125
|
+
* schedule(occurrence);
|
|
126
|
+
* }
|
|
127
|
+
*
|
|
128
|
+
* @param {import('./Event.js').Event} event - The event to iterate
|
|
129
|
+
* @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Window and timezone
|
|
130
|
+
* @returns {Generator<import('../types.js').EventOccurrence, void, undefined>} Occurrences in chronological order
|
|
131
|
+
* @throws {TypeError} If `after` or `before` is not a valid Date or timestamp
|
|
132
|
+
*/
|
|
133
|
+
static iterateOccurrences(event, options = {}) {
|
|
134
|
+
const window = this._occurrenceWindow(options);
|
|
135
|
+
if (!event.recurring || !event.recurrenceRule) {
|
|
136
|
+
return this._iterateSingle(
|
|
137
|
+
{ start: event.start, end: event.end, timezone: event.timeZone },
|
|
138
|
+
window
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const rule = this._getParsedRule(event.recurrenceRule);
|
|
143
|
+
let rangeEndMs = window.endMs;
|
|
144
|
+
// Same UNTIL clamp as expandEvent (a non-Date UNTIL compares false)
|
|
145
|
+
if (rule.until && rule.until < rangeEndMs) {
|
|
146
|
+
rangeEndMs = rule.until.valueOf();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let occurrences = this._iterateRule(
|
|
150
|
+
event,
|
|
151
|
+
rule,
|
|
152
|
+
window.startMs,
|
|
153
|
+
rangeEndMs,
|
|
154
|
+
options.timezone || event.timeZone || 'UTC',
|
|
155
|
+
TimezoneManager.getInstance(),
|
|
156
|
+
event.end - event.start
|
|
157
|
+
);
|
|
158
|
+
if (rule.bySetPos && rule.bySetPos.length > 0 && rule.freq !== 'MONTHLY') {
|
|
159
|
+
occurrences = this._iterateBySetPos(occurrences, rule);
|
|
160
|
+
}
|
|
161
|
+
return occurrences;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* First occurrence of an event after an instant, or null when the series
|
|
166
|
+
* has no occurrence after it (past COUNT or UNTIL, or a non-recurring
|
|
167
|
+
* event that already started).
|
|
168
|
+
*
|
|
169
|
+
* `after` is exclusive unless `options.inclusive` is set, so passing the
|
|
170
|
+
* start of a known occurrence returns the one that follows it. Not to be
|
|
171
|
+
* confused with getNextOccurrence, which steps a parsed rule once
|
|
172
|
+
* without regard to COUNT, UNTIL or exceptions.
|
|
173
|
+
*
|
|
174
|
+
* @example
|
|
175
|
+
* const upcoming = RecurrenceEngine.nextOccurrence(event, new Date());
|
|
176
|
+
*
|
|
177
|
+
* @param {import('./Event.js').Event} event - The event to query
|
|
178
|
+
* @param {Date|number} [after=null] - Instant to search from (defaults to the series start)
|
|
179
|
+
* @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Further window options
|
|
180
|
+
* @returns {import('../types.js').EventOccurrence|null} The next occurrence, or null
|
|
181
|
+
*/
|
|
182
|
+
static nextOccurrence(event, after = null, options = {}) {
|
|
183
|
+
for (const occurrence of this.iterateOccurrences(event, { ...options, after })) {
|
|
184
|
+
return occurrence;
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The first `count` occurrences of an event inside a window, generated
|
|
191
|
+
* lazily so an open-ended series costs only the occurrences taken.
|
|
192
|
+
* `count` is capped at MAX_OCCURRENCES_HARD_LIMIT; fewer are returned
|
|
193
|
+
* when the series or the window ends first.
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* const nextFive = RecurrenceEngine.takeOccurrences(event, 5, { after: new Date() });
|
|
197
|
+
*
|
|
198
|
+
* @param {import('./Event.js').Event} event - The event to query
|
|
199
|
+
* @param {number} count - Maximum number of occurrences to return
|
|
200
|
+
* @param {import('../types.js').OccurrenceIteratorOptions} [options={}] - Window and timezone
|
|
201
|
+
* @returns {import('../types.js').EventOccurrence[]} Up to `count` occurrences in chronological order
|
|
202
|
+
*/
|
|
203
|
+
static takeOccurrences(event, count, options = {}) {
|
|
204
|
+
const limit = Math.min(count, RecurrenceEngine.MAX_OCCURRENCES_HARD_LIMIT);
|
|
205
|
+
const taken = [];
|
|
206
|
+
if (!(limit > 0)) {
|
|
207
|
+
return taken;
|
|
208
|
+
}
|
|
209
|
+
for (const occurrence of this.iterateOccurrences(event, options)) {
|
|
210
|
+
taken.push(occurrence);
|
|
211
|
+
if (taken.length >= limit) {
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return taken;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Resolve iterator options into a closed window on numeric timestamps.
|
|
220
|
+
* Exclusive bounds are shifted by one millisecond, the resolution of
|
|
221
|
+
* Date, so the expansion loops only ever compare inclusively.
|
|
222
|
+
* @param {import('../types.js').OccurrenceIteratorOptions} options - Iterator options
|
|
223
|
+
* @returns {{ startMs: number, endMs: number }} Inclusive bounds (infinite when open)
|
|
224
|
+
* @throws {TypeError} If a bound is not a valid Date or timestamp
|
|
225
|
+
* @private
|
|
226
|
+
*/
|
|
227
|
+
static _occurrenceWindow(options) {
|
|
228
|
+
const { after = null, before = null, inclusive = false } = options;
|
|
229
|
+
const shift = inclusive ? 0 : 1;
|
|
230
|
+
return {
|
|
231
|
+
startMs: after == null ? -Infinity : this._boundMs(after, 'after') + shift,
|
|
232
|
+
endMs: before == null ? Infinity : this._boundMs(before, 'before') - shift
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Timestamp of a window bound given as a Date or a number
|
|
238
|
+
* @param {Date|number} value - Bound to convert
|
|
239
|
+
* @param {string} name - Option name for the error message
|
|
240
|
+
* @returns {number} Timestamp in milliseconds
|
|
241
|
+
* @throws {TypeError} If the value is not a valid Date or timestamp
|
|
242
|
+
* @private
|
|
243
|
+
*/
|
|
244
|
+
static _boundMs(value, name) {
|
|
245
|
+
const ms = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : NaN;
|
|
246
|
+
if (Number.isNaN(ms)) {
|
|
247
|
+
throw new TypeError(`RecurrenceEngine: ${name} must be a valid Date or timestamp`);
|
|
248
|
+
}
|
|
249
|
+
return ms;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Yield a single occurrence if it starts inside the window
|
|
254
|
+
* @param {import('../types.js').EventOccurrence} occurrence - The occurrence
|
|
255
|
+
* @param {{ startMs: number, endMs: number }} window - Inclusive bounds
|
|
256
|
+
* @returns {Generator<import('../types.js').EventOccurrence, void, undefined>}
|
|
257
|
+
* @private
|
|
258
|
+
*/
|
|
259
|
+
static *_iterateSingle(occurrence, window) {
|
|
260
|
+
const ms = new Date(occurrence.start).getTime();
|
|
261
|
+
if (ms >= window.startMs && ms <= window.endMs) {
|
|
262
|
+
yield occurrence;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Lazy counterpart of the expansion loops: seeks to the window, then
|
|
268
|
+
* advances a Date cursor per step and yields each in-window occurrence,
|
|
269
|
+
* applying the same DST adjustment and exception filtering.
|
|
270
|
+
* @private
|
|
271
|
+
*/
|
|
272
|
+
static *_iterateRule(event, rule, rangeStartMs, rangeEndMs, eventTimezone, tzManager, duration) {
|
|
273
|
+
const currentDate = new Date(event.start);
|
|
274
|
+
let currentMs = currentDate.getTime();
|
|
275
|
+
if (Number.isNaN(currentMs) || rangeStartMs > rangeEndMs) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
// Steps taken from DTSTART, which is what COUNT measures
|
|
279
|
+
let count = 0;
|
|
280
|
+
let lastOffset = tzManager.getTimezoneOffset(currentDate, eventTimezone);
|
|
281
|
+
const hasExceptions = !!(rule.exceptions && rule.exceptions.length > 0);
|
|
282
|
+
|
|
283
|
+
if (currentMs < rangeStartMs) {
|
|
284
|
+
const seek = this._seekToWindow(currentMs, currentDate.getDay(), rule, rangeStartMs);
|
|
285
|
+
if (seek) {
|
|
286
|
+
currentMs = seek.ms;
|
|
287
|
+
count = seek.steps;
|
|
288
|
+
currentDate.setTime(currentMs);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
let stuckCount = 0;
|
|
293
|
+
let idleSteps = 0;
|
|
294
|
+
while (currentMs <= rangeEndMs) {
|
|
295
|
+
if (currentMs >= rangeStartMs) {
|
|
296
|
+
const occurrenceStart = new Date(currentMs);
|
|
297
|
+
const occurrenceEnd = new Date(currentMs + duration);
|
|
298
|
+
|
|
299
|
+
const currentOffset = tzManager.getTimezoneOffset(occurrenceStart, eventTimezone);
|
|
300
|
+
if (currentOffset !== lastOffset) {
|
|
301
|
+
const offsetDiff = lastOffset - currentOffset;
|
|
302
|
+
occurrenceStart.setMinutes(occurrenceStart.getMinutes() + offsetDiff);
|
|
303
|
+
occurrenceEnd.setMinutes(occurrenceEnd.getMinutes() + offsetDiff);
|
|
304
|
+
}
|
|
305
|
+
lastOffset = currentOffset;
|
|
306
|
+
|
|
307
|
+
if (!hasExceptions || !this.isException(occurrenceStart, rule, event.id)) {
|
|
308
|
+
idleSteps = 0;
|
|
309
|
+
yield {
|
|
310
|
+
start: occurrenceStart,
|
|
311
|
+
end: occurrenceEnd,
|
|
312
|
+
recurringEventId: event.id,
|
|
313
|
+
timezone: eventTimezone,
|
|
314
|
+
originalStart: event.start
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (rule.count && count + 1 >= rule.count) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this._advanceInPlace(currentDate, rule);
|
|
324
|
+
const previousMs = currentMs;
|
|
325
|
+
currentMs = currentDate.getTime();
|
|
326
|
+
count++;
|
|
327
|
+
|
|
328
|
+
if (currentMs === previousMs) {
|
|
329
|
+
stuckCount++;
|
|
330
|
+
if (stuckCount >= 3) {
|
|
331
|
+
console.warn('RecurrenceEngine: Date not advancing, breaking to prevent infinite loop');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
stuckCount = 0;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
idleSteps++;
|
|
339
|
+
if (idleSteps >= RecurrenceEngine.MAX_ITERATIONS_HARD_LIMIT) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Seek the iteration cursor to the last occurrence before rangeStartMs
|
|
347
|
+
* using the same arithmetic as expandEvent. The system-transition scan
|
|
348
|
+
* is bounded by the target itself, so an open-ended window costs no
|
|
349
|
+
* more than a closed one.
|
|
350
|
+
* @param {number} fromMs - Cursor position (DTSTART)
|
|
351
|
+
* @param {number} weekday - Weekday of the cursor
|
|
352
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
353
|
+
* @param {number} rangeStartMs - Seek target
|
|
354
|
+
* @returns {{ ms: number, steps: number }|null} New cursor, or null for rules that cannot seek
|
|
355
|
+
* @private
|
|
356
|
+
*/
|
|
357
|
+
static _seekToWindow(fromMs, weekday, rule, rangeStartMs) {
|
|
358
|
+
const maxSteps = rule.count ? rule.count - 1 : Infinity;
|
|
359
|
+
if (rule.freq === 'WEEKLY' && rule.byDay && rule.byDay.length > 0) {
|
|
360
|
+
const daySet = rule._byDaySet || (rule._byDaySet = this._buildByDaySet(rule.byDay));
|
|
361
|
+
if (daySet.size === 0) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
rule._byDayDeltas = rule._byDayDeltas || this._buildByDayDeltas(daySet);
|
|
365
|
+
return this._seekWeekCycle(fromMs, weekday, rangeStartMs, rangeStartMs, rule, maxSteps);
|
|
366
|
+
}
|
|
367
|
+
const stepMs = this._seekStepMs(rule);
|
|
368
|
+
if (stepMs <= 0) {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
return this._seekFixedStep(fromMs, rangeStartMs, rangeStartMs, stepMs, maxSteps, cursor =>
|
|
372
|
+
this._advanceInPlace(cursor, rule)
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Milliseconds per _advanceInPlace step for every rule whose step is a
|
|
378
|
+
* fixed duration between system-timezone transitions: the sub-daily
|
|
379
|
+
* frequencies, DAILY and plain WEEKLY
|
|
380
|
+
* @param {Object} rule - Parsed recurrence rule
|
|
381
|
+
* @returns {number} Step length in milliseconds, or 0 when not fixed
|
|
382
|
+
* @private
|
|
383
|
+
*/
|
|
384
|
+
static _seekStepMs(rule) {
|
|
385
|
+
const interval = rule.interval;
|
|
386
|
+
if (!Number.isInteger(interval) || interval <= 0) {
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
389
|
+
if (rule.freq === 'DAILY') {
|
|
390
|
+
return interval * 86400000;
|
|
391
|
+
}
|
|
392
|
+
if (rule.freq === 'WEEKLY') {
|
|
393
|
+
return 7 * interval * 86400000;
|
|
394
|
+
}
|
|
395
|
+
return this._fixedStepMs(rule);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Streaming BYSETPOS filter: buffers the occurrences of one period and
|
|
400
|
+
* yields its selected positions once the next period begins. Periods
|
|
401
|
+
* arrive contiguously and in order, so the result matches _applyBySetPos.
|
|
402
|
+
* @param {Iterable<import('../types.js').EventOccurrence>} source - Occurrences in order
|
|
403
|
+
* @param {Object} rule - Rule with bySetPos
|
|
404
|
+
* @returns {Generator<import('../types.js').EventOccurrence, void, undefined>}
|
|
405
|
+
* @private
|
|
406
|
+
*/
|
|
407
|
+
static *_iterateBySetPos(source, rule) {
|
|
408
|
+
let key = null;
|
|
409
|
+
let group = [];
|
|
410
|
+
for (const occurrence of source) {
|
|
411
|
+
const occurrenceKey = this._bySetPosKey(occurrence, rule);
|
|
412
|
+
if (occurrenceKey !== key && group.length > 0) {
|
|
413
|
+
yield* this._selectBySetPos(group, rule).sort((a, b) => a.start - b.start);
|
|
414
|
+
group = [];
|
|
415
|
+
}
|
|
416
|
+
key = occurrenceKey;
|
|
417
|
+
group.push(occurrence);
|
|
418
|
+
}
|
|
419
|
+
if (group.length > 0) {
|
|
420
|
+
yield* this._selectBySetPos(group, rule).sort((a, b) => a.start - b.start);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
95
424
|
/**
|
|
96
425
|
* General expansion loop: advances a Date cursor per step. Handles every
|
|
97
426
|
* frequency and degenerate rules (non-advancing dates, invalid intervals).
|
|
@@ -564,17 +893,7 @@ export class RecurrenceEngine {
|
|
|
564
893
|
// Group occurrences by period
|
|
565
894
|
const groups = new Map();
|
|
566
895
|
for (const occ of occurrences) {
|
|
567
|
-
|
|
568
|
-
switch (rule.freq) {
|
|
569
|
-
case 'YEARLY':
|
|
570
|
-
key = occ.start.getFullYear();
|
|
571
|
-
break;
|
|
572
|
-
case 'WEEKLY':
|
|
573
|
-
key = `${occ.start.getFullYear()}-W${DateUtils.getWeekNumber(occ.start)}`;
|
|
574
|
-
break;
|
|
575
|
-
default:
|
|
576
|
-
key = `${occ.start.getFullYear()}-${occ.start.getMonth()}`;
|
|
577
|
-
}
|
|
896
|
+
const key = this._bySetPosKey(occ, rule);
|
|
578
897
|
if (!groups.has(key)) groups.set(key, []);
|
|
579
898
|
groups.get(key).push(occ);
|
|
580
899
|
}
|
|
@@ -582,17 +901,49 @@ export class RecurrenceEngine {
|
|
|
582
901
|
// Filter each group by BYSETPOS positions
|
|
583
902
|
const filtered = [];
|
|
584
903
|
for (const group of groups.values()) {
|
|
585
|
-
|
|
586
|
-
const idx = pos > 0 ? pos - 1 : group.length + pos;
|
|
587
|
-
if (idx >= 0 && idx < group.length) {
|
|
588
|
-
filtered.push(group[idx]);
|
|
589
|
-
}
|
|
590
|
-
}
|
|
904
|
+
filtered.push(...this._selectBySetPos(group, rule));
|
|
591
905
|
}
|
|
592
906
|
|
|
593
907
|
return filtered.sort((a, b) => a.start - b.start);
|
|
594
908
|
}
|
|
595
909
|
|
|
910
|
+
/**
|
|
911
|
+
* Key of the BYSETPOS period an occurrence belongs to
|
|
912
|
+
* @param {import('../types.js').EventOccurrence} occurrence - The occurrence
|
|
913
|
+
* @param {Object} rule - Recurrence rule
|
|
914
|
+
* @returns {string|number} Period key
|
|
915
|
+
* @private
|
|
916
|
+
*/
|
|
917
|
+
static _bySetPosKey(occurrence, rule) {
|
|
918
|
+
switch (rule.freq) {
|
|
919
|
+
case 'YEARLY':
|
|
920
|
+
return occurrence.start.getFullYear();
|
|
921
|
+
case 'WEEKLY':
|
|
922
|
+
return `${occurrence.start.getFullYear()}-W${DateUtils.getWeekNumber(occurrence.start)}`;
|
|
923
|
+
default:
|
|
924
|
+
return `${occurrence.start.getFullYear()}-${occurrence.start.getMonth()}`;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* Occurrences of one period selected by the rule's BYSETPOS positions,
|
|
930
|
+
* in BYSETPOS order
|
|
931
|
+
* @param {Array} group - Occurrences of a single period, in order
|
|
932
|
+
* @param {Object} rule - Rule with bySetPos
|
|
933
|
+
* @returns {Array} Selected occurrences
|
|
934
|
+
* @private
|
|
935
|
+
*/
|
|
936
|
+
static _selectBySetPos(group, rule) {
|
|
937
|
+
const selected = [];
|
|
938
|
+
for (const pos of rule.bySetPos) {
|
|
939
|
+
const idx = pos > 0 ? pos - 1 : group.length + pos;
|
|
940
|
+
if (idx >= 0 && idx < group.length) {
|
|
941
|
+
selected.push(group[idx]);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return selected;
|
|
945
|
+
}
|
|
946
|
+
|
|
596
947
|
/**
|
|
597
948
|
* Parse an RRULE string into a rule object
|
|
598
949
|
* @param {string|import('../types.js').RecurrenceRule} ruleString - RRULE string (e.g., "FREQ=DAILY;INTERVAL=1;COUNT=10") or rule object
|