-
-
Notifications
You must be signed in to change notification settings - Fork 60
Expand file tree
/
Copy pathnode-ical.js
More file actions
629 lines (561 loc) · 20.7 KB
/
node-ical.js
File metadata and controls
629 lines (561 loc) · 20.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
const fs = require('node:fs');
const ical = require('./ical.js');
const {getDateKey} = require('./lib/date-utils.js');
/**
* ICal event object.
*
* These two fields are always present:
* - type
* - params
*
* The rest of the fields may or may not be present depending on the input.
* Do not assume any of these fields are valid and check them before using.
* Most types are simply there as a general guide for IDEs and users.
*
* @typedef iCalEvent
* @type {object}
*
* @property {string} type - Type of event.
* @property {Array} params - Extra event parameters.
*
* @property {?object} start - When this event starts.
* @property {?object} end - When this event ends.
*
* @property {?string} summary - Event summary string.
* @property {?string} description - Event description.
*
* @property {?object} dtstamp - DTSTAMP field of this event.
*
* @property {?object} created - When this event was created.
* @property {?object} lastmodified - When this event was last modified.
*
* @property {?string} uid - Unique event identifier.
*
* @property {?string} status - Event status.
*
* @property {?string} sequence - Event sequence.
*
* @property {?string} url - URL of this event.
*
* @property {?string} location - Where this event occurs.
* @property {?{
* lat: number, lon: number
* }} geo - Lat/lon location of this event.
*
* @property {?Array.<string>} - Array of event catagories.
*/
/**
* Object containing iCal events.
* @typedef {Object.<string, iCalEvent>} iCalData
*/
/**
* Callback for iCal parsing functions with error and iCal data as a JavaScript object.
* @callback icsCallback
* @param {Error} err
* @param {iCalData} ics
*/
/**
* A Promise that is undefined if a compatible callback is passed.
* @typedef {(Promise.<iCalData>|undefined)} optionalPromise
*/
// utility to allow callbacks to be used for promises
function promiseCallback(fn, cb) {
const promise = new Promise(fn);
if (!cb) {
return promise;
}
// Store result/error outside .then/.catch to avoid double-callback
// if the user's callback throws (the thrown error would be caught by
// the promise chain and trigger .catch, calling cb a second time)
let callbackError = null;
let callbackResult = null;
let hasResult = false;
promise
.then(returnValue => {
callbackResult = returnValue;
hasResult = true;
})
.catch(error => {
callbackError = error;
})
.finally(() => {
if (callbackError) {
cb(callbackError, null);
} else if (hasResult) {
cb(null, callbackResult);
}
});
}
// Sync functions
const sync = {};
// Async functions
const async = {};
// Auto-detect functions for backwards compatibility.
const autodetect = {};
/**
* Download an iCal file from the web and parse it.
*
* @param {string} url - URL of file to request.
* @param {Object|icsCallback} [opts] - Options to pass to fetch(). Supports headers and any standard RequestInit fields.
* Alternatively you can pass the callback function directly.
* If no callback is provided a promise will be returned.
* @param {icsCallback} [cb] - Callback function.
* If no callback is provided a promise will be returned.
*
* @returns {optionalPromise} Promise is returned if no callback is passed.
*/
async.fromURL = function (url, options, cb) {
// Normalize overloads: (url, cb) or (url, options, cb)
if (typeof options === 'function' && cb === undefined) {
cb = options;
options = undefined;
}
return promiseCallback((resolve, reject) => {
const fetchOptions = (options && typeof options === 'object') ? {...options} : {};
fetch(url, fetchOptions)
.then(response => {
if (!response.ok) {
// Mimic previous error style
throw new Error(`${response.status} ${response.statusText}`);
}
return response.text();
})
.then(data => {
ical.parseICS(data, (error, ics) => {
if (error) {
reject(error);
return;
}
resolve(ics);
});
})
.catch(error => {
reject(error);
});
}, cb);
};
/**
* Load iCal data from a file and parse it.
*
* @param {string} filename - File path to load.
* @param {icsCallback} [cb] - Callback function.
* If no callback is provided a promise will be returned.
*
* @returns {optionalPromise} Promise is returned if no callback is passed.
*/
async.parseFile = function (filename, cb) {
return promiseCallback((resolve, reject) => {
fs.readFile(filename, 'utf8', (error, data) => {
if (error) {
reject(error);
return;
}
ical.parseICS(data, (error, ics) => {
if (error) {
reject(error);
return;
}
resolve(ics);
});
});
}, cb);
};
/**
* Parse iCal data from a string.
*
* @param {string} data - String containing iCal data.
* @param {icsCallback} [cb] - Callback function.
* If no callback is provided a promise will be returned.
*
* @returns {optionalPromise} Promise is returned if no callback is passed.
*/
async.parseICS = function (data, cb) {
return promiseCallback((resolve, reject) => {
ical.parseICS(data, (error, ics) => {
if (error) {
reject(error);
return;
}
resolve(ics);
});
}, cb);
};
/**
* Load iCal data from a file and parse it.
*
* @param {string} filename - File path to load.
*
* @returns {iCalData} Parsed iCal data.
*/
sync.parseFile = function (filename) {
const data = fs.readFileSync(filename, 'utf8');
return ical.parseICS(data);
};
/**
* Parse iCal data from a string.
*
* @param {string} data - String containing iCal data.
*
* @returns {iCalData} Parsed iCal data.
*/
sync.parseICS = function (data) {
return ical.parseICS(data);
};
/**
* Load iCal data from a file and parse it.
*
* @param {string} filename - File path to load.
* @param {icsCallback} [cb] - Callback function.
* If no callback is provided this function runs synchronously.
*
* @returns {iCalData|undefined} Parsed iCal data or undefined if a callback is being used.
*/
autodetect.parseFile = function (filename, cb) {
if (!cb) {
return sync.parseFile(filename);
}
async.parseFile(filename, cb);
};
/**
* Parse iCal data from a string.
*
* @param {string} data - String containing iCal data.
* @param {icsCallback} [cb] - Callback function.
* If no callback is provided this function runs synchronously.
*
* @returns {iCalData|undefined} Parsed iCal data or undefined if a callback is being used.
*/
autodetect.parseICS = function (data, cb) {
if (!cb) {
return sync.parseICS(data);
}
async.parseICS(data, cb);
};
/**
* Generate date key for EXDATE/RECURRENCE-ID lookups from an RRULE-generated date.
* RRULE-generated dates carry no .tz or .dateOnly metadata, so isFullDay must be
* passed explicitly to decide between local-time and UTC-based key extraction.
* (For parsed calendar dates that carry .tz/.dateOnly, use getDateKey directly.)
* @param {Date} date - RRULE-generated Date (no .tz, no .dateOnly)
* @param {boolean} isFullDay
* @returns {string} Date key in YYYY-MM-DD format
*/
function generateDateKey(date, isFullDay) {
if (isFullDay) {
// Full-day events: use local getters — RRULE returns local-midnight dates
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
// Timed events: UTC date portion
return date.toISOString().slice(0, 10);
}
/**
* Copy timezone metadata (tz, dateOnly) from source Date to target Date.
* @param {Date} target - Target Date object to copy metadata to
* @param {Date} source - Source Date object to copy metadata from
* @returns {Date} Target Date with copied metadata
*/
function copyDateMeta(target, source) {
if (source?.tz) {
target.tz = source.tz;
}
if (source?.dateOnly) {
target.dateOnly = source.dateOnly;
}
return target;
}
/**
* Create date from UTC components to avoid DST issues for full-day events.
* This ensures that a DATE value of 20250107 stays as January 7th regardless of timezone.
* For dateOnly events, uses local components (DATE values are timezone-independent).
* @param {Date} utcDate - Date from RRULE (UTC midnight) or dateOnly event
* @returns {Date} Date representing the same calendar day at local midnight
*/
function createLocalDateFromUTC(utcDate) {
// For DATE-only events (dateOnly flag set), use local components
// because DATE values represent calendar dates, not moments in time.
// This prevents timezone-shift issues (e.g., 20260227 in CET being
// stored as 2026-02-26T23:00:00Z and then wrongly extracted as Feb 26)
if (utcDate?.dateOnly) {
const year = utcDate.getFullYear();
const month = utcDate.getMonth();
const day = utcDate.getDate();
return new Date(year, month, day, 0, 0, 0, 0);
}
// For regular full-day events from RRULE (no dateOnly flag),
// extract UTC components to create the local date
const year = utcDate.getUTCFullYear();
const month = utcDate.getUTCMonth();
const day = utcDate.getUTCDate();
// Create date at midnight in local timezone with same calendar day
return new Date(year, month, day, 0, 0, 0, 0);
}
/**
* Get event duration in milliseconds.
* @param {object} eventData - The event data (original or override)
* @param {boolean} isFullDay - Whether this is a full-day event
* @returns {number} Duration in milliseconds
*/
function getEventDurationMs(eventData, isFullDay) {
if (eventData?.start && eventData?.end) {
return new Date(eventData.end).getTime() - new Date(eventData.start).getTime();
}
if (isFullDay) {
return 24 * 60 * 60 * 1000;
}
return 0;
}
/**
* Calculate end time for an event instance
* @param {Date} start - The start time of this specific instance
* @param {object} eventData - The event data (original or override)
* @param {boolean} isFullDay - Whether this is a full-day event
* @param {number} [baseDurationMs] - Base duration (used when override lacks end)
* @returns {Date} End time for this instance
*/
function calculateEndTime(start, eventData, isFullDay, baseDurationMs) {
const durationMs = (eventData?.start && eventData?.end)
? getEventDurationMs(eventData, isFullDay)
: (baseDurationMs ?? (isFullDay ? 24 * 60 * 60 * 1000 : 0));
return new Date(start.getTime() + durationMs);
}
/**
* Process a non-recurring event
* @param {object} event
* @param {object} options
* @returns {Array} Array of event instances
*/
function processNonRecurringEvent(event, options) {
const {from, to, expandOngoing} = options;
const isFullDay = event.datetype === 'date' || Boolean(event.start?.dateOnly);
const baseDurationMs = getEventDurationMs(event, isFullDay);
// Ensure we have a proper Date object
let eventStart = event.start instanceof Date ? event.start : new Date(event.start);
// For full-day events, normalize to local calendar date to avoid timezone shifts
if (isFullDay) {
eventStart = createLocalDateFromUTC(eventStart);
}
const eventEnd = calculateEndTime(eventStart, event, isFullDay, baseDurationMs);
// Check if event is within range
const inRange = expandOngoing
? (eventEnd >= from && eventStart <= to)
: (eventStart >= from && eventStart <= to);
if (!inRange) {
return [];
}
const instance = {
start: eventStart,
end: eventEnd,
summary: event.summary || '',
isFullDay,
isRecurring: false,
isOverride: false,
event,
};
// Preserve timezone metadata
copyDateMeta(instance.start, event.start);
copyDateMeta(instance.end, event.end);
return [instance];
}
/**
* Check if a date is excluded by EXDATE rules.
* @param {Date} date - The instance date to check
* @param {object} event - The calendar event
* @param {string} dateKey - Pre-computed date key
* @param {boolean} isFullDay - Whether the event is a full-day event
* @returns {boolean} True if the date is excluded
*/
function isExcludedByExdate(date, event, dateKey, isFullDay) {
if (!event.exdate) {
return false;
}
if (isFullDay) {
// Full-day: compare by calendar date using timezone-aware formatting
// (e.g., Exchange/O365 stores EXDATE as DATE-TIME with timezone, so we need
// to extract the calendar date in the EXDATE's timezone, not host-local time)
// Use Set to deduplicate — exdateParameter stores the same Date under both
// a date-key and an ISO-string key, so Object.values() can yield duplicates.
for (const exdateValue of new Set(Object.values(event.exdate))) {
if (exdateValue instanceof Date && getDateKey(exdateValue) === dateKey) {
return true;
}
}
return false;
}
// For timed events:
// 1. Prefer an exact ISO-string match — a DATE-TIME EXDATE is stored under
// both dateKey AND isoKey, so only checking isoKey ensures we don't
// accidentally exclude the 09:00 instance when only 14:00 is excluded.
// 2. Fall back to dateKey only when the EXDATE itself is DATE-only (dateOnly
// is true), which by RFC 5545 intentionally excludes every instance on
// that calendar day regardless of time.
return Boolean(event.exdate[date.toISOString()] || event.exdate[dateKey]?.dateOnly);
}
/**
* Validate that from/to are proper Dates in the right order.
* @param {Date} from
* @param {Date} to
*/
function validateDateRange(from, to) {
if (!(from instanceof Date) || Number.isNaN(from.getTime())) {
throw new TypeError('options.from must be a valid Date object');
}
if (!(to instanceof Date) || Number.isNaN(to.getTime())) {
throw new TypeError('options.to must be a valid Date object');
}
if (from > to) {
throw new RangeError('options.from must be before or equal to options.to');
}
}
/**
* Compute the effective RRULE search window from the user-facing range.
* For full-day events the upper bound is pushed to end-of-day so RRULE doesn't
* skip the last day due to timezone offsets.
* For expandOngoing mode the lower bound is moved back by the event duration.
* @param {Date} from
* @param {Date} to
* @param {boolean} isFullDay
* @param {boolean} expandOngoing
* @param {number} baseDurationMs
* @returns {{searchFrom: Date, searchTo: Date}}
*/
function adjustSearchRange(from, to, isFullDay, expandOngoing, baseDurationMs) {
const isMidnight = to.getHours() === 0 && to.getMinutes() === 0 && to.getSeconds() === 0;
const searchTo = (isFullDay && isMidnight)
? new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59, 999)
: to;
const searchFrom = expandOngoing ? new Date(from.getTime() - baseDurationMs) : from;
return {searchFrom, searchTo};
}
/**
* Build a single recurring event instance for an RRULE-generated date.
* Returns null when the date is excluded by EXDATE.
* @param {Date} date - RRULE-generated Date
* @param {object} event - The base VEVENT
* @param {boolean} isFullDay - Pre-computed full-day flag
* @param {number} baseDurationMs - Pre-computed base duration
* @param {{excludeExdates: boolean, includeOverrides: boolean}} options
* @returns {object|null} Event instance or null if excluded
*/
function buildRecurringInstance(date, event, isFullDay, baseDurationMs, options) {
const {excludeExdates, includeOverrides} = options;
const dateKey = generateDateKey(date, isFullDay);
if (excludeExdates && isExcludedByExdate(date, event, dateKey, isFullDay)) {
return null;
}
// For timed events use only the precise ISO key: storeRecurrenceOverride (ical.js)
// stores every DATE-TIME RECURRENCE-ID under both the ISO key and the date-only
// key, so a miss on the ISO key unambiguously means "no override for this
// specific instance". Falling back to the date-only key would incorrectly apply
// a different occurrence's override when two instances share the same calendar
// date (e.g. BYHOUR=9,15). Full-day events have no ISO key and use dateKey only.
const isoKey = isFullDay ? null : date.toISOString();
const overrideEvent = includeOverrides
&& (isoKey ? event.recurrences?.[isoKey] : event.recurrences?.[dateKey]);
const isOverride = Boolean(overrideEvent);
const instanceEvent = isOverride ? overrideEvent : event;
// Override's own DTSTART takes priority over the RRULE-generated date
let start = (isOverride && instanceEvent.start)
? (instanceEvent.start instanceof Date ? instanceEvent.start : new Date(instanceEvent.start))
: date;
// Normalise full-day dates to local calendar midnight to avoid DST shifts
if (isFullDay) {
start = createLocalDateFromUTC(start);
}
const end = calculateEndTime(start, instanceEvent, isFullDay, baseDurationMs);
const instance = {
start,
end,
summary: instanceEvent.summary || event.summary || '',
isFullDay,
isRecurring: true,
isOverride,
event: instanceEvent,
};
copyDateMeta(instance.start, isOverride ? instanceEvent.start : event.start);
copyDateMeta(instance.end, instanceEvent.end || event.end);
return instance;
}
/**
* Check if an event instance is within the specified date range
* @param {object} instance - Event instance with start, end, isFullDay
* @param {Date} from - Range start
* @param {Date} to - Range end
* @param {boolean} expandOngoing - Whether to include ongoing events
* @returns {boolean} Whether instance is in range
*/
function isInstanceInRange(instance, from, to, expandOngoing) {
if (instance.isFullDay) {
// For full-day events, compare calendar dates only (ignore time component)
const instanceDate = new Date(instance.start.getFullYear(), instance.start.getMonth(), instance.start.getDate());
const fromDate = new Date(from.getFullYear(), from.getMonth(), from.getDate());
const toDate = new Date(to.getFullYear(), to.getMonth(), to.getDate());
const instanceEndDate = new Date(instance.end.getFullYear(), instance.end.getMonth(), instance.end.getDate());
return expandOngoing
? (instanceEndDate >= fromDate && instanceDate <= toDate)
: (instanceDate >= fromDate && instanceDate <= toDate);
}
// For timed events: use exact timestamp comparison
return expandOngoing
? (instance.end >= from && instance.start <= to)
: (instance.start >= from && instance.start <= to);
}
/**
* Expand a recurring event into individual instances within a date range.
* Handles RRULE expansion, EXDATE filtering, and RECURRENCE-ID overrides.
* Also works for non-recurring events (returns single instance if within range).
*
* @param {object} event - The VEVENT object (with or without rrule)
* @param {object} options - Expansion options
* @param {Date} options.from - Start of date range (inclusive)
* @param {Date} options.to - End of date range (inclusive)
* @param {boolean} [options.includeOverrides=true] - Apply RECURRENCE-ID overrides
* @param {boolean} [options.excludeExdates=true] - Filter out EXDATE exclusions
* @param {boolean} [options.expandOngoing=false] - Include events that started before range but still ongoing
* @returns {Array<{start: Date, end: Date, summary: string, isFullDay: boolean, isRecurring: boolean, isOverride: boolean, event: object}>} Sorted array of event instances
*/
function expandRecurringEvent(event, options) {
const {
from,
to,
includeOverrides = true,
excludeExdates = true,
expandOngoing = false,
} = options;
validateDateRange(from, to);
// Handle non-recurring events
if (!event.rrule) {
return processNonRecurringEvent(event, {from, to, expandOngoing});
}
const isFullDay = event.datetype === 'date' || Boolean(event.start?.dateOnly);
const baseDurationMs = getEventDurationMs(event, isFullDay);
const {searchFrom, searchTo} = adjustSearchRange(from, to, isFullDay, expandOngoing, baseDurationMs);
const dates = event.rrule.between(searchFrom, searchTo, true);
const instances = [];
for (const date of dates) {
const instance = buildRecurringInstance(date, event, isFullDay, baseDurationMs, {excludeExdates, includeOverrides});
if (instance && isInstanceInRange(instance, from, to, expandOngoing)) {
instances.push(instance);
}
}
return instances.sort((a, b) => a.start - b.start);
}
// Export api functions
module.exports = {
// Autodetect
fromURL: async.fromURL,
parseFile: autodetect.parseFile,
parseICS: autodetect.parseICS,
// Sync
sync,
// Async
async,
// Recurring event expansion
expandRecurringEvent,
// Other backwards compat things
objectHandlers: ical.objectHandlers,
handleObject: ical.handleObject,
parseLines: ical.parseLines,
};