diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 3a3075a1..ab95b72b 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -52,11 +52,14 @@ jobs: run: npm ci - name: Generate windowsZones (strict) - run: node build/update-windows-zones.mjs + run: node build/update-windows-zones.js env: CI: true + - name: Build CommonJS entry + run: npm run build:cjs + - name: Unit Tests - run: npx mocha --timeout 8000 + run: npx mocha --extension js --timeout 8000 env: CI: true diff --git a/.gitignore b/.gitignore index c727bb73..f0e9a5d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules .idea .DS_Store + +# Generated CommonJS entry (built from node-ical.js via `npm run build:cjs`) +node-ical.cjs diff --git a/README.md b/README.md index 70d84576..e59e44f7 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,19 @@ node-ical is available on npm: npm install node-ical ``` +## Module formats +node-ical provides an ESM-first entry point while keeping CommonJS compatibility. + +```javascript +// ESM +import ical from 'node-ical'; +``` + +```javascript +// CommonJS +const ical = require('node-ical'); +``` + ## API The API has now been broken into three sections: - [sync](#sync) @@ -29,8 +42,8 @@ All functions will either return a promise for `async/await` or use a callback i `autodetect` provides a mix of both for backwards compatibility with older node-ical applications. -All API functions are documented using JSDoc in the [node-ical.js](node-ical.js) file. -This allows for IDE hinting! +All API functions are documented in the runtime entry files and exposed through the bundled typings in [node-ical.d.ts](node-ical.d.ts). +This allows for IDE hinting in both CommonJS and ESM projects. ### sync ```javascript @@ -197,9 +210,9 @@ Fetch the specified URL using the native fetch API (```options``` are passed to #### Example: Print list of upcoming node conferences -See [`examples/example.mjs`](./examples/example.mjs) for a full example script. +See [`examples/example.js`](./examples/example.js) for a full example script. -> **Note:** This snippet uses `import` and top-level `await` (ESM). Save it as a `.mjs` file, or add `"type": "module"` to your `package.json`. +> **Note:** This snippet uses `import` and top-level `await` (ESM). Save it as a `.mjs` file, or add `"type": "module"` to your `package.json`. If you prefer CommonJS in your own project, keep using `require('node-ical')` from a `.cjs` file. ```javascript import ical from 'node-ical'; diff --git a/build/README.md b/build/README.md index df738d3e..6d937618 100644 --- a/build/README.md +++ b/build/README.md @@ -4,7 +4,7 @@ This folder contains data and scripts used to generate `windowsZones.json`, the ## Files -- `update-windows-zones.mjs`: Node-only updater that fetches the upstream CLDR `windowsZones.xml` and regenerates `windowsZones.json` using fast-xml-parser. +- `update-windows-zones.js`: Node-only updater that fetches the upstream CLDR `windowsZones.xml` and regenerates `windowsZones.json` using fast-xml-parser. - `windowsZonesOld.json`: A curated map of legacy Windows display-name labels (the human-readable aliases used by various Outlook/Exchange/ICS exporters) to the canonical Windows time zone IDs (e.g., `"(UTC+03:00) Tbilisi" -> "Georgian Standard Time"`). ## Why `windowsZonesOld.json` exists @@ -19,7 +19,7 @@ To keep `node-ical` resilient, we preserve a set of these legacy labels and map ## How generation works -1. `update-windows-zones.mjs` downloads CLDR `windowsZones.xml` and parses it directly. +1. `update-windows-zones.js` downloads CLDR `windowsZones.xml` and parses it directly. 2. It builds a `zoneTable` from CLDR, mapping Windows IDs to `{ iana: [primaryIana] }`. 3. It then loads `build/windowsZonesOld.json` and, for each legacy label (top-level key), looks up the canonical Windows ID (value) and injects a mapping entry into the final `zoneTable` so that legacy labels resolve to the same IANA zone as the canonical ID. 4. The script writes `windowsZones.json` in a one-entry-per-line format with sorted keys for stable diffs. @@ -38,7 +38,7 @@ npm run build:strict # or CI=true npm run build # or (low-level) -node build/update-windows-zones.mjs --strict +node build/update-windows-zones.js --strict ``` Note: CI runs the generator in strict mode to catch unresolved aliases early in pull requests. diff --git a/build/build-cjs.js b/build/build-cjs.js new file mode 100644 index 00000000..68bb4b00 --- /dev/null +++ b/build/build-cjs.js @@ -0,0 +1,20 @@ +// Generate the CommonJS entry point (node-ical.cjs) from the ESM sources. +// +// ESM (*.js, type: module) is the single source of truth. This bundles +// node-ical.js into a single self-contained CommonJS file so that +// `require('node-ical')` keeps working, while runtime dependencies stay +// external `require()` calls. +import {build} from 'esbuild'; + +await build({ + entryPoints: ['node-ical.js'], + outfile: 'node-ical.cjs', + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node20', + // Keep node_modules dependencies as require() calls; only bundle our own code + // (and inline windowsZones.json, which is imported relatively). + packages: 'external', + logLevel: 'info', +}); diff --git a/build/update-windows-zones.mjs b/build/update-windows-zones.js similarity index 83% rename from build/update-windows-zones.mjs rename to build/update-windows-zones.js index 05101f04..f63c13b3 100644 --- a/build/update-windows-zones.mjs +++ b/build/update-windows-zones.js @@ -1,7 +1,6 @@ // Update windowsZones.json from the upstream CLDR windowsZones.xml using fast-xml-parser. // This replaces the old xml-js CLI + shell script pipeline with a single cross-platform Node script. -import https from 'node:https'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; @@ -14,34 +13,18 @@ const __dirname = path.dirname(__filename); const SOURCE_URL = 'https://raw.githubusercontent.com/unicode-org/cldr/master/common/supplemental/windowsZones.xml'; const OLD_MAP_PATH = path.join(__dirname, 'windowsZonesOld.json'); const OUTPUT_PATH = path.join(__dirname, '..', 'windowsZones.json'); +const FETCH_TIMEOUT_MS = 30_000; -function fetchText(url) { - return new Promise((resolve, reject) => { - https - .get(url, response => { - if (response.statusCode && response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { - // Follow redirect - fetchText(response.headers.location).then(resolve).catch(reject); - return; - } - - if (response.statusCode !== 200) { - reject(new Error(`HTTP ${response.statusCode} when fetching ${url}`)); - response.resume(); - return; - } - - let data = ''; - response.setEncoding('utf8'); - response.on('data', chunk => { - data += chunk; - }); - response.on('end', () => { - resolve(data); - }); - }) - .on('error', reject); +async function fetchText(url) { + const response = await fetch(url, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status} when fetching ${url}`); + } + + return response.text(); } function toArray(value) { diff --git a/examples/example-rrule-basic.js b/examples/example-rrule-basic.js index eb140561..57abc926 100644 --- a/examples/example-rrule-basic.js +++ b/examples/example-rrule-basic.js @@ -5,8 +5,11 @@ * RRULE within a fixed window, and print the resulting instances. */ -const path = require('node:path'); -const ical = require('../node-ical.js'); +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import ical from 'node-ical'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Load the basic example calendar that contains one recurring event. const data = ical.parseFile(path.join(__dirname, 'example-rrule-basic.ics')); diff --git a/examples/example-rrule-datefns.js b/examples/example-rrule-datefns.js index 93294016..bc5dc39b 100644 --- a/examples/example-rrule-datefns.js +++ b/examples/example-rrule-datefns.js @@ -15,13 +15,12 @@ * fixed window (here: calendar year 2017) keeps expansion finite and practical. */ -const path = require('node:path'); -const { - format, - differenceInMilliseconds, - parseISO, -} = require('date-fns'); -const ical = require('../node-ical.js'); +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {differenceInMilliseconds, format, parseISO} from 'date-fns'; +import ical from 'node-ical'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Load an example iCal file with various recurring events. const data = ical.parseFile(path.join(__dirname, 'example-rrule.ics')); diff --git a/examples/example-rrule-dayjs.js b/examples/example-rrule-dayjs.js index 14540f6b..61b03160 100644 --- a/examples/example-rrule-dayjs.js +++ b/examples/example-rrule-dayjs.js @@ -17,12 +17,15 @@ * fixed window (here: calendar year 2017) keeps expansion finite and practical. */ -const path = require('node:path'); -const dayjs = require('dayjs'); -const utc = require('dayjs/plugin/utc'); -const duration = require('dayjs/plugin/duration'); -const localizedFormat = require('dayjs/plugin/localizedFormat'); -const ical = require('../node-ical.js'); +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration.js'; +import localizedFormat from 'dayjs/plugin/localizedFormat.js'; +import utc from 'dayjs/plugin/utc.js'; +import ical from 'node-ical'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Extend Day.js with plugins for timezone and duration support dayjs.extend(utc); diff --git a/examples/example-rrule-luxon.js b/examples/example-rrule-luxon.js index 8c450374..bbff0797 100644 --- a/examples/example-rrule-luxon.js +++ b/examples/example-rrule-luxon.js @@ -16,9 +16,12 @@ * fixed window (here: calendar year 2017) keeps expansion finite and practical. */ -const path = require('node:path'); -const {DateTime} = require('luxon'); -const ical = require('../node-ical.js'); +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {DateTime} from 'luxon'; +import ical from 'node-ical'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Load an example iCal file with various recurring events. const data = ical.parseFile(path.join(__dirname, 'example-rrule.ics')); diff --git a/examples/example-rrule-moment.js b/examples/example-rrule-moment.js index 5482683a..eea3cc18 100644 --- a/examples/example-rrule-moment.js +++ b/examples/example-rrule-moment.js @@ -13,9 +13,12 @@ * fixed window (here: calendar year 2017) keeps expansion finite and practical. */ -const path = require('node:path'); -const moment = require('moment-timezone'); -const ical = require('../node-ical.js'); +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import moment from 'moment-timezone'; +import ical from 'node-ical'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Load an example iCal file with various recurring events. const data = ical.parseFile(path.join(__dirname, 'example-rrule.ics')); diff --git a/examples/example-rrule-vanilla.js b/examples/example-rrule-vanilla.js index 1ef201f4..9e23b40b 100644 --- a/examples/example-rrule-vanilla.js +++ b/examples/example-rrule-vanilla.js @@ -18,8 +18,11 @@ * fixed window (here: calendar year 2017) keeps expansion finite and practical. */ -const path = require('node:path'); -const ical = require('../node-ical.js'); +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import ical from 'node-ical'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Helper function to format duration from milliseconds to hours:minutes function formatDuration(ms) { diff --git a/examples/example.mjs b/examples/example.js similarity index 95% rename from examples/example.mjs rename to examples/example.js index 0ece64ae..2719a7a6 100644 --- a/examples/example.mjs +++ b/examples/example.js @@ -5,7 +5,7 @@ * resulting VEVENT entries using the Promise-based API. */ -import ical from '../node-ical.js'; +import ical from 'node-ical'; const url = 'https://raw.githubusercontent.com/jens-maus/node-ical/master/test/fixtures/festival-multiday-rrule.ics'; diff --git a/ical.js b/ical.js index 18875c82..d0c46699 100644 --- a/ical.js +++ b/ical.js @@ -1,49 +1,24 @@ -/* 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 -// eslint-disable-next-line unicorn/no-global-object-property-assignment -- simple polyfill bootstrap for CJS entrypoint -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} Extracted duration string - */ -function getDurationString(duration) { - if (typeof duration === 'object' && duration?.val) { - return String(duration.val); - } - - return duration ? String(duration) : ''; -} +/* eslint-disable max-params */ + +import {randomUUID} from 'node:crypto'; +import {RRuleTemporal} from 'rrule-temporal'; +import {toText as toTextFunction} from 'rrule-temporal/totext'; +import {getDateKey} from './lib/date-utils.js'; +import { + parseValue, + finalizeEndedComponent, + ensureRruleHasDtstart, + buildRruleStringForTemporal, + buildRruleCompatWrapper, + storeParameter, + typeParameter, + addTZFactory, + createDateParameterFactory, + createComponentParameterHandlers, + createExdateParameterFactory, +} from './lib/ical-parser-utils.js'; +import {Temporal} from './lib/temporal.js'; +import tzUtil from './lib/tz-utils.js'; /** * Store a recurrence override with dual-key strategy. @@ -209,331 +184,26 @@ class RRuleCompatWrapper { * */ -// 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(/\\n/giv, '\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') { - 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) { - // A schemed TZID like "tzone://Microsoft/Utc" gets split at its "://" colon - // by the line parser, so the scheme tail leaks into `value`. Repair both by - // re-splitting `value` at the date's colon. - const pi = parameters.indexOf('TZID=tzone'); - if (pi !== -1) { - const firstColon = value.indexOf(':'); - const tzidRemainder = value.slice(0, firstColon); - const dateValue = value.slice(firstColon + 1); - - parameters[pi] = `TZID=tzone:${tzidRemainder}`; - value = dateValue; - } - - 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(comps[2]) - 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(comps[1]); - const monthIndex = Number(comps[2]) - 1; - const day = Number(comps[3]); - const hour = Number(comps[4]); - const minute = Number(comps[5]); - const second = Number(comps[6]); - - 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], ...value.split(',').map(s => s.trim())]; - } - - return curr; - }; -}; +const addTZ = addTZFactory(tzUtil.attachTz); +const dateParameter = createDateParameterFactory({ + addTZ, + tzUtil, +}); + +const { + geoParameter, + categoriesParameter, + recurrenceParameter, + freebusyParameter, +} = createComponentParameterHandlers({ + dateParameter, + utcAdd: tzUtil.utcAdd, +}); + +const exdateParameter = createExdateParameterFactory({ + dateParameter, + getDateKey, +}); // 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. @@ -557,85 +227,10 @@ const categoriesParameter = function (name) { // 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, partName] of ['start', 'end'].entries()) { - dateParameter(partName)(parts[index], parameters, fb); - } - - return curr; - }; -}; - // Default batch size for async parsing to prevent event loop blocking const PARSE_BATCH_SIZE = 2000; -module.exports = { +const ical = { objectHandlers: { BEGIN(component, parameters, curr, stack) { stack.push(curr); @@ -643,186 +238,6 @@ module.exports = { return {type: component}; }, END(value, parameters, curr, stack) { - // Original end function - const originalEnd = function (component, parameters_, current, parentStack) { - // 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; - const vcalendarProps = {}; - - for (key in current) { - if (!Object.hasOwn(current, key)) { - continue; - } - - const object = current[key]; - if (typeof object === 'string') { - vcalendarProps[key] = object; - delete current[key]; - } - } - - // Store VCALENDAR properties in a dedicated object for easy access - if (Object.keys(vcalendarProps).length > 0) { - current.vcalendar = vcalendarProps; - } - - return current; - } - - const par = parentStack.pop(); - - if (!current.end) { // RFC5545, 3.6.1 - // Calculate end date based on DURATION or default rules - if (current.duration === undefined) { - // No DURATION: default end is same time (date-time) or +1 day (date-only) - current.end = current.datetype === 'date-time' - ? cloneDateWithMeta(current.start) - : cloneDateWithMeta(current.start, tzUtil.utcAdd(current.start, 1, 'days')); - } else { - const durationString = getDurationString(current.duration); - const durationParts = durationString.match(/-?\d{1,10}[DHMSW]/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 = current.start; - for (const part of durationParts) { - const durationValue = Number(part.slice(0, -1)) * sign; - const unit = units[part.slice(-1)]; - endTime = tzUtil.utcAdd(endTime, durationValue, unit); - } - - current.end = cloneDateWithMeta(current.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`); - current.end = cloneDateWithMeta(current.start); - } - } - } - - if (current.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. @@ -831,184 +246,25 @@ module.exports = { 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); - } - } + rule = ensureRruleHasDtstart(rule, curr, tzUtil).replace(/\.\d{3}/v, ''); // 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; - const untilStart = untilMatch.index; - const untilEnd = untilStart + untilMatch[0].length; - - if (curr.start.dateOnly) { - // DATE-only: strip time from UNTIL - if (timePart) { - rruleOnly = rruleOnly.slice(0, untilStart) + `UNTIL=${datePart}` + rruleOnly.slice(untilEnd); - } - } 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.slice(0, untilStart) + `UNTIL=${untilUtc}` + rruleOnly.slice(untilEnd); - 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 */); - } + const rruleOnly = buildRruleStringForTemporal(rule, curr.start, tzUtil); + curr.rrule = buildRruleCompatWrapper(curr, rruleOnly, { + RRuleTemporal, + RRuleCompatWrapper, + Temporal, + tzUtil, + }); } } - return originalEnd.call(this, value, parameters, curr, stack); + return finalizeEndedComponent(value, curr, stack, { + storeRecurrenceOverride, + randomIdFactory: randomUUID, + utcAdd: tzUtil.utcAdd, + }); }, SUMMARY: storeParameter('summary'), DESCRIPTION: storeParameter('description'), @@ -1206,3 +462,16 @@ module.exports = { } }, }; + +const {objectHandlers} = ical; +const handleObject = ical.handleObject.bind(ical); +const parseLines = ical.parseLines.bind(ical); +const parseICS = ical.parseICS.bind(ical); + +export { + objectHandlers, + handleObject, + parseLines, + parseICS, +}; +export default ical; diff --git a/lib/core-api.js b/lib/core-api.js new file mode 100644 index 00000000..cdc116d2 --- /dev/null +++ b/lib/core-api.js @@ -0,0 +1,132 @@ +/** + * Build core parse/fetch APIs shared by CJS and ESM entrypoints. + * + * @param {object} options + * @param {(ics: string, cb?: (error: Error | null, parsedData: object) => void) => object | void} options.parseICSImpl - Calendar parser function. + * @param {object} options.fsModule - fs module implementation. + * @param {(url: string, options?: object) => Promise<{ok: boolean, status: number, statusText: string, text: () => Promise}>} [options.fetchImpl] - Fetch implementation (defaults to global fetch). + * @returns {{ + * syncApi: object, + * asyncApi: object, + * autodetectApi: object, + * fromURL: (...args: Array) => unknown, + * parseFile: (...args: Array) => unknown, + * parseICS: (...args: Array) => unknown, + * }} Shared API object parts. + */ +function createCoreApi({parseICSImpl, fsModule, fetchImpl = fetch}) { + // Bridge promise-based internals to callback APIs while preserving callback error behavior. + function promiseCallback(promise, callback) { + if (!callback) { + return promise; + } + + const callCallback = (error, result) => { + try { + callback(error, result); + } catch (callbackError) { + queueMicrotask(() => { + throw callbackError; + }); + } + }; + + promise.then( + returnValue => { + callCallback(null, returnValue); + }, + error => { + callCallback(error, null); + }, + ); + } + + function parseICSAsync(data) { + return new Promise((resolve, reject) => { + parseICSImpl(data, (error, parsedData) => { + if (error) { + reject(error); + return; + } + + resolve(parsedData); + }); + }); + } + + async function parseFileAsync(filename) { + const data = await fsModule.promises.readFile(filename, 'utf8'); + return parseICSAsync(data); + } + + async function fromURLAsync(url, options) { + const fetchOptions = (options && typeof options === 'object') ? {...options} : {}; + const response = await fetchImpl(url, fetchOptions); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + const data = await response.text(); + return parseICSAsync(data); + } + + const syncApi = {}; + const asyncApi = {}; + const autodetectApi = {}; + + asyncApi.fromURL = function (url, options, callback) { + if (typeof options === 'function' && callback === undefined) { + callback = options; + options = undefined; + } + + return promiseCallback(fromURLAsync(url, options), callback); + }; + + asyncApi.parseFile = function (filename, callback) { + return promiseCallback(parseFileAsync(filename), callback); + }; + + asyncApi.parseICS = function (data, callback) { + return promiseCallback(parseICSAsync(data), callback); + }; + + syncApi.parseFile = function (filename) { + const data = fsModule.readFileSync(filename, 'utf8'); + return parseICSImpl(data); + }; + + syncApi.parseICS = function (data) { + return parseICSImpl(data); + }; + + autodetectApi.parseFile = function (filename, callback) { + if (!callback) { + return syncApi.parseFile(filename); + } + + asyncApi.parseFile(filename, callback); + }; + + autodetectApi.parseICS = function (data, callback) { + if (!callback) { + return syncApi.parseICS(data); + } + + asyncApi.parseICS(data, callback); + }; + + const {fromURL} = asyncApi; + const {parseFile, parseICS} = autodetectApi; + + return { + syncApi, + asyncApi, + autodetectApi, + fromURL, + parseFile, + parseICS, + }; +} + +export {createCoreApi}; diff --git a/lib/date-utils.js b/lib/date-utils.js index 67d4ba63..eb48c461 100644 --- a/lib/date-utils.js +++ b/lib/date-utils.js @@ -1,10 +1,5 @@ - -'use strict'; - -// Load Temporal polyfill if not natively available -const Temporal = globalThis.Temporal || require('temporal-polyfill').Temporal; - -const tzUtil = require('./tz-utils.js'); +import {Temporal} from './temporal.js'; +import tzUtil from './tz-utils.js'; /** * Construct a date-only key (YYYY-MM-DD) from a Date object. @@ -50,4 +45,4 @@ function getDateKey(dateValue) { return dateValue.toISOString().slice(0, 10); } -module.exports = {getDateKey}; +export {getDateKey}; diff --git a/lib/expand-recurring-event.js b/lib/expand-recurring-event.js new file mode 100644 index 00000000..886abd58 --- /dev/null +++ b/lib/expand-recurring-event.js @@ -0,0 +1,471 @@ +import {getDateKey} from './date-utils.js'; + +// Shared recurring expansion implementation used by both CJS and ESM entrypoints. + +/** + * 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) { + const copyMetaProperty = (name, value) => { + if (value === undefined) { + return; + } + + const descriptor = Object.getOwnPropertyDescriptor(target, name); + if (descriptor?.writable === false) { + // ESM runs in strict mode, so reassigning an existing read-only metadata property throws. + if (descriptor.value === value) { + return; + } + + if (descriptor.configurable) { + Object.defineProperty(target, name, {...descriptor, value}); + } + + return; + } + + target[name] = value; + }; + + if (source?.tz) { + copyMetaProperty('tz', source.tz); + } + + if (source?.dateOnly) { + copyMetaProperty('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) { + 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); + } + + const year = utcDate.getUTCFullYear(); + const month = utcDate.getUTCMonth(); + const day = utcDate.getUTCDate(); + return new Date(year, month, day, 0, 0, 0, 0); +} + +function getFullDaySpanDays(eventData) { + if (eventData?.start && eventData.end) { + const startDate = new Date(eventData.start); + const endDate = new Date(eventData.end); + const startDay = Date.UTC(startDate.getFullYear(), startDate.getMonth(), startDate.getDate()); + const endDay = Date.UTC(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()); + return Math.max(1, Math.round((endDay - startDay) / (24 * 60 * 60 * 1000))); + } + + return 1; +} + +/** + * 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 (isFullDay) { + return getFullDaySpanDays(eventData) * 24 * 60 * 60 * 1000; + } + + if (eventData?.start && eventData.end) { + return new Date(eventData.end).getTime() - new Date(eventData.start).getTime(); + } + + 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) { + if (isFullDay) { + const daySpan = eventData?.start && eventData.end + ? getFullDaySpanDays(eventData) + : Math.max(1, Math.round((baseDurationMs ?? (24 * 60 * 60 * 1000)) / (24 * 60 * 60 * 1000))); + + return new Date(start.getFullYear(), start.getMonth(), start.getDate() + daySpan, 0, 0, 0, 0); + } + + const durationMs = (eventData?.start && eventData.end) + ? getEventDurationMs(eventData, isFullDay) + : (baseDurationMs ?? 0); + + return new Date(start.getTime() + durationMs); +} + +function getOverrideRecurrenceKey(overrideEvent) { + if (!(overrideEvent?.recurrenceid instanceof Date)) { + return undefined; + } + + return overrideEvent.recurrenceid.dateOnly === true + ? getDateKey(overrideEvent.recurrenceid) + : overrideEvent.recurrenceid.toISOString(); +} + +function buildOverrideInstance(overrideEvent, event, isFullDay, baseDurationMs) { + if (!(overrideEvent?.start instanceof Date) && !(overrideEvent?.start)) { + return null; + } + + let start = overrideEvent.start instanceof Date ? overrideEvent.start : new Date(overrideEvent.start); + if (isFullDay) { + start = createLocalDateFromUTC(start); + } + + const end = calculateEndTime(start, overrideEvent, isFullDay, baseDurationMs); + const instance = { + start, + end, + summary: overrideEvent.summary || event.summary || '', + isFullDay, + isRecurring: true, + isOverride: true, + event: overrideEvent, + }; + + copyDateMeta(instance.start, overrideEvent.start); + copyDateMeta(instance.end, overrideEvent.end || event.end); + + return instance; +} + +function collectOverrideInstances(event, { + isFullDay, + baseDurationMs, + from, + to, + expandOngoing, + seenKeys, +}) { + if (!event.recurrences) { + return []; + } + + const overrideEvents = new Set(Object.values(event.recurrences)); + const instances = []; + + for (const overrideEvent of overrideEvents) { + const recurrenceKey = getOverrideRecurrenceKey(overrideEvent); + if (!recurrenceKey) { + continue; + } + + if (recurrenceKey && seenKeys.has(recurrenceKey)) { + continue; + } + + const instance = buildOverrideInstance(overrideEvent, event, isFullDay, baseDurationMs); + if (!instance || !isInstanceInRange(instance, from, to, expandOngoing)) { + continue; + } + + if (recurrenceKey) { + seenKeys.add(recurrenceKey); + } + + instances.push(instance); + } + + return instances; +} + +/** + * 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); + + let eventStart = event.start instanceof Date ? event.start : new Date(event.start); + + if (isFullDay) { + eventStart = createLocalDateFromUTC(eventStart); + } + + const eventEnd = calculateEndTime(eventStart, event, isFullDay, baseDurationMs); + + 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, + }; + + 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) { + for (const exdateValue of new Set(Object.values(event.exdate))) { + if (exdateValue instanceof Date && getDateKey(exdateValue) === dateKey) { + return true; + } + } + + return false; + } + + const isoKey = date.toISOString(); + const hasIsoExdate = Object.hasOwn(event.exdate, isoKey); + const dateKeyExdate = event.exdate[dateKey]; + return hasIsoExdate || Boolean(dateKeyExdate?.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. + * @param {Date} from + * @param {Date} to + * @param {boolean} isFullDay + * @param {boolean} expandOngoing + * @param {number} baseDurationMs + * @returns {{searchFrom: Date, searchTo: Date}} Adjusted search range bounds + */ +function adjustSearchRange(from, to, isFullDay, expandOngoing, baseDurationMs) { + let searchFrom; + let searchTo; + + if (isFullDay) { + searchFrom = new Date(Date.UTC(from.getFullYear(), from.getMonth(), from.getDate())); + searchTo = new Date(Date.UTC(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59, 999)); + } else { + const isMidnight = to.getHours() === 0 && to.getMinutes() === 0 && to.getSeconds() === 0; + searchFrom = from; + searchTo = isMidnight + ? new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59, 999) + : to; + } + + if (expandOngoing) { + searchFrom = new Date(searchFrom.getTime() - baseDurationMs); + } + + return {searchFrom, searchTo}; +} + +/** + * Build a single recurring event instance for an RRULE-generated date. + * @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; + } + + 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; + + let start = (isOverride && instanceEvent.start) + ? (instanceEvent.start instanceof Date ? instanceEvent.start : new Date(instanceEvent.start)) + : date; + + 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 : 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) { + 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); + } + + 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 a 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 ongoing events + * @returns {Array} Sorted array of event instances + */ +function expandRecurringEvent(event, options) { + const { + from, + to, + includeOverrides = true, + excludeExdates = true, + expandOngoing = false, + } = options; + + validateDateRange(from, to); + + 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 = []; + const seenRecurrenceKeys = new Set(); + + for (const date of dates) { + const instance = buildRecurringInstance(date, event, isFullDay, baseDurationMs, {excludeExdates, includeOverrides}); + if (instance && isInstanceInRange(instance, from, to, expandOngoing)) { + seenRecurrenceKeys.add(instance.isOverride ? getOverrideRecurrenceKey(instance.event) : date.toISOString()); + instances.push(instance); + } + } + + if (includeOverrides) { + instances.push(...collectOverrideInstances(event, { + isFullDay, + baseDurationMs, + from, + to, + expandOngoing, + seenKeys: seenRecurrenceKeys, + })); + } + + return instances.toSorted((a, b) => a.start - b.start); +} + +export default expandRecurringEvent; diff --git a/lib/ical-parser-utils.js b/lib/ical-parser-utils.js new file mode 100644 index 00000000..1bdc3c20 --- /dev/null +++ b/lib/ical-parser-utils.js @@ -0,0 +1,1037 @@ +// Unescape Text re RFC 4.3.11 +function text(t = '') { + return t + .replaceAll(String.raw`\,`, ',') // Unescape escaped commas + .replaceAll(String.raw`\;`, ';') // Unescape escaped semicolons + .replaceAll(/\\n/giv, '\n') // Replace escaped newlines with actual newlines + .replaceAll('\\\\', '\\') // Unescape backslashes + .replace(/^"(.*)"$/v, '$1'); // Remove surrounding double quotes, if present +} + +function parseValue(value) { + if (typeof value === 'string') { + const upperValue = value.toUpperCase(); + if (upperValue === 'TRUE') { + return true; + } + + if (upperValue === 'FALSE') { + return false; + } + } + + const number = Number(value); + if (!Number.isNaN(number)) { + return number; + } + + // Remove quotes if found + return value.replace(/^"(.*)"$/v, '$1'); +} + +function parseParameters(parameters) { + const out = {}; + for (const element of parameters) { + if (element.includes('=')) { + const segs = element.split('='); + // Parameter names are case-insensitive per RFC 5545; normalize to uppercase + // so lookups like `p.TZID`/`p.FBTYPE`/`p.VALUE` work regardless of input casing. + out[segs[0].toUpperCase()] = parseValue(segs.slice(1).join('=')); + } + } + + return out; +} + +function isDefaultTextParameter(parameter) { + if (typeof parameter !== 'string') { + return false; + } + + const index = parameter.indexOf('='); + if (index === -1) { + return false; + } + + const nameLower = parameter.slice(0, index).toLowerCase(); + const normalizedValue = parameter.slice(index + 1).toLowerCase().replaceAll('-', ''); + + return (nameLower === 'charset' && normalizedValue === 'utf8') + || (nameLower === 'value' && normalizedValue === 'text'); +} + +function splitUnescapedCommas(value) { + if (!value) { + return []; + } + + const parts = []; + let current = ''; + let backslashRun = 0; + + for (const character of value) { + if (character === '\\') { + backslashRun++; + current += character; + continue; + } + + if (character === ',' && backslashRun % 2 === 0) { + parts.push(current); + current = ''; + backslashRun = 0; + continue; + } + + backslashRun = 0; + current += character; + } + + parts.push(current); + return parts; +} + +function setOwnRecordField(target, key, value) { + if (key === '__proto__') { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); + return; + } + + target[key] = value; +} + +function getOwnRecordField(target, key) { + return Object.hasOwn(target, key) ? target[key] : undefined; +} + +function copyRecordFields(target, source) { + for (const [key, value] of Object.entries(source)) { + setOwnRecordField(target, key, value); + } +} + +// Fields that are parser-owned aggregate/inherited state rather than data parsed +// from the current VEVENT/VTODO/VJOURNAL occurrence itself. These must survive a +// SEQUENCE-based merge even though they never appear on the incoming entry. +const PRESERVED_MERGE_FIELDS = new Set(['recurrences', 'method']); + +function clearFieldsRemovedInRevision(target, source) { + for (const key of Object.keys(target)) { + if (PRESERVED_MERGE_FIELDS.has(key) || Object.hasOwn(source, key)) { + continue; + } + + delete target[key]; + } +} + +function applyUidSequenceMerge(existingEntry, incomingEntry, uid) { + // Special case: existing entry is a RECURRENCE-ID override while the incoming + // entry is the base series. The base series can drive recurrence via RRULE, + // via RDATE only, or be a plain singleton - the only thing that matters is + // that it is not itself a RECURRENCE-ID override. Keep both concerns by + // merging base fields. + const existingIsRecurrence = existingEntry.recurrenceid !== undefined; + const incomingIsBaseSeries = incomingEntry.recurrenceid === undefined; + + if (existingIsRecurrence && incomingIsBaseSeries) { + clearFieldsRemovedInRevision(existingEntry, incomingEntry); + copyRecordFields(existingEntry, incomingEntry); + return; + } + + // Otherwise apply RFC 5545 SEQUENCE precedence for duplicate base entries. + const existingSeq = Number.isFinite(existingEntry.sequence) ? existingEntry.sequence : 0; + const newSeq = Number.isFinite(incomingEntry.sequence) ? incomingEntry.sequence : 0; + + if (newSeq < existingSeq) { + console.warn(`[node-ical] Ignoring older event version (SEQUENCE ${newSeq} < ${existingSeq}) for UID ${uid}`); + return; + } + + // The accepted revision fully replaces the previous one: drop fields that no + // longer exist on the incoming entry (e.g. RRULE removed) instead of leaving + // stale data behind, while keeping parser-owned aggregate state intact and + // preserving object identity (existingEntry is mutated in place). + clearFieldsRemovedInRevision(existingEntry, incomingEntry); + copyRecordFields(existingEntry, incomingEntry); +} + +function buildRecurrenceOverrideObject(sourceEntry) { + const recurrenceObject = Object.create(null); + copyRecordFields(recurrenceObject, sourceEntry); + + if (recurrenceObject.recurrences !== undefined) { + delete recurrenceObject.recurrences; + } + + return recurrenceObject; +} + +function storeRecurrenceOverrideForEntry(parentEntry, sourceEntry, storeRecurrenceOverride) { + if (sourceEntry.recurrenceid === undefined) { + return; + } + + const recurrenceObject = buildRecurrenceOverrideObject(sourceEntry); + parentEntry.recurrences ||= {}; + storeRecurrenceOverride(parentEntry.recurrences, sourceEntry.recurrenceid, recurrenceObject); +} + +function cleanupBaseSeriesRecurrenceId(entry, uid) { + if (uid !== '__proto__' && entry.rrule !== undefined && entry.recurrenceid !== undefined) { + delete entry.recurrenceid; + } +} + +function handleUidEntryInParent(parentEntry, sourceEntry, storeRecurrenceOverride) { + if (!sourceEntry.uid) { + return false; + } + + const {uid} = sourceEntry; + let existingEntry = getOwnRecordField(parentEntry, uid); + + if (existingEntry === undefined) { + setOwnRecordField(parentEntry, uid, sourceEntry); + existingEntry = getOwnRecordField(parentEntry, uid); + + if (parentEntry.method) { + existingEntry.method = parentEntry.method; + } + } else if (sourceEntry.recurrenceid === undefined) { + applyUidSequenceMerge(existingEntry, sourceEntry, uid); + } + + if (sourceEntry.recurrenceid !== undefined) { + storeRecurrenceOverrideForEntry(existingEntry, sourceEntry, storeRecurrenceOverride); + } + + cleanupBaseSeriesRecurrenceId(existingEntry, uid); + return true; +} + +function splitVCalendarProperties(entry) { + const vcalendarProps = {}; + + for (const key in entry) { + if (!Object.hasOwn(entry, key)) { + continue; + } + + const value = entry[key]; + if (typeof value === 'string') { + vcalendarProps[key] = value; + delete entry[key]; + } + } + + if (Object.keys(vcalendarProps).length > 0) { + entry.vcalendar = vcalendarProps; + } + + return entry; +} + +function handleNonUidEntryInParent(parentEntry, sourceEntry, component, randomIdFactory) { + if (component === 'VALARM' && (parentEntry.type === 'VEVENT' || parentEntry.type === 'VTODO')) { + parentEntry.alarms ||= []; + parentEntry.alarms.push(sourceEntry); + return parentEntry; + } + + const id = randomIdFactory(); + parentEntry[id] = sourceEntry; + + if (parentEntry.method) { + parentEntry[id].method = parentEntry.method; + } + + return parentEntry; +} + +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; +} + +function getDurationString(duration) { + if (typeof duration === 'object' && duration?.val) { + return String(duration.val); + } + + return duration ? String(duration) : ''; +} + +const DURATION_UNITS = { + W: 'weeks', + D: 'days', + H: 'hours', + M: 'minutes', + S: 'seconds', +}; + +// Time-only designators (RFC 5545 §3.3.6 dur-time). "M" means minutes here, +// but means months in ISO 8601's date part - which RFC 5545 DURATION doesn't +// support at all. Requiring these to appear after a "T" is what disambiguates +// "P1M" (invalid - bare "M" has no valid meaning) from "PT1M" (1 minute). +const TIME_ONLY_UNITS = new Set(['H', 'M', 'S']); + +// A single "" token, e.g. "1D" or "30M". +const DURATION_TOKEN_PATTERN = /^(\d{1,10})([dhmsw])/iv; + +// Validates and parses an RFC 5545 §3.3.6 DURATION value token by token, +// consuming the optional sign and leading "P" and then walking the rest of +// the string one unit-fragment at a time. This rejects anything that isn't +// fully consumed by valid fragments (e.g. "P1DXYZ" or "garbage1D" - a +// valid-looking fragment surrounded by garbage) and rejects date-only +// letters appearing where only a time designator would make sense (e.g. the +// standalone "M" in "P1M"). It deliberately still tolerates some real-world +// vendor quirks seen in the wild, such as a "T" preceding a week count +// ("PT1W") or all unit letters bunched together after a single "T" +// ("-PT1W1D2H3M4S"), since those are unambiguous even though non-standard. +function applyDurationToDate(start, durationString, utcAdd) { + const trimmed = durationString.trim(); + const prefixMatch = /^([+\-]?)p/iv.exec(trimmed); + if (!prefixMatch) { + return undefined; + } + + const sign = prefixMatch[1] === '-' ? -1 : 1; + let rest = trimmed.slice(prefixMatch[0].length); + let seenTimeDesignator = false; + let hasComponent = false; + let endTime = start; + + while (rest.length > 0) { + if (/^t/iv.test(rest)) { + seenTimeDesignator = true; + rest = rest.slice(1); + continue; + } + + const tokenMatch = DURATION_TOKEN_PATTERN.exec(rest); + if (!tokenMatch) { + return undefined; + } + + const unitLetter = tokenMatch[2].toUpperCase(); + if (TIME_ONLY_UNITS.has(unitLetter) && !seenTimeDesignator) { + return undefined; + } + + hasComponent = true; + endTime = utcAdd(endTime, Number(tokenMatch[1]) * sign, DURATION_UNITS[unitLetter]); + rest = rest.slice(tokenMatch[0].length); + } + + return hasComponent ? endTime : undefined; +} + +function applyImplicitEndDate(entry, utcAdd) { + if (entry.end) { + return entry; + } + + if (entry.duration === undefined) { + entry.end = entry.datetype === 'date-time' + ? cloneDateWithMeta(entry.start) + : cloneDateWithMeta( + entry.start, + entry.start + ? new Date(entry.start.getFullYear(), entry.start.getMonth(), entry.start.getDate() + 1, 0, 0, 0, 0) + : entry.start, + ); + return entry; + } + + const durationString = getDurationString(entry.duration); + const endTime = applyDurationToDate(entry.start, durationString, utcAdd); + if (endTime !== undefined) { + entry.end = cloneDateWithMeta(entry.start, endTime); + return entry; + } + + console.warn(`[node-ical] Ignoring malformed DURATION value: "${durationString}" – treating as zero duration`); + entry.end = cloneDateWithMeta(entry.start); + return entry; +} + +function finalizeEndedComponent(component, current, parentStack, { + storeRecurrenceOverride, + randomIdFactory, + utcAdd, +}) { + if (component === 'VCALENDAR') { + return splitVCalendarProperties(current); + } + + const parentEntry = parentStack.pop(); + + // Implicit end derivation only applies to start-bearing VEVENT/VTODO components. + const supportsImplicitEnd = ['VEVENT', 'VTODO'].includes(component) && current.start instanceof Date; + if (supportsImplicitEnd) { + applyImplicitEndDate(current, utcAdd); + } + + if (handleUidEntryInParent(parentEntry, current, storeRecurrenceOverride)) { + return parentEntry; + } + + return handleNonUidEntryInParent(parentEntry, current, component, randomIdFactory); +} + +function ensureDateOnlyRruleStart(entry, attachTz) { + if (entry.datetype !== 'date') { + return; + } + + const originalStart = entry.start; + const year = originalStart.getFullYear(); + const month = originalStart.getMonth(); + const day = originalStart.getDate(); + + entry.start = new Date(year, month, day, 0, 0, 0, 0); + + if (originalStart?.tz) { + attachTz(entry.start, originalStart.tz); + } + + if (originalStart?.dateOnly === true) { + entry.start.dateOnly = true; + } +} + +function buildDateOnlyStamp(date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}${month}${day}`; +} + +function ensureRruleHasDtstart(rule, entry, tzUtil) { + if (rule.includes('DTSTART')) { + return rule; + } + + if (entry.datetype === 'date') { + ensureDateOnlyRruleStart(entry, tzUtil.attachTz); + } + + if (!entry.start || typeof entry.start.toISOString !== 'function') { + throw new Error('No toISOString function in curr.start ' + entry.start); + } + + try { + const isUtc = tzUtil.isUtcTimezone(entry.start.tz); + + if (entry.start.dateOnly) { + return `${rule};DTSTART;VALUE=DATE:${buildDateOnlyStamp(entry.start)}`; + } + + if (entry.start.tz && !isUtc) { + const tzInfo = tzUtil.resolveTZID(entry.start.tz); + const localStamp = tzUtil.formatDateForRrule(entry.start, tzInfo); + const tzidLabel = tzInfo.iana || tzInfo.etc || tzInfo.original; + + if (localStamp && tzidLabel) { + return `${rule};DTSTART;TZID=${tzidLabel}:${localStamp}`; + } + + if (localStamp) { + return `${rule};DTSTART=${localStamp}`; + } + } + + return `${rule};DTSTART=${entry.start.toISOString().replaceAll('-', '').replaceAll(':', '')}`; + } catch (error) { + throw new Error('ERROR when trying to convert to ISOString ' + error, {cause: error}); + } +} + +function normalizeRruleUntil(rruleOnly, startDate, tzUtil) { + if (!rruleOnly.includes('UNTIL=')) { + return rruleOnly; + } + + const untilMatch = rruleOnly.match(/UNTIL=(\d{8})(T\d{6})?(Z)?/v); + if (!untilMatch) { + return rruleOnly; + } + + const [, datePart, timePart, zSuffix] = untilMatch; + const untilStart = untilMatch.index; + const untilEnd = untilStart + untilMatch[0].length; + + if (startDate.dateOnly) { + if (timePart) { + return rruleOnly.slice(0, untilStart) + `UNTIL=${datePart}` + rruleOnly.slice(untilEnd); + } + + return rruleOnly; + } + + if (!timePart || zSuffix) { + return rruleOnly; + } + + let converted = false; + if (startDate.tz) { + try { + const tzInfo = tzUtil.resolveTZID(startDate.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.slice(0, untilStart) + `UNTIL=${untilUtc}` + rruleOnly.slice(untilEnd); + converted = true; + } + } catch { + // Fall through to append Z + } + } + + return converted ? rruleOnly : rruleOnly.replace(/UNTIL=(\d{8}T\d{6})(?!Z)/v, 'UNTIL=$1Z'); +} + +function buildRruleStringForTemporal(rule, startDate, tzUtil) { + const rruleOnly = rule.split(';') + .filter(segment => + !segment.startsWith('DTSTART') + && !segment.startsWith('VALUE=') + && !segment.startsWith('TZID=')) + .join(';'); + + return normalizeRruleUntil(rruleOnly, startDate, tzUtil); +} + +function buildDateOnlyRruleString(startDate, rruleOnly) { + const dtstartString = `DTSTART;VALUE=DATE:${buildDateOnlyStamp(startDate)}`; + return `${dtstartString}\nRRULE:${rruleOnly}`; +} + +function buildTemporalDtstart(startDate, Temporal, tzUtil) { + const tzInfo = startDate.tz ? tzUtil.resolveTZID(startDate.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.'); + } + + try { + return Temporal.Instant.fromEpochMilliseconds(startDate.getTime()) + .toZonedDateTimeISO(timeZone); + } catch (error) { + console.warn(`[node-ical] Failed to convert timezone "${timeZone}", falling back to UTC: ${error?.message ?? String(error)}`); + return Temporal.Instant.fromEpochMilliseconds(startDate.getTime()) + .toZonedDateTimeISO('UTC'); + } +} + +function buildRruleCompatWrapper(entry, rruleOnly, { + RRuleTemporal, + RRuleCompatWrapper, + Temporal, + tzUtil, +}) { + if (entry.start.dateOnly) { + const rruleTemporal = new RRuleTemporal({ + rruleString: buildDateOnlyRruleString(entry.start, rruleOnly), + }); + + return new RRuleCompatWrapper(rruleTemporal, true); + } + + const rruleTemporal = new RRuleTemporal({ + rruleString: rruleOnly, + dtstart: buildTemporalDtstart(entry.start, Temporal, tzUtil), + }); + + return new RRuleCompatWrapper(rruleTemporal, false); +} + +function storeValueParameter(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; + }; +} + +function storeParameter(name) { + return function (value, parameters, curr) { + const data = parameters && parameters.length > 0 + && !(parameters.length === 1 && isDefaultTextParameter(parameters[0])) + ? {params: parseParameters(parameters), val: text(value)} + : text(value); + + return storeValueParameter(name)(data, curr); + }; +} + +function isDateOnly(value, parameters) { + if (parameters) { + const valueParameter = parseParameters(parameters).VALUE; + if (typeof valueParameter === 'string') { + const normalized = valueParameter.toUpperCase(); + if (normalized === 'DATE') { + return true; + } + + if (normalized === 'DATE-TIME') { + return false; + } + } + } + + return /^\d{8}$/v.test(value) === true; +} + +function typeParameter(name) { + return function (value, parameters, curr) { + const returnValue = isDateOnly(value, parameters) ? 'date' : 'date-time'; + return storeValueParameter(name)(returnValue, curr); + }; +} + +function addTZFactory(attachTz) { + return 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 attachTz(dt, tzid); + } + + if (dt.tz) { + return attachTz(dt, dt.tz); + } + + return dt; + }; +} + +// 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. +function findVtimezoneInStack(stack, tzid) { + for (const item of (stack || [])) { + for (const v of Object.values(item)) { + if (v && v.type === 'VTIMEZONE') { + 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; + } + } + } + } +} + +/* eslint-disable complexity, max-depth -- extracted date parsing logic is intentionally kept structurally close to original implementation to minimize regression risk */ +function createDateParameterFactory({addTZ, tzUtil}) { + return function (name) { + return function (value, parameters, curr, stack) { + // A schemed TZID like "tzone://Microsoft/Utc" gets split at its "://" colon + // by the line parser, so the scheme tail leaks into `value`. Repair both by + // re-splitting `value` at the date's colon. + const pi = Array.isArray(parameters) + ? parameters.findIndex(parameter => { + if (typeof parameter !== 'string') { + return false; + } + + const index = parameter.indexOf('='); + if (index === -1) { + return false; + } + + const nameLower = parameter.slice(0, index).toLowerCase(); + const valueLower = parameter.slice(index + 1).toLowerCase(); + return nameLower === 'tzid' && valueLower === 'tzone'; + }) + : -1; + if (pi !== -1) { + const parameter = parameters[pi]; + const index = typeof parameter === 'string' ? parameter.indexOf('=') : -1; + const firstColon = value.indexOf(':'); + const tzidRemainder = value.slice(0, firstColon); + const dateValue = value.slice(firstColon + 1); + + if (index !== -1) { + const parameterName = parameter.slice(0, index); + const parameterValue = parameter.slice(index + 1); + parameters[pi] = `${parameterName}=${parameterValue}:${tzidRemainder}`; + } + + value = dateValue; + } + + 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(comps[2]) - 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(comps[1]); + const monthIndex = Number(comps[2]) - 1; + const day = Number(comps[3]); + const hour = Number(comps[4]); + const minute = Number(comps[5]); + const second = Number(comps[6]); + + 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 { + // Floating DATE-TIME values (no TZID parameter) are, per RFC 5545, meant to stay + // in local wall-clock time with no timezone conversion. However, some very common + // real-world exports (e.g. the WordPress "The Events Calendar" plugin) emit DTSTART + // without a TZID *and* a single VTIMEZONE block that was clearly meant to apply to it + // (see node-ical issue #305 / PR #307 - washougal.k12.wa.us school calendar). Borrowing + // that VTIMEZONE keeps those calendars working correctly instead of silently reverting + // to whatever timezone the host process happens to run in. + 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); + }; + }; +} +/* eslint-enable complexity, max-depth */ + +function createComponentParameterHandlers({dateParameter, utcAdd}) { + 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); + const parsedCategories = splitUnescapedCommas(value).map(category => text(category.trim())); + if (curr[name] === undefined) { + curr[name] = parsedCategories; + } else if (value) { + curr[name] = [...curr[name], ...parsedCategories]; + } + + return curr; + }; + }; + + const recurrenceParameter = function (name) { + return dateParameter(name); + }; + + const addFBType = function (fb, parameters) { + const p = parseParameters(parameters); + + if (parameters && p) { + // FBTYPE is an enumerated value (RFC 5545 §3.2.9); normalize to uppercase + // so lowercase/mixed-case input behaves the same as the canonical form. + fb.type = typeof p.FBTYPE === 'string' ? p.FBTYPE.toUpperCase() : 'BUSY'; + } + + return fb; + }; + + const freebusyParameter = function (name) { + const storeFreebusyValue = storeParameter(name); + const isDurationValue = value => /^[+\-]?p/iv.test(value); + + return function (value, parameters, curr) { + curr[name] ||= []; + + // FREEBUSY may list multiple comma-separated periods on a single property line + // (RFC 5545 §3.8.2.6); process each period independently. + const periods = value.split(',').map(period => period.trim()).filter(Boolean); + + for (const period of periods) { + const fb = addFBType({}, parameters); + curr[name].push(fb); + + storeFreebusyValue(period, parameters, fb); + + const parts = period.split('/'); + dateParameter('start')(parts[0], parameters, fb); + + const secondPart = parts[1]; + if (secondPart && isDurationValue(secondPart)) { + const durationEnd = applyDurationToDate(fb.start, secondPart, utcAdd); + if (durationEnd === undefined) { + console.warn(`[node-ical] Ignoring malformed FREEBUSY duration value: "${secondPart}" – end not set`); + } else { + fb.end = cloneDateWithMeta(fb.start, durationEnd); + } + } else { + dateParameter('end')(secondPart, parameters, fb); + } + } + + return curr; + }; + }; + + return { + geoParameter, + categoriesParameter, + recurrenceParameter, + addFBType, + freebusyParameter, + }; +} + +function createExdateParameterFactory({dateParameter, getDateKey}) { + return 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; + }; + }; +} + +export { + text, + parseValue, + parseParameters, + applyUidSequenceMerge, + storeRecurrenceOverrideForEntry, + cleanupBaseSeriesRecurrenceId, + handleUidEntryInParent, + splitVCalendarProperties, + handleNonUidEntryInParent, + cloneDateWithMeta, + getDurationString, + applyImplicitEndDate, + finalizeEndedComponent, + ensureRruleHasDtstart, + normalizeRruleUntil, + buildRruleStringForTemporal, + buildDateOnlyRruleString, + buildTemporalDtstart, + buildRruleCompatWrapper, + storeValueParameter, + storeParameter, + isDateOnly, + typeParameter, + addTZFactory, + findVtimezoneInStack, + createDateParameterFactory, + createComponentParameterHandlers, + createExdateParameterFactory, +}; diff --git a/lib/public-api.js b/lib/public-api.js new file mode 100644 index 00000000..c6f038ac --- /dev/null +++ b/lib/public-api.js @@ -0,0 +1,32 @@ +/** + * Build the exported node-ical API object. + * Keeping this in one helper reduces drift between entrypoint wrappers. + * + * @param {object} options + * @param {object} options.asyncApi + * @param {object} options.autodetectApi + * @param {object} options.syncApi + * @param {(event: object, options: object) => Array} options.expandRecurringEvent + * @param {object} options.icalCore + * @returns {object} Public API object exposed by the package entry points. + */ +function buildPublicApi({asyncApi, autodetectApi, syncApi, expandRecurringEvent, icalCore}) { + return { + // Autodetect + fromURL: asyncApi.fromURL, + parseFile: autodetectApi.parseFile, + parseICS: autodetectApi.parseICS, + // Sync + sync: syncApi, + // Async + async: asyncApi, + // Recurring event expansion + expandRecurringEvent, + // Other backwards compat things + objectHandlers: icalCore.objectHandlers, + handleObject: icalCore.handleObject, + parseLines: icalCore.parseLines, + }; +} + +export {buildPublicApi}; diff --git a/lib/temporal.js b/lib/temporal.js new file mode 100644 index 00000000..2daabd17 --- /dev/null +++ b/lib/temporal.js @@ -0,0 +1,11 @@ +import {Temporal as PolyfillTemporal} from 'temporal-polyfill'; + +// Prefer a natively available Temporal implementation and fall back to the +// polyfill otherwise. The resolved implementation is pinned on globalThis so +// that rrule-temporal (loaded afterwards) resolves the exact same Temporal. +// eslint-disable-next-line unicorn/no-global-object-property-assignment -- shared runtime bootstrap for a single Temporal implementation +globalThis.Temporal ??= PolyfillTemporal; + +const {Temporal} = globalThis; + +export {Temporal}; diff --git a/lib/tz-utils.js b/lib/tz-utils.js index b2e8f311..f9c603f6 100644 --- a/lib/tz-utils.js +++ b/lib/tz-utils.js @@ -1,14 +1,9 @@ +import windowsZones from '../windowsZones.json' with {type: 'json'}; +import {Temporal} from './temporal.js'; + // Thin abstraction over Intl to centralize all timezone logic // This simplifies swapping libraries later and is easy to mock in tests. -// Load Temporal polyfill if not natively available (mirrors ical.js) -const Temporal = globalThis.Temporal || require('temporal-polyfill').Temporal; -const windowsZones = require('../windowsZones.json'); - -// Ensure polyfill is globally available for downstream modules -// eslint-disable-next-line unicorn/no-global-object-property-assignment -- shared polyfill bootstrap keeps runtime behavior explicit -globalThis.Temporal ??= Temporal; - // Minimal alias map to emulate the subset of moment.tz.link behavior tests rely on const aliasMap = new Map(); @@ -621,7 +616,11 @@ function resolveVTimezoneToIana(vTimezone, year) { } // Public API -module.exports = { +const __test__ = { + isUtcTimezone, +}; + +const tzUtil = { guessLocalZone, getZoneNames, findExactZoneMatch, @@ -635,13 +634,28 @@ module.exports = { formatDateForRrule, attachTz, isUtcTimezone, + __test__, }; -// Expose some internals for testing -module.exports.__test__ = { +export { + guessLocalZone, + getZoneNames, + findExactZoneMatch, + isValidIana, + parseDateTimeInZone, + parseWithOffset, + utcAdd, + linkAlias, + resolveTZID, + resolveVTimezoneToIana, + formatDateForRrule, + attachTz, isUtcTimezone, + __test__, }; +export default tzUtil; + function isUtcTimezone(tz) { if (!tz) { return false; diff --git a/node-ical.d.ts b/node-ical.d.ts index 1e653ccf..1d5a12fa 100644 --- a/node-ical.d.ts +++ b/node-ical.d.ts @@ -109,6 +109,23 @@ declare module 'node-ical' { options: ExpandRecurringEventOptions, ): EventInstance[]; + declare const _default: { + fromURL: typeof fromURL; + parseFile: typeof parseFile; + parseICS: typeof parseICS; + sync: typeof sync; + async: typeof async; + expandRecurringEvent: typeof expandRecurringEvent; + /** Internal compatibility hooks; intentionally left loose to avoid encouraging direct use. */ + objectHandlers: unknown; + /** Internal compatibility hooks; intentionally left loose to avoid encouraging direct use. */ + handleObject: unknown; + /** Internal compatibility hooks; intentionally left loose to avoid encouraging direct use. */ + parseLines: unknown; + }; + + export default _default; + /** * Options for expanding recurring events */ diff --git a/node-ical.js b/node-ical.js index e31f60e3..7d7fe617 100644 --- a/node-ical.js +++ b/node-ical.js @@ -1,655 +1,55 @@ -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 {string[]} [categories] - Array of event categories. - */ -/** - * Object containing iCal events. - * @typedef {{ [eventId: 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.|undefined)} optionalPromise - */ - -// utility to allow callbacks to be used for promises -function promiseCallback(promise, cb) { - if (!cb) { - return promise; - } - - // Using the two-argument form of then() (instead of then().catch()) is what - // guarantees the callback runs at most once: an exception thrown by cb inside - // the onFulfilled handler rejects the returned promise, it is NOT routed to - // the onRejected handler, so cb can never be invoked a second time. - const callCb = (error, result) => { - try { - cb(error, result); - } catch (callbackError) { - // The consumer uses a callback API and should not have to know a promise - // is used internally. Surface their error as a normal uncaught exception - // rather than leaking it as an unhandled promise rejection. - queueMicrotask(() => { - throw callbackError; - }); - } - }; - - promise.then( - returnValue => { - callCb(null, returnValue); - }, - error => { - callCb(error, null); - }, - ); -} - -// 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} [options] - 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(fromURLAsync(url, options), cb); -}; - -/** - * Parse iCal data from a string and resolve with the result. - * - * @param {string} data - String containing iCal data. - * - * @returns {Promise} Promise resolving to the parsed iCal data. - */ -function parseICSAsync(data) { - return new Promise((resolve, reject) => { - ical.parseICS(data, (error, ics) => { - if (error) { - reject(error); - return; - } - - resolve(ics); - }); - }); -} - -/** - * Read a file from disk and parse its iCal data. - * - * @param {string} filename - File path to load. - * - * @returns {Promise} Promise resolving to the parsed iCal data. - */ -async function parseFileAsync(filename) { - const data = await fs.promises.readFile(filename, 'utf8'); - return parseICSAsync(data); -} - -/** - * Fetch an iCal file over HTTP and parse its contents. - * - * @param {string} url - URL of file to request. - * @param {object} [options] - Options to pass to fetch(). Supports headers and any standard RequestInit fields. - * - * @returns {Promise} Promise resolving to the parsed iCal data. - */ -async function fromURLAsync(url, options) { - const fetchOptions = (options && typeof options === 'object') ? {...options} : {}; - const response = await fetch(url, fetchOptions); - if (!response.ok) { - // Mimic previous error style - throw new Error(`${response.status} ${response.statusText}`); - } - - const data = await response.text(); - return parseICSAsync(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 a promise will be returned. - * - * @returns {optionalPromise} Promise is returned if no callback is passed. - */ -async.parseFile = function (filename, cb) { - return promiseCallback(parseFileAsync(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 a promise will be returned. - * - * @returns {optionalPromise} Promise is returned if no callback is passed. - */ -async.parseICS = function (data, cb) { - return promiseCallback(parseICSAsync(data), 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. - const isoKey = date.toISOString(); - const hasIsoExdate = Object.hasOwn(event.exdate, isoKey); - const dateKeyExdate = event.exdate[dateKey]; - return hasIsoExdate || Boolean(dateKeyExdate?.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}} Adjusted search range bounds - */ -function adjustSearchRange(from, to, isFullDay, expandOngoing, baseDurationMs) { - let searchFrom; - let searchTo; - - if (isFullDay) { - // VALUE=DATE occurrences are anchored to UTC midnight (rrule-temporal uses - // tzid='UTC' for all date-only events). Normalise the caller-supplied - // local-midnight boundaries to their UTC-midnight equivalents so that - // rrule.between() comparisons are host-TZ-independent. - searchFrom = new Date(Date.UTC(from.getFullYear(), from.getMonth(), from.getDate())); - searchTo = new Date(Date.UTC(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59, 999)); - } else { - // Timed events: if `to` is exactly local midnight, extend to end of that day - // so events starting at any time that day are included. - const isMidnight = to.getHours() === 0 && to.getMinutes() === 0 && to.getSeconds() === 0; - searchFrom = from; - searchTo = isMidnight - ? new Date(to.getFullYear(), to.getMonth(), to.getDate(), 23, 59, 59, 999) - : to; - } - - if (expandOngoing) { - searchFrom = new Date(searchFrom.getTime() - baseDurationMs); - } - - 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 : 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.toSorted((a, b) => a.start - b.start); -} +import fs from 'node:fs'; +import ical from './ical.js'; +import {createCoreApi} from './lib/core-api.js'; +import expandRecurringEventImpl from './lib/expand-recurring-event.js'; +import {buildPublicApi} from './lib/public-api.js'; + +// Runtime API wiring lives here; public typings are maintained in node-ical.d.ts. + +const { + syncApi, + asyncApi, + autodetectApi, +} = createCoreApi({ + parseICSImpl: ical.parseICS.bind(ical), + fsModule: fs, +}); + +const {objectHandlers} = ical; +const handleObject = ical.handleObject.bind(ical); +const parseLines = ical.parseLines.bind(ical); + +const publicApi = buildPublicApi({ + asyncApi, + autodetectApi, + syncApi, + expandRecurringEvent: expandRecurringEventImpl, + icalCore: { + objectHandlers, + handleObject, + parseLines, + }, +}); + +const { + fromURL, + parseFile, + parseICS, + sync, + async, +} = publicApi; -// Export api functions -module.exports = { - // Autodetect - fromURL: async.fromURL, - parseFile: autodetect.parseFile, - parseICS: autodetect.parseICS, - // Sync +export { + fromURL, + parseFile, + parseICS, sync, - // Async async, - // Recurring event expansion - expandRecurringEvent, - // Other backwards compat things - objectHandlers: ical.objectHandlers, - handleObject: ical.handleObject, - parseLines: ical.parseLines, + objectHandlers, + handleObject, + parseLines, }; + +export {default as expandRecurringEvent} from './lib/expand-recurring-event.js'; + +export default publicApi; diff --git a/package-lock.json b/package-lock.json index ada949e6..cd692e29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "devDependencies": { "date-fns": "^4.4.0", "dayjs": "^1.11.21", + "esbuild": "^0.28.1", "fast-xml-parser": "^5.9.3", "lint-staged": "^17.0.8", "luxon": "^3.7.2", @@ -114,6 +115,448 @@ "node": ">=10" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-plugin-eslint-comments": { "version": "4.7.2", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.7.2.tgz", @@ -1333,9 +1776,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1350,9 +1790,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1367,9 +1804,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1384,9 +1818,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1401,9 +1832,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1418,9 +1846,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1435,9 +1860,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1452,9 +1874,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1469,9 +1888,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1486,9 +1902,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2446,6 +2859,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", diff --git a/package.json b/package.json index 5416e539..1cf71c56 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,27 @@ { "name": "node-ical", "version": "0.26.1", - "main": "node-ical.js", + "type": "module", + "main": "node-ical.cjs", + "exports": { + ".": { + "types": "./node-ical.d.ts", + "import": "./node-ical.js", + "require": "./node-ical.cjs", + "default": "./node-ical.js" + }, + "./package.json": "./package.json" + }, "types": "node-ical.d.ts", "description": "NodeJS class for parsing iCalendar/ICS files", + "files": [ + "node-ical.cjs", + "node-ical.js", + "node-ical.d.ts", + "ical.js", + "lib/**/*.js", + "windowsZones.json" + ], "keywords": [ "ical", "ics", @@ -30,6 +48,7 @@ "devDependencies": { "date-fns": "^4.4.0", "dayjs": "^1.11.21", + "esbuild": "^0.28.1", "fast-xml-parser": "^5.9.3", "lint-staged": "^17.0.8", "luxon": "^3.7.2", @@ -56,14 +75,17 @@ } }, "scripts": { - "examples": "node examples/example-rrule-basic.js && node examples/example-rrule-moment.js && node examples/example-rrule-luxon.js && node examples/example-rrule-dayjs.js && node examples/example-rrule-datefns.js && node examples/example-rrule-vanilla.js && node examples/example.mjs", - "test": "npm run test:types && xo && mocha --timeout 8000", + "examples": "node examples/example-rrule-basic.js && node examples/example-rrule-moment.js && node examples/example-rrule-luxon.js && node examples/example-rrule-dayjs.js && node examples/example-rrule-datefns.js && node examples/example-rrule-vanilla.js && node examples/example.js", + "build:cjs": "node build/build-cjs.js", + "pretest": "npm run build:cjs", + "test": "npm run test:types && xo && mocha --extension js --timeout 8000", "test:types": "tsc --project test/tsconfig.json", "lint": "xo", "lintfix": "xo --fix", - "prepare": "simple-git-hooks", - "build": "node build/update-windows-zones.mjs", - "build:strict": "node build/update-windows-zones.mjs --strict" + "prepare": "simple-git-hooks && npm run build:cjs", + "prepack": "npm run build:cjs", + "build": "node build/update-windows-zones.js", + "build:strict": "node build/update-windows-zones.js --strict" }, "simple-git-hooks": { "pre-commit": "npx --no lint-staged", diff --git a/test/advanced.test.js b/test/advanced.test.js index 150fb184..5ed7cb34 100644 --- a/test/advanced.test.js +++ b/test/advanced.test.js @@ -1,8 +1,8 @@ /* eslint-disable max-nested-callbacks */ -const assert = require('node:assert/strict'); -const {describe, it, before} = require('mocha'); -const tz = require('../lib/tz-utils.js'); -const ical = require('../node-ical.js'); +import assert from 'node:assert/strict'; +import {before, describe, it} from 'mocha'; +import tz from '../lib/tz-utils.js'; +import ical from 'node-ical'; function assertDurationCase(event, testCase) { switch (testCase.expected) { @@ -574,7 +574,7 @@ END:VCALENDAR`; // Floating DTSTART (no TZID param) should still resolve via the VTIMEZONE // on the parser stack when the VTIMEZONE carries a custom TZID that - // resolveTZID alone cannot map. This exercises the resolveVTimezoneToIana + // resolveTZID alone cannot map. This exercises the resolveVTimezoneToIana // fallback inside fallbackWithStackTimezone(). it('resolves floating DTSTART via VTIMEZONE STANDARD/DAYLIGHT rules', () => { const data = ical.parseFile('./test/fixtures/floating-dtstart-custom-vtimezone.ics'); @@ -654,6 +654,7 @@ END:VCALENDAR`; }); // Test_with_multiple_tzids_in_vtimezone.ics – select correct tz across multiple components + // (node-ical issue #305 / PR #307 - washougal.k12.wa.us school calendar). it('chooses correct tz across components (test_with_multiple_tzids_in_vtimezone.ics)', () => { const data = ical.parseFile('./test/fixtures/test_with_multiple_tzids_in_vtimezone.ics'); const event = Object.values(data).find(x => x.uid === '1891-1709856000-1709942399@www.washougal.k12.wa.us'); diff --git a/test/async-error-handling.test.js b/test/async-error-handling.test.js index f81a2542..8440ad1f 100644 --- a/test/async-error-handling.test.js +++ b/test/async-error-handling.test.js @@ -4,10 +4,15 @@ * Related to Issue #144: Uncatchable exception in async mode * @see https://github.com/jens-maus/node-ical/issues/144 */ -const assert = require('node:assert/strict'); -const process = require('node:process'); -const {describe, it} = require('mocha'); -const ical = require('../node-ical.js'); +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; +import {describe, it} from 'mocha'; +import ical from 'node-ical'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Helper to promisify the callback-based async.parseICS API function parseICSPromise(data) { @@ -197,9 +202,7 @@ describe('parseICS sync vs async parity', () => { // This demonstrates the actual bug: exceptions thrown in setImmediate callbacks // escape to the global uncaughtException handler instead of being passed to the callback - const fs = require('node:fs'); - const path = require('node:path'); - const largeICS = fs.readFileSync( + const largeICS = readFileSync( path.join(__dirname, 'fixtures', 'large-file-with-late-error.ics'), 'utf8', ); diff --git a/test/basic.test.js b/test/basic.test.js index dbdce8d9..4b7851a6 100644 --- a/test/basic.test.js +++ b/test/basic.test.js @@ -1,7 +1,7 @@ -const assert_ = require('node:assert/strict'); -const {describe, it} = require('mocha'); -const tz = require('../lib/tz-utils.js'); -const ical = require('../node-ical.js'); +import assert_ from 'node:assert/strict'; +import {describe, it} from 'mocha'; +import tz from '../lib/tz-utils.js'; +import ical from 'node-ical'; // Map 'Etc/Unknown' TZID used in fixtures to a concrete zone tz.linkAlias('Etc/Unknown', 'Etc/GMT'); diff --git a/test/commonjs-compat.test.js b/test/commonjs-compat.test.js new file mode 100644 index 00000000..5052720f --- /dev/null +++ b/test/commonjs-compat.test.js @@ -0,0 +1,41 @@ +/* eslint-disable import-x/order */ + +import {createRequire} from 'node:module'; + +const require = createRequire(import.meta.url); +const assert = require('node:assert/strict'); +const {describe, it} = require('mocha'); +const nodeIcal = require('node-ical'); + +const ICS_FIXTURE = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//node-ical//CJS compatibility test//EN', + 'BEGIN:VEVENT', + 'UID:cjs-compat-1', + 'DTSTAMP:20260101T000000Z', + 'DTSTART:20260101T100000Z', + 'DTEND:20260101T110000Z', + 'SUMMARY:CommonJS Compatibility Event', + 'END:VEVENT', + 'END:VCALENDAR', +].join('\r\n'); + +function findFirstVevent(data) { + return Object.values(data).find(component => component?.type === 'VEVENT'); +} + +describe('CommonJS compatibility', () => { + it('loads the package through require() and parses a VEVENT', () => { + const expectedUid = 'cjs-compat-1'; + const expectedSummary = 'CommonJS Compatibility Event'; + + assert.equal(typeof nodeIcal.parseICS, 'function'); + const parsedCalendar = nodeIcal.parseICS(ICS_FIXTURE); + const parsedEvent = findFirstVevent(parsedCalendar); + + assert.ok(parsedEvent, 'Expected at least one VEVENT in parsed output'); + assert.equal(parsedEvent.uid, expectedUid); + assert.equal(parsedEvent.summary, expectedSummary); + }); +}); diff --git a/test/date-only-rrule-until.test.js b/test/date-only-rrule-until.test.js index e8d355f1..92970782 100644 --- a/test/date-only-rrule-until.test.js +++ b/test/date-only-rrule-until.test.js @@ -1,8 +1,8 @@ /* eslint-disable prefer-arrow-callback */ -const assert = require('node:assert/strict'); -const {describe, it} = require('mocha'); -const ical = require('../node-ical.js'); +import assert from 'node:assert/strict'; +import {describe, it} from 'mocha'; +import ical from 'node-ical'; describe('DATE-only RRULE with UNTIL (regression test for Google Calendar birthday events)', function () { it('should parse DATE-only events with yearly RRULE and UNTIL in the past', function () { diff --git a/test/examples-snapshot.test.js b/test/examples-snapshot.test.js index c986e49c..4ace84ed 100644 --- a/test/examples-snapshot.test.js +++ b/test/examples-snapshot.test.js @@ -1,9 +1,12 @@ -const assert = require('node:assert'); -const {spawnSync} = require('node:child_process'); -const process = require('node:process'); -const path = require('node:path'); -const fs = require('node:fs'); -const {describe, it} = require('mocha'); +import assert from 'node:assert'; +import {spawnSync} from 'node:child_process'; +import process from 'node:process'; +import path from 'node:path'; +import fs from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import {describe, it} from 'mocha'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const exampleScripts = [ 'example-rrule-basic.js', @@ -48,7 +51,7 @@ describe('example output snapshots', function () { for (const script of exampleScripts) { it(`${script} output matches snapshot`, function () { const output = normalizeNewlines(runExample(script)); - const snapshotFile = path.join(snapshotDir, script.replace(/\.m?js$/v, '.txt')); + const snapshotFile = path.join(snapshotDir, script.replace(/\.(?:c|m)?js$/v, '.txt')); if (fs.existsSync(snapshotFile)) { const expected = normalizeNewlines(fs.readFileSync(snapshotFile, 'utf8')).trim(); assert.strictEqual(output, expected, `Output of ${script} does not match snapshot. If this is intentional, update the snapshot.`); diff --git a/test/expand-recurring-event.test.js b/test/expand-recurring-event.test.js index 3b8077db..8ca6964c 100644 --- a/test/expand-recurring-event.test.js +++ b/test/expand-recurring-event.test.js @@ -1,7 +1,10 @@ -const assert = require('node:assert'); -const path = require('node:path'); -const {describe, it} = require('mocha'); -const ical = require('../node-ical.js'); +import assert from 'node:assert'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {describe, it} from 'mocha'; +import ical from 'node-ical'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); describe('expandRecurringEvent', () => { describe('Basic functionality', () => { diff --git a/test/extended-components.test.js b/test/extended-components.test.js index 6c6c9556..2101bcc1 100644 --- a/test/extended-components.test.js +++ b/test/extended-components.test.js @@ -3,9 +3,9 @@ * Ensures comprehensive coverage of all component types */ -const assert_ = require('node:assert/strict'); -const {describe, it} = require('mocha'); -const ical = require('../node-ical.js'); +import assert_ from 'node:assert/strict'; +import {describe, it} from 'mocha'; +import ical from 'node-ical'; function values(object) { return Object.values(object); diff --git a/test/from-url.test.js b/test/from-url.test.js index 4db67198..404aa432 100644 --- a/test/from-url.test.js +++ b/test/from-url.test.js @@ -1,7 +1,7 @@ -const assert = require('node:assert/strict'); -const http = require('node:http'); -const {describe, it} = require('mocha'); -const ical = require('../node-ical.js'); +import assert from 'node:assert/strict'; +import http from 'node:http'; +import {describe, it} from 'mocha'; +import ical from 'node-ical'; const ICS_TEMPLATE = [ 'BEGIN:VCALENDAR', diff --git a/test/google-calendar-until-bug.test.js b/test/google-calendar-until-bug.test.js index b6afdf6a..afd49eb4 100644 --- a/test/google-calendar-until-bug.test.js +++ b/test/google-calendar-until-bug.test.js @@ -1,8 +1,8 @@ /* eslint-disable prefer-arrow-callback */ -const assert = require('node:assert/strict'); -const {describe, it} = require('mocha'); -const ical = require('../node-ical.js'); +import assert from 'node:assert/strict'; +import {describe, it} from 'mocha'; +import ical from 'node-ical'; /** * Regression tests for Google Calendar's UNTIL format bug (#435, rrule-temporal#104). diff --git a/test/issue-459-exchange-recurrence-id.test.js b/test/issue-459-exchange-recurrence-id.test.js index 30054223..c4548578 100644 --- a/test/issue-459-exchange-recurrence-id.test.js +++ b/test/issue-459-exchange-recurrence-id.test.js @@ -10,10 +10,13 @@ * The RECURRENCE-ID midnight in CET (UTC+1) becomes 2026-02-25T23:00:00Z in UTC, * causing getDateKey() to return "2026-02-25" instead of the correct "2026-02-26". */ -const assert = require('node:assert'); -const path = require('node:path'); -const {describe, it, before} = require('mocha'); -const ical = require('../node-ical.js'); +import assert from 'node:assert'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {before, describe, it} from 'mocha'; +import ical from 'node-ical'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); describe('Issue #459 - Exchange DATE-TIME RECURRENCE-ID on DATE-only event', () => { let events; diff --git a/test/meta-preservation.test.js b/test/meta-preservation.test.js index 08abed42..9078caa9 100644 --- a/test/meta-preservation.test.js +++ b/test/meta-preservation.test.js @@ -1,6 +1,6 @@ -const assert = require('node:assert/strict'); -const {describe, it} = require('mocha'); -const ical = require('../node-ical.js'); +import assert from 'node:assert/strict'; +import {describe, it} from 'mocha'; +import ical from 'node-ical'; describe('parser: metadata preservation', () => { it('preserves tz/dateOnly metadata when normalizing all-day DTSTART for RRULE', () => { diff --git a/test/module-formats.test.js b/test/module-formats.test.js new file mode 100644 index 00000000..200ae9a2 --- /dev/null +++ b/test/module-formats.test.js @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {describe, it} from 'mocha'; + +const require = createRequire(import.meta.url); + +const ICS_SAMPLE = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//module format parity//EN', + 'BEGIN:VEVENT', + 'UID:module-format-1', + 'DTSTAMP:20250101T000000Z', + 'DTSTART:20250101T100000Z', + 'DTEND:20250101T110000Z', + 'SUMMARY:Module Format Event', + 'END:VEVENT', + 'END:VCALENDAR', +].join('\r\n'); + +function findFirstVevent(data) { + return Object.values(data).find(component => component?.type === 'VEVENT'); +} + +function assertCjsContainsEsmDefaultKeys(esmDefault, cjs) { + for (const key of Object.keys(esmDefault)) { + assert.ok(Object.hasOwn(cjs, key), `CJS entry is missing "${key}"`); + } +} + +describe('package entry points', () => { + it('loads CommonJS entry via require', () => { + const cjs = require('node-ical'); + + assert.equal(typeof cjs.parseICS, 'function'); + assert.equal(typeof cjs.parseFile, 'function'); + assert.equal(typeof cjs.fromURL, 'function'); + assert.equal(typeof cjs.expandRecurringEvent, 'function'); + assert.equal(typeof cjs.sync?.parseICS, 'function'); + assert.equal(typeof cjs.async?.parseICS, 'function'); + }); + + it('loads ESM entry via import with named and default exports', async () => { + const esm = await import('node-ical'); + + assert.equal(typeof esm.parseICS, 'function'); + assert.equal(typeof esm.parseFile, 'function'); + assert.equal(typeof esm.fromURL, 'function'); + assert.equal(typeof esm.expandRecurringEvent, 'function'); + assert.equal(typeof esm.default?.parseICS, 'function'); + assert.equal(typeof esm.default?.sync?.parseICS, 'function'); + assert.equal(typeof esm.default?.async?.parseICS, 'function'); + }); + + it('resolves the advertised package exports for both import and require', async () => { + const packageJson = require('../package.json'); + + assert.equal(packageJson.type, 'module'); + assert.equal(packageJson.exports['.'].require, './node-ical.cjs'); + assert.equal(packageJson.exports['.'].import, './node-ical.js'); + + const esm = await import('node-ical'); + const cjs = require('node-ical'); + + // The generated CJS entry may carry additive interop keys (e.g. `default`), + // so require every public ESM key to be present rather than an exact match. + assertCjsContainsEsmDefaultKeys(esm.default, cjs); + + assert.equal(esm.default.parseICS, esm.parseICS); + assert.equal(typeof cjs.parseICS, 'function'); + }); + + it('exposes matching top-level API keys for CJS and ESM default export', async () => { + const cjs = require('node-ical'); + const esm = await import('node-ical'); + + assertCjsContainsEsmDefaultKeys(esm.default, cjs); + }); + + it('parses ICS consistently between CJS and ESM named export', async () => { + const cjs = require('node-ical'); + const esm = await import('node-ical'); + + const parsedCjs = cjs.parseICS(ICS_SAMPLE); + const parsedEsm = esm.parseICS(ICS_SAMPLE); + const eventCjs = findFirstVevent(parsedCjs); + const eventEsm = findFirstVevent(parsedEsm); + + assert.ok(eventCjs); + assert.ok(eventEsm); + assert.equal(eventCjs.uid, eventEsm.uid); + assert.equal(eventCjs.summary, eventEsm.summary); + assert.equal(eventCjs.start.toISOString(), eventEsm.start.toISOString()); + assert.equal(eventCjs.end.toISOString(), eventEsm.end.toISOString()); + }); +}); diff --git a/test/monthly-bymonthday-multiple.test.js b/test/monthly-bymonthday-multiple.test.js index 56acfba5..151619d6 100644 --- a/test/monthly-bymonthday-multiple.test.js +++ b/test/monthly-bymonthday-multiple.test.js @@ -6,9 +6,9 @@ // value existed earlier in the same month, the entire start month was skipped. // Fixed in rrule-temporal v1.4.7 (https://github.com/neogermi/rrule-temporal/pull/111) -const assert = require('node:assert/strict'); -const {describe, it} = require('mocha'); -const ical = require('../node-ical.js'); +import assert from 'node:assert/strict'; +import {describe, it} from 'mocha'; +import ical from 'node-ical'; describe('FREQ=MONTHLY with multiple BYMONTHDAY values (regression test for rrule-temporal v1.4.7)', function () { it('should include remaining BYMONTHDAY occurrences in the DTSTART month when a earlier BYMONTHDAY value exists', function () { diff --git a/test/non-utc-until.test.js b/test/non-utc-until.test.js index 9749431b..740d7224 100644 --- a/test/non-utc-until.test.js +++ b/test/non-utc-until.test.js @@ -1,10 +1,13 @@ /* eslint-disable prefer-arrow-callback */ -const assert = require('node:assert/strict'); -const {readFileSync} = require('node:fs'); -const path = require('node:path'); -const {describe, it} = require('mocha'); -const ical = require('../node-ical.js'); +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {describe, it} from 'mocha'; +import ical from 'node-ical'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); describe('Non-UTC UNTIL in RRULE', function () { describe('when DTSTART has TZID=Europe/Berlin', function () { diff --git a/test/polyfill-check.test.js b/test/polyfill-check.test.js index ed17e2f2..3d1e3fd5 100644 --- a/test/polyfill-check.test.js +++ b/test/polyfill-check.test.js @@ -1,7 +1,10 @@ -const assert = require('node:assert/strict'); -const {execSync} = require('node:child_process'); -const path = require('node:path'); -const {describe, it} = require('mocha'); +import assert from 'node:assert/strict'; +import {execSync} from 'node:child_process'; +import {createRequire} from 'node:module'; +import path from 'node:path'; +import {describe, it} from 'mocha'; + +const require = createRequire(import.meta.url); describe('Temporal Polyfill Configuration', () => { it('should NOT have JSBI dependency installed', () => { diff --git a/test/regression-fixes.test.js b/test/regression-fixes.test.js new file mode 100644 index 00000000..bb48d10d --- /dev/null +++ b/test/regression-fixes.test.js @@ -0,0 +1,485 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; +import {describe, it} from 'mocha'; +import * as icalCore from '../ical.js'; +import * as packageEntry from '../node-ical.js'; +import ical from 'node-ical'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const ICS_SAMPLE = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//regression fixes//EN', + 'BEGIN:VEVENT', + 'UID:regression-1', + 'DTSTAMP:20250101T000000Z', + 'DTSTART:20250101T100000Z', + 'DTEND:20250101T110000Z', + 'SUMMARY:Regression Event', + 'END:VEVENT', + 'END:VCALENDAR', +].join('\r\n'); + +function findFirstVevent(data) { + return Object.values(data).find(component => component?.type === 'VEVENT'); +} + +describe('regression fixes', () => { + it('keeps internal named parser exports callable without an object receiver', () => { + const coreParsed = icalCore.parseICS(ICS_SAMPLE); + const entryParsed = packageEntry.parseLines(ICS_SAMPLE.split(/\r?\n/v)); + + assert.equal(findFirstVevent(coreParsed)?.uid, 'regression-1'); + assert.equal(findFirstVevent(entryParsed)?.uid, 'regression-1'); + + const coreCtx = icalCore.handleObject('UID', 'bound-core', [], {}, []); + const entryCtx = packageEntry.handleObject('UID', 'bound-entry', [], {}, []); + + assert.equal(coreCtx.uid, 'bound-core'); + assert.equal(entryCtx.uid, 'bound-entry'); + }); + + it('treats full-day recurring duration as calendar days instead of fixed milliseconds', () => { + const previousTZ = process.env.TZ; + process.env.TZ = 'Etc/UTC'; + + try { + const event = { + type: 'VEVENT', + uid: 'full-day-dst-duration@test', + summary: 'Full Day Event', + start: new Date('2025-03-30T00:00:00.000Z'), + end: new Date('2025-03-30T23:00:00.000Z'), + datetype: 'date', + rrule: { + between() { + return [new Date('2025-04-02T00:00:00.000Z')]; + }, + }, + }; + + const [instance] = ical.expandRecurringEvent(event, { + from: new Date('2025-04-02T00:00:00.000Z'), + to: new Date('2025-04-02T23:59:59.999Z'), + }); + + assert.ok(instance); + assert.equal(instance.start.getFullYear(), 2025); + assert.equal(instance.start.getMonth(), 3); + assert.equal(instance.start.getDate(), 2); + assert.equal(instance.start.getHours(), 0); + assert.equal(instance.end.getFullYear(), 2025); + assert.equal(instance.end.getMonth(), 3); + assert.equal(instance.end.getDate(), 3); + assert.equal(instance.end.getHours(), 0); + } finally { + if (previousTZ === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = previousTZ; + } + } + }); + + it('resolves a floating DTSTART using the single VTIMEZONE present in the file (node-ical #305/#307)', () => { + const previousTZ = process.env.TZ; + process.env.TZ = 'Etc/UTC'; + + try { + const parsed = ical.parseICS([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//floating-no-tzid//EN', + 'BEGIN:VTIMEZONE', + 'TZID:Europe/Berlin', + 'BEGIN:STANDARD', + 'DTSTART:19700101T030000', + 'TZOFFSETFROM:+0200', + 'TZOFFSETTO:+0100', + 'END:STANDARD', + 'BEGIN:DAYLIGHT', + 'DTSTART:19700101T020000', + 'TZOFFSETFROM:+0100', + 'TZOFFSETTO:+0200', + 'END:DAYLIGHT', + 'END:VTIMEZONE', + 'BEGIN:VEVENT', + 'UID:floating-no-tzid@test', + 'DTSTAMP:20250101T000000Z', + 'DTSTART:20250101T120000', + 'DTEND:20250101T130000', + 'SUMMARY:Floating time event', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n')); + + const event = findFirstVevent(parsed); + assert.ok(event); + // 2025-01-01 is in CET (+01:00) in Europe/Berlin, so 12:00 wall → 11:00 UTC. + assert.equal(event.start.toISOString(), '2025-01-01T11:00:00.000Z'); + assert.equal(event.end.toISOString(), '2025-01-01T12:00:00.000Z'); + } finally { + if (previousTZ === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = previousTZ; + } + } + }); + + it('includes moved overrides whose effective start falls inside the requested range', () => { + const overrideEvent = { + type: 'VEVENT', + uid: 'moved-override@test', + recurrenceid: new Date('2025-01-05T09:00:00.000Z'), + summary: 'Moved Override', + start: new Date('2025-01-10T12:00:00.000Z'), + end: new Date('2025-01-10T13:00:00.000Z'), + }; + + const event = { + type: 'VEVENT', + uid: 'moved-override@test', + summary: 'Base Event', + start: new Date('2025-01-01T09:00:00.000Z'), + end: new Date('2025-01-01T10:00:00.000Z'), + rrule: { + between(start, end) { + return [new Date('2025-01-05T09:00:00.000Z')].filter(date => date >= start && date <= end); + }, + }, + recurrences: { + '2025-01-05T09:00:00.000Z': overrideEvent, + '2025-01-05': overrideEvent, + }, + }; + + const instances = ical.expandRecurringEvent(event, { + from: new Date('2025-01-10T00:00:00.000Z'), + to: new Date('2025-01-10T23:59:59.999Z'), + includeOverrides: true, + }); + + assert.equal(instances.length, 1); + assert.equal(instances[0].isOverride, true); + assert.equal(instances[0].summary, 'Moved Override'); + assert.equal(instances[0].start.toISOString(), '2025-01-10T12:00:00.000Z'); + }); + + it('stores VFREEBUSY raw values and parameters on each busy period', () => { + const data = ical.parseFile(path.join(__dirname, 'fixtures', 'vfreebusy-zimbra.ics')); + const vfreebusy = Object.values(data).find(component => component?.type === 'VFREEBUSY'); + const period = vfreebusy.freebusy[0]; + + assert.deepEqual(period.freebusy.params, {FBTYPE: 'BUSY'}); + assert.match(period.freebusy.val, /^\d{8}T\d{6}Z\/\d{8}T\d{6}Z$/v); + }); + + it('parses FREEBUSY start/end and start/duration periods', () => { + const parsed = ical.parseICS([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//freebusy-periods//EN', + 'BEGIN:VFREEBUSY', + 'UID:freebusy-periods@test', + 'DTSTAMP:20250101T000000Z', + 'FREEBUSY;FBTYPE=BUSY:20250101T100000Z/20250101T103000Z', + 'FREEBUSY;FBTYPE=BUSY-TENTATIVE:20250101T110000Z/PT45M', + 'END:VFREEBUSY', + 'END:VCALENDAR', + ].join('\r\n')); + + const vfreebusy = Object.values(parsed).find(component => component?.type === 'VFREEBUSY'); + assert.ok(vfreebusy); + assert.equal(vfreebusy.freebusy.length, 2); + + const explicitEndPeriod = vfreebusy.freebusy[0]; + assert.equal(explicitEndPeriod.start.toISOString(), '2025-01-01T10:00:00.000Z'); + assert.equal(explicitEndPeriod.end.toISOString(), '2025-01-01T10:30:00.000Z'); + + const durationPeriod = vfreebusy.freebusy[1]; + assert.equal(durationPeriod.start.toISOString(), '2025-01-01T11:00:00.000Z'); + assert.equal(durationPeriod.end.toISOString(), '2025-01-01T11:45:00.000Z'); + assert.deepEqual(durationPeriod.freebusy.params, {FBTYPE: 'BUSY-TENTATIVE'}); + assert.equal(durationPeriod.freebusy.val, '20250101T110000Z/PT45M'); + }); + + it('accepts FREEBUSY durations with a leading plus sign and lowercase unit letters', () => { + const parsed = ical.parseICS([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//freebusy-plus-lowercase-duration//EN', + 'BEGIN:VFREEBUSY', + 'UID:freebusy-plus-lowercase-duration@test', + 'DTSTAMP:20250101T000000Z', + 'FREEBUSY;FBTYPE=BUSY:20250101T110000Z/+pt45m', + 'END:VFREEBUSY', + 'END:VCALENDAR', + ].join('\r\n')); + + const vfreebusy = Object.values(parsed).find(component => component?.type === 'VFREEBUSY'); + const period = vfreebusy.freebusy[0]; + + assert.equal(period.start.toISOString(), '2025-01-01T11:00:00.000Z'); + assert.equal(period.end.toISOString(), '2025-01-01T11:45:00.000Z'); + }); + + it('leaves FREEBUSY end unset when the duration value is malformed', () => { + const parsed = ical.parseICS([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//freebusy-malformed-duration//EN', + 'BEGIN:VFREEBUSY', + 'UID:freebusy-malformed-duration@test', + 'DTSTAMP:20250101T000000Z', + 'FREEBUSY;FBTYPE=BUSY:20250101T100000Z/PXYZ', + 'END:VFREEBUSY', + 'END:VCALENDAR', + ].join('\r\n')); + + const vfreebusy = Object.values(parsed).find(component => component?.type === 'VFREEBUSY'); + const period = vfreebusy.freebusy[0]; + + assert.equal(period.start.toISOString(), '2025-01-01T10:00:00.000Z'); + assert.equal(period.end, undefined, 'Malformed duration should not produce a fabricated end date'); + }); + + it('parses every comma-separated FREEBUSY period on a single property line', () => { + const parsed = ical.parseICS([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//freebusy-comma-separated//EN', + 'BEGIN:VFREEBUSY', + 'UID:freebusy-comma-separated@test', + 'DTSTAMP:20250101T000000Z', + 'FREEBUSY;FBTYPE=BUSY:20250101T100000Z/20250101T103000Z,20250101T110000Z/PT30M', + 'END:VFREEBUSY', + 'END:VCALENDAR', + ].join('\r\n')); + + const vfreebusy = Object.values(parsed).find(component => component?.type === 'VFREEBUSY'); + assert.equal(vfreebusy.freebusy.length, 2); + + const [first, second] = vfreebusy.freebusy; + assert.equal(first.start.toISOString(), '2025-01-01T10:00:00.000Z'); + assert.equal(first.end.toISOString(), '2025-01-01T10:30:00.000Z'); + assert.equal(second.start.toISOString(), '2025-01-01T11:00:00.000Z'); + assert.equal(second.end.toISOString(), '2025-01-01T11:30:00.000Z'); + }); + + it('handles parameter names and enumerated values case-insensitively', () => { + const parsed = ical.parseICS([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//case-insensitive-parameters//EN', + 'BEGIN:VEVENT', + 'UID:case-insensitive-parameters@test', + 'DTSTAMP:20250101T000000Z', + 'DTSTART;value=date:20250101', + 'DTEND;value=date:20250102', + 'ATTENDEE;rsvp=true;partstat=accepted:mailto:test@example.com', + 'SUMMARY:Case Insensitive Parameters', + 'END:VEVENT', + 'BEGIN:VFREEBUSY', + 'UID:case-insensitive-freebusy@test', + 'DTSTAMP:20250101T000000Z', + 'FREEBUSY;fbtype=busy:20250101T100000Z/20250101T103000Z', + 'END:VFREEBUSY', + 'END:VCALENDAR', + ].join('\r\n')); + + const event = Object.values(parsed).find(component => component?.type === 'VEVENT'); + assert.equal(event.datetype, 'date', 'lowercase value=date should still be treated as a date-only DTSTART'); + + assert.equal(event.attendee.params.RSVP, true, 'lowercase rsvp=true should parse as boolean true'); + assert.equal(event.attendee.params.PARTSTAT, 'accepted', 'lowercase parameter name PARTSTAT should still be readable via its uppercase key'); + + const vfreebusy = Object.values(parsed).find(component => component?.type === 'VFREEBUSY'); + assert.equal(vfreebusy.freebusy[0].type, 'BUSY', 'lowercase fbtype=busy should normalize to uppercase BUSY'); + }); + + it('drops a property removed by a higher-SEQUENCE revision of the same UID instead of leaving it stale', () => { + const parsed = ical.parseICS([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//sequence-removes-property//EN', + 'BEGIN:VEVENT', + 'UID:sequence-removes-rrule@test', + 'SEQUENCE:0', + 'DTSTAMP:20250101T000000Z', + 'DTSTART:20250101T100000Z', + 'DTEND:20250101T110000Z', + 'RRULE:FREQ=DAILY;COUNT=5', + 'SUMMARY:Recurring Meeting', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:sequence-removes-rrule@test', + 'SEQUENCE:1', + 'DTSTAMP:20250102T000000Z', + 'DTSTART:20250101T100000Z', + 'DTEND:20250101T110000Z', + 'SUMMARY:No Longer Recurring', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n')); + + const event = Object.values(parsed).find(component => component?.type === 'VEVENT'); + assert.equal(event.summary, 'No Longer Recurring'); + assert.equal(event.rrule, undefined, 'RRULE removed by the higher-SEQUENCE revision should not persist from the older revision'); + }); + + it('fully replaces an override with a higher-SEQUENCE base series without leaving override-only fields behind', () => { + const parsed = ical.parseICS([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//override-to-base-series-merge//EN', + 'BEGIN:VEVENT', + 'UID:override-to-base-series@test', + 'SEQUENCE:2', + 'RECURRENCE-ID:20250102T100000Z', + 'DTSTAMP:20250102T000000Z', + 'DTSTART:20250102T120000Z', + 'DTEND:20250102T130000Z', + 'LOCATION:Override Room', + 'SUMMARY:Override Instance', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:override-to-base-series@test', + 'SEQUENCE:3', + 'DTSTAMP:20250103T000000Z', + 'DTSTART:20250101T100000Z', + 'DTEND:20250101T110000Z', + 'RRULE:FREQ=DAILY;COUNT=2', + 'SUMMARY:Base Series', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n')); + + const event = Object.values(parsed).find(component => component?.type === 'VEVENT'); + assert.equal(event.summary, 'Base Series'); + assert.equal(event.location, undefined, 'override-only fields should not persist after the higher-SEQUENCE base series replaces the override'); + assert.equal(event.recurrenceid, undefined, 'base series should not retain the old override recurrence id'); + assert.ok(event.rrule, 'base series should still keep its RRULE'); + }); + + it('does not derive an implicit end date for VJOURNAL components', () => { + const parsed = ical.parseICS([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//journal-no-implicit-end//EN', + 'BEGIN:VJOURNAL', + 'UID:journal-no-implicit-end@test', + 'DTSTAMP:20250101T000000Z', + 'DTSTART:20250101T100000Z', + 'SUMMARY:Journal Entry', + 'END:VJOURNAL', + 'END:VCALENDAR', + ].join('\r\n')); + + const journal = Object.values(parsed).find(component => component?.type === 'VJOURNAL'); + assert.equal(journal.end, undefined, 'VJOURNAL should not receive an implicit end date'); + }); + + it('treats __proto__ UIDs as data instead of mutating the result prototype', () => { + const parsed = ical.parseICS([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//proto UID//EN', + 'BEGIN:VEVENT', + 'UID:__proto__', + 'DTSTAMP:20250101T000000Z', + 'DTSTART:20250101T100000Z', + 'DTEND:20250101T110000Z', + 'SUMMARY:Prototype Safety', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n')); + + assert.equal(Object.getPrototypeOf(parsed), Object.prototype); + assert.equal(Object.hasOwn(parsed, '__proto__'), true); + assert.equal(Object.getOwnPropertyDescriptor(parsed, '__proto__')?.value?.uid, '__proto__'); + assert.equal('uid' in {}, false); + }); + + it('rejects DURATION values that look valid but have the wrong shape', () => { + const originalWarn = console.warn; + console.warn = () => { + // No-op + }; + + try { + // 'M' outside a T-time part means "months", which RFC 5545 DURATION does + // not support - this must not be silently treated as minutes. + const monthsLike = ical.parseICS([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//duration-p1m//EN', + 'BEGIN:VEVENT', + 'UID:duration-p1m@test', + 'DTSTAMP:20250101T000000Z', + 'DTSTART:20250101T100000Z', + 'DURATION:P1M', + 'SUMMARY:Ambiguous duration', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n')); + const monthsEvent = findFirstVevent(monthsLike); + assert.equal(monthsEvent.end.toISOString(), monthsEvent.start.toISOString(), 'P1M must not be treated as 1 minute'); + + // A valid-looking fragment with trailing garbage must be rejected outright, + // not partially matched. + const trailingGarbage = ical.parseICS([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//duration-trailing-garbage//EN', + 'BEGIN:VEVENT', + 'UID:duration-trailing-garbage@test', + 'DTSTAMP:20250101T000000Z', + 'DTSTART:20250101T100000Z', + 'DURATION:P1DXYZ', + 'SUMMARY:Trailing garbage duration', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n')); + const garbageEvent = findFirstVevent(trailingGarbage); + assert.equal(garbageEvent.end.toISOString(), garbageEvent.start.toISOString(), 'P1DXYZ must not be treated as 1 day'); + } finally { + console.warn = originalWarn; + } + }); + + it('preserves an RDATE-only base series that arrives after its RECURRENCE-ID override', () => { + const parsed = ical.parseICS([ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//TEST//rdate-base-after-override//EN', + 'BEGIN:VEVENT', + 'UID:rdate-base-after-override@test', + 'DTSTAMP:20250101T000000Z', + 'RECURRENCE-ID:20250110T100000Z', + 'SEQUENCE:1', + 'DTSTART:20250110T120000Z', + 'DTEND:20250110T130000Z', + 'SUMMARY:Moved Override', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:rdate-base-after-override@test', + 'DTSTAMP:20250101T000000Z', + 'SEQUENCE:0', + 'DTSTART:20250101T100000Z', + 'DTEND:20250101T110000Z', + 'RDATE:20250101T100000Z,20250110T100000Z,20250120T100000Z', + 'SUMMARY:Base Series (RDATE only)', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n')); + + const event = Object.values(parsed).find(component => component?.type === 'VEVENT'); + assert.equal(event.summary, 'Base Series (RDATE only)', 'RDATE-only base series must not be discarded as a stale duplicate'); + assert.ok(event.rdate, 'base series should keep its RDATE'); + assert.ok(event.recurrences, 'the RECURRENCE-ID override should still be recorded'); + }); +}); diff --git a/test/tsconfig.json b/test/tsconfig.json index 5b209669..36a469d5 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,12 +1,12 @@ { "compilerOptions": { - "target": "ES2020", - "module": "Node16", - "lib": ["ES2020", "DOM"], + "target": "es2025", + "module": "nodenext", + "lib": ["ES2025", "DOM"], "strict": true, "noEmit": true, "skipLibCheck": true, - "moduleResolution": "node16" + "moduleResolution": "nodenext" }, "include": ["types.test.ts", "../node-ical.d.ts"] } diff --git a/test/tz-utils.test.js b/test/tz-utils.test.js index 22345581..398b067b 100644 --- a/test/tz-utils.test.js +++ b/test/tz-utils.test.js @@ -1,7 +1,7 @@ -const assert = require('node:assert/strict'); -const process = require('node:process'); -const {describe, it} = require('mocha'); -const tz = require('../lib/tz-utils.js'); +import assert from 'node:assert/strict'; +import process from 'node:process'; +import {describe, it} from 'mocha'; +import tz from '../lib/tz-utils.js'; describe('unit: tz-utils', () => { it('validates IANA zone names', () => { diff --git a/tsconfig.json b/tsconfig.json index c5ee9782..31c128e9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "ES2022", - "module": "commonjs", + "target": "es2025", + "module": "nodenext", "allowJs": true, "checkJs": false, "declaration": false, @@ -10,7 +10,7 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "strict": true, - "moduleResolution": "node", + "moduleResolution": "nodenext", "resolveJsonModule": true, "noEmit": true },