Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add timezones support #284

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,66 @@ console.log(ics.createEvents(events).value)
// END:VCALENDAR
```

5) Create a calendar with time zone information
```javascript
import ics from 'ics'
import axios from 'axios'

const event = {
start: [2024, 4, 28, 15, 36],
startTimezone: 'Europe/London',
end: [2024, 5, 5, 17, 15],
endTimezone: 'America/New_York',
title: 'Atlantic Crossing'
}

const responses = await Promise.all([
'https://www.tzurl.org/zoneinfo/Europe/London.ics',
'https://www.tzurl.org/zoneinfo/America/New_York.ics']
.map(url => axios.get(url)))

const timezones = responses
.map(res => res.data.match(/BEGIN:VTIMEZONE.*END:VTIMEZONE/s))
.join('\r\n')

console.log(ics.createEvents([event], {timezones}).value)

// BEGIN:VCALENDAR
// VERSION:2.0
// CALSCALE:GREGORIAN
// PRODID:adamgibbons/ics
// METHOD:PUBLISH
// X-PUBLISHED-TTL:PT1H
// BEGIN:VTIMEZONE
// TZID:Europe/London
// LAST-MODIFIED:20240422T053450Z
// TZURL:https://www.tzurl.org/zoneinfo/Europe/London
// X-LIC-LOCATION:Europe/London
// X-PROLEPTIC-TZNAME:LMT
// BEGIN:STANDARD
// [ ... ]
// END:STANDARD
// END:VTIMEZONE
// BEGIN:VTIMEZONE
// TZID:America/New_York
// LAST-MODIFIED:20240422T053450Z
// TZURL:https://www.tzurl.org/zoneinfo/America/New_York
// X-LIC-LOCATION:America/New_York
// X-PROLEPTIC-TZNAME:LMT
// BEGIN:STANDARD
// [ ... ]
// END:STANDARD
// END:VTIMEZONE
// BEGIN:VEVENT
// UID:AsgRW6JD-gIdi-Dszo3nR
// SUMMARY:Atlantic Crossing
// DTSTAMP:20241116T213905Z
// DTSTART;TZID=Europe/London:20240428T153600
// DTEND;TZID=America/New_York:20240505T171500
// END:VEVENT
// END:VCALENDAR
```

#### Using ESModules & in the browser

```javascript
Expand Down Expand Up @@ -259,9 +319,11 @@ The following properties are accepted:
| start | **Required**. Date and time at which the event begins. | `[2000, 1, 5, 10, 0]` (January 5, 2000) or a `number`
| startInputType | Type of the date/time data in `start`:<br>`local` (default): passed data is in local time.<br>`utc`: passed data is UTC |
| startOutputType | Format of the start date/time in the output:<br>`utc` (default): the start date will be sent in UTC format.<br>`local`: the start date will be sent as "floating" (form #1 in [RFC 5545](https://tools.ietf.org/html/rfc5545#section-3.3.5)) |
| startTimezone | Time zone ID of `start`. If present, `startOutputType` is implicitly set to `local`. Make sure to include a timezone definition for all used timezones in the `timezones` header. | `'Europe/London'` |
| end | Time at which event ends. *Either* `end` or `duration` is required, but *not* both. | `[2000, 1, 5, 13, 5]` (January 5, 2000 at 1pm) or a `number`
| endInputType | Type of the date/time data in `end`:<br>`local`: passed data is in local time.<br>`utc`: passed data is UTC.<br>The default is the value of `startInputType` |
| endOutputType | Format of the start date/time in the output:<br>`utc`: the start date will be sent in UTC format.<br>`local`: the start date will be sent as "floating" (form #1 in [RFC 5545](https://tools.ietf.org/html/rfc5545#section-3.3.5)).<br>The default is the value of `startOutputType` |
| endTimezone | Time zone ID of `end`. If present, `endOutputType` is implicitly set to `local`. Make sure to include a timezone definition for all used timezones in the `timezones` header. | `'America/New_York'` |
| duration | How long the event lasts. Object literal having form `{ weeks, days, hours, minutes, seconds }` *Either* `end` or `duration` is required, but *not* both. | `{ hours: 1, minutes: 45 }` (1 hour and 45 minutes)
| title | Title of event. | `'Code review'`
| description | Description of event. | `'A constructive roasting of those seeking to merge into master branch'`
Expand All @@ -278,6 +340,7 @@ The following properties are accepted:
| method | This property defines the iCalendar object method associated with the calendar object. When used in a MIME message entity, the value of this property MUST be the same as the Content-Type "method" parameter value. If either the "METHOD" property or the Content-Type "method" parameter is specified, then the other MUST also be specified. | `PUBLISH`
| recurrenceRule | A recurrence rule, commonly referred to as an RRULE, defines the repeat pattern or rule for to-dos, journal entries and events. If specified, RRULE can be used to compute the recurrence set (the complete set of recurrence instances in a calendar component). You can use a generator like this [one](https://www.textmagic.com/free-tools/rrule-generator). | `FREQ=DAILY`
| exclusionDates | Array of date-time exceptions for recurring events, to-dos, journal entries, or time zone definitions. | `[[2000, 1, 5, 10, 0], [2000, 2, 5, 10, 0]]` OR `[1694941727477, 1694945327477]`
| exclusionDatesTimezone | Time zone ID of `exclusionDates`. Make sure to include a timezone definition for all used timezones in the `timezones` header. | `'Europe/London'` |
| sequence | For sending an update for an event (with the same uid), defines the revision sequence number. | `2`
| busyStatus | Used to specify busy status for Microsoft applications, like Outlook. See [Microsoft spec](https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcical/cd68eae7-ed65-4dd3-8ea7-ad585c76c736). | `'BUSY'` OR `'FREE'` OR `'TENTATIVE`' OR `'OOF'`
| transp | Used to specify event transparency (does event consume actual time of an individual). Used by Google Calendar to determine if event should change attendees availability to 'Busy' or not. | `'TRANSPARENT'` OR `'OPAQUE'`
Expand Down Expand Up @@ -329,6 +392,14 @@ If a callback is provided, returns a Node-style callback.

Array of `attributes` objects (as described in `createEvent`).

#### `headerParams`
| Property | Description | Example |
| ------------- | ------------- | -------- |
| productId | See [RFC 5545 Product Identifier](https://datatracker.ietf.org/doc/html/rfc5545#section-3.7.3), defaults to `adamgibbons/ics` | |
| method | See [RFC 5545 Method](https://datatracker.ietf.org/doc/html/rfc5545#section-3.7.2) Defaults to `PUBLISH` | |
| calName | Sets `X-WR-CALNAME` | `'OSC training schedule'` |
| timezones | Time zone definitions for this calendar, without trailing `\r\n`. See [RFC 5545 Time Zone Component](https://datatracker.ietf.org/doc/html/rfc5545#section-3.6.5) | see https://www.tzurl.org/ |

#### `callback`

Optional.
Expand Down
4 changes: 4 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,18 @@ export type HeaderAttributes = {
productId?: string;
method?: string;
calName?: string;
timezones?: string;
}

export type EventAttributes = {
start: DateTime;
startInputType?: 'local' | 'utc';
startOutputType?: 'local' | 'utc';
startTimezone?: string

endInputType?: 'local' | 'utc';
endOutputType?: 'local' | 'utc';
endTimezone?: string,

title?: string;
description?: string;
Expand All @@ -113,6 +116,7 @@ export type EventAttributes = {
method?: HeaderAttributes['method'];
recurrenceRule?: string;
exclusionDates?: DateTime[];
exclusionDatesTimezone?: string;
sequence?: number;
calName?: HeaderAttributes['calName'];
classification?: classificationType;
Expand Down
12 changes: 9 additions & 3 deletions src/pipeline/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
setContact,
setOrganizer,
formatDate,
formatTzidParam,
setDescription,
setLocation,
setSummary,
Expand All @@ -17,6 +18,7 @@ export function formatHeader(attributes = {}) {
productId,
method,
calName,
timezones
} = attributes

let icsFormat = ''
Expand All @@ -27,6 +29,7 @@ export function formatHeader(attributes = {}) {
icsFormat += foldLine(`METHOD:${encodeNewLines(method)}`) + '\r\n'
icsFormat += calName ? (foldLine(`X-WR-CALNAME:${encodeNewLines(calName)}`) + '\r\n') : ''
icsFormat += `X-PUBLISHED-TTL:PT1H\r\n`
icsFormat += timezones ? timezones + '\r\n' : ''

return icsFormat
}
Expand All @@ -45,10 +48,12 @@ export function formatEvent(attributes = {}) {
startType,
startInputType,
startOutputType,
startTimezone,
duration,
end,
endInputType,
endOutputType,
endTimezone,
description,
url,
geo,
Expand All @@ -60,6 +65,7 @@ export function formatEvent(attributes = {}) {
alarms,
recurrenceRule,
exclusionDates,
exclusionDatesTimezone,
busyStatus,
transp,
classification,
Expand All @@ -75,12 +81,12 @@ export function formatEvent(attributes = {}) {
icsFormat += foldLine(`DTSTAMP:${encodeNewLines(formatDate(timestamp))}`) + '\r\n'

// All day events like anniversaries must be specified as VALUE type DATE
icsFormat += foldLine(`DTSTART${start && start.length == 3 ? ";VALUE=DATE" : ""}:${encodeNewLines(formatDate(start, startOutputType || startType, startInputType))}`) + '\r\n'
icsFormat += foldLine(`DTSTART${start && start.length == 3 ? ";VALUE=DATE" : ""}${formatTzidParam(startTimezone)}:${encodeNewLines(formatDate(start, (startTimezone && 'local') || startOutputType || startType, startInputType))}`) + '\r\n'

// End is not required for all day events on single days (like anniversaries)
if (!end || end.length !== 3 || start.length !== end.length || start.some((val, i) => val !== end[i])) {
if (end) {
icsFormat += foldLine(`DTEND${end.length === 3 ? ";VALUE=DATE" : ""}:${encodeNewLines(formatDate(end, endOutputType || startOutputType || startType, endInputType || startInputType))}`) + '\r\n'
icsFormat += foldLine(`DTEND${end.length === 3 ? ";VALUE=DATE" : ""}${formatTzidParam(endTimezone)}:${encodeNewLines(formatDate(end, (endTimezone && 'local') || endOutputType || startOutputType || startType, endInputType || startInputType))}`) + '\r\n'
}
}

Expand All @@ -104,7 +110,7 @@ export function formatEvent(attributes = {}) {
})
}
icsFormat += recurrenceRule ? foldLine(`RRULE:${encodeNewLines(recurrenceRule)}`) + '\r\n' : ''
icsFormat += exclusionDates ? foldLine(`EXDATE:${encodeNewLines(exclusionDates.map((a) => formatDate(a)).join(','))}`) + '\r\n': ''
icsFormat += exclusionDates ? foldLine(`EXDATE${formatTzidParam(exclusionDatesTimezone)}:${encodeNewLines(exclusionDates.map((a) => formatDate(a, exclusionDatesTimezone && 'local')).join(','))}`) + '\r\n': ''
icsFormat += duration ? foldLine(`DURATION:${formatDuration(duration)}`) + '\r\n' : ''
if (alarms) {
alarms.forEach((alarm) => {
Expand Down
6 changes: 5 additions & 1 deletion src/schema/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ const alarmSchema = yup.object().shape({
const headerShape = {
productId: yup.string(),
method: yup.string(),
calName: yup.string()
calName: yup.string(),
timezones: yup.string().matches(/^BEGIN:VTIMEZONE.*END:VTIMEZONE$/s)
}

const headerSchema = yup.object().shape(headerShape).noUnknown()
Expand All @@ -91,9 +92,11 @@ const eventShape = {
startType: yup.string().matches(/^(utc|local)$/),
startInputType: yup.string().matches(/^(utc|local)$/),
startOutputType: yup.string().matches(/^(utc|local)$/),
startTimezone: yup.string(),
end: dateTimeSchema({ required: false }),
endInputType: yup.string().matches(/^(utc|local)$/),
endOutputType: yup.string().matches(/^(utc|local)$/),
endTimezone: yup.string(),
description: yup.string(),
url: yup.string().matches(urlRegex),
geo: yup.object().shape({lat: yup.number(), lon: yup.number()}),
Expand All @@ -110,6 +113,7 @@ const eventShape = {
created: dateTimeSchema({ required: false }),
lastModified: dateTimeSchema({ required: false }),
exclusionDates: yup.array().of(dateTimeSchema({ required: true })),
exclusionDatesTimezone: yup.object().string,
htmlContent: yup.string()
}

Expand Down
3 changes: 3 additions & 0 deletions src/utils/format-tzid-param.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function formatTzidParam(tzid) {
return tzid ? ";TZID=" + tzid : ""
}
2 changes: 2 additions & 0 deletions src/utils/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import formatDate from './format-date'
import formatTzidParam from './format-tzid-param'
import setGeolocation from './set-geolocation'
import setContact from './set-contact'
import setOrganizer from './set-organizer'
Expand All @@ -12,6 +13,7 @@ import encodeParamValue from './encode-param-value'

export {
formatDate,
formatTzidParam,
setGeolocation,
setContact,
setOrganizer,
Expand Down
16 changes: 16 additions & 0 deletions test/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ describe('ics', () => {
expect(error).to.be.null
expect(value).to.contain('X-WR-CALNAME:test')
})

it('writes timezone information', () => {
const timezones = 'BEGIN:VTIMEZONE\r\nTZID:Europe/Zurich\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19961027T030000\r\nEND:STANDARD\r\nEND:VTIMEZONE';
const { error, value } = createEvents([], { timezones })
expect(error).to.be.null
expect(value).to.contain(timezones + '\r\n')
})
})

describe('when a callback is provided', () => {
Expand Down Expand Up @@ -115,6 +122,15 @@ describe('ics', () => {
done()
})
})

it('writes timezone information', (done) => {
const timezones = 'BEGIN:VTIMEZONE\r\nTZID:Europe/Zurich\r\nBEGIN:STANDARD\r\nTZNAME:CET\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nDTSTART:19961027T030000\r\nEND:STANDARD\r\nEND:VTIMEZONE';
createEvents([], { timezones }, (error, value) => {
expect(error).to.be.null
expect(value).to.contain(timezones + '\r\n')
done()
})
})
})
})

Expand Down
19 changes: 19 additions & 0 deletions test/pipeline/format.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,25 @@ describe('pipeline.formatEvent', () => {
expect(formattedStartEndEvent).to.contain('DTEND;VALUE=DATE:20170518')
})

it('writes timezone ids', () => {
const event = buildEvent({
start: [2024, 11, 16],
startTimezone: 'Europe/Zurich',
end: [2024, 11, 16, 19, 54, 32],
endTimezone: 'America/Chicago',
exclusionDates: [
[2024, 5, 6, 15, 36, 21],
[2024, 7, 21, 9, 38, 7]
],
exclusionDatesTimezone: 'Africa/Nairobi'
})
const formattedEvent = formatEvent(event)
// match against \r\n to ensure times are written in local time (no 'Z' at the end)
expect(formattedEvent).to.contain('DTSTART;VALUE=DATE;TZID=Europe/Zurich:20241116\r\n')
expect(formattedEvent).to.contain('DTEND;TZID=America/Chicago:20241116T195432\r\n')
expect(formattedEvent).to.contain('EXDATE;TZID=Africa/Nairobi:20240506T153621,20240721T093807\r\n')
})

it('writes attendees', () => {
const event = buildEvent({ attendees: [
{name: 'Adam Gibbons', email: '[email protected]'},
Expand Down