From 2c665ada2171f79858b1dc4394263981aeba5d5d Mon Sep 17 00:00:00 2001 From: Adam Gibbons Date: Wed, 13 Dec 2023 09:46:00 -0700 Subject: [PATCH] Revert "Allow `organiser` to contain comma, ensure that new lines in values don't break output, and various other tweaks" (#260) * Revert "Allow `organiser` to contain comma, ensure that new lines in values don't break output, and various other tweaks (#252)" This reverts commit 0e753a81dbc29886f93ea3fc239acfbc84683730. --- README.md | 57 ++++++----- index.d.ts | 28 ++---- package.json | 2 +- src/defaults.js | 17 ++-- src/index.js | 137 ++++++++++++++++++-------- src/pipeline/build.js | 50 ++++++---- src/pipeline/format.js | 79 +++++++-------- src/pipeline/index.js | 12 +-- src/pipeline/validate.js | 4 +- src/schema/index.js | 77 +++++---------- src/utils/encode-new-lines.js | 3 - src/utils/encode-param-value.js | 3 - src/utils/format-date.js | 7 -- src/utils/format-text.js | 2 +- src/utils/index.js | 4 +- src/utils/set-alarm.js | 17 ++-- src/utils/set-contact.js | 12 +-- src/utils/set-organizer.js | 8 +- test/index.spec.js | 18 +--- test/pipeline/build.spec.js | 41 ++++---- test/pipeline/format.spec.js | 109 +++++--------------- test/pipeline/validate.spec.js | 20 ++-- test/schema/index.spec.js | 69 +++++++------ test/utils/encode-param-value.spec.js | 9 -- test/utils/format-date.spec.js | 6 -- test/utils/set-contact.spec.js | 26 ++--- 26 files changed, 366 insertions(+), 451 deletions(-) delete mode 100644 src/utils/encode-new-lines.js delete mode 100644 src/utils/encode-param-value.js delete mode 100644 test/utils/encode-param-value.spec.js diff --git a/README.md b/README.md index e9928775..d77840f8 100644 --- a/README.md +++ b/README.md @@ -121,20 +121,18 @@ console.log(value) // VERSION:2.0 // CALSCALE:GREGORIAN // PRODID:adamgibbons/ics -// METHOD:PUBLISH -// X-PUBLISHED-TTL:PT1H // BEGIN:VEVENT -// UID:pP83XzQPo5RlvjDCMIINs +// UID:mPfHOGi_sif_xO493Mgi6 // SUMMARY:Lunch -// DTSTAMP:20230917T142209Z -// DTSTART:20180115T121500Z +// DTSTAMP:20180210T093900Z +// DTSTART:20180115T191500Z // DURATION:PT45M // END:VEVENT // BEGIN:VEVENT -// UID:gy5vfUVv6wjyBeNkkFmBX +// UID:ho-KcKyhNaQVDqJCcGfXD // SUMMARY:Dinner -// DTSTAMP:20230917T142209Z -// DTSTART:20180115T121500Z +// DTSTAMP:20180210T093900Z +// DTSTART:20180115T191500Z // DURATION:PT1H30M // END:VEVENT // END:VCALENDAR @@ -147,8 +145,8 @@ let moment = require("moment") let events = [] let alarms = [] -let start = moment().format('YYYY-M-D-H-m').split("-").map((a) => parseInt(a)) -let end = moment().add({'hours':2, "minutes":30}).format("YYYY-M-D-H-m").split("-").map((a) => parseInt(a)) +let start = moment().format('YYYY-M-D-H-m').split("-") +let end = moment().add({'hours':2, "minutes":30}).format("YYYY-M-D-H-m").split("-") alarms.push({ action: 'audio', @@ -169,28 +167,34 @@ let event = { alarms: alarms } events.push(event) -console.log(ics.createEvents(events).value) +console.log(ics.createEvents(events)) // BEGIN:VCALENDAR // VERSION:2.0 // CALSCALE:GREGORIAN -// PRODID:myCalendarId +// PRODID:MyCalendarId // METHOD:PUBLISH // X-PUBLISHED-TTL:PT1H // BEGIN:VEVENT -// UID:123@ics.com +// UID:123@MyCalendarIdics.com // SUMMARY:test here -// DTSTAMP:20230917T142621Z -// DTSTART:20230917T152600 -// DTEND:20230917T175600 +// DTSTAMP:20180409T072100Z +// DTSTART:20180409 +// DTEND:20180409 +// BEGIN:VALARM +// ACTION:DISPLAY +// DESCRIPTION:Reminder +// TRIGGER:-PT2H30M +// END:VALARM // BEGIN:VALARM // ACTION:AUDIO // REPEAT:2 -// DESCRIPTION:Reminder // ATTACH;VALUE=URI:Glass -// TRIGGER:-PT2H30M\nEND:VALARM +// TRIGGER:PT2H +// END:VALARM // END:VEVENT // END:VCALENDAR + ``` #### Using ESModules & in the browser @@ -242,17 +246,14 @@ If a callback is provided, returns a Node-style callback. Object literal containing event information. Only the `start` property is required. - -Note all date/time fields can be the array form, or a number representing the unix timestamp in milliseconds (e.g. `getTime()` on a `Date`). - The following properties are accepted: | Property | Description | Example | | ------------- | ------------- | ---------- -| start | **Required**. Date and time at which the event begins. | `[2000, 1, 5, 10, 0]` (January 5, 2000) or a `number` +| start | **Required**. Date and time at which the event begins. | `[2000, 1, 5, 10, 0]` (January 5, 2000) | startInputType | Type of the date/time data in `start`:
`local` (default): passed data is in local time.
`utc`: passed data is UTC | | startOutputType | Format of the start date/time in the output:
`utc` (default): the start date will be sent in UTC format.
`local`: the start date will be sent as "floating" (form #1 in [RFC 5545](https://tools.ietf.org/html/rfc5545#section-3.3.5)) | -| 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` +| 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) | endInputType | Type of the date/time data in `end`:
`local`: passed data is in local time.
`utc`: passed data is UTC.
The default is the value of `startInputType` | | endOutputType | Format of the start date/time in the output:
`utc`: the start date will be sent in UTC format.
`local`: the start date will be sent as "floating" (form #1 in [RFC 5545](https://tools.ietf.org/html/rfc5545#section-3.3.5)).
The default is the value of `startOutputType` | | 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) @@ -270,13 +271,13 @@ The following properties are accepted: | uid | Universal unique id for event, produced by default with `nanoid`. **Warning:** This value must be **globally unique**. It is recommended that it follow the [RFC 822 addr-spec](https://www.w3.org/Protocols/rfc822/) (i.e. `localpart@domain`). Including the `@domain` half is a good way to ensure uniqueness. | `'LZfXLFzPPR4NNrgjlWDxn'` | 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]` +| exclusionDates| This property defines the list of DATE-TIME exceptions for recurring events, to-dos, journal entries, or time zone definitions. Uses a comma-delimited list of [Date-Time](https://tools.ietf.org/html/rfc5545#section-3.3.5) values. See [EXDATE spec](https://tools.ietf.org/html/rfc5545#section-3.8.5.1).|`'20230620T131500Z,20230621T131500'` (June 20th, 2023 at 1:15pm UTC and June 21st, 2000 at 1:15pm LOCAL) | 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'` | classification | This property defines the access classification for a calendar component. See [iCalender spec](https://icalendar.org/iCalendar-RFC-5545/3-8-1-3-classification.html). | `'PUBLIC'` OR `'PRIVATE'` OR `'CONFIDENTIAL`' OR any non-standard string -| created | Date-time representing event's creation date. Provide a date-time in local time | `[2000, 1, 5, 10, 0]` or a `number` -| lastModified | Date-time representing date when event was last modified. Provide a date-time in local time | [2000, 1, 5, 10, 0] or a `number` +| created | Date-time representing event's creation date. Provide a date-time in UTC | [2000, 1, 5, 10, 0] (January 5, 2000 GMT +00:00) +| lastModified | Date-time representing date when event was last modified. Provide a date-time in UTC | [2000, 1, 5, 10, 0] (January 5, 2000 GMT +00:00) | calName | Specifies the _calendar_ (not event) name. Used by Apple iCal and Microsoft Outlook; see [Open Specification](https://docs.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxcical/1da58449-b97e-46bd-b018-a1ce576f3e6d) | `'Example Calendar'` | | htmlContent | Used to include HTML markup in an event's description. Standard DESCRIPTION tag should contain non-HTML version. | `

This is
test
html code.

` | @@ -307,12 +308,10 @@ function (err, value) { } ``` -### `createEvents(events[, headerParams, callback])` +### `createEvents(events[, callback])` Generates an iCal-compliant VCALENDAR string with multiple VEVENTS. -`headerParams` may be omitted, and in this case they will be read from the first event. - If a callback is not provided, returns an object having the form `{ error, value }`, where value is an iCal-compliant text string if `error` is `null`. diff --git a/index.d.ts b/index.d.ts index 84a4d3ef..8cba459b 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,3 @@ -export type DateTime = DateArray | number | string; - export type DateArray = | [number, number, number, number, number] | [number, number, number, number] @@ -69,20 +67,14 @@ export type Alarm = { description?: string; summary?: string; duration?: DurationObject; - trigger?: DurationObject | DateTime; + trigger?: DurationObject; // @todo DateArray | DurationObject; repeat?: number; attachType?: string; attach?: string; }; -export type HeaderAttributes = { - productId?: string; - method?: string; - calName?: string; -} - export type EventAttributes = { - start: DateTime; + start: DateArray; startInputType?: 'local' | 'utc'; startOutputType?: 'local' | 'utc'; @@ -108,18 +100,18 @@ export type EventAttributes = { categories?: string[]; alarms?: Alarm[]; - productId?: HeaderAttributes['productId']; + productId?: string; uid?: string; - method?: HeaderAttributes['method']; + method?: string; recurrenceRule?: string; exclusionDates?: string; sequence?: number; - calName?: HeaderAttributes['calName']; + calName?: string; classification?: classificationType; - created?: DateTime; - lastModified?: DateTime; + created?: DateArray; + lastModified?: DateArray; htmlContent?: string; -} & ({ end: DateTime } | { duration: DurationObject }); +} & ({ end: DateArray } | { duration: DurationObject }); export type ReturnObject = { error?: Error; value?: string }; @@ -130,7 +122,7 @@ export function createEvent(attributes: EventAttributes, callback: NodeCallback) export function createEvent(attributes: EventAttributes): ReturnObject; export function createEvents(events: EventAttributes[], callback: NodeCallback): void; -export function createEvents(events: EventAttributes[], headerAttributes?: HeaderAttributes): ReturnObject; -export function createEvents(events: EventAttributes[], headerAttributes: HeaderAttributes, callback: NodeCallback): void; + +export function createEvents(events: EventAttributes[]): ReturnObject; export function convertTimestampToArray(timestamp: Number, inputType: String): DateArray; diff --git a/package.json b/package.json index 96fe0280..b73ad997 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ics", - "version": "3.6.0", + "version": "3.6.1", "description": "iCal (ics) file generator", "exports": { "types": "./index.d.ts", diff --git a/src/defaults.js b/src/defaults.js index d453f093..7ee8f465 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -1,12 +1,13 @@ import { nanoid } from 'nanoid' +import { formatDate } from './utils' -export const headerDefaults = () => ({ - productId: 'adamgibbons/ics', - method: 'PUBLISH' -}) - -export const eventDefaults = () => ({ +const defaults = { title: 'Untitled event', + productId: 'adamgibbons/ics', + method: 'PUBLISH', uid: nanoid(), - timestamp: Date.now() -}) + timestamp: formatDate(null, 'utc'), + start: formatDate(null, 'utc') +} + +export default defaults diff --git a/src/index.js b/src/index.js index df23c170..8462e4f6 100644 --- a/src/index.js +++ b/src/index.js @@ -1,19 +1,56 @@ +import { nanoid } from 'nanoid' import { - buildHeader, buildEvent, - validateHeader, - validateHeaderAndEvent, - formatHeader, - formatEvent, - formatFooter, + validateEvent, + formatEvent } from './pipeline' -function buildHeaderAndValidate(header) { - return validateHeader(buildHeader(header)) +function assignUniqueId(event) { + event.uid = event.uid || nanoid() + return event } +function validateAndBuildEvent(event) { + return validateEvent(buildEvent(event)) +} + +function applyInitialFormatting({ error, value }) { + if (error) { + return { error, value: null } + } + + return { error: null, value: formatEvent(value) } +} + +function reformatEventsByPosition({ error, value }, idx, list) { + if (error) return { error, value } + + if (idx === 0) { + // beginning of list + return { value: value.slice(0, value.indexOf('END:VCALENDAR')), error: null } + } + + if (idx === list.length - 1) { + // end of list + return { value: value.slice(value.indexOf('BEGIN:VEVENT')), error: null} + } + + return { error: null, value: value.slice(value.indexOf('BEGIN:VEVENT'), value.indexOf('END:VEVENT') + 12) } +} + +function catenateEvents(accumulator, { error, value }, idx) { + if (error) { + accumulator.error = error + accumulator.value = null + return accumulator + } + + if (accumulator.value) { + accumulator.value = accumulator.value.concat(value) + return accumulator + } -function buildHeaderAndEventAndValidate(event) { - return validateHeaderAndEvent({...buildHeader(event), ...buildEvent(event) }) + accumulator.value = value + return accumulator } export function convertTimestampToArray(timestamp, inputType = 'local') { @@ -28,51 +65,69 @@ export function convertTimestampToArray(timestamp, inputType = 'local') { } export function createEvent (attributes, cb) { - return createEvents([attributes], cb) -} + if (!attributes) { Error('Attributes argument is required') } -export function createEvents (events, headerAttributesOrCb, cb) { - const resolvedHeaderAttributes = typeof headerAttributesOrCb === 'object' ? headerAttributesOrCb : {}; - const resolvedCb = arguments.length === 3 ? cb : (typeof headerAttributesOrCb === 'function' ? headerAttributesOrCb : null); + assignUniqueId(attributes) - const run = () => { - if (!events) { - return { error: new Error('one argument is required'), value: null } - } + if (!cb) { + // No callback, so return error or value in an object + const { error, value } = validateAndBuildEvent(attributes) + + if (error) return { error, value } - const { error: headerError, value: headerValue } = events.length === 0 - ? buildHeaderAndValidate(resolvedHeaderAttributes) - : buildHeaderAndEventAndValidate({...events[0], ...resolvedHeaderAttributes}); + let event = '' - if (headerError) { - return {error: headerError, value: null} + try { + event = formatEvent(value) + } catch(error) { + return { error, value: null } } - let value = '' - value += formatHeader(headerValue) + return { error: null, value: event } + } - for (let i = 0; i < events.length; i++) { - const { error: eventError, value: eventValue } = buildHeaderAndEventAndValidate(events[i]) - if (eventError) return {error: eventError, value: null} + // Return a node-style callback + const { error, value } = validateAndBuildEvent(attributes) - value += formatEvent(eventValue); - } + if (error) return cb(error) + + return cb(null, formatEvent(value)) +} - value += formatFooter(); +export function createEvents (events, cb) { + if (!events) { + return { error: Error('one argument is required'), value: null } + } - return { error: null, value } + if (events.length === 0) { + const {error, value: dummy} = createEvent({ + start: [2000, 10, 5, 5, 0], + duration: { hours: 1 } + }) + if (error) return {error, value: null} + + return { + error: null, + value: ( + dummy.slice(0, dummy.indexOf('BEGIN:VEVENT')) + + dummy.slice(dummy.indexOf('END:VEVENT') + 10 + 2) + ) + } } - let returnValue; - try { - returnValue = run(); - } catch (e) { - returnValue = { error: e, value: null } + if (events.length === 1) { + return createEvent(events[0], cb) } - if (!resolvedCb) { - return returnValue + const { error, value } = events.map(assignUniqueId) + .map(validateAndBuildEvent) + .map(applyInitialFormatting) + .map(reformatEventsByPosition) + .reduce(catenateEvents, { error: null, value: null }) + + if (!cb) { + return { error, value } } - return resolvedCb(returnValue.error, returnValue.value) + return cb(error, value) } diff --git a/src/pipeline/build.js b/src/pipeline/build.js index a9b4ef3d..1039c7ca 100644 --- a/src/pipeline/build.js +++ b/src/pipeline/build.js @@ -1,22 +1,38 @@ -import { headerDefaults, eventDefaults } from "../defaults"; +import defaultAttributes from "../defaults"; -function removeUndefined(input) { - return Object.entries(input).reduce( - (clean, entry) => typeof entry[1] !== 'undefined' ? Object.assign(clean, {[entry[0]]: entry[1]}) : clean, - {} - ) -} +export default function buildEvent(attributes = {}) { + const { + title, + productId, + method, + uid, + sequence, + start, + startType, + duration, + end, + description, + url, + geo, + location, + status, + categories, + organizer, + attendees, + alarms, + recurrenceRule, + created, + lastModified, + calName, + htmlContent + } = attributes; -export function buildHeader(attributes = {}) { // fill in default values where necessary - const output = Object.assign({}, headerDefaults(), attributes); - - return removeUndefined(output) -} + const output = Object.assign({}, defaultAttributes, attributes); -export function buildEvent(attributes = {}) { - // fill in default values where necessary - const output = Object.assign({}, eventDefaults(), attributes); - - return removeUndefined(output) + // remove undefined values + return Object.entries(output).reduce( + (clean, entry) => typeof entry[1] !== 'undefined' ? Object.assign(clean, {[entry[0]]: entry[1]}) : clean, + {} + ) } diff --git a/src/pipeline/format.js b/src/pipeline/format.js index 02349b92..92d2fa90 100644 --- a/src/pipeline/format.js +++ b/src/pipeline/format.js @@ -10,34 +10,12 @@ import { formatDuration, foldLine } from '../utils' -import encodeNewLines from '../utils/encode-new-lines' -export function formatHeader(attributes = {}) { +export default function formatEvent(attributes = {}) { const { + title, productId, method, - calName, - } = attributes - - let icsFormat = '' - icsFormat += 'BEGIN:VCALENDAR\r\n' - icsFormat += 'VERSION:2.0\r\n' - icsFormat += 'CALSCALE:GREGORIAN\r\n' - icsFormat += foldLine(`PRODID:${encodeNewLines(productId)}`) + '\r\n' - icsFormat += foldLine(`METHOD:${encodeNewLines(method)}`) + '\r\n' - icsFormat += calName ? (foldLine(`X-WR-CALNAME:${encodeNewLines(calName)}`) + '\r\n') : '' - icsFormat += `X-PUBLISHED-TTL:PT1H\r\n` - - return icsFormat -} - -export function formatFooter() { - return `END:VCALENDAR\r\n` -} - -export function formatEvent(attributes = {}) { - const { - title, uid, sequence, timestamp, @@ -65,53 +43,62 @@ export function formatEvent(attributes = {}) { classification, created, lastModified, + calName, htmlContent } = attributes let icsFormat = '' + icsFormat += 'BEGIN:VCALENDAR\r\n' + icsFormat += 'VERSION:2.0\r\n' + icsFormat += 'CALSCALE:GREGORIAN\r\n' + icsFormat += foldLine(`PRODID:${productId}`) + '\r\n' + icsFormat += foldLine(`METHOD:${method}`) + '\r\n' + icsFormat += calName ? (foldLine(`X-WR-CALNAME:${calName}`) + '\r\n') : '' + icsFormat += `X-PUBLISHED-TTL:PT1H\r\n` icsFormat += 'BEGIN:VEVENT\r\n' - icsFormat += foldLine(`UID:${encodeNewLines(uid)}`) + '\r\n' - icsFormat += title ? foldLine(`SUMMARY:${encodeNewLines(setSummary(title))}`) + '\r\n' : '' - icsFormat += foldLine(`DTSTAMP:${encodeNewLines(formatDate(timestamp))}`) + '\r\n' + icsFormat += `UID:${uid}\r\n` + icsFormat += foldLine(`SUMMARY:${title ? setSummary(title) : title}`) + '\r\n' + icsFormat += `DTSTAMP:${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 += `DTSTART${start && start.length == 3 ? ";VALUE=DATE" : ""}:${formatDate(start, 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 += `DTEND${end.length === 3 ? ";VALUE=DATE" : ""}:${formatDate(end, endOutputType || startOutputType || startType, endInputType || startInputType)}\r\n`; } } icsFormat += typeof sequence !== 'undefined' ? (`SEQUENCE:${sequence}\r\n`) : '' - icsFormat += description ? (foldLine(`DESCRIPTION:${encodeNewLines(setDescription(description))}`) + '\r\n') : '' - icsFormat += url ? (foldLine(`URL:${encodeNewLines(url)}`) + '\r\n') : '' + icsFormat += description ? (foldLine(`DESCRIPTION:${setDescription(description)}`) + '\r\n') : '' + icsFormat += url ? (foldLine(`URL:${url}`) + '\r\n') : '' icsFormat += geo ? (foldLine(`GEO:${setGeolocation(geo)}`) + '\r\n') : '' - icsFormat += location ? (foldLine(`LOCATION:${encodeNewLines(setLocation(location))}`) + '\r\n') : '' - icsFormat += status ? (foldLine(`STATUS:${encodeNewLines(status)}`) + '\r\n') : '' - icsFormat += categories ? (foldLine(`CATEGORIES:${encodeNewLines(categories.join(','))}`) + '\r\n') : '' + icsFormat += location ? (foldLine(`LOCATION:${setLocation(location)}`) + '\r\n') : '' + icsFormat += status ? (foldLine(`STATUS:${status}`) + '\r\n') : '' + icsFormat += categories ? (foldLine(`CATEGORIES:${categories}`) + '\r\n') : '' icsFormat += organizer ? (foldLine(`ORGANIZER;${setOrganizer(organizer)}`) + '\r\n') : '' - icsFormat += busyStatus ? (foldLine(`X-MICROSOFT-CDO-BUSYSTATUS:${encodeNewLines(busyStatus)}`) + '\r\n') : '' - icsFormat += transp ? (foldLine(`TRANSP:${encodeNewLines(transp)}`) + '\r\n') : '' - icsFormat += classification ? (foldLine(`CLASS:${encodeNewLines(classification)}`) + '\r\n') : '' - icsFormat += created ? ('CREATED:' + encodeNewLines(formatDate(created)) + '\r\n') : '' - icsFormat += lastModified ? ('LAST-MODIFIED:' + encodeNewLines(formatDate(lastModified)) + '\r\n') : '' - icsFormat += htmlContent ? (foldLine(`X-ALT-DESC;FMTTYPE=text/html:${encodeNewLines(htmlContent)}`) + '\r\n') : '' + icsFormat += busyStatus ? (foldLine(`X-MICROSOFT-CDO-BUSYSTATUS:${busyStatus}`) + '\r\n') : '' + icsFormat += transp ? (foldLine(`TRANSP:${transp}`) + '\r\n') : '' + icsFormat += classification ? (foldLine(`CLASS:${classification}`) + '\r\n') : '' + icsFormat += created ? ('CREATED:' + formatDate(created) + '\r\n') : '' + icsFormat += lastModified ? ('LAST-MODIFIED:' + formatDate(lastModified) + '\r\n') : '' + icsFormat += htmlContent ? (foldLine(`X-ALT-DESC;FMTTYPE=text/html:${htmlContent}`) + '\r\n') : '' if (attendees) { - attendees.forEach((attendee) => { - icsFormat += foldLine(`ATTENDEE;${encodeNewLines(setContact(attendee))}`) + '\r\n' + attendees.map(function (attendee) { + icsFormat += foldLine(`ATTENDEE;${setContact(attendee)}`) + '\r\n' }) } - icsFormat += recurrenceRule ? foldLine(`RRULE:${encodeNewLines(recurrenceRule)}`) + '\r\n' : '' - icsFormat += exclusionDates ? foldLine(`EXDATE:${encodeNewLines(exclusionDates.map((a) => formatDate(a)).join(','))}`) + '\r\n': '' - icsFormat += duration ? foldLine(`DURATION:${formatDuration(duration)}`) + '\r\n' : '' + icsFormat += recurrenceRule ? `RRULE:${recurrenceRule}\r\n` : '' + icsFormat += exclusionDates ? `EXDATE:${exclusionDates}\r\n` : '' + icsFormat += duration ? `DURATION:${formatDuration(duration)}\r\n` : '' if (alarms) { - alarms.forEach((alarm) => { + alarms.map(function (alarm) { icsFormat += setAlarm(alarm) }) } icsFormat += `END:VEVENT\r\n` + icsFormat += `END:VCALENDAR\r\n` return icsFormat } diff --git a/src/pipeline/index.js b/src/pipeline/index.js index a20e5e11..7c0e31f7 100644 --- a/src/pipeline/index.js +++ b/src/pipeline/index.js @@ -1,13 +1,9 @@ -import { buildHeader, buildEvent } from './build' -import { formatHeader, formatEvent, formatFooter } from './format' -import { validateHeader, validateHeaderAndEvent } from './validate' +import buildEvent from './build' +import formatEvent from './format' +import validateEvent from './validate' export { - buildHeader, buildEvent, - formatHeader, formatEvent, - formatFooter, - validateHeader, - validateHeaderAndEvent + validateEvent } diff --git a/src/pipeline/validate.js b/src/pipeline/validate.js index 50d0dba8..e169acc7 100644 --- a/src/pipeline/validate.js +++ b/src/pipeline/validate.js @@ -1 +1,3 @@ -export * from '../schema' +import validate from '../schema' + +export default validate diff --git a/src/schema/index.js b/src/schema/index.js index 6f8c64e1..70458a19 100644 --- a/src/schema/index.js +++ b/src/schema/index.js @@ -6,18 +6,7 @@ import * as yup from 'yup' // "url must match the following: ...." as opposed to "url must be a valid URL" const urlRegex = /^(?:([a-z0-9+.-]+):\/\/)(?:\S+(?::\S*)?@)?(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*\.?)(?::\d{2,5})?(?:[/?#]\S*)?$/ -const dateTimeSchema = ({ required }) => yup.lazy((value) => { - if (typeof value === 'number') { - return yup.number().integer().min(0) - } - if (typeof value === 'string') { - return yup.string().required() - } - if (!required && typeof value === 'undefined') { - return yup.mixed().oneOf([undefined]) - } - - return yup.array().required().min(3).max(7).of(yup.lazy((item, options) => { +const dateTimeSchema = yup.array().min(3).max(7).of(yup.lazy((item, options) => { const itemIndex = options.parent.indexOf(options.value) return [ yup.number().integer(), @@ -27,8 +16,7 @@ const dateTimeSchema = ({ required }) => yup.lazy((value) => { yup.number().integer().min(0).max(60), yup.number().integer().min(0).max(60) ][itemIndex] - })) - } + }) ) const durationSchema = yup.object().shape({ @@ -59,7 +47,7 @@ const organizerSchema = yup.object().shape({ }).noUnknown() const alarmSchema = yup.object().shape({ - action: yup.string().matches(/^(audio|display|email)$/).required(), + action: yup.string().matches(/audio|display|email/).required(), trigger: yup.mixed().required(), description: yup.string(), duration: durationSchema, @@ -72,66 +60,49 @@ const alarmSchema = yup.object().shape({ 'iana-prop': yup.mixed() }).noUnknown() -const headerShape = { - productId: yup.string(), - method: yup.string(), - calName: yup.string() -} - -const headerSchema = yup.object().shape(headerShape).noUnknown() - -const eventShape = { +const schema = yup.object().shape({ summary: yup.string(), - timestamp: dateTimeSchema({ required: false }), + timestamp: yup.mixed(), title: yup.string(), - uid: yup.string(), + productId: yup.string(), + method: yup.string(), + uid: yup.string().required(), sequence: yup.number().integer().max(2_147_483_647), - start: dateTimeSchema({ required: true }), + start: dateTimeSchema.required(), duration: durationSchema, - startType: yup.string().matches(/^(utc|local)$/), - startInputType: yup.string().matches(/^(utc|local)$/), - startOutputType: yup.string().matches(/^(utc|local)$/), - end: dateTimeSchema({ required: false }), - endInputType: yup.string().matches(/^(utc|local)$/), - endOutputType: yup.string().matches(/^(utc|local)$/), + startType: yup.string().matches(/utc|local/), + startInputType: yup.string().matches(/utc|local/), + startOutputType: yup.string().matches(/utc|local/), + end: dateTimeSchema, + endInputType: yup.string().matches(/utc|local/), + endOutputType: yup.string().matches(/utc|local/), description: yup.string(), url: yup.string().matches(urlRegex), geo: yup.object().shape({lat: yup.number(), lon: yup.number()}), location: yup.string(), - status: yup.string().matches(/^(TENTATIVE|CANCELLED|CONFIRMED)$/i), + status: yup.string().matches(/TENTATIVE|CANCELLED|CONFIRMED/i), categories: yup.array().of(yup.string()), organizer: organizerSchema, attendees: yup.array().of(contactSchema), alarms: yup.array().of(alarmSchema), recurrenceRule: yup.string(), - busyStatus: yup.string().matches(/^(TENTATIVE|FREE|BUSY|OOF)$/i), - transp: yup.string().matches(/^(TRANSPARENT|OPAQUE)$/i), + busyStatus: yup.string().matches(/TENTATIVE|FREE|BUSY|OOF/i), + transp: yup.string().matches(/TRANSPARENT|OPAQUE/i), classification: yup.string(), - created: dateTimeSchema({ required: false }), - lastModified: dateTimeSchema({ required: false }), - exclusionDates: yup.array().of(dateTimeSchema({ required: true })), + created: dateTimeSchema, + lastModified: dateTimeSchema, + calName: yup.string(), htmlContent: yup.string() -} - -const headerAndEventSchema = yup.object().shape({ ...headerShape, ...eventShape }).test('xor', `object should have end or duration (but not both)`, val => { +}).test('xor', `object should have end or duration (but not both)`, val => { const hasEnd = !!val.end const hasDuration = !!val.duration return ((hasEnd && !hasDuration) || (!hasEnd && hasDuration) || (!hasEnd && !hasDuration)) }).noUnknown() +export default function validateEvent (candidate) { -export function validateHeader (candidate) { - try { - const value = headerSchema.validateSync(candidate, {abortEarly: false, strict: true}) - return {error: null, value} - } catch (error) { - return {error: Object.assign({}, error), value: undefined} - } -} - -export function validateHeaderAndEvent (candidate) { try { - const value = headerAndEventSchema.validateSync(candidate, {abortEarly: false, strict: true}) + const value = schema.validateSync(candidate, {abortEarly: false, strict: true}) return {error: null, value} } catch (error) { return {error: Object.assign({}, error), value: undefined} diff --git a/src/utils/encode-new-lines.js b/src/utils/encode-new-lines.js deleted file mode 100644 index f846d6b8..00000000 --- a/src/utils/encode-new-lines.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function encodeNewLines (text) { - return text.replace(/\r?\n/gm, "\\n") -} diff --git a/src/utils/encode-param-value.js b/src/utils/encode-param-value.js deleted file mode 100644 index 2233f574..00000000 --- a/src/utils/encode-param-value.js +++ /dev/null @@ -1,3 +0,0 @@ -export default function encodeParamValue (value) { - return `"${value.replaceAll('"', '\\"')}"` -} diff --git a/src/utils/format-date.js b/src/utils/format-date.js index 75cc874d..8afdf5cb 100644 --- a/src/utils/format-date.js +++ b/src/utils/format-date.js @@ -1,10 +1,6 @@ const pad = n => n < 10 ? `0${n}` : `${n}` export default function formatDate(args = [], outputType = 'utc', inputType = 'local') { - if (typeof args === 'string') { - return args; - } - if (Array.isArray(args) && args.length === 3) { const [year, month, date] = args return `${year}${pad(month)}${pad(date)}` @@ -18,9 +14,6 @@ export default function formatDate(args = [], outputType = 'utc', inputType = 'l } else { outDate = new Date(Date.UTC(year, month - 1, date, hours, minutes, seconds)) } - } else if (!Array.isArray(args)) { - // it's a unix time stamp (ms) - outDate = new Date(args); } if (outputType === 'local') { diff --git a/src/utils/format-text.js b/src/utils/format-text.js index a2d5aba3..99b2d9c4 100644 --- a/src/utils/format-text.js +++ b/src/utils/format-text.js @@ -4,4 +4,4 @@ export default function formatText (text) { .replace(/\r?\n/gm, "\\n") .replace(/;/gm, "\\;") .replace(/,/gm, "\\,") -} +} \ No newline at end of file diff --git a/src/utils/index.js b/src/utils/index.js index 964d4bbc..0ecdd04c 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -8,7 +8,6 @@ import setSummary from './set-summary' import formatDuration from './format-duration' import foldLine from './fold-line' import setLocation from './set-location' -import encodeParamValue from './encode-param-value' export { formatDate, @@ -20,6 +19,5 @@ export { setSummary, setDescription, foldLine, - setLocation, - encodeParamValue + setLocation } diff --git a/src/utils/set-alarm.js b/src/utils/set-alarm.js index 526f4ea4..81f5d8ca 100644 --- a/src/utils/set-alarm.js +++ b/src/utils/set-alarm.js @@ -1,6 +1,5 @@ import formatDate from './format-date' import foldLine from './fold-line' -import encodeNewLines from './encode-new-lines' function setDuration ({ weeks, @@ -22,11 +21,11 @@ function setDuration ({ function setTrigger (trigger) { let formattedString = '' - if(Array.isArray(trigger) || typeof trigger === 'number' || typeof trigger === 'string') { - formattedString = `TRIGGER;VALUE=DATE-TIME:${encodeNewLines(formatDate(trigger))}\r\n` + if(Array.isArray(trigger)) { + formattedString = `TRIGGER;VALUE=DATE-TIME:${formatDate(trigger)}\r\n` } else { let alert = trigger.before ? '-' : '' - formattedString = `TRIGGER:${encodeNewLines(alert+setDuration(trigger))}\r\n` + formattedString = `TRIGGER:${alert+setDuration(trigger)}\r\n` } return formattedString @@ -49,14 +48,14 @@ export default function setAlarm(attributes = {}) { } = attributes let formattedString = 'BEGIN:VALARM\r\n' - formattedString += foldLine(`ACTION:${encodeNewLines(setAction(action))}`) + '\r\n' + formattedString += foldLine(`ACTION:${setAction(action)}`) + '\r\n' formattedString += repeat ? foldLine(`REPEAT:${repeat}`) + '\r\n' : '' - formattedString += description ? foldLine(`DESCRIPTION:${encodeNewLines(description)}`) + '\r\n' : '' + formattedString += description ? foldLine(`DESCRIPTION:${description}`) + '\r\n' : '' formattedString += duration ? foldLine(`DURATION:${setDuration(duration)}`) + '\r\n' : '' let attachInfo = attachType ? attachType : 'FMTTYPE=audio/basic' - formattedString += attach ? foldLine(encodeNewLines(`ATTACH;${attachInfo}:${attach}`)) + '\r\n' : '' - formattedString += trigger ? encodeNewLines(setTrigger(trigger)) : '' - formattedString += summary ? (foldLine(`SUMMARY:${encodeNewLines(summary)}`) + '\r\n') : '' + formattedString += attach ? foldLine(`ATTACH;${attachInfo}:${attach}`) + '\r\n' : '' + formattedString += trigger ? setTrigger(trigger) : '' + formattedString += summary ? (foldLine(`SUMMARY:${summary}`) + '\r\n') : '' formattedString += 'END:VALARM\r\n' return formattedString diff --git a/src/utils/set-contact.js b/src/utils/set-contact.js index 9f8edeae..cee2e09c 100644 --- a/src/utils/set-contact.js +++ b/src/utils/set-contact.js @@ -1,5 +1,3 @@ -import encodeParamValue from "./encode-param-value"; - export default function setContact({ name, email, rsvp, dir, partstat, role, cutype, xNumGuests }) { let formattedParts = []; @@ -7,21 +5,21 @@ export default function setContact({ name, email, rsvp, dir, partstat, role, cut formattedParts.push(rsvp ? 'RSVP=TRUE' : 'RSVP=FALSE'); } if(cutype){ - formattedParts.push("CUTYPE=".concat(encodeParamValue(cutype))); + formattedParts.push("CUTYPE=".concat(cutype)); } if(xNumGuests !== undefined){ formattedParts.push(`X-NUM-GUESTS=${xNumGuests}`); } if(role){ - formattedParts.push("ROLE=".concat(encodeParamValue(role))); + formattedParts.push("ROLE=".concat(role)); } if(partstat){ - formattedParts.push("PARTSTAT=".concat(encodeParamValue(partstat))); + formattedParts.push("PARTSTAT=".concat(partstat)); } if(dir){ - formattedParts.push("DIR=".concat(encodeParamValue(dir))); + formattedParts.push("DIR=".concat(dir)); } - formattedParts.push('CN='.concat((encodeParamValue(name || 'Unnamed attendee')))); + formattedParts.push('CN='.concat((name || 'Unnamed attendee'))); var formattedAttendee = formattedParts.join(';').concat(email ? ":mailto:".concat(email) : ''); diff --git a/src/utils/set-organizer.js b/src/utils/set-organizer.js index 01a6f450..26adf005 100644 --- a/src/utils/set-organizer.js +++ b/src/utils/set-organizer.js @@ -1,11 +1,9 @@ -import encodeParamValue from "./encode-param-value" - export default function setOrganizer({ name, email, dir, sentBy }) { let formattedOrganizer = '' - formattedOrganizer += dir ? `DIR=${encodeParamValue(dir)};` : '' - formattedOrganizer += sentBy ? `SENT-BY=${encodeParamValue(`MAILTO:${sentBy}`)};` : '' + formattedOrganizer += dir ? `DIR="${dir}";` : '' + formattedOrganizer += sentBy ? `SENT-BY="MAILTO:${sentBy}";` : '' formattedOrganizer += 'CN=' - formattedOrganizer += encodeParamValue(name || 'Organizer') + formattedOrganizer += name || 'Organizer' formattedOrganizer += email ? `:MAILTO:${email}` : '' return formattedOrganizer diff --git a/test/index.spec.js b/test/index.spec.js index 6cbec956..2738a46a 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -75,12 +75,6 @@ describe('ics', () => { expect(error).to.be.null expect(value).to.contain('BEGIN:VCALENDAR') }) - - it('support header params', () => { - const { error, value } = createEvents([], { calName: 'test' }) - expect(error).to.be.null - expect(value).to.contain('X-WR-CALNAME:test') - }) }) describe('when a callback is provided', () => { @@ -100,21 +94,13 @@ describe('ics', () => { }) }) - it('returns an iCal string when passed 0 events', (done) => { + it('returns an iCal string when passed 0 events', () => { createEvents([], (error, value) => { expect(error).to.be.null expect(value).to.contain('BEGIN:VCALENDAR') done() }) }) - - it('support header params', (done) => { - createEvents([], { calName: 'test' }, (error, value) => { - expect(error).to.be.null - expect(value).to.contain('X-WR-CALNAME:test') - done() - }) - }) }) }) -}) +}) \ No newline at end of file diff --git a/test/pipeline/build.spec.js b/test/pipeline/build.spec.js index 6ef2d28e..cead4013 100644 --- a/test/pipeline/build.spec.js +++ b/test/pipeline/build.spec.js @@ -1,38 +1,35 @@ import { expect } from 'chai' -import { buildHeader, buildEvent } from '../../src/pipeline' +import { buildEvent } from '../../src/pipeline' -describe('pipeline.buildHeader properties', () => { - describe('productId', () => { +describe('pipeline.build properties', () => { + describe('title', () => { it('sets a default', () => { - const header = buildHeader() - expect(header.productId).to.equal('adamgibbons/ics') + const event = buildEvent() + expect(event.title).to.equal('Untitled event') }) - it('sets a productId', () => { - const header = buildHeader({ productId: 'productId' }) - expect(header.productId).to.equal('productId') + it('sets a title', () => { + const event = buildEvent({ title: 'Hello event!' }) + expect(event.title).to.equal('Hello event!') }) }) - describe('method', () => { + describe('productId', () => { it('sets a default', () => { - const header = buildHeader() - expect(header.method).to.equal('PUBLISH') + const event = buildEvent() + expect(event.productId).to.equal('adamgibbons/ics') }) - it('sets a method', () => { - const header = buildHeader({ method: 'method' }) - expect(header.method).to.equal('method') + it('sets a product id', () => { + const event = buildEvent({ productId: 'myProductId' }) + expect(event.productId).to.equal('myProductId') }) }) -}) - -describe('pipeline.buildEvent properties', () => { - describe('title', () => { + describe('method', () => { it('sets a default', () => { const event = buildEvent() - expect(event.title).to.equal('Untitled event') + expect(event.method).to.equal('PUBLISH') }) - it('sets a title', () => { - const event = buildEvent({ title: 'Hello event!' }) - expect(event.title).to.equal('Hello event!') + it('sets a method', () => { + const event = buildEvent({ method: 'REQUEST' }) + expect(event.method).to.equal('REQUEST') }) }) describe('uid', () => { diff --git a/test/pipeline/format.spec.js b/test/pipeline/format.spec.js index 06cb2b6d..5bf9928b 100644 --- a/test/pipeline/format.spec.js +++ b/test/pipeline/format.spec.js @@ -2,41 +2,17 @@ import dayjs from 'dayjs'; import { expect } from 'chai' import { formatEvent, - buildEvent, - formatHeader, - buildHeader + buildEvent } from '../../src/pipeline' import {foldLine} from "../../src/utils"; -describe('pipeline.formatHeader', () => { - it('writes default values when no attributes passed', () => { - const header = buildHeader() - const formattedHeader = formatHeader(header) - expect(formattedHeader).to.contain('BEGIN:VCALENDAR') - expect(formattedHeader).to.contain('VERSION:2.0') - expect(formattedHeader).to.contain('PRODID:adamgibbons/ics') - }) - it('writes a product id', () => { - const header = buildHeader({ productId: 'productId'}) - const formattedHeader = formatHeader(header) - expect(formattedHeader).to.contain('PRODID:productId') - }) - it('writes a method', () => { - const header = buildHeader({ method: 'method'}) - const formattedHeader = formatHeader(header) - expect(formattedHeader).to.contain('METHOD:method') - }) - it('writes a calName', () => { - const header = buildHeader({ calName: 'calName'}) - const formattedHeader = formatHeader(header) - expect(formattedHeader).to.contain('X-WR-CALNAME:calName') - }) -}) - describe('pipeline.formatEvent', () => { it('writes default values when no attributes passed', () => { const event = buildEvent() const formattedEvent = formatEvent(event) + expect(formattedEvent).to.contain('BEGIN:VCALENDAR') + expect(formattedEvent).to.contain('VERSION:2.0') + expect(formattedEvent).to.contain('PRODID:adamgibbons/ics') expect(formattedEvent).to.contain('BEGIN:VEVENT') expect(formattedEvent).to.contain('SUMMARY:Untitled event') expect(formattedEvent).to.contain('UID:') @@ -44,6 +20,7 @@ describe('pipeline.formatEvent', () => { expect(formattedEvent).to.contain('DTSTART:') expect(formattedEvent).to.contain('DTSTAMP:20') expect(formattedEvent).to.contain('END:VEVENT') + expect(formattedEvent).to.contain('END:VCALENDAR') }) it('writes a title', () => { const event = buildEvent({ title: 'foo bar' }) @@ -123,6 +100,11 @@ describe('pipeline.formatEvent', () => { const formattedEvent = formatEvent(event) expect(formattedEvent).to.contain('LAST-MODIFIED:20170515') }) + it('writes a cal name', () => { + const event = buildEvent({ calName: 'John\'s Calendar' }) + const formattedEvent = formatEvent(event) + expect(formattedEvent).to.contain('X-WR-CALNAME:John\'s Calendar') + }) it('writes a html content and folds correctly', () => { const event = buildEvent({ htmlContent: '

This is
test
html code.

' }) const formattedEvent = formatEvent(event) @@ -193,8 +175,8 @@ describe('pipeline.formatEvent', () => { {name: 'Brittany Seaton', email: 'brittany@example.com', rsvp: true } ]}) const formattedEvent = formatEvent(event) - expect(formattedEvent).to.contain('ATTENDEE;CN="Adam Gibbons":mailto:adam@example.com') - expect(formattedEvent).to.contain('ATTENDEE;RSVP=TRUE;CN="Brittany Seaton":mailto:brittany@example.com') + expect(formattedEvent).to.contain('ATTENDEE;CN=Adam Gibbons:mailto:adam@example.com') + expect(formattedEvent).to.contain('ATTENDEE;RSVP=TRUE;CN=Brittany Seaton:mailto:brittany@example.com') }) it('writes a busystatus', () => { const eventFree = buildEvent({ busyStatus: "FREE" }) @@ -233,34 +215,22 @@ describe('pipeline.formatEvent', () => { expect(formattedEventAnyClass).to.contain('CLASS:non-standard-property') }) it('writes an organizer', () => { - const formattedEvent = formatEvent({ - productId: 'productId', - method: 'method', - uid: 'uid', - timestamp: 'timestamp', - organizer: { + const formattedEvent = formatEvent({ organizer: { name: 'Adam Gibbons', email: 'adam@example.com', dir: 'test-dir-value', - sentBy: 'test@example.com', - } - }) - expect(formattedEvent).to.contain(foldLine('ORGANIZER;DIR="test-dir-value";SENT-BY="MAILTO:test@example.com";CN="Adam Gibbons":MAILTO:adam@example.com')) + sentBy: 'test@example.com' + }}) + expect(formattedEvent).to.contain(foldLine('ORGANIZER;DIR="test-dir-value";SENT-BY="MAILTO:test@example.com";CN=Adam Gibbons:MAILTO:adam@example.com')) }) it('writes an alarm', () => { - const formattedEvent = formatEvent({ - productId: 'productId', - method: 'method', - uid: 'uid', - timestamp: 'timestamp', - alarms: [{ - action: 'audio', - trigger: [1997, 2, 17, 1, 30], - repeat: 4, - duration: { minutes: 15 }, - attach: 'ftp://example.com/pub/sounds/bell-01.aud' - }] - }) + const formattedEvent = formatEvent({ alarms: [{ + action: 'audio', + trigger: [1997, 2, 17, 1, 30], + repeat: 4, + duration: { minutes: 15 }, + attach: 'ftp://example.com/pub/sounds/bell-01.aud' + }]}) expect(formattedEvent).to.contain('BEGIN:VALARM') expect(formattedEvent).to.contain('TRIGGER;VALUE=DATE-TIME:199702') @@ -274,15 +244,13 @@ describe('pipeline.formatEvent', () => { const formattedEvent = formatEvent({ productId: '*'.repeat(1000), method: '*'.repeat(1000), - timestamp: '*'.repeat(1000), - uid: '*'.repeat(1000), title: '*'.repeat(1000), description: '*'.repeat(1000), url: '*'.repeat(1000), geo: '*'.repeat(1000), location: '*'.repeat(1000), status: '*'.repeat(1000), - categories: ['*'.repeat(1000)], + categories: '*'.repeat(1000), organizer: '*'.repeat(1000), attendees: [ {name: '*'.repeat(1000), email: '*'.repeat(1000)}, @@ -293,38 +261,13 @@ describe('pipeline.formatEvent', () => { expect(max).to.be.at.most(75) }) it('writes a recurrence rule', () => { - const formattedEvent = formatEvent({ - productId: 'productId', - method: 'method', - uid: 'uid', - timestamp: 'timestamp', - recurrenceRule: 'FREQ=DAILY' - }) + const formattedEvent = formatEvent({ recurrenceRule: 'FREQ=DAILY' }) expect(formattedEvent).to.contain('RRULE:FREQ=DAILY') }) it('writes exception date-time', () => { - const date1 = new Date(0); - date1.setUTCFullYear(2000); - date1.setUTCMonth(6); - date1.setUTCDate(20); - date1.setUTCHours(2); - date1.setUTCMinutes(0); - date1.setUTCSeconds(0); + const formattedEvent = formatEvent({ exclusionDates: '20000620T010000Z,20000621T010000Z' }) - const date2 = new Date(date1); - date2.setUTCDate(21); - - const formattedEvent = formatEvent({ - productId: 'productId', - method: 'method', - uid: 'uid', - timestamp: 'timestamp', - exclusionDates: [ - [date1.getUTCFullYear(), date1.getUTCMonth(), date1.getUTCDate(), date1.getUTCHours(), date1.getUTCMinutes(), date1.getUTCSeconds()], - [date2.getUTCFullYear(), date2.getUTCMonth(), date2.getUTCDate(), date2.getUTCHours(), date2.getUTCMinutes(), date2.getUTCSeconds()] - ] - }) expect(formattedEvent).to.contain('EXDATE:20000620T010000Z,20000621T010000Z') }) }) diff --git a/test/pipeline/validate.spec.js b/test/pipeline/validate.spec.js index 665299f8..6820428a 100644 --- a/test/pipeline/validate.spec.js +++ b/test/pipeline/validate.spec.js @@ -1,9 +1,9 @@ import { expect } from 'chai' -import { validateHeaderAndEvent } from '../../src/pipeline' +import { validateEvent } from '../../src/pipeline' -describe('pipeline.validateHeaderAndEvent', () => { +describe('pipeline.validate', () => { it('validates an event', () => { - const { error, value } = validateHeaderAndEvent({ + const { error, value } = validateEvent({ uid: '1', start: [1997, 10, 1, 22, 30], duration: { hours: 1 } @@ -12,7 +12,7 @@ describe('pipeline.validateHeaderAndEvent', () => { expect(value.uid).to.equal('1') }) it('returns an error if the sequence number is too long', () => { - const { error, value } = validateHeaderAndEvent({ + const { error, value } = validateEvent({ uid: '1', start: [1997, 10, 1, 22, 30], duration: { hours: 1 }, @@ -21,13 +21,13 @@ describe('pipeline.validateHeaderAndEvent', () => { expect(error).to.exist }) it('returns undefined when passed no event', () => { - const { error, value } = validateHeaderAndEvent() + const { error, value } = validateEvent() expect(value).to.be.undefined }) it('returns an error when invalid data passed', () => { - expect(validateHeaderAndEvent(null).error).to.exist - expect(validateHeaderAndEvent(1).error).to.exist - expect(validateHeaderAndEvent('foo').error).to.exist - expect(validateHeaderAndEvent({}).error).to.exist + expect(validateEvent(null).error).to.exist + expect(validateEvent(1).error).to.exist + expect(validateEvent('foo').error).to.exist + expect(validateEvent({}).error).to.exist }) -}) +}) \ No newline at end of file diff --git a/test/schema/index.spec.js b/test/schema/index.spec.js index e1d68f8d..deea7ee5 100644 --- a/test/schema/index.spec.js +++ b/test/schema/index.spec.js @@ -1,17 +1,22 @@ import { expect } from 'chai' -import { validateHeaderAndEvent } from '../../src/schema' +import validateEvent from '../../src/schema' -describe('.validateHeaderAndEvent', () => { +describe('.validateEvent', () => { describe('must have one and only one occurrence of', () => { + it('uid', () => { + const {error} = validateEvent({title: 'foo'}) + expect(error.errors.some(p => p === 'uid is a required field')).to.be.true + }) + it('start', () => { - const {error} = validateHeaderAndEvent({title: 'foo', uid: 'foo'}) + const {error} = validateEvent({title: 'foo', uid: 'foo'}) expect(error.errors.some(p => p === 'start is a required field')).to.be.true }) }) describe('must have duration XOR end', () => { it('duration and end are not allowed together', () => { - const {error, value} = validateHeaderAndEvent({ + const {error, value} = validateEvent({ uid: 'foo', start: [2018, 12, 1, 10, 30], duration: {hours: 1}, @@ -23,7 +28,7 @@ describe('.validateHeaderAndEvent', () => { describe('may have one and only one occurrence of', () => { it('summary', () => { - const {errors} = validateHeaderAndEvent({ + const {errors} = validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -32,7 +37,7 @@ describe('.validateHeaderAndEvent', () => { expect(errors.some(p => p.match(/summary must be a `string` type/))).to.be.true - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -41,7 +46,7 @@ describe('.validateHeaderAndEvent', () => { }) it('description', () => { - const {errors} = validateHeaderAndEvent({ + const {errors} = validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -49,7 +54,7 @@ describe('.validateHeaderAndEvent', () => { }).error expect(errors.some(p => p.match(/description must be a `string` type/))).to.be.true - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -58,14 +63,14 @@ describe('.validateHeaderAndEvent', () => { }) it('url', () => { - const {errors} = validateHeaderAndEvent({ + const {errors} = validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], url: 'abc' }).error expect(errors.some(p => p.match(/url must/))).to.be.true - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -74,21 +79,21 @@ describe('.validateHeaderAndEvent', () => { }) it('geo', () => { - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], geo: 'abc' }).error.name === 'ValidationError') - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], geo: {lat: 'thing', lon: 32.1}, }).error.name === 'ValidationError') - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -96,7 +101,7 @@ describe('.validateHeaderAndEvent', () => { }).value.geo).to.exist }) it('location', () => { - const {errors} = validateHeaderAndEvent({ + const {errors} = validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -104,7 +109,7 @@ describe('.validateHeaderAndEvent', () => { }).error expect(errors.some(p => p.match(/location must be a `string` type/))).to.be.true - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -113,25 +118,25 @@ describe('.validateHeaderAndEvent', () => { }) it('status', () => { - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], status: 'tentativo' }).error).to.exist - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], status: 'tentative' }).value.status).to.equal('tentative') - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], status: 'cancelled' }).value.status).to.equal('cancelled') - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -140,7 +145,7 @@ describe('.validateHeaderAndEvent', () => { }) it('categories', () => { - const {errors} = validateHeaderAndEvent({ + const {errors} = validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -149,7 +154,7 @@ describe('.validateHeaderAndEvent', () => { expect(errors.some(p => p.match(/categories\[0] must be a `string` type/))).to.be.true - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -158,14 +163,14 @@ describe('.validateHeaderAndEvent', () => { }) it('organizer', () => { - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], organizer: {name: 'Adam', email: 'adam@example.com'} }).value.organizer).to.include({name: 'Adam', email: 'adam@example.com'}) - const {errors} = validateHeaderAndEvent({ + const {errors} = validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -175,7 +180,7 @@ describe('.validateHeaderAndEvent', () => { }) it('attendees', () => { - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -184,7 +189,7 @@ describe('.validateHeaderAndEvent', () => { {name: 'Brittany', email: 'brittany@example.com'}] }).value.attendees).to.be.an('array').that.is.not.empty - const {errors} = validateHeaderAndEvent({ + const {errors} = validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -194,7 +199,7 @@ describe('.validateHeaderAndEvent', () => { }).error expect(errors.some(p => p === 'attendees[0] field has unspecified keys: foo')).to.be.true - const res = validateHeaderAndEvent({ + const res = validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -207,7 +212,7 @@ describe('.validateHeaderAndEvent', () => { }) it('created', () => { - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -216,7 +221,7 @@ describe('.validateHeaderAndEvent', () => { }) it('transp', () => { - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -225,7 +230,7 @@ describe('.validateHeaderAndEvent', () => { }) it('lastModified', () => { - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', start: [2018, 12, 1, 10, 30], @@ -234,7 +239,7 @@ describe('.validateHeaderAndEvent', () => { }) it('calName', () => { - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', calName: 'John\'s Calendar', @@ -243,7 +248,7 @@ describe('.validateHeaderAndEvent', () => { }) it('htmlContent', () => { - expect(validateHeaderAndEvent({ + expect(validateEvent({ title: 'foo', uid: 'foo', htmlContent: '

This is
test
html code.

', @@ -254,7 +259,7 @@ describe('.validateHeaderAndEvent', () => { describe('may have one or more occurrences of', () => { it('alarm component', () => { - const event = validateHeaderAndEvent({ + const event = validateEvent({ uid: 'foo', start: [2018, 12, 1, 10, 30], duration: {hours: 1}, diff --git a/test/utils/encode-param-value.spec.js b/test/utils/encode-param-value.spec.js deleted file mode 100644 index d3b5effb..00000000 --- a/test/utils/encode-param-value.spec.js +++ /dev/null @@ -1,9 +0,0 @@ -import { expect } from 'chai'; -import { encodeParamValue } from '../../src/utils' - -describe('utils.encodeParamValue', () => { - it('encodes correctly', () => { - expect(encodeParamValue('test')).to.equal(`"test"`) - expect(encodeParamValue('a"b"c')).to.equal(`"a\\"b\\"c"`) - }); -}) diff --git a/test/utils/format-date.spec.js b/test/utils/format-date.spec.js index 0a1dbc54..e8e09c1e 100644 --- a/test/utils/format-date.spec.js +++ b/test/utils/format-date.spec.js @@ -33,10 +33,4 @@ describe('utils.formatDate', () => { expect(formatDate([1998, 1, 18, 23, 9, 59], 'local', 'local')) .to.equal('19980118T230959') }) - it('sets a UTC date-time when passed a unix timestamp', () => { - expect(formatDate(1694940441442)).to.equal('20230917T084721Z') - }) - it('returns a string as is', () => { - expect(formatDate('20230917T084721Z')).to.equal('20230917T084721Z') - }) }) diff --git a/test/utils/set-contact.spec.js b/test/utils/set-contact.spec.js index d683074a..3e9fb8d2 100644 --- a/test/utils/set-contact.spec.js +++ b/test/utils/set-contact.spec.js @@ -5,23 +5,23 @@ describe('utils.setContact', () => { it('set a contact with role', () => { const contact = { name: 'm-vinc', email: 'vinc@example.com' } expect(setContact(contact)) - .to.equal(`CN="m-vinc":mailto:vinc@example.com`) + .to.equal(`CN=m-vinc:mailto:vinc@example.com`) const contactChair = Object.assign({role: 'CHAIR'}, contact) expect(setContact(contactChair)) - .to.equal(`ROLE="CHAIR";CN="m-vinc":mailto:vinc@example.com`) + .to.equal(`ROLE=CHAIR;CN=m-vinc:mailto:vinc@example.com`) const contactRequired = Object.assign({role: 'REQ-PARTICIPANT', rsvp: true }, contact) expect(setContact(contactRequired)) - .to.equal(`RSVP=TRUE;ROLE="REQ-PARTICIPANT";CN="m-vinc":mailto:vinc@example.com`) + .to.equal(`RSVP=TRUE;ROLE=REQ-PARTICIPANT;CN=m-vinc:mailto:vinc@example.com`) const contactOptional = Object.assign({role: 'OPT-PARTICIPANT', rsvp: false }, contact) expect(setContact(contactOptional)) - .to.equal(`RSVP=FALSE;ROLE="OPT-PARTICIPANT";CN="m-vinc":mailto:vinc@example.com`) + .to.equal(`RSVP=FALSE;ROLE=OPT-PARTICIPANT;CN=m-vinc:mailto:vinc@example.com`) const contactNon = Object.assign({role: 'NON-PARTICIPANT' }, contact) expect(setContact(contactNon)) - .to.equal(`ROLE="NON-PARTICIPANT";CN="m-vinc":mailto:vinc@example.com`) + .to.equal(`ROLE=NON-PARTICIPANT;CN=m-vinc:mailto:vinc@example.com`) }) it('set a contact with partstat', () => { const contact = { name: 'm-vinc', email: 'vinc@example.com' } @@ -32,19 +32,19 @@ describe('utils.setContact', () => { const contactTentative = Object.assign({contact, partstat: 'TENTATIVE'}, contact) expect(setContact(contactUndefined)) - .to.equal('CN="m-vinc":mailto:vinc@example.com') + .to.equal('CN=m-vinc:mailto:vinc@example.com') expect(setContact(contactNeedsAction)) - .to.equal('PARTSTAT="NEEDS-ACTION";CN="m-vinc":mailto:vinc@example.com') + .to.equal('PARTSTAT=NEEDS-ACTION;CN=m-vinc:mailto:vinc@example.com') expect(setContact(contactDeclined)) - .to.equal('PARTSTAT="DECLINED";CN="m-vinc":mailto:vinc@example.com') + .to.equal('PARTSTAT=DECLINED;CN=m-vinc:mailto:vinc@example.com') expect(setContact(contactTentative)) - .to.equal('PARTSTAT="TENTATIVE";CN="m-vinc":mailto:vinc@example.com') + .to.equal('PARTSTAT=TENTATIVE;CN=m-vinc:mailto:vinc@example.com') expect(setContact(contactAccepted)) - .to.equal('PARTSTAT="ACCEPTED";CN="m-vinc":mailto:vinc@example.com') + .to.equal('PARTSTAT=ACCEPTED;CN=m-vinc:mailto:vinc@example.com') }) it('sets a contact and only sets RSVP if specified', () => { const contact1 = { @@ -60,17 +60,17 @@ describe('utils.setContact', () => { } expect(setContact(contact1)) - .to.equal('CN="Adam Gibbons":mailto:adam@example.com') + .to.equal('CN=Adam Gibbons:mailto:adam@example.com') expect(setContact(contact2)) - .to.equal('RSVP=TRUE;DIR="https://example.com/contacts/adam";CN="Adam Gibbons":mailto:adam@example.com') + .to.equal('RSVP=TRUE;DIR=https://example.com/contacts/adam;CN=Adam Gibbons:mailto:adam@example.com') }) it('set a contact with cutype and guests', () => { const contact = { name: 'm-vinc', email: 'vinc@example.com' } const contactCuGuests = Object.assign({ cutype: 'INDIVIDUAL', xNumGuests: 0 }, contact) const contactString = setContact(contactCuGuests) - expect(contactString).to.contain('CUTYPE="INDIVIDUAL"') + expect(contactString).to.contain('CUTYPE=INDIVIDUAL') expect(contactString).to.contain('X-NUM-GUESTS=0') }) })