-
-
Notifications
You must be signed in to change notification settings - Fork 60
Expand file tree
/
Copy pathical.js
More file actions
1199 lines (1022 loc) · 45.6 KB
/
ical.js
File metadata and controls
1199 lines (1022 loc) · 45.6 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
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* eslint-disable max-depth, max-params, no-warning-comments, complexity */
const {randomUUID} = require('node:crypto');
// Load Temporal polyfill if not natively available
// TODO: Drop the polyfill branch once our minimum Node version ships Temporal
const Temporal = globalThis.Temporal || require('temporal-polyfill').Temporal;
// Ensure Temporal exists before loading rrule-temporal
globalThis.Temporal ??= Temporal;
const {RRuleTemporal} = require('rrule-temporal');
const {toText: toTextFunction} = require('rrule-temporal/totext');
const tzUtil = require('./lib/tz-utils.js');
const {getDateKey} = require('./lib/date-utils.js');
/**
* Clone a Date object and preserve custom metadata (tz, dateOnly).
* @param {Date} source - Source Date object with optional tz and dateOnly properties
* @param {Date|number} newTime - New time value (defaults to source)
* @returns {Date} Cloned Date with preserved metadata
*/
function cloneDateWithMeta(source, newTime = source) {
const cloned = new Date(newTime);
if (source?.tz) {
cloned.tz = source.tz;
}
if (source?.dateOnly) {
cloned.dateOnly = source.dateOnly;
}
return cloned;
}
/**
* Extract string value from DURATION (handles {params, val} shape).
* @param {string|object} duration - Duration value (string or object with val property)
* @returns {string} Duration string
*/
function getDurationString(duration) {
if (typeof duration === 'object' && duration?.val) {
return String(duration.val);
}
return duration ? String(duration) : '';
}
/**
* Store a recurrence override with dual-key strategy.
* Uses both date-only (YYYY-MM-DD) and full ISO keys for DATE-TIME entries.
* Implements RFC 5545 SEQUENCE logic: newer versions (higher SEQUENCE) replace older ones.
* @param {Object} recurrences - Recurrences object to store in
* @param {Date} recurrenceId - RECURRENCE-ID date value
* @param {Object} recurrenceObject - Recurrence override data
*/
function storeRecurrenceOverride(recurrences, recurrenceId, recurrenceObject) {
if (typeof recurrenceId.toISOString !== 'function') {
console.warn(`[node-ical] Invalid recurrenceid (no toISOString): ${recurrenceId}`);
return;
}
const dateKey = getDateKey(recurrenceId);
const isoKey = recurrenceId.dateOnly === true ? null : recurrenceId.toISOString();
// Check for existing override: prefer ISO key if available (more precise), fallback to date key
// This handles both DATE-TIME (precise time) and DATE (date-only) recurrence IDs
const existing = (isoKey && recurrences[isoKey]) || recurrences[dateKey];
// Check SEQUENCE to determine which version to keep (RFC 5545)
// Normalize SEQUENCE to number, default to 0 if invalid/missing
if (existing !== undefined) {
const existingSeq = Number.isFinite(existing.sequence) ? existing.sequence : 0;
const newSeq = Number.isFinite(recurrenceObject.sequence) ? recurrenceObject.sequence : 0;
if (newSeq < existingSeq) {
// Older version - ignore it
const key = isoKey || dateKey;
console.warn(`[node-ical] Ignoring older RECURRENCE-ID override (SEQUENCE ${newSeq} < ${existingSeq}) for ${key}`);
return;
}
// If newSeq >= existingSeq, continue and overwrite (newer or same version)
}
recurrences[dateKey] = recurrenceObject;
// Also store with full ISO key for DATE-TIME entries (enables precise matching)
if (isoKey) {
recurrences[isoKey] = recurrenceObject;
}
}
/**
* Wrapper class to convert RRuleTemporal (Temporal.ZonedDateTime) to Date objects
* This maintains backward compatibility while using rrule-temporal internally
*/
class RRuleCompatWrapper {
constructor(rruleTemporal, dateOnly = false) {
this._rrule = rruleTemporal;
// VALUE=DATE events are anchored to UTC midnight in rrule-temporal.
// Converting via epochMilliseconds shifts the date backwards in timezones
// west of UTC; instead we use the ZonedDateTime calendar components directly.
this._dateOnly = dateOnly;
}
static #temporalToDate(value) {
if (value === undefined || value === null) {
return value;
}
if (Array.isArray(value)) {
return value.map(item => RRuleCompatWrapper.#temporalToDate(item));
}
// Convert known Temporal instances to Date
if (typeof value === 'object' && !(value instanceof Date) && typeof value.epochMilliseconds === 'number') {
return new Date(value.epochMilliseconds);
}
return value;
}
#serializeOptions() {
const raw = this._rrule.options();
const converted = {};
for (const [key, value] of Object.entries(raw)) {
converted[key] = RRuleCompatWrapper.#temporalToDate(value);
}
// Map rrule-temporal `byDay` to legacy `byweekday`
if (converted.byweekday === undefined && raw.byDay !== undefined) {
converted.byweekday = RRuleCompatWrapper.#temporalToDate(raw.byDay);
}
return converted;
}
// Convert a ZonedDateTime to a JS Date.
// For VALUE=DATE events the ZDT calendar components (year/month/day in UTC)
// represent the intended calendar date; create a local-midnight Date so that
// .toDateString() returns the correct day regardless of the host timezone.
// Mark the result with dateOnly=true so that downstream helpers that
// distinguish date-only from timed dates (e.g. createLocalDateFromUTC) also
// use local getters rather than UTC getters.
#zdtToDate(zdt) {
if (this._dateOnly) {
const d = new Date(zdt.year, zdt.month - 1, zdt.day, 0, 0, 0, 0);
d.dateOnly = true;
return d;
}
return new Date(zdt.epochMilliseconds);
}
between(after, before, inclusive = false) {
const results = this._rrule.between(after, before, inclusive);
return results.map(zdt => this.#zdtToDate(zdt));
}
all(iterator) {
// If the caller supplied an iterator, wrap it so it receives a converted Date
// rather than a raw Temporal.ZonedDateTime — keeping the public API consistent
// with between() and matching the declared return type.
const wrappedIterator = iterator
? (zdt, index) => iterator(this.#zdtToDate(zdt), index)
: undefined;
const results = this._rrule.all(wrappedIterator);
return results.map(zdt => this.#zdtToDate(zdt));
}
before(date, inclusive = false) {
const result = this._rrule.previous(date, inclusive);
return result ? this.#zdtToDate(result) : undefined;
}
after(date, inclusive = false) {
const result = this._rrule.next(date, inclusive);
return result ? this.#zdtToDate(result) : undefined;
}
toText(locale) {
return toTextFunction(this._rrule, locale);
}
// Delegate other methods
toString() {
return this._rrule.toString();
}
// Expose options as a property for compatibility with the old rrule.js API
// (the wrapper hides the underlying method-based interface)
get options() {
return this.#serializeOptions();
}
// OrigOptions: the original options as passed to the constructor (before processing).
// In rrule.js, this was used for toString() and clone() operations.
// For rrule-temporal, options() already returns the unprocessed original options,
// so origOptions and options are equivalent.
get origOptions() {
return this.#serializeOptions();
}
}
/** **************
* A tolerant, minimal icalendar parser
* (http://tools.ietf.org/html/rfc5545)
*
* <peterbraden@peterbraden.co.uk>
* ************* */
// Unescape Text re RFC 4.3.11
const text = function (t = '') {
return t
.replaceAll(String.raw`\,`, ',') // Unescape escaped commas
.replaceAll(String.raw`\;`, ';') // Unescape escaped semicolons
.replaceAll(/\\[nN]/gv, '\n') // Replace escaped newlines with actual newlines
.replaceAll('\\\\', '\\') // Unescape backslashes
.replace(/^"(.*)"$/v, '$1'); // Remove surrounding double quotes, if present
};
const parseValue = function (value) {
if (value === 'TRUE') {
return true;
}
if (value === 'FALSE') {
return false;
}
const number = Number(value);
if (!Number.isNaN(number)) {
return number;
}
// Remove quotes if found
value = value.replace(/^"(.*)"$/v, '$1');
return value;
};
const parseParameters = function (p) {
const out = {};
for (const element of p) {
if (element.includes('=')) {
const segs = element.split('=');
out[segs[0]] = parseValue(segs.slice(1).join('='));
}
}
// Sp is not defined in this scope, typo?
// original code from peterbraden
// return out || sp;
return out;
};
const storeValueParameter = function (name) {
return function (value, curr) {
const current = curr[name];
if (Array.isArray(current)) {
current.push(value);
return curr;
}
curr[name] = current === undefined ? value : [current, value];
return curr;
};
};
const storeParameter = function (name) {
return function (value, parameters, curr) {
const data = parameters && parameters.length > 0 && !(parameters.length === 1 && (parameters[0] === 'CHARSET=utf-8' || parameters[0] === 'VALUE=TEXT')) ? {params: parseParameters(parameters), val: text(value)} : text(value);
return storeValueParameter(name)(data, curr);
};
};
const addTZ = function (dt, parameters) {
if (!dt) {
return dt;
}
const p = parseParameters(parameters);
if (parameters && p && p.TZID !== undefined) {
let tzid = p.TZID.toString();
// Remove surrounding quotes if found at the beginning and at the end of the string
// (Occurs when parsing Microsoft Exchange events containing TZID with Windows standard format instead IANA)
tzid = tzid.replace(/^"(.*)"$/v, '$1');
return tzUtil.attachTz(dt, tzid);
}
if (dt.tz) {
return tzUtil.attachTz(dt, dt.tz);
}
return dt;
};
function isDateOnly(value, parameters) {
const dateOnly = ((parameters && parameters.includes('VALUE=DATE') && !parameters.includes('VALUE=DATE-TIME')) || /^\d{8}$/v.test(value) === true);
return dateOnly;
}
const typeParameter = function (name) {
// Typename is not used in this function?
return function (value, parameters, curr) {
const returnValue = isDateOnly(value, parameters) ? 'date' : 'date-time';
return storeValueParameter(name)(returnValue, curr);
};
};
// Find a VTIMEZONE block in the parser stack. When tzid is given, only
// the block whose (quote-stripped) tzid matches is returned; without tzid
// the first VTIMEZONE found is returned (floating-DTSTART branch).
function findVtimezoneInStack(stack, tzid) {
for (const item of (stack || [])) {
for (const v of Object.values(item)) {
if (!v || v.type !== 'VTIMEZONE') {
continue;
}
if (!tzid) {
return v;
}
const ids = Array.isArray(v.tzid) ? v.tzid : [v.tzid];
if (ids.some(id => String(id).replace(/^"(.*)"$/v, '$1') === tzid)) {
return v;
}
}
}
}
const dateParameter = function (name) {
return function (value, parameters, curr, stack) {
// The regex from main gets confused by extra :
const pi = parameters.indexOf('TZID=tzone');
if (pi !== -1) {
// Correct the parameters with the part on the value
parameters[pi] = parameters[pi] + ':' + value.split(':')[0];
// Get the date from the field, other code uses the value parameter
value = value.split(':')[1];
}
let newDate = text(value);
// Process 'VALUE=DATE' and EXDATE
if (isDateOnly(value, parameters)) {
// Just Date
const comps = /^(\d{4})(\d{2})(\d{2}).*$/v.exec(value);
if (comps !== null) {
// No TZ info - assume same timezone as this computer
newDate = new Date(comps[1], Number.parseInt(comps[2], 10) - 1, comps[3]);
newDate.dateOnly = true;
// Store as string - worst case scenario
return storeValueParameter(name)(newDate, curr);
}
}
// Typical RFC date-time format
const comps = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/v.exec(value);
if (comps !== null) {
const year = Number.parseInt(comps[1], 10);
const monthIndex = Number.parseInt(comps[2], 10) - 1;
const day = Number.parseInt(comps[3], 10);
const hour = Number.parseInt(comps[4], 10);
const minute = Number.parseInt(comps[5], 10);
const second = Number.parseInt(comps[6], 10);
if (comps[7] === 'Z') {
// GMT
newDate = new Date(Date.UTC(year, monthIndex, day, hour, minute, second));
tzUtil.attachTz(newDate, 'Etc/UTC');
} else if (curr.type === 'STANDARD' || curr.type === 'DAYLIGHT') {
// Inside a VTIMEZONE observance block the DTSTART is a plain local
// wall-clock time that defines when the rule takes effect — it must
// NOT trigger timezone resolution (which would look up the *enclosing*
// VTIMEZONE and could crash on exotic years like 0001).
newDate = new Date(year, monthIndex, day, hour, minute, second);
newDate.setFullYear(year);
} else {
const fallbackWithStackTimezone = () => {
const vTimezone = findVtimezoneInStack(stack);
// If the VTIMEZONE contains multiple TZIDs (against RFC), use last one
const normalizedTzId = vTimezone
? (Array.isArray(vTimezone.tzid) ? vTimezone.tzid.at(-1) : vTimezone.tzid)
: null;
if (!normalizedTzId) {
return new Date(year, monthIndex, day, hour, minute, second);
}
let resolvedTzId = String(normalizedTzId).replace(/^"(.*)"$/v, '$1');
// When a VTIMEZONE block is present, prefer its STANDARD/DAYLIGHT offset data over
// a pure string-based TZID lookup. This handles both well-known IANA names (where
// the embedded rules may be more historically precise) and completely custom TZIDs
// (e.g. Microsoft's "Customized Time Zone", "tzone://Microsoft/Custom") that
// resolveTZID cannot look up at all.
// Only replace resolvedTzId when resolution actually succeeds; otherwise keep the
// original value so resolveTZID can make a best effort — never substitute the host
// zone via guessLocalZone().
if (vTimezone) {
const resolved = tzUtil.resolveVTimezoneToIana(vTimezone, year);
if (resolved.iana || resolved.offset) {
resolvedTzId = resolved.iana || resolved.offset;
}
}
const tzInfo = tzUtil.resolveTZID(resolvedTzId);
const offsetString = typeof tzInfo.offset === 'string' ? tzInfo.offset : undefined;
if (offsetString) {
return tzUtil.parseWithOffset(value, offsetString);
}
if (tzInfo.iana) {
return tzUtil.parseDateTimeInZone(value, tzInfo.iana);
}
return new Date(year, monthIndex, day, hour, minute, second);
};
if (parameters) {
const parameterMap = parseParameters(parameters);
let tz = parameterMap.TZID;
const findTZIDIndex = () => {
if (!Array.isArray(parameters)) {
return -1;
}
return parameters.findIndex(parameter => typeof parameter === 'string' && parameter.toUpperCase().startsWith('TZID='));
};
let tzParameterIndex = findTZIDIndex();
const setTZIDParameter = newTZID => {
if (!Array.isArray(parameters)) {
return;
}
const normalized = 'TZID=' + newTZID;
if (tzParameterIndex >= 0) {
parameters[tzParameterIndex] = normalized;
} else {
parameters.push(normalized);
tzParameterIndex = parameters.length - 1;
}
};
if (tz) {
tz = tz.toString().replace(/^"(.*)"$/v, '$1');
if (tz === 'tzone://Microsoft/Custom' || tz === '(no TZ description)' || tz.startsWith('Customized Time Zone') || tz.startsWith('tzone://Microsoft/')) {
// Outlook and Exchange often emit custom TZID values (e.g. "Customized Time Zone")
// together with a VTIMEZONE section that contains the real STANDARD/DAYLIGHT rules.
// Try to match those rules to a known IANA zone so that recurring events that span
// DST boundaries are handled correctly. Falls back to guessLocalZone() when no
// VTIMEZONE is present or its offsets cannot be resolved.
const originalTz = tz;
const stackVTimezone = findVtimezoneInStack(stack, originalTz);
if (stackVTimezone) {
const resolved = tzUtil.resolveVTimezoneToIana(stackVTimezone, year);
// Only override when resolution succeeds; keep the original tz otherwise
// so resolveTZID can make a best effort — never substitute guessLocalZone()
if (resolved.iana || resolved.offset) {
tz = resolved.iana || resolved.offset;
}
} else {
tz = tzUtil.guessLocalZone();
}
}
const tzInfo = tzUtil.resolveTZID(tz);
const resolvedTZID = tzInfo.iana || tzInfo.original || tz;
setTZIDParameter(resolvedTZID);
// Prefer an explicit numeric offset because it keeps DTSTART wall-time semantics accurate across DST transitions.
const offsetString = typeof tzInfo.offset === 'string' ? tzInfo.offset : undefined;
if (offsetString) {
newDate = tzUtil.parseWithOffset(value, offsetString);
} else if (tzInfo.iana) {
newDate = tzUtil.parseDateTimeInZone(value, tzInfo.iana);
} else {
newDate = new Date(year, monthIndex, day, hour, minute, second);
}
// Make sure to correct the parameters if the TZID= is changed
newDate = addTZ(newDate, parameters);
} else {
newDate = fallbackWithStackTimezone();
}
} else {
newDate = fallbackWithStackTimezone();
}
}
}
// Store as string - worst case scenario
return storeValueParameter(name)(newDate, curr);
};
};
const geoParameter = function (name) {
return function (value, parameters, curr) {
storeParameter(value, parameters, curr);
const parts = value.split(';');
curr[name] = {lat: Number(parts[0]), lon: Number(parts[1])};
return curr;
};
};
const categoriesParameter = function (name) {
return function (value, parameters, curr) {
storeParameter(value, parameters, curr);
if (curr[name] === undefined) {
curr[name] = value ? value.split(',').map(s => s.trim()) : [];
} else if (value) {
curr[name] = curr[name].concat(value.split(',').map(s => s.trim()));
}
return curr;
};
};
// EXDATE is an entry that represents exceptions to a recurrence rule (ex: "repeat every day except on 7/4").
// The EXDATE entry itself can also contain a comma-separated list, so we parse each date separately.
// Multiple EXDATE entries can exist in a calendar record.
//
// Storage strategy (RFC 5545 compliant):
// We create an object with the exception dates as keys and Date objects as values.
// - For VALUE=DATE (date-only): key is "YYYY-MM-DD"
// - For DATE-TIME: BOTH "YYYY-MM-DD" AND full ISO string keys are created
//
// This dual-key approach provides:
// 1. Backward compatibility: date-only lookups continue to work
// 2. Precision matching: events recurring multiple times per day can exclude specific instances
// 3. RFC 5545 compliance: supports both DATE and DATE-TIME exclusions
//
// Usage examples:
// if (event.exdate?.['2024-01-15']) { ... } // Check if any instance on this day is excluded
// if (event.exdate?.['2024-01-15T14:00:00.000Z']) { ... } // Check specific time instance
//
// NOTE: We intentionally use date-based keys as the primary lookup because:
// 1. Floating times (without timezone) would create inconsistent ISO strings
// 2. DST transitions can affect exact time matching
// 3. Real-world calendar data often has mismatched times between RRULE and EXDATE
const exdateParameter = function (name) {
return function (value, parameters, curr) {
curr[name] ||= {};
const dates = value ? value.split(',').map(s => s.trim()) : [];
for (const entry of dates) {
// Temporary container for dateParameter() to write to
const temporaryContainer = {};
dateParameter(name)(entry, parameters, temporaryContainer);
const dateValue = temporaryContainer[name];
if (!dateValue) {
continue;
}
if (typeof dateValue.toISOString !== 'function') {
console.warn(`[node-ical] Invalid exdate value (no toISOString): ${dateValue}`);
continue;
}
const isoString = dateValue.toISOString();
// For date-only events, use local date components to avoid UTC timezone shift
// (e.g., 2024-07-15 midnight in UTC+2 would be 2024-07-14T22:00Z, giving wrong dateKey)
const dateKey = getDateKey(dateValue);
// Always store with date-only key for backward compatibility and simple lookups
curr[name][dateKey] = dateValue;
// For DATE-TIME entries, also store with full ISO string for precise matching
// This enables excluding specific instances when events recur multiple times per day
// Note: dateOnly is already set by dateParameter() which checks the raw value and parameters
if (!dateValue.dateOnly) {
curr[name][isoString] = dateValue;
}
}
return curr;
};
};
// RECURRENCE-ID is the ID of a specific recurrence within a recurrence rule.
// TODO: It's also possible for it to have a range, like "THISANDPRIOR", "THISANDFUTURE". This isn't currently handled.
const recurrenceParameter = function (name) {
return dateParameter(name);
};
const addFBType = function (fb, parameters) {
const p = parseParameters(parameters);
if (parameters && p) {
fb.type = p.FBTYPE || 'BUSY';
}
return fb;
};
const freebusyParameter = function (name) {
return function (value, parameters, curr) {
const fb = addFBType({}, parameters);
curr[name] ||= [];
curr[name].push(fb);
storeParameter(value, parameters, fb);
const parts = value.split('/');
for (const [index, name] of ['start', 'end'].entries()) {
dateParameter(name)(parts[index], parameters, fb);
}
return curr;
};
};
// Default batch size for async parsing to prevent event loop blocking
const PARSE_BATCH_SIZE = 2000;
module.exports = {
objectHandlers: {
BEGIN(component, parameters, curr, stack) {
stack.push(curr);
return {type: component};
},
END(value, parameters, curr, stack) {
// Original end function
const originalEnd = function (component, parameters_, curr, stack) {
// Prevents the need to search the root of the tree for the VCALENDAR object
if (component === 'VCALENDAR') {
// Preserve VCALENDAR string properties in a separate 'vcalendar' object
// for easy access to calendar metadata
// (X-WR-CALNAME, X-WR-CALDESC, X-WR-TIMEZONE, METHOD, etc.)
let key;
let object;
const vcalendarProps = {};
for (key in curr) {
if (!Object.hasOwn(curr, key)) {
continue;
}
object = curr[key];
if (typeof object === 'string') {
vcalendarProps[key] = object;
delete curr[key];
}
}
// Store VCALENDAR properties in a dedicated object for easy access
if (Object.keys(vcalendarProps).length > 0) {
curr.vcalendar = vcalendarProps;
}
return curr;
}
const par = stack.pop();
if (!curr.end) { // RFC5545, 3.6.1
// Calculate end date based on DURATION or default rules
if (curr.duration === undefined) {
// No DURATION: default end is same time (date-time) or +1 day (date-only)
curr.end = curr.datetype === 'date-time'
? cloneDateWithMeta(curr.start)
: cloneDateWithMeta(curr.start, tzUtil.utcAdd(curr.start, 1, 'days'));
} else {
const durationString = getDurationString(curr.duration);
const durationParts = durationString.match(/-?\d{1,10}[WDHMS]/gv);
if (durationParts && durationParts.length > 0) {
// Valid DURATION: apply each component (W/D/H/M/S)
const units = {
W: 'weeks',
D: 'days',
H: 'hours',
M: 'minutes',
S: 'seconds',
};
const sign = durationString.startsWith('-') ? -1 : 1;
let endTime = curr.start;
for (const part of durationParts) {
const value = Number.parseInt(part, 10) * sign;
const unit = units[part.slice(-1)];
endTime = tzUtil.utcAdd(endTime, value, unit);
}
curr.end = cloneDateWithMeta(curr.start, endTime);
} else {
// Malformed DURATION (e.g., "P", "PT", "") → treat as zero duration
// Follows Postel's Law: be liberal in what you accept
console.warn(`[node-ical] Ignoring malformed DURATION value: "${durationString}" – treating as zero duration`);
curr.end = cloneDateWithMeta(curr.start);
}
}
}
if (curr.uid) {
// If this is the first time we run into this UID, just save it.
if (par[curr.uid] === undefined) {
par[curr.uid] = curr;
if (par.method) { // RFC5545, 3.2
par[curr.uid].method = par.method;
}
} else if (curr.recurrenceid === undefined) {
// If we have multiple ical entries with the same UID, it's either going to be a
// modification to a recurrence (RECURRENCE-ID), and/or a significant modification
// to the entry (SEQUENCE).
// Special case: If existing entry is a RECURRENCE-ID override but current entry is the base series (has RRULE),
// we should always accept the base series regardless of SEQUENCE, as they serve different purposes.
// The RECURRENCE-ID will be stored separately in the recurrences array later.
const existingIsRecurrence = par[curr.uid].recurrenceid !== undefined;
// Note: This only detects RRULE-based series. RDATE-based recurring series
// (without RRULE) will fall through to SEQUENCE comparison.
const currentIsBaseSeries = curr.rrule !== undefined;
if (existingIsRecurrence && currentIsBaseSeries) {
// Existing is a recurrence override, current is the base series - always accept the base series
// Note: The stale recurrenceid on par[curr.uid] will be cleaned up by the
// existing recurrenceid-cleanup block below (after the recurrence-id handling section).
for (const key in curr) {
if (key !== null) {
par[curr.uid][key] = curr[key];
}
}
} else {
// Both are base series entries (no RECURRENCE-ID) - apply SEQUENCE logic
// Check SEQUENCE to determine which version to keep (RFC 5545)
// Normalize SEQUENCE to number, default to 0 if invalid/missing
const existingSeq = Number.isFinite(par[curr.uid].sequence) ? par[curr.uid].sequence : 0;
const newSeq = Number.isFinite(curr.sequence) ? curr.sequence : 0;
if (newSeq < existingSeq) {
// Older version - ignore it entirely
console.warn(`[node-ical] Ignoring older event version (SEQUENCE ${newSeq} < ${existingSeq}) for UID ${curr.uid}`);
} else {
// Newer or same version - merge fields from the new record into the existing one
for (const key in curr) {
if (key !== null) {
par[curr.uid][key] = curr[key];
}
}
}
}
}
// If we have recurrence-id entries, list them as an array of recurrences keyed off of recurrence-id.
// To use - as you're running through the dates of an rrule, you can try looking it up in the recurrences
// array. If it exists, then use the data from the calendar object in the recurrence instead of the parent
// for that day.
// NOTE: Sometimes the RECURRENCE-ID record will show up *before* the record with the RRULE entry. In that
// case, what happens is that the RECURRENCE-ID record ends up becoming both the parent record and an entry
// in the recurrences array, and then when we process the RRULE entry later it overwrites the appropriate
// fields in the parent record.
if (curr.recurrenceid !== undefined) {
// Create a copy of the current object to save in our recurrences array. (We *could* just do par = curr,
// except for the case that we get the RECURRENCE-ID record before the RRULE record. In that case, we
// would end up with a shared reference that would cause us to overwrite *both* records at the point
// that we try and fix up the parent record.)
const recurrenceObject = {};
let key;
for (key in curr) {
if (key !== null) {
recurrenceObject[key] = curr[key];
}
}
if (recurrenceObject.recurrences !== undefined) {
delete recurrenceObject.recurrences;
}
// If we don't have an array to store recurrences in yet, create it.
if (par[curr.uid].recurrences === undefined) {
par[curr.uid].recurrences = {};
}
// Store the recurrence override with dual-key strategy (same as EXDATE)
storeRecurrenceOverride(par[curr.uid].recurrences, curr.recurrenceid, recurrenceObject);
}
// One more specific fix - in the case that an RRULE entry shows up after a RECURRENCE-ID entry,
// let's make sure to clear the recurrenceid off the parent field.
if (curr.uid !== '__proto__'
&& par[curr.uid].rrule !== undefined
&& par[curr.uid].recurrenceid !== undefined) {
delete par[curr.uid].recurrenceid;
}
} else if (component === 'VALARM' && (par.type === 'VEVENT' || par.type === 'VTODO')) {
par.alarms ??= [];
par.alarms.push(curr);
} else {
const id = randomUUID();
par[id] = curr;
if (par.method) { // RFC5545, 3.2
par[id].method = par.method;
}
}
return par;
};
// Recurrence rules are only valid for VEVENT, VTODO, and VJOURNAL.
// More specifically, we need to filter the VCALENDAR type because we might end up with a defined rrule
// due to the subtypes.
if ((value === 'VEVENT' || value === 'VTODO' || value === 'VJOURNAL') && curr.rrule) {
let rule = curr.rrule.replace('RRULE:', '');
// Make sure the rrule starts with FREQ=
rule = rule.slice(rule.lastIndexOf('FREQ='));
// If no rule start date
if (rule.includes('DTSTART') === false) {
// This a whole day event
if (curr.datetype === 'date') {
const originalStart = curr.start;
// Date-only: pass the wall-clock date from the local components directly,
// no system-timezone offset compensation needed.
const y = originalStart.getFullYear();
const m = originalStart.getMonth();
const d = originalStart.getDate();
// Rebuild as local midnight so downstream RRULE string formatting is unaffected
curr.start = new Date(y, m, d, 0, 0, 0, 0);
// Preserve any metadata that was attached to the original Date instance.
if (originalStart && originalStart.tz) {
tzUtil.attachTz(curr.start, originalStart?.tz);
}
if (originalStart && originalStart.dateOnly === true) {
curr.start.dateOnly = true;
}
}
// If the date has an toISOString function
if (curr.start && typeof curr.start.toISOString === 'function') {
try {
// If the original date has a TZID, add it
// BUT: UTC (Etc/UTC, UTC, Etc/GMT) should use ISO format with Z, not TZID
const isUtc = tzUtil.isUtcTimezone(curr.start.tz);
// For date-only events (VALUE=DATE), we need to preserve that information
// so rrule-temporal can properly validate UNTIL values.
// Use local date components since dateOnly dates are created with local timezone
// (see dateParameter where new Date(year, month, day) is used without UTC)
if (curr.start.dateOnly) {
// Format: YYYYMMDD using local date components
const year = curr.start.getFullYear();
const month = String(curr.start.getMonth() + 1).padStart(2, '0');
const day = String(curr.start.getDate()).padStart(2, '0');
rule += `;DTSTART;VALUE=DATE:${year}${month}${day}`;
} else if (curr.start.tz && !isUtc) {
const tzInfo = tzUtil.resolveTZID(curr.start.tz);
const localStamp = tzUtil.formatDateForRrule(curr.start, tzInfo);
const tzidLabel = tzInfo.iana || tzInfo.etc || tzInfo.original;
if (localStamp && tzidLabel) {
// RFC5545 requires DTSTART to be expressed in local time when a TZID is present.
rule += `;DTSTART;TZID=${tzidLabel}:${localStamp}`;
} else if (localStamp) {
// Fall back to a floating DTSTART (still without a trailing Z) if we lack a dependable TZ label.
rule += `;DTSTART=${localStamp}`;
} else {
// Ultimate fallback: emit a UTC value (legacy behaviour) rather than crashing.
rule += `;DTSTART=${curr.start.toISOString().replaceAll('-', '').replaceAll(':', '')}`;
}
} else {
rule += `;DTSTART=${curr.start.toISOString().replaceAll('-', '').replaceAll(':', '')}`;
}
rule = rule.replace(/\.\d{3}/v, '');
} catch (error) { // This should not happen, issue #56
throw new Error('ERROR when trying to convert to ISOString ' + error, {cause: error});
}
} else {
throw new Error('No toISOString function in curr.start ' + curr.start);
}
}
// Create RRuleTemporal with separate DTSTART and RRULE parameters
if (curr.start) {
// Extract RRULE segments while preserving everything except inline DTSTART
// When rule contains DTSTART;TZID=..., splitting on ';' produces orphaned
// TZID= and VALUE= segments that must also be filtered out
let rruleOnly = rule.split(';')
.filter(segment =>
!segment.startsWith('DTSTART')
&& !segment.startsWith('VALUE=')
&& !segment.startsWith('TZID='))
.join(';');
// Normalize UNTIL for rrule-temporal 1.4.2+ compatibility:
// - DATE-only DTSTART: UNTIL must also be DATE-only (strip time)
// - DATE-TIME DTSTART: UNTIL must be UTC with Z suffix
if (rruleOnly.includes('UNTIL=')) {
const untilMatch = rruleOnly.match(/UNTIL=(\d{8})(T\d{6})?(Z)?/v);
if (untilMatch) {
const [, datePart, timePart, zSuffix] = untilMatch;
if (curr.start.dateOnly) {
// DATE-only: strip time from UNTIL
if (timePart) {
rruleOnly = rruleOnly.replace(/UNTIL=\d{8}T\d{6}Z?/v, `UNTIL=${datePart}`);
}
} else if (timePart && !zSuffix) {
// DATE-TIME without Z: convert to UTC if we have a timezone, otherwise just append Z
let converted = false;
if (curr.start.tz) {
try {
const tzInfo = tzUtil.resolveTZID(curr.start.tz);
const untilLocal = datePart + timePart;
let untilDateObject;
if (tzInfo.iana && tzUtil.isValidIana(tzInfo.iana)) {
untilDateObject = tzUtil.parseDateTimeInZone(untilLocal, tzInfo.iana);
} else if (Number.isFinite(tzInfo.offsetMinutes)) {
untilDateObject = tzUtil.parseWithOffset(untilLocal, tzInfo.offset);
}
if (untilDateObject) {
const untilUtc = untilDateObject.toISOString().replaceAll('-', '').replaceAll(':', '').replace(/\.\d{3}/v, '');
rruleOnly = rruleOnly.replace(/UNTIL=\d{8}T\d{6}/v, `UNTIL=${untilUtc}`);
converted = true;
}
} catch {
// Fall through to append Z
}
}
if (!converted) {
rruleOnly = rruleOnly.replace(/UNTIL=(\d{8}T\d{6})(?!Z)/v, 'UNTIL=$1Z');
}
}
}
}
// For DATE-only events, we need to include DTSTART;VALUE=DATE in the rruleString
// because rrule-temporal needs to know it's a DATE (not DATE-TIME) to validate UNTIL
if (curr.start.dateOnly) {
// Build DTSTART;VALUE=DATE:YYYYMMDD from curr.start
// Use local getters (not UTC) to match dateParameter which creates Date with local components
const year = curr.start.getFullYear();
const month = String(curr.start.getMonth() + 1).padStart(2, '0');
const day = String(curr.start.getDate()).padStart(2, '0');
const dtstartString = `DTSTART;VALUE=DATE:${year}${month}${day}`;
// Prepend DTSTART to rruleString
const fullRruleString = `${dtstartString}\nRRULE:${rruleOnly}`;
const rruleTemporal = new RRuleTemporal({
rruleString: fullRruleString,
});
curr.rrule = new RRuleCompatWrapper(rruleTemporal, true /* dateOnly */);
} else {
// DATE-TIME events: convert curr.start (Date) to Temporal.ZonedDateTime
const tzInfo = curr.start.tz ? tzUtil.resolveTZID(curr.start.tz) : undefined;
let timeZone = 'UTC';
if (tzInfo?.iana || tzInfo?.offset) {
timeZone = tzInfo.iana || tzInfo.offset;
} else if (tzInfo) {
console.warn('[node-ical] TZID resolved to neither IANA nor UTC offset; falling back to UTC for DTSTART conversion.');
}
let dtstartTemporal;
try {
dtstartTemporal = Temporal.Instant.fromEpochMilliseconds(curr.start.getTime())
.toZonedDateTimeISO(timeZone);
} catch (error) {
console.warn(`[node-ical] Failed to convert timezone "${timeZone}", falling back to UTC: ${error?.message ?? String(error)}`);
dtstartTemporal = Temporal.Instant.fromEpochMilliseconds(curr.start.getTime())
.toZonedDateTimeISO('UTC');
}
const rruleTemporal = new RRuleTemporal({
rruleString: rruleOnly,
dtstart: dtstartTemporal,
});
curr.rrule = new RRuleCompatWrapper(rruleTemporal, false /* dateOnly */);
}