CRITICAL fixes: - C1: Error swallowing - throw TransportError on network failure in getEmails/searchEmails - C2: Recurrence expansion ID delimiter changed from ':' to '::occurrence::' - C3: Cross-account calendar event UID dedup after multi-account aggregation - C4: Admin session token revocation via JTI blacklist on logout - C6: FTS5 schema-drop - add warning log for automatic reindex trigger - C7: Settings lock - gate updateSetting() with isSettingLocked() check - C8: Offline push pause - add offline event handler that closes push transports HIGH fixes: - H1: Push handler - add ContactCard and FileNode branches - H2: WS fallback - await state snapshot before reconcileAfterWebSocketFallback - H3: Auth rate limiting - add checkUserAuthRateLimit to session and token routes - H4: OAuth logs - strip access_token from error log context - H7: Template XSS - apply DOMPurify to HTML template body on import - H8: Secure cookie - derive from x-forwarded-proto, not NODE_ENV - H9: bcrypt fix - remove bcrypt prefixes from isHashed() so scrypt-only - H13: calendarTasksEnabled - apply admin gate at runtime in calendar page - H14: Task mutations - add try/catch error handling to update/delete/toggle - H18: autoSelectReplyIdentity default changed from false to true Deferred: P1.3 (C5 auth localStorage encryption) - requires custom Zustand persist adapter.
861 lines
31 KiB
TypeScript
861 lines
31 KiB
TypeScript
/**
|
|
* Client-side recurrence expansion for JSCalendar events.
|
|
*
|
|
* Implements JSCalendar 2.0 (draft-ietf-calext-jscalendarbis-15) §3.3.3.1
|
|
* recurrence rule interpretation algorithm with full byX filtering,
|
|
* implicit byX property addition, and bySetPosition support.
|
|
*
|
|
* Stalwart does not yet support mutations on the synthetic IDs produced by
|
|
* CalendarEvent/query?expandRecurrences=true, so we fetch raw events (with
|
|
* real, mutable IDs) and expand recurring series into individual occurrences
|
|
* in the browser.
|
|
*/
|
|
|
|
import { parseISO, format, addDays, addWeeks, addMonths, addYears, differenceInCalendarDays } from 'date-fns';
|
|
import type { CalendarEvent, CalendarRecurrenceRule, CalendarNDay } from '@/lib/jmap/types';
|
|
|
|
const DAY_INDEX: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
|
|
const INDEX_TO_DAY: string[] = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'];
|
|
|
|
/**
|
|
* Given a master event and a date range, return an array of "virtual" occurrence
|
|
* events. Each occurrence carries a synthetic `id` (for dedup in React) and a
|
|
* `masterEventId` field that points back to the real server-side ID so the
|
|
* store can use it for mutations.
|
|
*
|
|
* Non-recurring events are returned as-is. For recurring events the master is
|
|
* **not** returned - only expanded instances within the range.
|
|
*/
|
|
export function expandRecurringEvents(
|
|
events: CalendarEvent[],
|
|
rangeStart: string,
|
|
rangeEnd: string,
|
|
): CalendarEvent[] {
|
|
const start = parseISO(rangeStart);
|
|
const end = parseISO(rangeEnd);
|
|
const result: CalendarEvent[] = [];
|
|
|
|
// Collect UIDs of master recurring events so we can skip their
|
|
// server-returned override instances (which have recurrenceId set).
|
|
// The master's recurrenceOverrides already accounts for them.
|
|
const recurringUids = new Set<string>();
|
|
for (const event of events) {
|
|
if (event.recurrenceRules?.length && event.uid && !event.recurrenceId) {
|
|
recurringUids.add(event.uid);
|
|
}
|
|
}
|
|
|
|
for (const event of events) {
|
|
// Skip override instances returned by the server - they belong to a
|
|
// master recurring event and are already handled via recurrenceOverrides.
|
|
if (event.recurrenceId && event.uid && recurringUids.has(event.uid)) {
|
|
continue;
|
|
}
|
|
|
|
if (!event.recurrenceRules?.length) {
|
|
result.push(event);
|
|
continue;
|
|
}
|
|
|
|
const occurrences = expandEvent(event, start, end);
|
|
result.push(...occurrences);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function occurrenceDateKey(master: CalendarEvent, date: Date): string {
|
|
return master.showWithoutTime ? format(date, 'yyyy-MM-dd') : date.toISOString();
|
|
}
|
|
|
|
function expandEvent(
|
|
master: CalendarEvent,
|
|
rangeStart: Date,
|
|
rangeEnd: Date,
|
|
): CalendarEvent[] {
|
|
const eventStart = parseISO(master.start);
|
|
if (isNaN(eventStart.getTime())) return [];
|
|
|
|
const rules = master.recurrenceRules || [];
|
|
const overrides = master.recurrenceOverrides || {};
|
|
const occurrences: CalendarEvent[] = [];
|
|
const seenDates = new Set<string>();
|
|
|
|
// RFC 8984 §4.3.3: occurrences produced by excludedRecurrenceRules
|
|
// (iCalendar EXRULE) are removed from the recurrence set.
|
|
const excludedDates = new Set<string>();
|
|
for (const exRule of master.excludedRecurrenceRules || []) {
|
|
// includeStartDate=false: "the series start is always an occurrence"
|
|
// applies to recurrence rules, not to exclusion rules.
|
|
for (const date of generateDates(eventStart, exRule, rangeStart, rangeEnd, false)) {
|
|
excludedDates.add(occurrenceDateKey(master, date));
|
|
}
|
|
}
|
|
|
|
for (const rule of rules) {
|
|
const dates = generateDates(eventStart, rule, rangeStart, rangeEnd);
|
|
for (const date of dates) {
|
|
const dateKey = occurrenceDateKey(master, date);
|
|
|
|
if (excludedDates.has(dateKey)) continue;
|
|
if (seenDates.has(dateKey)) continue;
|
|
seenDates.add(dateKey);
|
|
|
|
const recurrenceId = master.showWithoutTime
|
|
? format(date, "yyyy-MM-dd'T'00:00:00")
|
|
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
|
|
|
|
const override = overrides[recurrenceId] as (Partial<CalendarEvent> & { excluded?: boolean }) | undefined;
|
|
if (override?.excluded) continue;
|
|
|
|
occurrences.push(createOccurrence(master, date, recurrenceId, override));
|
|
}
|
|
}
|
|
|
|
// Add overrides that define new dates not generated by rules (RDATE equivalent)
|
|
for (const [recurrenceId, rawOverride] of Object.entries(overrides)) {
|
|
const override = rawOverride as Partial<CalendarEvent> & { excluded?: boolean };
|
|
if (override.excluded) continue;
|
|
const overrideDate = parseISO(recurrenceId);
|
|
if (isNaN(overrideDate.getTime())) continue;
|
|
if (overrideDate < rangeStart || overrideDate >= rangeEnd) continue;
|
|
|
|
// Overrides take precedence over excludedRecurrenceRules (they re-add
|
|
// a concrete instance), so only dedupe against already-generated dates.
|
|
const dateKey = occurrenceDateKey(master, overrideDate);
|
|
if (seenDates.has(dateKey)) continue;
|
|
seenDates.add(dateKey);
|
|
|
|
occurrences.push(createOccurrence(master, overrideDate, recurrenceId, override));
|
|
}
|
|
|
|
return occurrences;
|
|
}
|
|
|
|
// Cache Intl formatters per IANA timezone id - constructing them is expensive
|
|
// and expansion runs over hundreds of occurrences. `null` marks ids the
|
|
// runtime rejected so we don't retry them.
|
|
const tzFormatterCache = new Map<string, Intl.DateTimeFormat | null>();
|
|
|
|
function getTzFormatter(timeZone: string): Intl.DateTimeFormat | null {
|
|
let formatter = tzFormatterCache.get(timeZone);
|
|
if (formatter === undefined) {
|
|
try {
|
|
formatter = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
});
|
|
} catch {
|
|
formatter = null;
|
|
}
|
|
tzFormatterCache.set(timeZone, formatter);
|
|
}
|
|
return formatter;
|
|
}
|
|
|
|
/** The wall-clock reading of `instant` in the formatter's zone, re-encoded as a UTC timestamp. */
|
|
function wallClockAsUtcTimestamp(formatter: Intl.DateTimeFormat, instant: number): number {
|
|
const map: Record<string, string> = {};
|
|
for (const part of formatter.formatToParts(new Date(instant))) {
|
|
if (part.type !== 'literal') map[part.type] = part.value;
|
|
}
|
|
const hour = map.hour === '24' ? 0 : Number(map.hour);
|
|
return Date.UTC(
|
|
Number(map.year), Number(map.month) - 1, Number(map.day),
|
|
hour, Number(map.minute), Number(map.second),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Interpret the wall-clock fields of `wall` as a local time in `timeZone`
|
|
* and return the corresponding UTC instant. Two fixup iterations converge
|
|
* for all real offsets, including across DST transitions.
|
|
*/
|
|
function zonedWallTimeToUtc(wall: Date, timeZone: string): Date | null {
|
|
const formatter = getTzFormatter(timeZone);
|
|
if (!formatter) return null;
|
|
const wallAsUtc = Date.UTC(
|
|
wall.getFullYear(), wall.getMonth(), wall.getDate(),
|
|
wall.getHours(), wall.getMinutes(), wall.getSeconds(),
|
|
);
|
|
let guess = wallAsUtc;
|
|
for (let i = 0; i < 2; i++) {
|
|
guess = wallAsUtc - (wallClockAsUtcTimestamp(formatter, guess) - guess);
|
|
}
|
|
return new Date(guess);
|
|
}
|
|
|
|
function createOccurrence(
|
|
master: CalendarEvent,
|
|
date: Date,
|
|
recurrenceId: string,
|
|
override?: Partial<CalendarEvent>,
|
|
): CalendarEvent {
|
|
const startStr = master.showWithoutTime
|
|
? format(date, "yyyy-MM-dd'T'00:00:00")
|
|
: format(date, "yyyy-MM-dd'T'HH:mm:ss");
|
|
|
|
// Compute utcStart/utcEnd for this occurrence so that getEventStartDate()
|
|
// and getEventEndDate() (which prefer utcStart/utcEnd for timed events)
|
|
// return the correct dates instead of the master's original UTC times.
|
|
let utcStart: string | undefined;
|
|
let utcEnd: string | undefined;
|
|
if (!master.showWithoutTime) {
|
|
let durationMs: number | null = null;
|
|
if (master.utcStart && master.utcEnd) {
|
|
const ms = parseISO(master.utcEnd).getTime() - parseISO(master.utcStart).getTime();
|
|
if (!isNaN(ms)) durationMs = ms;
|
|
}
|
|
|
|
// Convert the occurrence's wall time using the event's own timezone, so
|
|
// occurrences on the other side of a DST transition in that zone keep
|
|
// their wall time. Reusing the master's fixed UTC offset (the fallback
|
|
// below) would shift them by the DST delta.
|
|
const zoned = master.timeZone ? zonedWallTimeToUtc(date, master.timeZone) : null;
|
|
if (zoned) {
|
|
utcStart = zoned.toISOString();
|
|
if (durationMs !== null) {
|
|
utcEnd = new Date(zoned.getTime() + durationMs).toISOString();
|
|
}
|
|
} else if (master.utcStart && master.start) {
|
|
// Floating events (or an unrecognized timezone id): keep the master's
|
|
// offset, which by definition doesn't vary.
|
|
const masterLocal = parseISO(master.start);
|
|
const masterUtc = parseISO(master.utcStart);
|
|
const offsetMs = masterUtc.getTime() - masterLocal.getTime();
|
|
utcStart = new Date(date.getTime() + offsetMs).toISOString();
|
|
if (durationMs !== null) {
|
|
utcEnd = new Date(date.getTime() + offsetMs + durationMs).toISOString();
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
...master,
|
|
...(override || {}),
|
|
id: `${master.id}::occurrence::${recurrenceId}`,
|
|
originalId: master.originalId || master.id,
|
|
uid: master.uid,
|
|
calendarIds: master.calendarIds,
|
|
start: (override?.start) || startStr,
|
|
...(utcStart && !override?.utcStart ? { utcStart } : {}),
|
|
...(utcEnd && !override?.utcEnd ? { utcEnd } : {}),
|
|
recurrenceId,
|
|
recurrenceRules: master.recurrenceRules,
|
|
recurrenceOverrides: master.recurrenceOverrides,
|
|
excludedRecurrenceRules: master.excludedRecurrenceRules,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// §3.3.3.1 - Implicit byX property addition
|
|
// ---------------------------------------------------------------------------
|
|
function addImplicitByX(
|
|
rule: CalendarRecurrenceRule,
|
|
eventStart: Date,
|
|
): CalendarRecurrenceRule {
|
|
const r = { ...rule };
|
|
const freq = r.frequency;
|
|
|
|
// bySecond
|
|
if (freq !== 'secondly' && !r.bySecond?.length) {
|
|
r.bySecond = [eventStart.getSeconds()];
|
|
}
|
|
// byMinute
|
|
if (freq !== 'secondly' && freq !== 'minutely' && !r.byMinute?.length) {
|
|
r.byMinute = [eventStart.getMinutes()];
|
|
}
|
|
// byHour
|
|
if (freq !== 'secondly' && freq !== 'minutely' && freq !== 'hourly' && !r.byHour?.length) {
|
|
r.byHour = [eventStart.getHours()];
|
|
}
|
|
// weekly: implicit byDay
|
|
if (freq === 'weekly' && !r.byDay?.length) {
|
|
r.byDay = [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
|
|
}
|
|
// monthly: implicit byMonthDay
|
|
if (freq === 'monthly' && !r.byDay?.length && !r.byMonthDay?.length) {
|
|
r.byMonthDay = [eventStart.getDate()];
|
|
}
|
|
// yearly: implicit byMonth / byMonthDay / byDay
|
|
if (freq === 'yearly' && !r.byYearDay?.length) {
|
|
if (!r.byMonth?.length && !r.byWeekNo?.length && (r.byMonthDay?.length || !r.byDay?.length)) {
|
|
r.byMonth = [String(eventStart.getMonth() + 1)];
|
|
}
|
|
if (!r.byMonthDay?.length && !r.byWeekNo?.length && !r.byDay?.length) {
|
|
r.byMonthDay = [eventStart.getDate()];
|
|
}
|
|
if (r.byWeekNo?.length && !r.byMonthDay?.length && !r.byDay?.length) {
|
|
r.byDay = [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
|
|
}
|
|
}
|
|
|
|
return r;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// §3.3.3.1 - Generate occurrence dates
|
|
// ---------------------------------------------------------------------------
|
|
function generateDates(
|
|
eventStart: Date,
|
|
rawRule: CalendarRecurrenceRule,
|
|
rangeStart: Date,
|
|
rangeEnd: Date,
|
|
includeStartDate = true,
|
|
): Date[] {
|
|
const rule = addImplicitByX(rawRule, eventStart);
|
|
const dates: Date[] = [];
|
|
const interval = rule.interval || 1;
|
|
const countLimit = rule.count || Infinity;
|
|
const until = rule.until ? parseISO(rule.until) : null;
|
|
let totalCount = 0;
|
|
// Without a `count` limit we can jump straight to the period containing
|
|
// the visible range. With one, every occurrence since the series start
|
|
// must be generated so it counts against `count`.
|
|
let current = rule.count
|
|
? new Date(eventStart)
|
|
: fastForwardToRange(eventStart, rule.frequency, interval, rangeStart);
|
|
|
|
const maxIterations = 2000;
|
|
// Cap on *emitted* (in-range) dates. This must not count occurrences
|
|
// before rangeStart, otherwise a series started long ago (e.g. a daily
|
|
// event from two years back) exhausts the budget before reaching the
|
|
// visible range and silently renders nothing.
|
|
const maxOccurrences = 500;
|
|
let iterations = 0;
|
|
|
|
while (iterations++ < maxIterations) {
|
|
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
|
|
if (until && current > until) break;
|
|
// Stop once no candidate in this or a later period can fall before
|
|
// rangeEnd. Monthly/yearly candidates may precede the period anchor
|
|
// within the same month/year, so floor the anchor before comparing.
|
|
if (rule.frequency === 'monthly') {
|
|
if (new Date(current.getFullYear(), current.getMonth(), 1) >= rangeEnd) break;
|
|
} else if (rule.frequency === 'yearly') {
|
|
if (new Date(current.getFullYear(), 0, 1) >= rangeEnd) break;
|
|
} else if (current >= rangeEnd) {
|
|
break;
|
|
}
|
|
|
|
// Generate candidates for the current period, then filter
|
|
const candidates = generateCandidatesForPeriod(current, rule, eventStart);
|
|
|
|
// Apply bySetPosition if present
|
|
const filtered = rule.bySetPosition?.length
|
|
? applyBySetPosition(candidates, rule.bySetPosition)
|
|
: candidates;
|
|
|
|
for (const d of filtered) {
|
|
if (until && d > until) break;
|
|
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
|
|
|
|
// Spec rule 4: eliminate dates before event start
|
|
if (d < eventStart) continue;
|
|
|
|
totalCount++;
|
|
if (d >= rangeStart && d < rangeEnd) {
|
|
dates.push(d);
|
|
}
|
|
if (d >= rangeEnd) break;
|
|
}
|
|
|
|
if (totalCount >= countLimit || dates.length >= maxOccurrences) break;
|
|
|
|
current = advancePeriod(current, rule.frequency, interval, rule.firstDayOfWeek || 'mo');
|
|
if (current <= eventStart && iterations === 1) {
|
|
// Safety: ensure we don't go backward
|
|
current = advancePeriod(eventStart, rule.frequency, interval, rule.firstDayOfWeek || 'mo');
|
|
}
|
|
}
|
|
|
|
// Spec rule 1: the initial start date-time is ALWAYS the first occurrence
|
|
if (includeStartDate && dates.length > 0 && dates[0].getTime() !== eventStart.getTime()) {
|
|
if (eventStart >= rangeStart && eventStart < rangeEnd) {
|
|
// Check it's not already in the list
|
|
if (!dates.some(d => d.getTime() === eventStart.getTime())) {
|
|
dates.unshift(eventStart);
|
|
}
|
|
}
|
|
}
|
|
|
|
return dates;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Generate all candidates within one period, filtered by byX properties
|
|
// ---------------------------------------------------------------------------
|
|
function generateCandidatesForPeriod(
|
|
periodStart: Date,
|
|
rule: CalendarRecurrenceRule,
|
|
eventStart: Date,
|
|
): Date[] {
|
|
const freq = rule.frequency;
|
|
let candidates: Date[];
|
|
|
|
// Step 1: Generate base candidates based on frequency + expansion byX
|
|
switch (freq) {
|
|
case 'yearly':
|
|
candidates = expandYearly(periodStart, rule, eventStart);
|
|
break;
|
|
case 'monthly':
|
|
candidates = expandMonthly(periodStart, rule, eventStart);
|
|
break;
|
|
case 'weekly':
|
|
candidates = expandWeekly(periodStart, rule, eventStart);
|
|
break;
|
|
case 'daily':
|
|
case 'hourly':
|
|
case 'minutely':
|
|
case 'secondly':
|
|
candidates = [new Date(periodStart)];
|
|
break;
|
|
default:
|
|
candidates = [new Date(periodStart)];
|
|
}
|
|
|
|
// Step 2: Filter candidates by all applicable byX constraints
|
|
candidates = candidates.filter(d => matchesByX(d, rule));
|
|
|
|
candidates.sort((a, b) => a.getTime() - b.getTime());
|
|
return candidates;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Yearly expansion
|
|
// ---------------------------------------------------------------------------
|
|
function expandYearly(
|
|
periodStart: Date,
|
|
rule: CalendarRecurrenceRule,
|
|
eventStart: Date,
|
|
): Date[] {
|
|
const year = periodStart.getFullYear();
|
|
const h = eventStart.getHours();
|
|
const m = eventStart.getMinutes();
|
|
const s = eventStart.getSeconds();
|
|
let dates: Date[] = [];
|
|
|
|
// Determine which months to iterate
|
|
const months = rule.byMonth?.length
|
|
? rule.byMonth.map(ms => parseInt(ms.replace('L', ''), 10) - 1)
|
|
: [eventStart.getMonth()];
|
|
|
|
if (rule.byWeekNo?.length) {
|
|
// Expand by ISO week numbers
|
|
for (const wn of rule.byWeekNo) {
|
|
const weekDates = datesInISOWeek(year, wn, rule.firstDayOfWeek || 'mo');
|
|
dates.push(...weekDates.map(d => { d.setHours(h, m, s, 0); return d; }));
|
|
}
|
|
} else if (rule.byYearDay?.length) {
|
|
// Expand by day-of-year
|
|
for (const yd of rule.byYearDay) {
|
|
const d = dayOfYear(year, yd);
|
|
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
|
|
}
|
|
} else if (rule.byDay?.length && rule.byMonthDay?.length) {
|
|
// Both byDay and byMonthDay: expand byMonthDay in each month, then byDay filters later
|
|
for (const mo of months) {
|
|
for (const md of rule.byMonthDay) {
|
|
const d = resolveMonthDay(year, mo, md);
|
|
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
|
|
}
|
|
}
|
|
} else if (rule.byDay?.length) {
|
|
// byDay with nthOfPeriod in yearly context = nth weekday of year or month
|
|
for (const mo of months) {
|
|
const expanded = expandByDayInMonth(year, mo, rule.byDay, h, m, s);
|
|
dates.push(...expanded);
|
|
}
|
|
} else if (rule.byMonthDay?.length) {
|
|
for (const mo of months) {
|
|
for (const md of rule.byMonthDay) {
|
|
const d = resolveMonthDay(year, mo, md);
|
|
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
|
|
}
|
|
}
|
|
} else {
|
|
// Simple yearly: same date each year
|
|
for (const mo of months) {
|
|
const d = new Date(year, mo, eventStart.getDate(), h, m, s, 0);
|
|
if (d.getMonth() === mo) dates.push(d);
|
|
}
|
|
}
|
|
|
|
return dates;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Monthly expansion
|
|
// ---------------------------------------------------------------------------
|
|
function expandMonthly(
|
|
periodStart: Date,
|
|
rule: CalendarRecurrenceRule,
|
|
eventStart: Date,
|
|
): Date[] {
|
|
const year = periodStart.getFullYear();
|
|
const month = periodStart.getMonth();
|
|
const h = eventStart.getHours();
|
|
const m = eventStart.getMinutes();
|
|
const s = eventStart.getSeconds();
|
|
const dates: Date[] = [];
|
|
|
|
if (rule.byDay?.length) {
|
|
const expanded = expandByDayInMonth(year, month, rule.byDay, h, m, s);
|
|
dates.push(...expanded);
|
|
} else if (rule.byMonthDay?.length) {
|
|
for (const md of rule.byMonthDay) {
|
|
const d = resolveMonthDay(year, month, md);
|
|
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
|
|
}
|
|
} else {
|
|
// Implicit byMonthDay already added, but as fallback:
|
|
const d = new Date(year, month, eventStart.getDate(), h, m, s, 0);
|
|
if (d.getMonth() === month) dates.push(d);
|
|
}
|
|
|
|
return dates;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Weekly expansion
|
|
// ---------------------------------------------------------------------------
|
|
function expandWeekly(
|
|
periodStart: Date,
|
|
rule: CalendarRecurrenceRule,
|
|
eventStart: Date,
|
|
): Date[] {
|
|
const dates: Date[] = [];
|
|
const baseDay = periodStart.getDay();
|
|
|
|
const byDay = rule.byDay?.length ? rule.byDay : [{ day: INDEX_TO_DAY[eventStart.getDay()] }];
|
|
|
|
for (const { day } of byDay) {
|
|
const targetDay = DAY_INDEX[day];
|
|
if (targetDay === undefined) continue;
|
|
let diff = targetDay - baseDay;
|
|
if (diff < 0) diff += 7;
|
|
const d = addDays(periodStart, diff);
|
|
d.setHours(eventStart.getHours(), eventStart.getMinutes(), eventStart.getSeconds(), 0);
|
|
dates.push(d);
|
|
}
|
|
|
|
return dates.sort((a, b) => a.getTime() - b.getTime());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// byX matching (Step 2 of §3.3.3.1)
|
|
// ---------------------------------------------------------------------------
|
|
function matchesByX(date: Date, rule: CalendarRecurrenceRule): boolean {
|
|
if (rule.byMonth?.length) {
|
|
const month = String(date.getMonth() + 1);
|
|
if (!rule.byMonth.some(m => m.replace('L', '') === month)) return false;
|
|
}
|
|
if (rule.byWeekNo?.length) {
|
|
const wn = getISOWeekNumber(date);
|
|
const weeksInYear = getISOWeeksInYear(date.getFullYear());
|
|
if (!rule.byWeekNo.some(w => (w > 0 ? w : weeksInYear + 1 + w) === wn)) return false;
|
|
}
|
|
if (rule.byYearDay?.length) {
|
|
const yd = getDayOfYear(date);
|
|
const daysInYear = isLeapYear(date.getFullYear()) ? 366 : 365;
|
|
if (!rule.byYearDay.some(d => (d > 0 ? d : daysInYear + 1 + d) === yd)) return false;
|
|
}
|
|
if (rule.byMonthDay?.length) {
|
|
const md = date.getDate();
|
|
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
|
if (!rule.byMonthDay.some(d => (d > 0 ? d : daysInMonth + 1 + d) === md)) return false;
|
|
}
|
|
if (rule.byDay?.length) {
|
|
const dayName = INDEX_TO_DAY[date.getDay()];
|
|
const freq = rule.frequency;
|
|
if (!rule.byDay.some(nd => {
|
|
if (nd.day !== dayName) return false;
|
|
if (nd.nthOfPeriod == null) return true;
|
|
if (freq === 'monthly') {
|
|
return nd.nthOfPeriod === nthWeekdayInMonth(date, nd.nthOfPeriod);
|
|
}
|
|
if (freq === 'yearly') {
|
|
// When byMonth is present, nthOfPeriod scopes to the month (iCalendar semantics)
|
|
if (rule.byMonth?.length) {
|
|
return nd.nthOfPeriod === nthWeekdayInMonth(date, nd.nthOfPeriod);
|
|
}
|
|
return nd.nthOfPeriod === nthWeekdayInYear(date, nd.nthOfPeriod);
|
|
}
|
|
return true;
|
|
})) return false;
|
|
}
|
|
if (rule.byHour?.length) {
|
|
if (!rule.byHour.includes(date.getHours())) return false;
|
|
}
|
|
if (rule.byMinute?.length) {
|
|
if (!rule.byMinute.includes(date.getMinutes())) return false;
|
|
}
|
|
if (rule.bySecond?.length) {
|
|
if (!rule.bySecond.includes(date.getSeconds())) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// bySetPosition (Step 3 of §3.3.3.1)
|
|
// ---------------------------------------------------------------------------
|
|
function applyBySetPosition(dates: Date[], positions: number[]): Date[] {
|
|
if (!dates.length) return dates;
|
|
const result: Date[] = [];
|
|
const len = dates.length;
|
|
for (const pos of positions) {
|
|
const idx = pos > 0 ? pos - 1 : len + pos;
|
|
if (idx >= 0 && idx < len) {
|
|
result.push(dates[idx]);
|
|
}
|
|
}
|
|
return result.sort((a, b) => a.getTime() - b.getTime());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Advance to the start of the next period
|
|
// ---------------------------------------------------------------------------
|
|
function advancePeriod(
|
|
date: Date,
|
|
frequency: CalendarRecurrenceRule['frequency'],
|
|
interval: number,
|
|
_firstDayOfWeek: string,
|
|
): Date {
|
|
switch (frequency) {
|
|
case 'daily': return addDays(date, interval);
|
|
case 'weekly': return addWeeks(date, interval);
|
|
case 'monthly': return addMonths(date, interval);
|
|
case 'yearly': return addYears(date, interval);
|
|
case 'hourly': return new Date(date.getTime() + interval * 3600000);
|
|
case 'minutely': return new Date(date.getTime() + interval * 60000);
|
|
case 'secondly': return new Date(date.getTime() + interval * 1000);
|
|
default: return addDays(date, interval);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Jump to the period just before the visible range in O(1), so long-running
|
|
// series don't burn the iteration budget on years of out-of-range periods.
|
|
// Only valid for rules without `count` (counted rules must enumerate every
|
|
// occurrence from the series start).
|
|
// ---------------------------------------------------------------------------
|
|
function fastForwardToRange(
|
|
eventStart: Date,
|
|
frequency: CalendarRecurrenceRule['frequency'],
|
|
interval: number,
|
|
rangeStart: Date,
|
|
): Date {
|
|
if (eventStart >= rangeStart) return new Date(eventStart);
|
|
|
|
let periods: number;
|
|
switch (frequency) {
|
|
case 'secondly':
|
|
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (1000 * interval));
|
|
break;
|
|
case 'minutely':
|
|
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (60000 * interval));
|
|
break;
|
|
case 'hourly':
|
|
periods = Math.floor((rangeStart.getTime() - eventStart.getTime()) / (3600000 * interval));
|
|
break;
|
|
case 'daily':
|
|
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / interval);
|
|
break;
|
|
case 'weekly':
|
|
periods = Math.floor(differenceInCalendarDays(rangeStart, eventStart) / (7 * interval));
|
|
break;
|
|
case 'monthly': {
|
|
const months = (rangeStart.getFullYear() - eventStart.getFullYear()) * 12
|
|
+ (rangeStart.getMonth() - eventStart.getMonth());
|
|
periods = Math.floor(months / interval);
|
|
break;
|
|
}
|
|
case 'yearly':
|
|
periods = Math.floor((rangeStart.getFullYear() - eventStart.getFullYear()) / interval);
|
|
break;
|
|
default:
|
|
return new Date(eventStart);
|
|
}
|
|
|
|
// Land one full period early so candidates inside the boundary period
|
|
// (which can precede the period anchor) are still generated.
|
|
periods = Math.max(0, periods - 1);
|
|
if (periods === 0) return new Date(eventStart);
|
|
|
|
switch (frequency) {
|
|
case 'secondly': return new Date(eventStart.getTime() + periods * interval * 1000);
|
|
case 'minutely': return new Date(eventStart.getTime() + periods * interval * 60000);
|
|
case 'hourly': return new Date(eventStart.getTime() + periods * interval * 3600000);
|
|
case 'daily': return addDays(eventStart, periods * interval);
|
|
case 'weekly': return addWeeks(eventStart, periods * interval);
|
|
case 'monthly': return addMonths(eventStart, periods * interval);
|
|
case 'yearly': return addYears(eventStart, periods * interval);
|
|
default: return new Date(eventStart);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: expand byDay within a specific month (monthly or yearly context)
|
|
// ---------------------------------------------------------------------------
|
|
function expandByDayInMonth(
|
|
year: number,
|
|
month: number,
|
|
byDay: CalendarNDay[],
|
|
h: number, m: number, s: number,
|
|
): Date[] {
|
|
const dates: Date[] = [];
|
|
|
|
for (const { day, nthOfPeriod } of byDay) {
|
|
const targetDay = DAY_INDEX[day];
|
|
if (targetDay === undefined) continue;
|
|
|
|
if (nthOfPeriod != null && nthOfPeriod !== 0) {
|
|
const d = nthWeekdayOfMonth(year, month, targetDay, nthOfPeriod);
|
|
if (d) { d.setHours(h, m, s, 0); dates.push(d); }
|
|
} else {
|
|
// Every occurrence of this weekday in the month
|
|
let d = new Date(year, month, 1);
|
|
while (d.getDay() !== targetDay) d = addDays(d, 1);
|
|
while (d.getMonth() === month) {
|
|
const occ = new Date(d);
|
|
occ.setHours(h, m, s, 0);
|
|
dates.push(occ);
|
|
d = addDays(d, 7);
|
|
}
|
|
}
|
|
}
|
|
|
|
return dates;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: find nth weekday of month
|
|
// ---------------------------------------------------------------------------
|
|
function nthWeekdayOfMonth(
|
|
year: number,
|
|
month: number,
|
|
weekday: number,
|
|
nth: number,
|
|
): Date | null {
|
|
if (nth > 0) {
|
|
let d = new Date(year, month, 1);
|
|
while (d.getDay() !== weekday) d = addDays(d, 1);
|
|
d = addDays(d, (nth - 1) * 7);
|
|
return d.getMonth() === month ? d : null;
|
|
} else {
|
|
let d = new Date(year, month + 1, 0); // last day of month
|
|
while (d.getDay() !== weekday) d = addDays(d, -1);
|
|
if (nth < -1) d = addDays(d, (nth + 1) * 7);
|
|
return d.getMonth() === month ? d : null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: check if date is the nth (or nth-last) weekday in its month
|
|
// ---------------------------------------------------------------------------
|
|
function nthWeekdayInMonth(date: Date, nth: number): number {
|
|
if (nth > 0) {
|
|
// Count from start: which occurrence of this weekday is it?
|
|
return Math.floor((date.getDate() - 1) / 7) + 1;
|
|
} else {
|
|
// Count from end
|
|
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
|
return -(Math.floor((daysInMonth - date.getDate()) / 7) + 1);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: check if date is the nth weekday in its year
|
|
// ---------------------------------------------------------------------------
|
|
function nthWeekdayInYear(date: Date, nth: number): number {
|
|
const yd = getDayOfYear(date);
|
|
const weekday = date.getDay();
|
|
if (nth > 0) {
|
|
// Find first occurrence of this weekday in the year
|
|
const jan1 = new Date(date.getFullYear(), 0, 1);
|
|
let first = jan1;
|
|
while (first.getDay() !== weekday) first = addDays(first, 1);
|
|
const firstYd = getDayOfYear(first);
|
|
return Math.floor((yd - firstYd) / 7) + 1;
|
|
} else {
|
|
const dec31 = new Date(date.getFullYear(), 11, 31);
|
|
let last = dec31;
|
|
while (last.getDay() !== weekday) last = addDays(last, -1);
|
|
const lastYd = getDayOfYear(last);
|
|
return -(Math.floor((lastYd - yd) / 7) + 1);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: resolve negative/positive byMonthDay to a real date
|
|
// ---------------------------------------------------------------------------
|
|
function resolveMonthDay(year: number, month: number, day: number): Date | null {
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
const actualDay = day > 0 ? day : daysInMonth + 1 + day;
|
|
if (actualDay < 1 || actualDay > daysInMonth) return null;
|
|
return new Date(year, month, actualDay);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: day of year (1-indexed)
|
|
// ---------------------------------------------------------------------------
|
|
function getDayOfYear(date: Date): number {
|
|
const start = new Date(date.getFullYear(), 0, 0);
|
|
const diff = date.getTime() - start.getTime();
|
|
return Math.floor(diff / 86400000);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: day-of-year to Date
|
|
// ---------------------------------------------------------------------------
|
|
function dayOfYear(year: number, yd: number): Date | null {
|
|
const daysInYear = isLeapYear(year) ? 366 : 365;
|
|
const actual = yd > 0 ? yd : daysInYear + 1 + yd;
|
|
if (actual < 1 || actual > daysInYear) return null;
|
|
const d = new Date(year, 0, actual);
|
|
return d;
|
|
}
|
|
|
|
function isLeapYear(year: number): boolean {
|
|
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ISO week helpers
|
|
// ---------------------------------------------------------------------------
|
|
function getISOWeekNumber(date: Date): number {
|
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
|
}
|
|
|
|
function getISOWeeksInYear(year: number): number {
|
|
const dec28 = new Date(Date.UTC(year, 11, 28));
|
|
return getISOWeekNumber(dec28);
|
|
}
|
|
|
|
function datesInISOWeek(year: number, weekNo: number, _firstDayOfWeek: string): Date[] {
|
|
const weeksInYear = getISOWeeksInYear(year);
|
|
const actual = weekNo > 0 ? weekNo : weeksInYear + 1 + weekNo;
|
|
if (actual < 1 || actual > weeksInYear) return [];
|
|
|
|
// Find Monday of ISO week 1
|
|
const jan4 = new Date(year, 0, 4);
|
|
const dayOfWeek = jan4.getDay() || 7; // Monday=1 ... Sunday=7
|
|
const week1Monday = addDays(jan4, 1 - dayOfWeek);
|
|
const targetMonday = addDays(week1Monday, (actual - 1) * 7);
|
|
|
|
const dates: Date[] = [];
|
|
for (let i = 0; i < 7; i++) {
|
|
dates.push(addDays(targetMonday, i));
|
|
}
|
|
return dates;
|
|
}
|