Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 96 additions & 20 deletions ical.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
/* eslint-disable max-depth, max-params, no-warning-comments, complexity, import-x/order */
/* 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('./tz-utils.js');
const tzUtil = require('./lib/tz-utils.js');
const {getDateKey} = require('./lib/date-utils.js');

/**
Expand Down Expand Up @@ -95,8 +93,12 @@ function storeRecurrenceOverride(recurrences, recurrenceId, recurrenceObject) {
* This maintains backward compatibility while using rrule-temporal internally
*/
class RRuleCompatWrapper {
constructor(rruleTemporal) {
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) {
Expand Down Expand Up @@ -132,25 +134,47 @@ class RRuleCompatWrapper {
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);
// Convert Temporal.ZonedDateTime → Date
return results.map(zdt => new Date(zdt.epochMilliseconds));
return results.map(zdt => this.#zdtToDate(zdt));
}

all(iterator) {
const results = this._rrule.all(iterator);
return results.map(zdt => new Date(zdt.epochMilliseconds));
// 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.before(date, inclusive);
return result ? new Date(result.epochMilliseconds) : null;
return result ? this.#zdtToDate(result) : undefined;
}

after(date, inclusive = false) {
const result = this._rrule.after(date, inclusive);
return result ? new Date(result.epochMilliseconds) : null;
return result ? this.#zdtToDate(result) : undefined;
}

toText(locale) {
Expand Down Expand Up @@ -287,6 +311,28 @@ const typeParameter = function (name) {
};
};

// 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(/^"(.*)"$/, '$1') === tzid)) {
return v;
}
}
}
}

const dateParameter = function (name) {
return function (value, parameters, curr, stack) {
// The regex from main gets confused by extra :
Expand Down Expand Up @@ -332,11 +378,7 @@ const dateParameter = function (name) {
tzUtil.attachTz(newDate, 'Etc/UTC');
} else {
const fallbackWithStackTimezone = () => {
// Get the time zone from the stack
const stackItemWithTimeZone
= (stack || []).find(item => Object.values(item).find(subItem => subItem.type === 'VTIMEZONE')) || {};
const vTimezone
= Object.values(stackItemWithTimeZone).find(({type}) => type === 'VTIMEZONE');
const vTimezone = findVtimezoneInStack(stack);

// If the VTIMEZONE contains multiple TZIDs (against RFC), use last one
const normalizedTzId = vTimezone
Expand All @@ -347,7 +389,24 @@ const dateParameter = function (name) {
return new Date(year, monthIndex, day, hour, minute, second);
}

const tzInfo = tzUtil.resolveTZID(normalizedTzId);
let resolvedTzId = String(normalizedTzId).replace(/^"(.*)"$/, '$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);
Expand Down Expand Up @@ -391,7 +450,24 @@ const dateParameter = function (name) {
tz = tz.toString().replace(/^"(.*)"$/, '$1');

if (tz === 'tzone://Microsoft/Custom' || tz === '(no TZ description)' || tz.startsWith('Customized Time Zone') || tz.startsWith('tzone://Microsoft/')) {
tz = tzUtil.guessLocalZone();
// 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);
Expand Down Expand Up @@ -885,7 +961,7 @@ module.exports = {
rruleString: fullRruleString,
});

curr.rrule = new RRuleCompatWrapper(rruleTemporal);
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;
Expand All @@ -911,7 +987,7 @@ module.exports = {
dtstart: dtstartTemporal,
});

curr.rrule = new RRuleCompatWrapper(rruleTemporal);
curr.rrule = new RRuleCompatWrapper(rruleTemporal, false /* dateOnly */);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion lib/date-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// Load Temporal polyfill if not natively available
const Temporal = globalThis.Temporal || require('temporal-polyfill').Temporal;

const tzUtil = require('../tz-utils.js');
const tzUtil = require('./tz-utils.js');

/**
* Construct a date-only key (YYYY-MM-DD) from a Date object.
Expand Down
143 changes: 142 additions & 1 deletion tz-utils.js → lib/tz-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

// Load Temporal polyfill if not natively available (mirrors ical.js)
const Temporal = globalThis.Temporal || require('temporal-polyfill').Temporal;
const windowsZones = require('./windowsZones.json');
const windowsZones = require('../windowsZones.json');

// Ensure polyfill is globally available for downstream modules
globalThis.Temporal ??= Temporal;
Expand Down Expand Up @@ -473,6 +473,146 @@ function linkAlias(arg1, arg2) {
aliasMap.set(String(arg1), String(arg2));
}

// Memoize VTIMEZONE→IANA lookups keyed by "stdOffset|dstOffset|year"
const vtimezoneIanaCache = new Map();

/**
* Pick the STANDARD or DAYLIGHT sub-component that applies to a given reference year.
* A VTIMEZONE may carry multiple historic observance blocks (e.g. the US rule changed in 2007).
* We want the block whose DTSTART year is the largest one that is ≤ refYear — i.e. the most
* recent rule that has already come into effect.
*
* @param {Array} blocks - Array of STANDARD or DAYLIGHT components (all same type)
* @param {number} refYear - The event year to look up the rule for
* @returns {Object|undefined}
*/
function pickApplicableBlock(blocks, refYear) {
if (blocks.length === 0) {
return undefined;
}

if (blocks.length === 1) {
return blocks[0];
}

// Sort descending by the DTSTART year of each observance block.
// "start" is the parsed Date for the DTSTART field inside STANDARD/DAYLIGHT.
const getYear = block => (block.start instanceof Date ? block.start.getFullYear() : 0);
const sorted = [...blocks].sort((a, b) => getYear(b) - getYear(a));

// Take the first block whose DTSTART year is ≤ refYear (most recent applicable rule).
// Fall back to the oldest block if all blocks start after refYear (future-only rules).
return sorted.find(b => getYear(b) <= refYear) ?? sorted.at(-1);
}

/**
* Attempt to match a parsed VTIMEZONE (with STANDARD/DAYLIGHT sub-components) to a
* known IANA timezone by comparing UTC offsets at two probe dates (January and July).
*
* This resolves Outlook's "Customized Time Zone" and similar Microsoft-generated
* identifiers to a real IANA zone so that recurring events that span DST boundaries
* are handled correctly by rrule-temporal.
*
* Return value shape — three possible cases:
*
* 1. IANA zone found (DST zone): { iana: string, offset: string }
* Both fields are set; `offset` holds the STANDARD (winter) offset as a
* convenience for callers that prefer a fixed-offset representation.
*
* 2. Non-DST VTIMEZONE (no DAYLIGHT block): { iana: string|undefined, offset: string }
* `offset` is always the raw fixed UTC offset (e.g. "-05:00"). `iana` is
* set to an Etc/GMT-style zone when one maps exactly, otherwise undefined.
*
* 3. DST zone but no IANA match: { iana: undefined, offset: undefined }
* No reliable representation is available; callers should fall back to
* floating/local time rather than returning a confidently wrong offset.
*
* @param {Object} vTimezone - Parsed VTIMEZONE object (from the node-ical parser stack)
* @param {number} year - Reference year used to select the applicable observance block
* and to probe the IANA database (DST boundaries can change historically).
* @returns {{ iana: string|undefined, offset: string|undefined }}
*/
function resolveVTimezoneToIana(vTimezone, year) {
if (!vTimezone || typeof vTimezone !== 'object') {
return {iana: undefined, offset: undefined};
}

// Reject unusable year values before they can corrupt the cache key or cause
// Temporal.Instant.from() to throw on a malformed ISO string.
const yearNumber = Number(year);
if (!Number.isFinite(yearNumber) || !Number.isInteger(yearNumber)) {
return {iana: undefined, offset: undefined};
}

// Collect STANDARD and DAYLIGHT sub-components
const components = Object.values(vTimezone).filter(v => v && typeof v === 'object' && typeof v.type === 'string' && (v.type === 'STANDARD' || v.type === 'DAYLIGHT'));

if (components.length === 0) {
return {iana: undefined, offset: undefined};
}

// When multiple observance blocks exist (e.g. US pre-/post-2007 DST rule change),
// pick the one whose DTSTART year is the newest that is still ≤ the event year.
const standard = pickApplicableBlock(components.filter(c => c.type === 'STANDARD'), yearNumber);
const daylight = pickApplicableBlock(components.filter(c => c.type === 'DAYLIGHT'), yearNumber);

const stdMins = standard ? offsetLabelToMinutes(standard.tzoffsetto) : undefined;
const dstMins = daylight ? offsetLabelToMinutes(daylight.tzoffsetto) : undefined;

// Need at least a STANDARD offset to do anything useful
if (!Number.isFinite(stdMins)) {
return {iana: undefined, offset: undefined};
}

const stdOffset = minutesToOffset(stdMins);

// No DST component → fixed-offset zone; try Etc/GMT mapping or return raw offset
if (!Number.isFinite(dstMins)) {
const etc = minutesToEtcZone(stdMins);
return {iana: etc || undefined, offset: stdOffset};
}

// Cache key: unique per offset pair and year (DST boundaries can change historically)
const cacheKey = `${stdMins}|${dstMins}|${yearNumber}`;
if (vtimezoneIanaCache.has(cacheKey)) {
return vtimezoneIanaCache.get(cacheKey);
}

// Probe two dates: mid-January (winter in NH / summer in SH) and mid-July (inverse)
const probeJan = Temporal.Instant.from(`${yearNumber}-01-15T12:00:00Z`);
const probeJul = Temporal.Instant.from(`${yearNumber}-07-15T12:00:00Z`);

for (const zone of getZoneNames()) {
try {
const janOffset = probeJan.toZonedDateTimeISO(zone).offsetNanoseconds / 60_000_000_000;
const julOffset = probeJul.toZonedDateTimeISO(zone).offsetNanoseconds / 60_000_000_000;

// Match: both probe offsets must equal one of {stdMins, dstMins} (in either order,
// to handle both northern and southern hemisphere DST conventions)
const offsets = new Set([stdMins, dstMins]);
if (offsets.has(janOffset) && offsets.has(julOffset) && janOffset !== julOffset) {
const result = {iana: zone, offset: stdOffset};
vtimezoneIanaCache.set(cacheKey, result);
return result;
}
} catch {
// Skip zones that Temporal/Intl cannot resolve
}
}

// No IANA zone matched both probe offsets.
// Returning stdOffset here would be silently wrong for ~50 % of timestamps
// (those that fall in the DST period). Return undefined instead so callers
// fall back to floating/local time rather than applying a confident wrong offset.
// This path should be unreachable in practice because every real DST zone is
// present in the Temporal/Intl database; the warning is here to surface any
// exception quickly.
console.warn(`[node-ical] resolveVTimezoneToIana: no IANA zone matched STD=${stdMins} DST=${dstMins} for year ${yearNumber}; falling back to floating time`);
const fallback = {iana: undefined, offset: undefined};
vtimezoneIanaCache.set(cacheKey, fallback);
return fallback;
}

// Public API
module.exports = {
guessLocalZone,
Expand All @@ -484,6 +624,7 @@ module.exports = {
utcAdd,
linkAlias,
resolveTZID,
resolveVTimezoneToIana,
formatDateForRrule,
attachTz,
isUtcTimezone,
Expand Down
Loading