├── .nvmrc ├── .babelrc ├── src ├── pipeline │ ├── validate.js │ ├── index.js │ ├── build.js │ └── format.js ├── utils │ ├── set-geolocation.js │ ├── encode-new-lines.js │ ├── encode-param-value.js │ ├── set-summary.js │ ├── set-location.js │ ├── set-description.js │ ├── format-text.js │ ├── fold-line.js │ ├── format-duration.js │ ├── set-organizer.js │ ├── index.js │ ├── set-contact.js │ ├── format-date.js │ └── set-alarm.js ├── defaults.js ├── index.js └── schema │ └── index.js ├── Gemfile ├── .gitignore ├── test ├── utils │ ├── set-gelocation.spec.js │ ├── encode-param-value.spec.js │ ├── fold-line.spec.js │ ├── set-alarm.spec.js │ ├── format-date.spec.js │ └── set-contact.spec.js ├── pipeline │ ├── validate.spec.js │ ├── build.spec.js │ └── format.spec.js ├── index.spec.js └── schema │ └── index.spec.js ├── .travis.yml ├── gulpfile.js ├── .github └── workflows │ └── build.yml ├── LICENSE ├── package.json ├── notes.md ├── index.d.ts └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.17.1 -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env"] 3 | } 4 | 5 | -------------------------------------------------------------------------------- /src/pipeline/validate.js: -------------------------------------------------------------------------------- 1 | export * from '../schema' 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'travis' 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | write-event.js 4 | output.ics 5 | dist/ 6 | package-lock.json -------------------------------------------------------------------------------- /src/utils/set-geolocation.js: -------------------------------------------------------------------------------- 1 | export default function setGeolocation({ lat, lon }) { 2 | return `${lat};${lon}` 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/encode-new-lines.js: -------------------------------------------------------------------------------- 1 | export default function encodeNewLines (text) { 2 | return text.replace(/\r?\n/gm, "\\n") 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/encode-param-value.js: -------------------------------------------------------------------------------- 1 | export default function encodeParamValue (value) { 2 | return `"${value.replaceAll('"', '\\"')}"` 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/set-summary.js: -------------------------------------------------------------------------------- 1 | import formatText from './format-text' 2 | 3 | export default function setSummary(summary) { 4 | return formatText(summary) 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/set-location.js: -------------------------------------------------------------------------------- 1 | import formatText from "./format-text"; 2 | 3 | export default function setLocation(location) { 4 | return formatText(location); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/set-description.js: -------------------------------------------------------------------------------- 1 | import formatText from './format-text' 2 | 3 | export default function setDescription(description) { 4 | return formatText(description) 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/format-text.js: -------------------------------------------------------------------------------- 1 | export default function formatText (text) { 2 | return text 3 | .replace(/\\/gm, "\\\\") 4 | .replace(/\r?\n/gm, "\\n") 5 | .replace(/;/gm, "\\;") 6 | .replace(/,/gm, "\\,") 7 | } 8 | -------------------------------------------------------------------------------- /test/utils/set-gelocation.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { setGeolocation } from '../../src/utils' 3 | 4 | describe('utils.setGeolocation', () => { 5 | it('exists', () => { 6 | expect(setGeolocation).to.exist 7 | }) 8 | }) -------------------------------------------------------------------------------- /src/defaults.js: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid' 2 | 3 | export const headerDefaults = () => ({ 4 | productId: 'adamgibbons/ics', 5 | method: 'PUBLISH' 6 | }) 7 | 8 | export const eventDefaults = () => ({ 9 | title: 'Untitled event', 10 | uid: nanoid(), 11 | timestamp: Date.now() 12 | }) 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8' 4 | deploy: 5 | provider: npm 6 | email: adam.d.gibbons+ics@gmail.com 7 | api_key: 8 | secure: zCYFz20TzxdwUVvHs2HAw71DWQbDzlid2AuF1db/QVxupuG0CUsE01i7AYH1xKzrxM0SQ2feuYIVNb3xZZqy1KHb0lY0ir/bBTv+AZ5ij9x4n83/7bv8KCobQp00qHfeK6ReOmdz1r6ZD/x5kCGNSo2eewWDoWRhy1BX72edQGU= 9 | -------------------------------------------------------------------------------- /test/utils/encode-param-value.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { encodeParamValue } from '../../src/utils' 3 | 4 | describe('utils.encodeParamValue', () => { 5 | it('encodes correctly', () => { 6 | expect(encodeParamValue('test')).to.equal(`"test"`) 7 | expect(encodeParamValue('a"b"c')).to.equal(`"a\\"b\\"c"`) 8 | }); 9 | }) 10 | -------------------------------------------------------------------------------- /src/utils/fold-line.js: -------------------------------------------------------------------------------- 1 | 2 | import { runes, substring } from 'runes2' 3 | 4 | export default function foldLine(line) { 5 | const parts = [] 6 | let length = 75 7 | while (runes(line).length > length) { 8 | parts.push(substring(line, 0, length)) 9 | line = substring(line, length) 10 | length = 74 11 | } 12 | parts.push(line) 13 | return parts.join('\r\n\t') 14 | } 15 | -------------------------------------------------------------------------------- /src/pipeline/index.js: -------------------------------------------------------------------------------- 1 | import { buildHeader, buildEvent } from './build' 2 | import { formatHeader, formatEvent, formatFooter } from './format' 3 | import { validateHeader, validateHeaderAndEvent, urlRegex } from './validate' 4 | 5 | export { 6 | buildHeader, 7 | buildEvent, 8 | formatHeader, 9 | formatEvent, 10 | formatFooter, 11 | validateHeader, 12 | validateHeaderAndEvent, 13 | urlRegex 14 | } 15 | -------------------------------------------------------------------------------- /test/utils/fold-line.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { foldLine } from '../../src/utils' 3 | 4 | describe('utils.foldLine', () => { 5 | it('fold a line with emoji', () => { 6 | const line = 'some text some text some text some text some text some text some text abc 🍅🍅🍅🍅' 7 | expect(foldLine(line)).to.equal('some text some text some text some text some text some text some text abc 🍅\r\n\t🍅🍅🍅') 8 | }) 9 | }) -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var babel = require('gulp-babel'); 3 | 4 | var paths = { 5 | src: 'src/**/*.js', 6 | dest: 'dist', 7 | test: 'test/**/*.js' 8 | }; 9 | 10 | gulp.task('watch', function() { 11 | return gulp.watch(paths.src, ['build']); 12 | }); 13 | 14 | gulp.task('build', function () { 15 | return gulp.src(paths.src) 16 | .pipe(babel()) 17 | .pipe(gulp.dest(paths.dest)); 18 | }); 19 | 20 | gulp.task('dev', gulp.series(['watch', 'build'])); 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | run: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Use Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version-file: '.nvmrc' 19 | - name: npm install, build and test 20 | run: | 21 | npm ci 22 | npm run build 23 | npm test 24 | -------------------------------------------------------------------------------- /src/utils/format-duration.js: -------------------------------------------------------------------------------- 1 | export default function formatDuration ( attributes = {}) { 2 | const { weeks, days, hours, minutes, seconds } = attributes 3 | 4 | let formattedDuration = 'P' 5 | formattedDuration += weeks ? `${weeks}W` : '' 6 | formattedDuration += days ? `${days}D` : '' 7 | formattedDuration += 'T' 8 | formattedDuration += hours ? `${hours}H` : '' 9 | formattedDuration += minutes ? `${minutes}M` : '' 10 | formattedDuration += seconds ? `${seconds}S` : '' 11 | 12 | return formattedDuration 13 | } -------------------------------------------------------------------------------- /src/utils/set-organizer.js: -------------------------------------------------------------------------------- 1 | import encodeParamValue from "./encode-param-value" 2 | 3 | export default function setOrganizer({ name, email, dir, sentBy }) { 4 | let formattedOrganizer = '' 5 | formattedOrganizer += dir ? `DIR=${encodeParamValue(dir)};` : '' 6 | formattedOrganizer += sentBy ? `SENT-BY=${encodeParamValue(`MAILTO:${sentBy}`)};` : '' 7 | formattedOrganizer += 'CN=' 8 | formattedOrganizer += encodeParamValue(name || 'Organizer') 9 | formattedOrganizer += email ? `:MAILTO:${email}` : '' 10 | 11 | return formattedOrganizer 12 | } 13 | -------------------------------------------------------------------------------- /src/pipeline/build.js: -------------------------------------------------------------------------------- 1 | import { headerDefaults, eventDefaults } from "../defaults"; 2 | 3 | function removeUndefined(input) { 4 | return Object.entries(input).reduce( 5 | (clean, entry) => typeof entry[1] !== 'undefined' ? Object.assign(clean, {[entry[0]]: entry[1]}) : clean, 6 | {} 7 | ) 8 | } 9 | 10 | export function buildHeader(attributes = {}) { 11 | // fill in default values where necessary 12 | const output = Object.assign({}, headerDefaults(), attributes); 13 | 14 | return removeUndefined(output) 15 | } 16 | 17 | export function buildEvent(attributes = {}) { 18 | // fill in default values where necessary 19 | const output = Object.assign({}, eventDefaults(), attributes); 20 | 21 | return removeUndefined(output) 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | import formatDate from './format-date' 2 | import setGeolocation from './set-geolocation' 3 | import setContact from './set-contact' 4 | import setOrganizer from './set-organizer' 5 | import setAlarm from './set-alarm' 6 | import setDescription from './set-description' 7 | import setSummary from './set-summary' 8 | import formatDuration from './format-duration' 9 | import foldLine from './fold-line' 10 | import setLocation from './set-location' 11 | import encodeParamValue from './encode-param-value' 12 | 13 | export { 14 | formatDate, 15 | setGeolocation, 16 | setContact, 17 | setOrganizer, 18 | setAlarm, 19 | formatDuration, 20 | setSummary, 21 | setDescription, 22 | foldLine, 23 | setLocation, 24 | encodeParamValue 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License (ISC) 2 | Copyright (c) 2016, Adam Gibbons 3 | 4 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 5 | 6 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /src/utils/set-contact.js: -------------------------------------------------------------------------------- 1 | import encodeParamValue from "./encode-param-value"; 2 | 3 | export default function setContact({ name, email, rsvp, dir, partstat, role, cutype, xNumGuests }) { 4 | let formattedParts = []; 5 | 6 | if(rsvp !== undefined){ 7 | formattedParts.push(rsvp ? 'RSVP=TRUE' : 'RSVP=FALSE'); 8 | } 9 | if(cutype){ 10 | formattedParts.push("CUTYPE=".concat(encodeParamValue(cutype))); 11 | } 12 | if(xNumGuests !== undefined){ 13 | formattedParts.push(`X-NUM-GUESTS=${xNumGuests}`); 14 | } 15 | if(role){ 16 | formattedParts.push("ROLE=".concat(encodeParamValue(role))); 17 | } 18 | if(partstat){ 19 | formattedParts.push("PARTSTAT=".concat(encodeParamValue(partstat))); 20 | } 21 | if(dir){ 22 | formattedParts.push("DIR=".concat(encodeParamValue(dir))); 23 | } 24 | formattedParts.push('CN='.concat((encodeParamValue(name || 'Unnamed attendee')))); 25 | 26 | var formattedAttendee = formattedParts.join(';').concat(email ? ":mailto:".concat(email) : ''); 27 | 28 | return formattedAttendee 29 | } 30 | -------------------------------------------------------------------------------- /test/pipeline/validate.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { validateHeaderAndEvent } from '../../src/pipeline' 3 | 4 | describe('pipeline.validateHeaderAndEvent', () => { 5 | it('validates an event', () => { 6 | const { error, value } = validateHeaderAndEvent({ 7 | uid: '1', 8 | start: [1997, 10, 1, 22, 30], 9 | duration: { hours: 1 } 10 | }) 11 | expect(error).not.to.exist 12 | expect(value.uid).to.equal('1') 13 | }) 14 | it('returns an error if the sequence number is too long', () => { 15 | const { error, value } = validateHeaderAndEvent({ 16 | uid: '1', 17 | start: [1997, 10, 1, 22, 30], 18 | duration: { hours: 1 }, 19 | sequence: 3_456_789_123, // bigger than 2,147,483,647 20 | }) 21 | expect(error).to.exist 22 | }) 23 | it('returns undefined when passed no event', () => { 24 | const { error, value } = validateHeaderAndEvent() 25 | expect(value).to.be.undefined 26 | }) 27 | it('returns an error when invalid data passed', () => { 28 | expect(validateHeaderAndEvent(null).error).to.exist 29 | expect(validateHeaderAndEvent(1).error).to.exist 30 | expect(validateHeaderAndEvent('foo').error).to.exist 31 | expect(validateHeaderAndEvent({}).error).to.exist 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/utils/format-date.js: -------------------------------------------------------------------------------- 1 | const pad = n => n < 10 ? `0${n}` : `${n}` 2 | 3 | export default function formatDate(args = [], outputType = 'utc', inputType = 'local') { 4 | if (typeof args === 'string') { 5 | return args; 6 | } 7 | 8 | if (Array.isArray(args) && args.length === 3) { 9 | const [year, month, date] = args 10 | return `${year}${pad(month)}${pad(date)}` 11 | } 12 | 13 | let outDate = new Date() 14 | if (Array.isArray(args) && args.length > 0 && args[0]) { 15 | const [year, month, date, hours = 0, minutes = 0, seconds = 0] = args 16 | if (inputType === 'local') { 17 | outDate = new Date(year, month - 1, date, hours, minutes, seconds) 18 | } else { 19 | outDate = new Date(Date.UTC(year, month - 1, date, hours, minutes, seconds)) 20 | } 21 | } else if (!Array.isArray(args)) { 22 | // it's a unix time stamp (ms) 23 | outDate = new Date(args); 24 | } 25 | 26 | if (outputType === 'local') { 27 | return [ 28 | outDate.getFullYear(), 29 | pad(outDate.getMonth() + 1), 30 | pad(outDate.getDate()), 31 | 'T', 32 | pad(outDate.getHours()), 33 | pad(outDate.getMinutes()), 34 | pad(outDate.getSeconds()) 35 | ].join('') 36 | } 37 | 38 | return [ 39 | outDate.getUTCFullYear(), 40 | pad(outDate.getUTCMonth() + 1), 41 | pad(outDate.getUTCDate()), 42 | 'T', 43 | pad(outDate.getUTCHours()), 44 | pad(outDate.getUTCMinutes()), 45 | pad(outDate.getUTCSeconds()), 46 | 'Z' 47 | ].join('') 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ics", 3 | "version": "3.8.1", 4 | "description": "iCal (ics) file generator", 5 | "exports": { 6 | "types": "./index.d.ts", 7 | "default": "./dist/index.js" 8 | }, 9 | "main": "./dist/index.js", 10 | "types": "index.d.ts", 11 | "scripts": { 12 | "start": "TZ=utc mocha --require @babel/register --watch --recursive", 13 | "test": "TZ=utc mocha --require @babel/register --recursive", 14 | "build": "rm -r dist; gulp build" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/adamgibbons/ics.git" 19 | }, 20 | "keywords": [ 21 | "ical", 22 | "ics", 23 | "calendar", 24 | "icalendar", 25 | "generator", 26 | "date", 27 | "date-time", 28 | "events", 29 | "alarms" 30 | ], 31 | "author": "Adam Gibbons (http://agibbons.com/)", 32 | "license": "ISC", 33 | "bugs": { 34 | "url": "https://github.com/adamgibbons/ics/issues" 35 | }, 36 | "homepage": "https://github.com/adamgibbons/ics", 37 | "devDependencies": { 38 | "@babel/core": "^7.6.2", 39 | "@babel/preset-env": "^7.23.2", 40 | "@babel/register": "^7.6.2", 41 | "chai": "^4.2.0", 42 | "dayjs": "^1.8.33", 43 | "gulp": "^5.0.0", 44 | "gulp-babel": "^8.0.0", 45 | "mocha": "^9.1.3", 46 | "yargs-parser": ">=13.1.2" 47 | }, 48 | "dependencies": { 49 | "nanoid": "^3.1.23", 50 | "runes2": "^1.1.2", 51 | "yup": "^1.2.0" 52 | }, 53 | "files": [ 54 | "dist/", 55 | "index.d.ts" 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /test/utils/set-alarm.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { setAlarm } from '../../src/utils' 3 | 4 | describe('utils.setAlarm', () => { 5 | it('sets an alarm', () => { 6 | const attributes = { 7 | repeat: 5, 8 | description: 'Foo', 9 | action: 'audio', 10 | attach: 'ftp://example.com/pub/sounds/bell-01.aud', 11 | duration: { 12 | weeks: 1, 13 | days: 15, 14 | hours: 3, 15 | minutes: 4, 16 | seconds: 50 17 | }, 18 | trigger: [1997, 2, 17, 6, 30], 19 | summary: 'Bar baz' 20 | } 21 | const alarm = setAlarm(attributes) 22 | expect(alarm).to.equal([ 23 | `BEGIN:VALARM`, 24 | `ACTION:AUDIO`, 25 | `REPEAT:5`, 26 | `DESCRIPTION:Foo`, 27 | `DURATION:P1W15DT3H4M50S`, 28 | `ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud`, 29 | `TRIGGER;VALUE=DATE-TIME:19970217T063000Z`, 30 | `SUMMARY:Bar baz`, 31 | `END:VALARM`, 32 | `` 33 | ].join('\r\n')) 34 | }) 35 | }) 36 | 37 | // BEGIN:VALARM 38 | // TRIGGER;VALUE=DATE-TIME:19970317T133000Z 39 | // REPEAT:4 40 | // DURATION:PT15M 41 | // ACTION:AUDIO 42 | // ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/ 43 | // sounds/bell-01.aud 44 | // END:VALARM 45 | 46 | // action: Joi.string().regex(/audio|display|email/).required(), 47 | // trigger: Joi.string().required(), 48 | // description: Joi.string(), 49 | // duration: Joi.string(), 50 | // repeat: Joi.string(), 51 | // attach: Joi.any(), 52 | // summary: Joi.string(), 53 | // attendee: contactSchema, 54 | // 'x-prop': Joi.any(), 55 | // 'iana-prop': Joi.any() 56 | -------------------------------------------------------------------------------- /test/utils/format-date.spec.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import utc from 'dayjs/plugin/utc' 3 | 4 | dayjs.extend(utc) 5 | 6 | import { formatDate } from '../../src/utils' 7 | import { expect } from 'chai' 8 | 9 | describe('utils.formatDate', () => { 10 | it('defaults to local time input and UTC time output when no type passed', () => { 11 | const now = dayjs(new Date(2017, 7-1, 16, 22, 30, 35)).utc().format('YYYYMMDDTHHmmss') 12 | expect(formatDate([2017, 7, 16, 22, 30, 35])).to.equal(now+'Z') 13 | }) 14 | it('sets a local (i.e. floating) time when specified', () => { 15 | expect(formatDate([1998, 6, 18, 23, 0], 'local', 'local')).to.equal('19980618T230000') 16 | }) 17 | it('sets a date value when passed only three args', () => { 18 | expect(formatDate([2018, 2, 11])).to.equal('20180211') 19 | }) 20 | it('defaults to NOW in UTC date-time when no args passed', () => { 21 | const now = dayjs().utc().format('YYYYMMDDTHHmmss') + 'Z' 22 | expect(formatDate(undefined, 'utc')).to.equal(now) 23 | }) 24 | it('sets a UTC date-time when passed well-formed args', () => { 25 | expect(formatDate([2017, 9, 25, 0, 30], 'utc', 'utc')).to.equal('20170925T003000Z') 26 | expect(formatDate([2017, 1, 31], 'utc','utc')).to.equal('20170131') 27 | }) 28 | it('sets a local DATE-TIME value to NOW when passed nothing', () => { 29 | const now = dayjs().format('YYYYMMDDTHHmmss') 30 | expect(formatDate(undefined, 'local', 'local')).to.equal(now) 31 | }) 32 | it('sets a local DATE-TIME value when passed args', () => { 33 | expect(formatDate([1998, 1, 18, 23, 9, 59], 'local', 'local')) 34 | .to.equal('19980118T230959') 35 | }) 36 | it('sets a UTC date-time when passed a unix timestamp', () => { 37 | expect(formatDate(1694940441442)).to.equal('20230917T084721Z') 38 | }) 39 | it('returns a string as is', () => { 40 | expect(formatDate('20230917T084721Z')).to.equal('20230917T084721Z') 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /src/utils/set-alarm.js: -------------------------------------------------------------------------------- 1 | import formatDate from './format-date' 2 | import foldLine from './fold-line' 3 | import encodeNewLines from './encode-new-lines' 4 | 5 | function setDuration ({ 6 | weeks, 7 | days, 8 | hours, 9 | minutes, 10 | seconds 11 | }) { 12 | let formattedString = 'P' 13 | formattedString += weeks ? `${weeks}W` : '' 14 | formattedString += days ? `${days}D` : '' 15 | formattedString += 'T' 16 | formattedString += hours ? `${hours}H` : '' 17 | formattedString += minutes ? `${minutes}M` : '' 18 | formattedString += seconds ? `${seconds}S` : '' 19 | 20 | return formattedString 21 | } 22 | 23 | function setTrigger (trigger) { 24 | let formattedString = '' 25 | if(Array.isArray(trigger) || typeof trigger === 'number' || typeof trigger === 'string') { 26 | formattedString = `TRIGGER;VALUE=DATE-TIME:${encodeNewLines(formatDate(trigger))}\r\n` 27 | } else { 28 | let alert = trigger.before ? '-' : '' 29 | formattedString = `TRIGGER:${encodeNewLines(alert+setDuration(trigger))}\r\n` 30 | } 31 | 32 | return formattedString 33 | } 34 | 35 | function setAction (action){ 36 | return action.toUpperCase() 37 | } 38 | 39 | export default function setAlarm(attributes = {}) { 40 | const { 41 | action, 42 | repeat, 43 | description, 44 | duration, 45 | attach, 46 | attachType, 47 | trigger, 48 | summary 49 | } = attributes 50 | 51 | let formattedString = 'BEGIN:VALARM\r\n' 52 | formattedString += foldLine(`ACTION:${encodeNewLines(setAction(action))}`) + '\r\n' 53 | formattedString += repeat ? foldLine(`REPEAT:${repeat}`) + '\r\n' : '' 54 | formattedString += description ? foldLine(`DESCRIPTION:${encodeNewLines(description)}`) + '\r\n' : '' 55 | formattedString += duration ? foldLine(`DURATION:${setDuration(duration)}`) + '\r\n' : '' 56 | let attachInfo = attachType ? attachType : 'FMTTYPE=audio/basic' 57 | formattedString += attach ? foldLine(encodeNewLines(`ATTACH;${attachInfo}:${attach}`)) + '\r\n' : '' 58 | formattedString += trigger ? (setTrigger(trigger)) : '' 59 | formattedString += summary ? (foldLine(`SUMMARY:${encodeNewLines(summary)}`) + '\r\n') : '' 60 | formattedString += 'END:VALARM\r\n' 61 | 62 | return formattedString 63 | } 64 | 65 | // Example: A duration of 15 days, 5 hours, and 20 seconds would be: 66 | 67 | // P15DT5H0M20S 68 | 69 | // A duration of 7 weeks would be: 70 | 71 | // P7W 72 | -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- 1 | # RFC5545 - Notes 2 | 3 | ## 3.2.19. Time Zone Identifier 4 | 5 | This parameter MUST be specified on the "DTSTART", "DTEND", "DUE", "EXDATE", and "RDATE" properties when either a DATE-TIME or TIME value type is specified and when the value is neither a UTC or a "floating" time. 6 | 7 | The "TZID" property parameter MUST NOT be applied to DATE properties and DATE-TIME or TIME properties whose time values are specified in UTC. 8 | 9 | The use of local time in a DATE-TIME or TIME value without the "TZID" property parameter is to be interpreted as floating time, regardless of the existence of "VTIMEZONE" calendar components in the iCalendar object. 10 | 11 | Examples of this property parameter: 12 | ``` 13 | DTSTART;TZID=America/New_York:19980119T020000 14 | DTEND;TZID=America/New_York:19980119T030000 15 | ``` 16 | 17 | ## 3.3.5. Date-Time 18 | 19 | The "DATE-TIME" value type expresses time values in three forms: 20 | 21 | ### FORM #1: DATE WITH LOCAL TIME 22 | January 18, 1998, at 11 PM: 23 | ``` 24 | 19980118T230000 25 | ``` 26 | 27 | ### FORM #2: DATE WITH UTC TIME 28 | January 19, 1998, at 0700 UTC: 29 | ``` 30 | 19980119T070000Z 31 | ``` 32 | 33 | ### FORM #3: DATE WITH LOCAL TIME AND TIME ZONE REFERENCE 34 | 2:00 A.M. in New York on January 19, 1998: 35 | ``` 36 | TZID=America/New_York:19980119T020000 37 | ``` 38 | 39 | ## 3.6.1. Event Component 40 | 41 | ### REQUIRED, MUST NOT occur more than once 42 | - [x] dtstamp 43 | - [x] uid 44 | 45 | ### REQUIRED when METHOD is not specified, MUST NOT occur more than once 46 | - [x] dtstart 47 | 48 | ### OPTIONAL but MUST NOT occur more than once 49 | - [x] class 50 | - [ ] created 51 | - [x] description 52 | - [x] geo 53 | - [ ] last-mod 54 | - [x] location 55 | - [x] organizer 56 | - [ ] priority 57 | - [ ] seq 58 | - [x] status 59 | - [x] summary 60 | - [x] transp 61 | - [x] url 62 | - [ ] recurid 63 | 64 | Either 'dtend' or 'duration' MAY appear in a 'eventprop', but 'dtend' and 'duration' MUST NOT occur in the same 'eventprop'. 65 | 66 | - [x] dtend 67 | - [ ] duration 68 | 69 | The following are OPTIONAL, and MAY occur more than once. 70 | - [ ] attach 71 | - [x] attendee 72 | - [x] categories 73 | - [ ] comment 74 | - [ ] contact 75 | - [ ] exdate 76 | - [ ] rstatus 77 | - [ ] related 78 | - [ ] resources 79 | - [ ] rdate 80 | - [ ] x-prop 81 | - [ ] iana-prop 82 | 83 | 84 | ## 3.8.2.4. Date-Time Start 85 | - Property Name: `DTSTART` 86 | - Value Type: The default value type is DATE-TIME. The time value MUST be one of the forms defined for the DATE-TIME value type. The value type can be set to a DATE value type. 87 | 88 | 3.8.2.2. Date-Time End 89 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | buildHeader, 3 | buildEvent, 4 | validateHeader, 5 | validateHeaderAndEvent, 6 | formatHeader, 7 | formatEvent, 8 | formatFooter, 9 | urlRegex, 10 | } from './pipeline' 11 | 12 | function buildHeaderAndValidate(header) { 13 | return validateHeader(buildHeader(header)) 14 | } 15 | 16 | function buildHeaderAndEventAndValidate(event) { 17 | return validateHeaderAndEvent({...buildHeader(event), ...buildEvent(event) }) 18 | } 19 | 20 | export function convertTimestampToArray(timestamp, inputType = 'local') { 21 | const dateArray = []; 22 | const d = new Date(timestamp); 23 | dateArray.push(inputType === 'local' ? d.getFullYear() : d.getUTCFullYear()); 24 | dateArray.push((inputType === 'local' ? d.getMonth() : d.getUTCMonth()) + 1); 25 | dateArray.push(inputType === 'local' ? d.getDate() : d.getUTCDate()); 26 | dateArray.push(inputType === 'local' ? d.getHours() : d.getUTCHours()); 27 | dateArray.push(inputType === 'local' ? d.getMinutes() : d.getUTCMinutes()); 28 | return dateArray; 29 | } 30 | 31 | export function createEvent (attributes, cb) { 32 | return createEvents([attributes], cb) 33 | } 34 | 35 | export function createEvents (events, headerAttributesOrCb, cb) { 36 | const resolvedHeaderAttributes = typeof headerAttributesOrCb === 'object' ? headerAttributesOrCb : {}; 37 | const resolvedCb = arguments.length === 3 ? cb : (typeof headerAttributesOrCb === 'function' ? headerAttributesOrCb : null); 38 | 39 | const run = () => { 40 | if (!events) { 41 | return { error: new Error('one argument is required'), value: null } 42 | } 43 | 44 | const { error: headerError, value: headerValue } = events.length === 0 45 | ? buildHeaderAndValidate(resolvedHeaderAttributes) 46 | : buildHeaderAndEventAndValidate({...events[0], ...resolvedHeaderAttributes}); 47 | 48 | if (headerError) { 49 | return {error: headerError, value: null} 50 | } 51 | 52 | let value = '' 53 | value += formatHeader(headerValue) 54 | 55 | for (let i = 0; i < events.length; i++) { 56 | const { error: eventError, value: eventValue } = buildHeaderAndEventAndValidate(events[i]) 57 | if (eventError) return {error: eventError, value: null} 58 | 59 | value += formatEvent(eventValue); 60 | } 61 | 62 | value += formatFooter(); 63 | 64 | return { error: null, value } 65 | } 66 | 67 | let returnValue; 68 | try { 69 | returnValue = run(); 70 | } catch (e) { 71 | returnValue = { error: e, value: null } 72 | } 73 | 74 | if (!resolvedCb) { 75 | return returnValue 76 | } 77 | 78 | return resolvedCb(returnValue.error, returnValue.value) 79 | } 80 | 81 | export function isValidURL(url) { 82 | return urlRegex.test(url); 83 | } 84 | -------------------------------------------------------------------------------- /test/utils/set-contact.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { setContact } from '../../src/utils' 3 | 4 | describe('utils.setContact', () => { 5 | it('set a contact with role', () => { 6 | const contact = { name: 'm-vinc', email: 'vinc@example.com' } 7 | expect(setContact(contact)) 8 | .to.equal(`CN="m-vinc":mailto:vinc@example.com`) 9 | 10 | const contactChair = Object.assign({role: 'CHAIR'}, contact) 11 | expect(setContact(contactChair)) 12 | .to.equal(`ROLE="CHAIR";CN="m-vinc":mailto:vinc@example.com`) 13 | 14 | const contactRequired = Object.assign({role: 'REQ-PARTICIPANT', rsvp: true }, contact) 15 | expect(setContact(contactRequired)) 16 | .to.equal(`RSVP=TRUE;ROLE="REQ-PARTICIPANT";CN="m-vinc":mailto:vinc@example.com`) 17 | 18 | const contactOptional = Object.assign({role: 'OPT-PARTICIPANT', rsvp: false }, contact) 19 | expect(setContact(contactOptional)) 20 | .to.equal(`RSVP=FALSE;ROLE="OPT-PARTICIPANT";CN="m-vinc":mailto:vinc@example.com`) 21 | 22 | const contactNon = Object.assign({role: 'NON-PARTICIPANT' }, contact) 23 | expect(setContact(contactNon)) 24 | .to.equal(`ROLE="NON-PARTICIPANT";CN="m-vinc":mailto:vinc@example.com`) 25 | }) 26 | it('set a contact with partstat', () => { 27 | const contact = { name: 'm-vinc', email: 'vinc@example.com' } 28 | const contactUndefined = Object.assign({partstat: undefined}, contact) 29 | const contactNeedsAction = Object.assign({partstat: 'NEEDS-ACTION'}, contact) 30 | const contactAccepted = Object.assign({partstat: 'ACCEPTED'}, contact) 31 | const contactDeclined = Object.assign({partstat: 'DECLINED'}, contact) 32 | const contactTentative = Object.assign({contact, partstat: 'TENTATIVE'}, contact) 33 | 34 | expect(setContact(contactUndefined)) 35 | .to.equal('CN="m-vinc":mailto:vinc@example.com') 36 | 37 | expect(setContact(contactNeedsAction)) 38 | .to.equal('PARTSTAT="NEEDS-ACTION";CN="m-vinc":mailto:vinc@example.com') 39 | 40 | expect(setContact(contactDeclined)) 41 | .to.equal('PARTSTAT="DECLINED";CN="m-vinc":mailto:vinc@example.com') 42 | 43 | expect(setContact(contactTentative)) 44 | .to.equal('PARTSTAT="TENTATIVE";CN="m-vinc":mailto:vinc@example.com') 45 | 46 | expect(setContact(contactAccepted)) 47 | .to.equal('PARTSTAT="ACCEPTED";CN="m-vinc":mailto:vinc@example.com') 48 | }) 49 | it('sets a contact and only sets RSVP if specified', () => { 50 | const contact1 = { 51 | name: 'Adam Gibbons', 52 | email: 'adam@example.com' 53 | } 54 | 55 | const contact2 = { 56 | name: 'Adam Gibbons', 57 | email: 'adam@example.com', 58 | rsvp: true, 59 | dir: 'https://example.com/contacts/adam' 60 | } 61 | 62 | expect(setContact(contact1)) 63 | .to.equal('CN="Adam Gibbons":mailto:adam@example.com') 64 | 65 | expect(setContact(contact2)) 66 | .to.equal('RSVP=TRUE;DIR="https://example.com/contacts/adam";CN="Adam Gibbons":mailto:adam@example.com') 67 | }) 68 | it('set a contact with cutype and guests', () => { 69 | const contact = { name: 'm-vinc', email: 'vinc@example.com' } 70 | const contactCuGuests = Object.assign({ cutype: 'INDIVIDUAL', xNumGuests: 0 }, contact) 71 | const contactString = setContact(contactCuGuests) 72 | 73 | expect(contactString).to.contain('CUTYPE="INDIVIDUAL"') 74 | expect(contactString).to.contain('X-NUM-GUESTS=0') 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export type DateTime = DateArray | number | string; 2 | 3 | export type DateArray = 4 | | [number, number, number, number, number] 5 | | [number, number, number, number] 6 | | [number, number, number]; 7 | 8 | export type DurationObject = { 9 | weeks?: number; 10 | days?: number; 11 | hours?: number; 12 | minutes?: number; 13 | seconds?: number; 14 | before?: boolean; 15 | }; 16 | 17 | export type GeoCoordinates = { 18 | lat: number; 19 | lon: number; 20 | }; 21 | 22 | export type EventStatus = 'TENTATIVE' | 'CONFIRMED' | 'CANCELLED'; 23 | 24 | export type ParticipationStatus = 25 | | 'NEEDS-ACTION' 26 | | 'ACCEPTED' 27 | | 'DECLINED' 28 | | 'TENTATIVE' 29 | | 'DELEGATED' 30 | | 'COMPLETED' 31 | | 'IN-PROCESS'; 32 | 33 | export type ParticipationRole = 34 | | 'CHAIR' 35 | | 'REQ-PARTICIPANT' 36 | | 'OPT-PARTICIPANT' 37 | | 'NON-PARTICIPANT'; 38 | 39 | export type ParticipationType = 40 | | 'INDIVIDUAL' 41 | | 'GROUP' 42 | | 'RESOURCE' 43 | | 'ROOM' 44 | | 'UNKNOWN'; 45 | 46 | export type Person = { 47 | name?: string; 48 | email?: string; 49 | dir?: string; 50 | }; 51 | 52 | export type Attendee = Person & { 53 | rsvp?: boolean; 54 | partstat?: ParticipationStatus; 55 | role?: ParticipationRole; 56 | cutype?: ParticipationType; 57 | xNumGuests?: number; 58 | }; 59 | 60 | export type ActionType = 'audio' | 'display' | 'email' | 'procedure'; 61 | 62 | /** 63 | * This property defines the access classification for a calendar component. 64 | */ 65 | export type classificationType = 'PUBLIC' | 'PRIVATE' | 'CONFIDENTIAL' | string; 66 | 67 | export type Alarm = { 68 | action?: ActionType; 69 | description?: string; 70 | summary?: string; 71 | duration?: DurationObject; 72 | trigger?: DurationObject | DateTime; 73 | repeat?: number; 74 | attachType?: string; 75 | attach?: string; 76 | }; 77 | 78 | export type HeaderAttributes = { 79 | productId?: string; 80 | method?: string; 81 | calName?: string; 82 | } 83 | 84 | export type EventAttributes = { 85 | start: DateTime; 86 | startInputType?: 'local' | 'utc'; 87 | startOutputType?: 'local' | 'utc'; 88 | 89 | endInputType?: 'local' | 'utc'; 90 | endOutputType?: 'local' | 'utc'; 91 | 92 | title?: string; 93 | description?: string; 94 | 95 | location?: string; 96 | geo?: GeoCoordinates; 97 | 98 | url?: string; 99 | status?: EventStatus; 100 | busyStatus?: 'FREE' | 'BUSY' | 'TENTATIVE' | 'OOF'; 101 | transp?: 'TRANSPARENT' | 'OPAQUE'; 102 | 103 | organizer?: Person & { 104 | sentBy?: string; 105 | }; 106 | attendees?: Attendee[]; 107 | 108 | categories?: string[]; 109 | alarms?: Alarm[]; 110 | 111 | productId?: HeaderAttributes['productId']; 112 | uid?: string; 113 | method?: HeaderAttributes['method']; 114 | recurrenceRule?: string; 115 | exclusionDates?: DateTime[]; 116 | sequence?: number; 117 | calName?: HeaderAttributes['calName']; 118 | classification?: classificationType; 119 | created?: DateTime; 120 | lastModified?: DateTime; 121 | htmlContent?: string; 122 | } & ({ end: DateTime } | { duration: DurationObject }); 123 | 124 | export type ReturnObject = { error?: Error; value?: string }; 125 | 126 | type NodeCallback = (error: Error | undefined, value: string) => void; 127 | 128 | export function createEvent(attributes: EventAttributes, callback: NodeCallback): void; 129 | 130 | export function createEvent(attributes: EventAttributes): ReturnObject; 131 | 132 | export function createEvents(events: EventAttributes[], callback: NodeCallback): void; 133 | export function createEvents(events: EventAttributes[], headerAttributes?: HeaderAttributes): ReturnObject; 134 | export function createEvents(events: EventAttributes[], headerAttributes: HeaderAttributes, callback: NodeCallback): void; 135 | 136 | export function convertTimestampToArray(timestamp: Number, inputType: String): DateArray; 137 | -------------------------------------------------------------------------------- /src/pipeline/format.js: -------------------------------------------------------------------------------- 1 | import { 2 | setAlarm, 3 | setContact, 4 | setOrganizer, 5 | formatDate, 6 | setDescription, 7 | setLocation, 8 | setSummary, 9 | setGeolocation, 10 | formatDuration, 11 | foldLine 12 | } from '../utils' 13 | import encodeNewLines from '../utils/encode-new-lines' 14 | 15 | export function formatHeader(attributes = {}) { 16 | const { 17 | productId, 18 | method, 19 | calName, 20 | } = attributes 21 | 22 | let icsFormat = '' 23 | icsFormat += 'BEGIN:VCALENDAR\r\n' 24 | icsFormat += 'VERSION:2.0\r\n' 25 | icsFormat += 'CALSCALE:GREGORIAN\r\n' 26 | icsFormat += foldLine(`PRODID:${encodeNewLines(productId)}`) + '\r\n' 27 | icsFormat += foldLine(`METHOD:${encodeNewLines(method)}`) + '\r\n' 28 | icsFormat += calName ? (foldLine(`X-WR-CALNAME:${encodeNewLines(calName)}`) + '\r\n') : '' 29 | icsFormat += `X-PUBLISHED-TTL:PT1H\r\n` 30 | 31 | return icsFormat 32 | } 33 | 34 | export function formatFooter() { 35 | return `END:VCALENDAR\r\n` 36 | } 37 | 38 | export function formatEvent(attributes = {}) { 39 | const { 40 | title, 41 | uid, 42 | sequence, 43 | timestamp, 44 | start, 45 | startType, 46 | startInputType, 47 | startOutputType, 48 | duration, 49 | end, 50 | endInputType, 51 | endOutputType, 52 | description, 53 | url, 54 | geo, 55 | location, 56 | status, 57 | categories, 58 | organizer, 59 | attendees, 60 | alarms, 61 | recurrenceRule, 62 | exclusionDates, 63 | busyStatus, 64 | transp, 65 | classification, 66 | created, 67 | lastModified, 68 | htmlContent 69 | } = attributes 70 | 71 | let icsFormat = '' 72 | icsFormat += 'BEGIN:VEVENT\r\n' 73 | icsFormat += foldLine(`UID:${encodeNewLines(uid)}`) + '\r\n' 74 | icsFormat += title ? foldLine(`SUMMARY:${encodeNewLines(setSummary(title))}`) + '\r\n' : '' 75 | icsFormat += foldLine(`DTSTAMP:${encodeNewLines(formatDate(timestamp))}`) + '\r\n' 76 | 77 | // All day events like anniversaries must be specified as VALUE type DATE 78 | icsFormat += foldLine(`DTSTART${start && start.length == 3 ? ";VALUE=DATE" : ""}:${encodeNewLines(formatDate(start, startOutputType || startType, startInputType))}`) + '\r\n' 79 | 80 | // End is not required for all day events on single days (like anniversaries) 81 | if (!end || end.length !== 3 || start.length !== end.length || start.some((val, i) => val !== end[i])) { 82 | if (end) { 83 | icsFormat += foldLine(`DTEND${end.length === 3 ? ";VALUE=DATE" : ""}:${encodeNewLines(formatDate(end, endOutputType || startOutputType || startType, endInputType || startInputType))}`) + '\r\n' 84 | } 85 | } 86 | 87 | icsFormat += typeof sequence !== 'undefined' ? (`SEQUENCE:${sequence}\r\n`) : '' 88 | icsFormat += description ? (foldLine(`DESCRIPTION:${encodeNewLines(setDescription(description))}`) + '\r\n') : '' 89 | icsFormat += url ? (foldLine(`URL:${encodeNewLines(url)}`) + '\r\n') : '' 90 | icsFormat += geo ? (foldLine(`GEO:${setGeolocation(geo)}`) + '\r\n') : '' 91 | icsFormat += location ? (foldLine(`LOCATION:${encodeNewLines(setLocation(location))}`) + '\r\n') : '' 92 | icsFormat += status ? (foldLine(`STATUS:${encodeNewLines(status)}`) + '\r\n') : '' 93 | icsFormat += categories ? (foldLine(`CATEGORIES:${encodeNewLines(categories.join(','))}`) + '\r\n') : '' 94 | icsFormat += organizer ? (foldLine(`ORGANIZER;${setOrganizer(organizer)}`) + '\r\n') : '' 95 | icsFormat += busyStatus ? (foldLine(`X-MICROSOFT-CDO-BUSYSTATUS:${encodeNewLines(busyStatus)}`) + '\r\n') : '' 96 | icsFormat += transp ? (foldLine(`TRANSP:${encodeNewLines(transp)}`) + '\r\n') : '' 97 | icsFormat += classification ? (foldLine(`CLASS:${encodeNewLines(classification)}`) + '\r\n') : '' 98 | icsFormat += created ? ('CREATED:' + encodeNewLines(formatDate(created)) + '\r\n') : '' 99 | icsFormat += lastModified ? ('LAST-MODIFIED:' + encodeNewLines(formatDate(lastModified)) + '\r\n') : '' 100 | icsFormat += htmlContent ? (foldLine(`X-ALT-DESC;FMTTYPE=text/html:${encodeNewLines(htmlContent)}`) + '\r\n') : '' 101 | if (attendees) { 102 | attendees.forEach((attendee) => { 103 | icsFormat += foldLine(`ATTENDEE;${encodeNewLines(setContact(attendee))}`) + '\r\n' 104 | }) 105 | } 106 | icsFormat += recurrenceRule ? foldLine(`RRULE:${encodeNewLines(recurrenceRule)}`) + '\r\n' : '' 107 | icsFormat += exclusionDates ? foldLine(`EXDATE:${encodeNewLines(exclusionDates.map((a) => formatDate(a)).join(','))}`) + '\r\n': '' 108 | icsFormat += duration ? foldLine(`DURATION:${formatDuration(duration)}`) + '\r\n' : '' 109 | if (alarms) { 110 | alarms.forEach((alarm) => { 111 | icsFormat += setAlarm(alarm) 112 | }) 113 | } 114 | icsFormat += `END:VEVENT\r\n` 115 | 116 | return icsFormat 117 | } 118 | -------------------------------------------------------------------------------- /src/schema/index.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup' 2 | 3 | // yup url validation blocks localhost, so use a more flexible regex instead 4 | // taken from https://github.com/jquense/yup/issues/224#issuecomment-417172609 5 | // This does mean that the url validation error is 6 | // "url must match the following: ...." as opposed to "url must be a valid URL" 7 | export 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*)?$/ 8 | 9 | const dateTimeSchema = ({ required }) => yup.lazy((value) => { 10 | if (typeof value === 'number') { 11 | return yup.number().integer().min(0) 12 | } 13 | if (typeof value === 'string') { 14 | return yup.string().required() 15 | } 16 | if (!required && typeof value === 'undefined') { 17 | return yup.mixed().oneOf([undefined]) 18 | } 19 | 20 | return yup.array().required().min(3).max(7).of(yup.lazy((item, options) => { 21 | const itemIndex = options.parent.indexOf(options.value) 22 | return [ 23 | yup.number().integer(), 24 | yup.number().integer().min(1).max(12), 25 | yup.number().integer().min(1).max(31), 26 | yup.number().integer().min(0).max(23), 27 | yup.number().integer().min(0).max(60), 28 | yup.number().integer().min(0).max(60) 29 | ][itemIndex] 30 | })) 31 | } 32 | ) 33 | 34 | const durationSchema = yup.object().shape({ 35 | before: yup.boolean(),//option to set before alaram 36 | weeks: yup.number(), 37 | days: yup.number(), 38 | hours: yup.number(), 39 | minutes: yup.number(), 40 | seconds: yup.number() 41 | }).noUnknown() 42 | 43 | const contactSchema = yup.object().shape({ 44 | name: yup.string(), 45 | email: yup.string().email(), 46 | rsvp: yup.boolean(), 47 | dir: yup.string().matches(urlRegex), 48 | partstat: yup.string(), 49 | role: yup.string(), 50 | cutype: yup.string(), 51 | xNumGuests: yup.number() 52 | }).noUnknown() 53 | 54 | const organizerSchema = yup.object().shape({ 55 | name: yup.string(), 56 | email: yup.string().email(), 57 | dir: yup.string(), 58 | sentBy: yup.string() 59 | }).noUnknown() 60 | 61 | const alarmSchema = yup.object().shape({ 62 | action: yup.string().matches(/^(audio|display|email)$/).required(), 63 | trigger: yup.mixed().required(), 64 | description: yup.string(), 65 | duration: durationSchema, 66 | repeat: yup.number(), 67 | attach: yup.string(), 68 | attachType: yup.string(), 69 | summary: yup.string(), 70 | attendee: contactSchema, 71 | 'x-prop': yup.mixed(), 72 | 'iana-prop': yup.mixed() 73 | }).noUnknown() 74 | 75 | const headerShape = { 76 | productId: yup.string(), 77 | method: yup.string(), 78 | calName: yup.string() 79 | } 80 | 81 | const headerSchema = yup.object().shape(headerShape).noUnknown() 82 | 83 | const eventShape = { 84 | summary: yup.string(), 85 | timestamp: dateTimeSchema({ required: false }), 86 | title: yup.string(), 87 | uid: yup.string(), 88 | sequence: yup.number().integer().max(2_147_483_647), 89 | start: dateTimeSchema({ required: true }), 90 | duration: durationSchema, 91 | startType: yup.string().matches(/^(utc|local)$/), 92 | startInputType: yup.string().matches(/^(utc|local)$/), 93 | startOutputType: yup.string().matches(/^(utc|local)$/), 94 | end: dateTimeSchema({ required: false }), 95 | endInputType: yup.string().matches(/^(utc|local)$/), 96 | endOutputType: yup.string().matches(/^(utc|local)$/), 97 | description: yup.string(), 98 | url: yup.string().matches(urlRegex), 99 | geo: yup.object().shape({lat: yup.number(), lon: yup.number()}), 100 | location: yup.string(), 101 | status: yup.string().matches(/^(TENTATIVE|CANCELLED|CONFIRMED)$/i), 102 | categories: yup.array().of(yup.string()), 103 | organizer: organizerSchema, 104 | attendees: yup.array().of(contactSchema), 105 | alarms: yup.array().of(alarmSchema), 106 | recurrenceRule: yup.string(), 107 | busyStatus: yup.string().matches(/^(TENTATIVE|FREE|BUSY|OOF)$/i), 108 | transp: yup.string().matches(/^(TRANSPARENT|OPAQUE)$/i), 109 | classification: yup.string(), 110 | created: dateTimeSchema({ required: false }), 111 | lastModified: dateTimeSchema({ required: false }), 112 | exclusionDates: yup.array().of(dateTimeSchema({ required: true })), 113 | htmlContent: yup.string() 114 | } 115 | 116 | const headerAndEventSchema = yup.object().shape({ ...headerShape, ...eventShape }).test('xor', `object should have end or duration (but not both)`, val => { 117 | const hasEnd = !!val.end 118 | const hasDuration = !!val.duration 119 | return ((hasEnd && !hasDuration) || (!hasEnd && hasDuration) || (!hasEnd && !hasDuration)) 120 | }).noUnknown() 121 | 122 | 123 | export function validateHeader (candidate) { 124 | try { 125 | const value = headerSchema.validateSync(candidate, {abortEarly: false, strict: true}) 126 | return {error: null, value} 127 | } catch (error) { 128 | return {error: Object.assign({}, error), value: undefined} 129 | } 130 | } 131 | 132 | export function validateHeaderAndEvent (candidate) { 133 | try { 134 | const value = headerAndEventSchema.validateSync(candidate, {abortEarly: false, strict: true}) 135 | return {error: null, value} 136 | } catch (error) { 137 | return {error: Object.assign({}, error), value: undefined} 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /test/index.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { createEvent, createEvents, isValidURL } from '../src' 3 | 4 | const invalidAttributes = { start: [] } 5 | const validAttributes = { start: [2000, 10, 5, 5, 0], duration: { hours: 1 } } 6 | const validAttributes2 = { start: [2001, 10, 5, 5, 0], duration: { hours: 1 } } 7 | const validAttributes3 = { start: [2002, 10, 5, 5, 0], duration: { hours: 1 } } 8 | 9 | describe('ics', () => { 10 | describe('.createEvent', () => { 11 | it('returns an error or value when not passed a callback', () => { 12 | const event1 = createEvent(validAttributes) 13 | const event2 = createEvent(invalidAttributes) 14 | 15 | expect(event1.error).to.be.null 16 | expect(event1.value).to.be.a('string') 17 | expect(event2.error).to.exist 18 | }) 19 | 20 | it('returns an error when passed an empty object', (done) => { 21 | createEvent({}, (error, success) => { 22 | expect(error.name).to.equal('ValidationError') 23 | expect(success).not.to.exist 24 | done() 25 | }) 26 | }) 27 | 28 | it('returns a node-style callback', (done) => { 29 | createEvent(validAttributes, (error, success) => { 30 | expect(error).not.to.exist 31 | expect(success).to.contain('DTSTART:200010') 32 | done() 33 | }) 34 | }) 35 | 36 | it('returns UUIDs for multiple calls', () => { 37 | const event1 = createEvent(validAttributes); 38 | const event2 = createEvent(validAttributes2); 39 | 40 | var uidRegex = /UID:(.*)/; 41 | 42 | const event1Id = uidRegex.exec(event1.value)[1]; 43 | const event2Id = uidRegex.exec(event2.value)[1]; 44 | expect(event1Id).to.not.equal(event2Id); 45 | }); 46 | }) 47 | 48 | describe('.createEvents', () => { 49 | it('returns an error when no arguments are passed', () => { 50 | const events = createEvents() 51 | expect(events.error).to.exist 52 | }) 53 | 54 | it('writes begin and end calendar tags', () => { 55 | const { error, value } = createEvents([validAttributes]) 56 | expect(error).to.be.null 57 | expect(value).to.contain('BEGIN:VCALENDAR') 58 | expect(value).to.contain('END:VCALENDAR') 59 | }) 60 | 61 | describe('when no callback is provided', () => { 62 | it('returns an iCal string and a null error when passed valid events', () => { 63 | const { error, value } = createEvents([validAttributes, validAttributes2, validAttributes3]) 64 | expect(error).to.be.null 65 | expect(value).to.contain('BEGIN:VCALENDAR') 66 | }) 67 | it('returns an error and a null value when passed an invalid event', () => { 68 | const { error, value } = createEvents([validAttributes, validAttributes2, invalidAttributes]) 69 | expect(error).to.exist 70 | expect(value).not.to.exist 71 | }) 72 | 73 | it('returns an iCal string when passed 0 events', () => { 74 | const { error, value } = createEvents([]) 75 | expect(error).to.be.null 76 | expect(value).to.contain('BEGIN:VCALENDAR') 77 | }) 78 | 79 | it('support header params', () => { 80 | const { error, value } = createEvents([], { calName: 'test' }) 81 | expect(error).to.be.null 82 | expect(value).to.contain('X-WR-CALNAME:test') 83 | }) 84 | }) 85 | 86 | describe('when a callback is provided', () => { 87 | it('returns an iCal string as the second argument when passed valid events', (done) => { 88 | createEvents([validAttributes, validAttributes2, validAttributes3], (error, success) => { 89 | expect(error).not.to.exist 90 | expect(success).to.contain('BEGIN:VCALENDAR') 91 | done() 92 | }) 93 | }) 94 | 95 | it('returns an error when passed an invalid event', (done) => { 96 | createEvents([validAttributes, validAttributes2, invalidAttributes], (error, success) => { 97 | expect(error).to.exist 98 | expect(success).not.to.exist 99 | done() 100 | }) 101 | }) 102 | 103 | it('returns an iCal string when passed 0 events', (done) => { 104 | createEvents([], (error, value) => { 105 | expect(error).to.be.null 106 | expect(value).to.contain('BEGIN:VCALENDAR') 107 | done() 108 | }) 109 | }) 110 | 111 | it('support header params', (done) => { 112 | createEvents([], { calName: 'test' }, (error, value) => { 113 | expect(error).to.be.null 114 | expect(value).to.contain('X-WR-CALNAME:test') 115 | done() 116 | }) 117 | }) 118 | }) 119 | }) 120 | 121 | describe(".isValidURL", () => { 122 | [ 123 | { 124 | result: true, 125 | condition: "http urls", 126 | url: "http://domain.com", 127 | }, 128 | { 129 | result: true, 130 | condition: "https urls", 131 | url: "https://domain.com", 132 | }, 133 | { 134 | result: true, 135 | condition: "localhost urls", 136 | url: "http://localhost:8080", 137 | }, 138 | { 139 | result: false, 140 | condition: "urls that start with www", 141 | url: "www.domain.com", 142 | }, 143 | { 144 | result: false, 145 | condition: "urls that start with domain", 146 | url: "domain.com", 147 | }, 148 | ].forEach((test) => { 149 | it(`${test.result ? "passes" : "fails"} for ${test.condition}`, () => { 150 | const response = isValidURL(test.url); 151 | 152 | expect(response).equal(test.result); 153 | }) 154 | }) 155 | }) 156 | }) 157 | -------------------------------------------------------------------------------- /test/pipeline/build.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { buildHeader, buildEvent } from '../../src/pipeline' 3 | 4 | describe('pipeline.buildHeader properties', () => { 5 | describe('productId', () => { 6 | it('sets a default', () => { 7 | const header = buildHeader() 8 | expect(header.productId).to.equal('adamgibbons/ics') 9 | }) 10 | it('sets a productId', () => { 11 | const header = buildHeader({ productId: 'productId' }) 12 | expect(header.productId).to.equal('productId') 13 | }) 14 | }) 15 | describe('method', () => { 16 | it('sets a default', () => { 17 | const header = buildHeader() 18 | expect(header.method).to.equal('PUBLISH') 19 | }) 20 | it('sets a method', () => { 21 | const header = buildHeader({ method: 'method' }) 22 | expect(header.method).to.equal('method') 23 | }) 24 | }) 25 | }) 26 | 27 | describe('pipeline.buildEvent properties', () => { 28 | describe('title', () => { 29 | it('sets a default', () => { 30 | const event = buildEvent() 31 | expect(event.title).to.equal('Untitled event') 32 | }) 33 | it('sets a title', () => { 34 | const event = buildEvent({ title: 'Hello event!' }) 35 | expect(event.title).to.equal('Hello event!') 36 | }) 37 | }) 38 | describe('uid', () => { 39 | it('sets a default', () => { 40 | const event = buildEvent() 41 | expect(event.uid).to.exist 42 | }) 43 | it('sets a product id', () => { 44 | const event = buildEvent({ uid: 'myuid' }) 45 | expect(event.uid).to.equal('myuid') 46 | }) 47 | }) 48 | describe('sequence', () => { 49 | it('sets a sequence number', () => { 50 | const event = buildEvent({ sequence: 5 }) 51 | expect(event.sequence).to.equal(5) 52 | }) 53 | it('sets a sequence number when the sequence is 0', () => { 54 | const event = buildEvent({ sequence: 0 }) 55 | expect(event.sequence).to.equal(0) 56 | }) 57 | }) 58 | describe('start and end', () => { 59 | it('defaults to UTC date-time format', () => { 60 | const event = buildEvent({ 61 | start: [2017, 1, 19, 1, 30], 62 | end: [2017, 1, 19, 12, 0] 63 | }) 64 | expect(event.start).to.be.an('array') 65 | expect(event.end).to.be.an('array') 66 | }) 67 | }) 68 | describe('end', () => { 69 | it('defaults to UTC date-time format', () => { 70 | const event = buildEvent({ start: [2017, 1, 19, 1, 30] }) 71 | expect(event.start).to.be.an('array') 72 | }) 73 | }) 74 | describe('created', () => { 75 | it('sets a created timestamp', () => { 76 | const event = buildEvent({ created: [2017, 1, 19, 1, 30] }) 77 | expect(event.created).to.be.an('array') 78 | }) 79 | }) 80 | describe('lastModified', () => { 81 | it('sets a last last modified timestamp', () => { 82 | const event = buildEvent({ lastModified: [2017, 1, 19, 1, 30] }) 83 | expect(event.lastModified).to.be.an('array') 84 | }) 85 | }) 86 | describe('calName', () => { 87 | it('sets a cal name', () => { 88 | const event = buildEvent({ calName: 'John\'s Calendar' }) 89 | expect(event.calName).to.equal('John\'s Calendar') 90 | }) 91 | }) 92 | describe('htmlContent', () => { 93 | it('sets a html content', () => { 94 | const event = buildEvent({ htmlContent: '

This is
test
html code.

' }) 95 | expect(event.htmlContent).to.equal('

This is
test
html code.

') 96 | }) 97 | }) 98 | describe('description', () => { 99 | it('removes a falsey value', () => { 100 | const event = buildEvent() 101 | expect(event.description).not.to.exist 102 | }) 103 | it('sets a description', () => { 104 | const event = buildEvent({ description: 'feels so good' }) 105 | expect(event.description).to.equal('feels so good') 106 | }) 107 | }) 108 | describe('url', () => { 109 | it('removes a falsey value', () => { 110 | const event = buildEvent() 111 | expect(event.url).not.to.exist 112 | }) 113 | it('sets a url', () => { 114 | const event = buildEvent({ url: 'http://www.google.com' }) 115 | expect(event.url).to.equal('http://www.google.com') 116 | }) 117 | }) 118 | describe('geo', () => { 119 | it('removes a falsey value', () => { 120 | const event = buildEvent() 121 | expect(event.geo).not.to.exist 122 | }) 123 | it('sets a url', () => { 124 | const event = buildEvent({ geo: {lat: 1, lon: 2} }) 125 | expect(event.geo).to.deep.equal({lat: 1, lon: 2}) 126 | }) 127 | }) 128 | describe('location', () => { 129 | it('removes a falsey value', () => { 130 | const event = buildEvent() 131 | expect(event.location).not.to.exist 132 | }) 133 | it('sets a url', () => { 134 | const event = buildEvent({ location: 'little boxes' }) 135 | expect(event.location).to.equal('little boxes') 136 | }) 137 | }) 138 | describe('categories', () => { 139 | it('removes a falsey value', () => { 140 | const event = buildEvent() 141 | expect(event.categories).not.to.exist 142 | }) 143 | it('sets categories', () => { 144 | const event = buildEvent({ categories: ['foo', 'bar', 'baz'] }) 145 | expect(event.categories).to.include('foo', 'bar', 'baz') 146 | }) 147 | }) 148 | describe('organizer', () => { 149 | it('removes a falsey value', () => { 150 | const event = buildEvent() 151 | expect(event.organizer).not.to.exist 152 | }) 153 | it('sets an organizer', () => { 154 | const event = buildEvent({ organizer: { 155 | name: 'Adam Gibbons', 156 | email: 'adam@example.com' 157 | }}) 158 | expect(event.organizer).to.deep.equal({ 159 | name: 'Adam Gibbons', 160 | email: 'adam@example.com' 161 | }) 162 | }) 163 | }) 164 | describe('attendees', () => { 165 | it('removes a falsey value', () => { 166 | const event = buildEvent() 167 | expect(event.attendees).not.to.exist 168 | }) 169 | it('sets attendees', () => { 170 | const event = buildEvent({ attendees: [ 171 | { name: 'Adam Gibbons', email: 'adam@example.com' }, 172 | { name: 'Brittany Seaton', email: 'brittany@example.com' } 173 | ]}) 174 | expect(event.attendees).to.be.an('array').to.have.length(2) 175 | }) 176 | }) 177 | describe('alarms', () => { 178 | it('removes falsey values', () => { 179 | const event = buildEvent() 180 | expect(event.alarms).not.to.exist 181 | }) 182 | it('sets alarms', () => { 183 | const event = buildEvent({ 184 | alarms: [{ 185 | action: 'audio', 186 | trigger: [1997, 3, 17, 13, 30, 0], 187 | repeat: 4, 188 | duration: { 189 | hours: 1 190 | }, 191 | description: 'Breakfast meeting with executive\nteam.' 192 | }] 193 | }) 194 | expect(event.alarms).to.be.an('array') 195 | }) 196 | }) 197 | }) 198 | -------------------------------------------------------------------------------- /test/schema/index.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { validateHeaderAndEvent } from '../../src/schema' 3 | 4 | describe('.validateHeaderAndEvent', () => { 5 | describe('must have one and only one occurrence of', () => { 6 | it('start', () => { 7 | const {error} = validateHeaderAndEvent({title: 'foo', uid: 'foo'}) 8 | expect(error.errors.some(p => p === 'start is a required field')).to.be.true 9 | }) 10 | }) 11 | 12 | describe('must have duration XOR end', () => { 13 | it('duration and end are not allowed together', () => { 14 | const {error, value} = validateHeaderAndEvent({ 15 | uid: 'foo', 16 | start: [2018, 12, 1, 10, 30], 17 | duration: {hours: 1}, 18 | end: [2018, 12, 1, 11, 45] 19 | }) 20 | expect(error).to.exist 21 | }) 22 | }) 23 | 24 | describe('may have one and only one occurrence of', () => { 25 | it('summary', () => { 26 | const {errors} = validateHeaderAndEvent({ 27 | title: 'foo', 28 | uid: 'foo', 29 | start: [2018, 12, 1, 10, 30], 30 | summary: 1 31 | }).error 32 | 33 | expect(errors.some(p => p.match(/summary must be a `string` type/))).to.be.true 34 | 35 | expect(validateHeaderAndEvent({ 36 | title: 'foo', 37 | uid: 'foo', 38 | start: [2018, 12, 1, 10, 30], 39 | summary: 'be concise' 40 | }).value.summary).to.exist 41 | }) 42 | 43 | it('description', () => { 44 | const {errors} = validateHeaderAndEvent({ 45 | title: 'foo', 46 | uid: 'foo', 47 | start: [2018, 12, 1, 10, 30], 48 | description: 1 49 | }).error 50 | expect(errors.some(p => p.match(/description must be a `string` type/))).to.be.true 51 | 52 | expect(validateHeaderAndEvent({ 53 | title: 'foo', 54 | uid: 'foo', 55 | start: [2018, 12, 1, 10, 30], 56 | description: 'abc' 57 | }).value.description).to.exist 58 | 59 | }) 60 | it('url', () => { 61 | const {errors} = validateHeaderAndEvent({ 62 | title: 'foo', 63 | uid: 'foo', 64 | start: [2018, 12, 1, 10, 30], 65 | url: 'abc' 66 | }).error 67 | expect(errors.some(p => p.match(/url must/))).to.be.true 68 | expect(validateHeaderAndEvent({ 69 | title: 'foo', 70 | uid: 'foo', 71 | start: [2018, 12, 1, 10, 30], 72 | url: 'http://github.com' 73 | }).value.url).to.exist 74 | }) 75 | 76 | it('geo', () => { 77 | expect(validateHeaderAndEvent({ 78 | title: 'foo', 79 | uid: 'foo', 80 | start: [2018, 12, 1, 10, 30], 81 | geo: 'abc' 82 | }).error.name === 'ValidationError') 83 | 84 | expect(validateHeaderAndEvent({ 85 | title: 'foo', 86 | uid: 'foo', 87 | start: [2018, 12, 1, 10, 30], 88 | geo: {lat: 'thing', lon: 32.1}, 89 | }).error.name === 'ValidationError') 90 | 91 | expect(validateHeaderAndEvent({ 92 | title: 'foo', 93 | uid: 'foo', 94 | start: [2018, 12, 1, 10, 30], 95 | geo: {lat: 13.23, lon: 32.1}, 96 | }).value.geo).to.exist 97 | }) 98 | it('location', () => { 99 | const {errors} = validateHeaderAndEvent({ 100 | title: 'foo', 101 | uid: 'foo', 102 | start: [2018, 12, 1, 10, 30], 103 | location: 1 104 | }).error 105 | expect(errors.some(p => p.match(/location must be a `string` type/))).to.be.true 106 | 107 | expect(validateHeaderAndEvent({ 108 | title: 'foo', 109 | uid: 'foo', 110 | start: [2018, 12, 1, 10, 30], 111 | location: 'abc' 112 | }).value.location).to.exist 113 | }) 114 | 115 | it('status', () => { 116 | expect(validateHeaderAndEvent({ 117 | title: 'foo', 118 | uid: 'foo', 119 | start: [2018, 12, 1, 10, 30], 120 | status: 'tentativo' 121 | }).error).to.exist 122 | expect(validateHeaderAndEvent({ 123 | title: 'foo', 124 | uid: 'foo', 125 | start: [2018, 12, 1, 10, 30], 126 | status: 'tentative' 127 | }).value.status).to.equal('tentative') 128 | expect(validateHeaderAndEvent({ 129 | title: 'foo', 130 | uid: 'foo', 131 | start: [2018, 12, 1, 10, 30], 132 | status: 'cancelled' 133 | }).value.status).to.equal('cancelled') 134 | expect(validateHeaderAndEvent({ 135 | title: 'foo', 136 | uid: 'foo', 137 | start: [2018, 12, 1, 10, 30], 138 | status: 'confirmed' 139 | }).value.status).to.equal('confirmed') 140 | }) 141 | 142 | it('categories', () => { 143 | const {errors} = validateHeaderAndEvent({ 144 | title: 'foo', 145 | uid: 'foo', 146 | start: [2018, 12, 1, 10, 30], 147 | categories: [1] 148 | }).error 149 | 150 | expect(errors.some(p => p.match(/categories\[0] must be a `string` type/))).to.be.true 151 | 152 | expect(validateHeaderAndEvent({ 153 | title: 'foo', 154 | uid: 'foo', 155 | start: [2018, 12, 1, 10, 30], 156 | categories: ['foo', 'bar'] 157 | }).value.categories).to.include('foo', 'bar') 158 | }) 159 | 160 | it('organizer', () => { 161 | expect(validateHeaderAndEvent({ 162 | title: 'foo', 163 | uid: 'foo', 164 | start: [2018, 12, 1, 10, 30], 165 | organizer: {name: 'Adam', email: 'adam@example.com'} 166 | }).value.organizer).to.include({name: 'Adam', email: 'adam@example.com'}) 167 | 168 | const {errors} = validateHeaderAndEvent({ 169 | title: 'foo', 170 | uid: 'foo', 171 | start: [2018, 12, 1, 10, 30], 172 | organizer: {foo: 'Adam'} 173 | }).error 174 | expect(errors.some(p => p === 'organizer field has unspecified keys: foo')).to.be.true 175 | }) 176 | 177 | it('attendees', () => { 178 | expect(validateHeaderAndEvent({ 179 | title: 'foo', 180 | uid: 'foo', 181 | start: [2018, 12, 1, 10, 30], 182 | attendees: [ 183 | {name: 'Adam', email: 'adam@example.com'}, 184 | {name: 'Brittany', email: 'brittany@example.com'}] 185 | }).value.attendees).to.be.an('array').that.is.not.empty 186 | 187 | const {errors} = validateHeaderAndEvent({ 188 | title: 'foo', 189 | uid: 'foo', 190 | start: [2018, 12, 1, 10, 30], 191 | attendees: [ 192 | {foo: 'Adam', email: 'adam@example.com'}, 193 | {name: 'Brittany', email: 'brittany@example.com'}] 194 | }).error 195 | expect(errors.some(p => p === 'attendees[0] field has unspecified keys: foo')).to.be.true 196 | 197 | const res = validateHeaderAndEvent({ 198 | title: 'foo', 199 | uid: 'foo', 200 | start: [2018, 12, 1, 10, 30], 201 | end: [2018, 12, 1, 11, 0], 202 | attendees: [ 203 | {name: 'toto', email: 'toto@toto.fr', role: 'REQ-PARTICIPANT', partstat: 'ACCEPTED'} 204 | ] 205 | }).error 206 | expect(res).to.be.null 207 | }) 208 | 209 | it('created', () => { 210 | expect(validateHeaderAndEvent({ 211 | title: 'foo', 212 | uid: 'foo', 213 | start: [2018, 12, 1, 10, 30], 214 | created: [2018, 12, 1, 9, 30] 215 | }).value.created).to.exist 216 | }) 217 | 218 | it('transp', () => { 219 | expect(validateHeaderAndEvent({ 220 | title: 'foo', 221 | uid: 'foo', 222 | start: [2018, 12, 1, 10, 30], 223 | transp: 'TRANSPARENT' 224 | }).value.transp).to.exist 225 | }) 226 | 227 | it('lastModified', () => { 228 | expect(validateHeaderAndEvent({ 229 | title: 'foo', 230 | uid: 'foo', 231 | start: [2018, 12, 1, 10, 30], 232 | lastModified: [2018, 12, 1, 9, 30] 233 | }).value.lastModified).to.exist 234 | }) 235 | 236 | it('calName', () => { 237 | expect(validateHeaderAndEvent({ 238 | title: 'foo', 239 | uid: 'foo', 240 | calName: 'John\'s Calendar', 241 | start: [2018, 12, 1, 10, 30], 242 | }).value.calName).to.exist 243 | }) 244 | 245 | it('htmlContent', () => { 246 | expect(validateHeaderAndEvent({ 247 | title: 'foo', 248 | uid: 'foo', 249 | htmlContent: '

This is
test
html code.

', 250 | start: [2018, 12, 1, 10, 30], 251 | }).value.htmlContent).to.exist 252 | }) 253 | }) 254 | 255 | describe('may have one or more occurrences of', () => { 256 | it('alarm component', () => { 257 | const event = validateHeaderAndEvent({ 258 | uid: 'foo', 259 | start: [2018, 12, 1, 10, 30], 260 | duration: {hours: 1}, 261 | alarms: [{ 262 | action: 'audio', 263 | trigger: [] 264 | }] 265 | }) 266 | 267 | expect(event.error).to.be.null 268 | expect(event.value.alarms).to.be.an('array') 269 | expect(event.value.alarms[0]).to.have.all.keys('action', 'trigger') 270 | }) 271 | }) 272 | }) 273 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ics 2 | ================== 3 | 4 | The [iCalendar](http://tools.ietf.org/html/rfc5545) generator 5 | 6 | [![npm version](https://badge.fury.io/js/ics.svg)](http://badge.fury.io/js/ics) 7 | [![CI](https://github.com/adamgibbons/ics/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/adamgibbons/ics/actions/workflows/build.yml) 8 | [![Downloads](https://img.shields.io/npm/dm/ics.svg)](http://npm-stat.com/charts.html?package=ics) 9 | 10 | ## Install 11 | 12 | `npm install -S ics` 13 | 14 | ## Example Usage 15 | 16 | #### In node / CommonJS 17 | 18 | 1) Create an iCalendar event: 19 | 20 | ```javascript 21 | const ics = require('ics') 22 | // or, in ESM: import * as ics from 'ics' 23 | 24 | const event = { 25 | start: [2018, 5, 30, 6, 30], 26 | duration: { hours: 6, minutes: 30 }, 27 | title: 'Bolder Boulder', 28 | description: 'Annual 10-kilometer run in Boulder, Colorado', 29 | location: 'Folsom Field, University of Colorado (finish line)', 30 | url: 'http://www.bolderboulder.com/', 31 | geo: { lat: 40.0095, lon: 105.2669 }, 32 | categories: ['10k races', 'Memorial Day Weekend', 'Boulder CO'], 33 | status: 'CONFIRMED', 34 | busyStatus: 'BUSY', 35 | organizer: { name: 'Admin', email: 'Race@BolderBOULDER.com' }, 36 | attendees: [ 37 | { name: 'Adam Gibbons', email: 'adam@example.com', rsvp: true, partstat: 'ACCEPTED', role: 'REQ-PARTICIPANT' }, 38 | { name: 'Brittany Seaton', email: 'brittany@example2.org', dir: 'https://linkedin.com/in/brittanyseaton', role: 'OPT-PARTICIPANT' } 39 | ] 40 | } 41 | 42 | ics.createEvent(event, (error, value) => { 43 | if (error) { 44 | console.log(error) 45 | return 46 | } 47 | 48 | console.log(value) 49 | }) 50 | // BEGIN:VCALENDAR 51 | // VERSION:2.0 52 | // CALSCALE:GREGORIAN 53 | // PRODID:adamgibbons/ics 54 | // METHOD:PUBLISH 55 | // X-PUBLISHED-TTL:PT1H 56 | // BEGIN:VEVENT 57 | // UID:S8h0Vj7mTB74p9vt5pQzJ 58 | // SUMMARY:Bolder Boulder 59 | // DTSTAMP:20181017T204900Z 60 | // DTSTART:20180530T043000Z 61 | // DESCRIPTION:Annual 10-kilometer run in Boulder\, Colorado 62 | // X-MICROSOFT-CDO-BUSYSTATUS:BUSY 63 | // URL:http://www.bolderboulder.com/ 64 | // GEO:40.0095;105.2669 65 | // LOCATION:Folsom Field, University of Colorado (finish line) 66 | // STATUS:CONFIRMED 67 | // CATEGORIES:10k races,Memorial Day Weekend,Boulder CO 68 | // ORGANIZER;CN=Admin:mailto:Race@BolderBOULDER.com 69 | // ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED;CN=Adam Gibbons:mailto:adam@example.com 70 | // ATTENDEE;RSVP=FALSE;ROLE=OPT-PARTICIPANT;DIR=https://linkedin.com/in/brittanyseaton;CN=Brittany 71 | // Seaton:mailto:brittany@example2.org 72 | // DURATION:PT6H30M 73 | // END:VEVENT 74 | // END:VCALENDAR 75 | ``` 76 | 77 | 2) Write an iCalendar file: 78 | ```javascript 79 | const { writeFileSync } = require('fs') 80 | const ics = require('ics') 81 | 82 | ics.createEvent({ 83 | title: 'Dinner', 84 | description: 'Nightly thing I do', 85 | busyStatus: 'FREE', 86 | start: [2018, 1, 15, 6, 30], 87 | duration: { minutes: 50 } 88 | }, (error, value) => { 89 | if (error) { 90 | console.log(error) 91 | } 92 | 93 | writeFileSync(`${__dirname}/event.ics`, value) 94 | 95 | /* 96 | You cannot use fs in Frontend libraries like React so you rather import a module to save files to the browser as follow [ import { saveAs } from 'file-saver'; // For saving the file in the browser] 97 | const blob = new Blob([value], { type: 'text/calendar' }); 98 | saveAs(blob, `${title}.ics`); 99 | 100 | */ 101 | }) 102 | ``` 103 | 104 | 3) Create multiple iCalendar events: 105 | ```javascript 106 | const ics = require('./dist') 107 | 108 | const { error, value } = ics.createEvents([ 109 | { 110 | title: 'Lunch', 111 | start: [2018, 1, 15, 12, 15], 112 | duration: { minutes: 45 } 113 | }, 114 | { 115 | title: 'Dinner', 116 | start: [2018, 1, 15, 12, 15], 117 | duration: { hours: 1, minutes: 30 } 118 | } 119 | ]) 120 | 121 | if (error) { 122 | console.log(error) 123 | return 124 | } 125 | 126 | console.log(value) 127 | // BEGIN:VCALENDAR 128 | // VERSION:2.0 129 | // CALSCALE:GREGORIAN 130 | // PRODID:adamgibbons/ics 131 | // METHOD:PUBLISH 132 | // X-PUBLISHED-TTL:PT1H 133 | // BEGIN:VEVENT 134 | // UID:pP83XzQPo5RlvjDCMIINs 135 | // SUMMARY:Lunch 136 | // DTSTAMP:20230917T142209Z 137 | // DTSTART:20180115T121500Z 138 | // DURATION:PT45M 139 | // END:VEVENT 140 | // BEGIN:VEVENT 141 | // UID:gy5vfUVv6wjyBeNkkFmBX 142 | // SUMMARY:Dinner 143 | // DTSTAMP:20230917T142209Z 144 | // DTSTART:20180115T121500Z 145 | // DURATION:PT1H30M 146 | // END:VEVENT 147 | // END:VCALENDAR 148 | ``` 149 | 150 | 4) Create iCalendar events with Audio (Mac): 151 | ```javascript 152 | let ics = require("ics") 153 | let moment = require("moment") 154 | let events = [] 155 | let alarms = [] 156 | 157 | let start = moment().format('YYYY-M-D-H-m').split("-").map((a) => parseInt(a)) 158 | let end = moment().add({'hours':2, "minutes":30}).format("YYYY-M-D-H-m").split("-").map((a) => parseInt(a)) 159 | 160 | alarms.push({ 161 | action: 'audio', 162 | description: 'Reminder', 163 | trigger: {hours:2,minutes:30,before:true}, 164 | repeat: 2, 165 | attachType:'VALUE=URI', 166 | attach: 'Glass' 167 | }) 168 | 169 | let event = { 170 | productId:"myCalendarId", 171 | uid: "123"+"@ics.com", 172 | startOutputType:"local", 173 | start: start, 174 | end: end, 175 | title: "test here", 176 | alarms: alarms 177 | } 178 | events.push(event) 179 | console.log(ics.createEvents(events).value) 180 | 181 | // BEGIN:VCALENDAR 182 | // VERSION:2.0 183 | // CALSCALE:GREGORIAN 184 | // PRODID:myCalendarId 185 | // METHOD:PUBLISH 186 | // X-PUBLISHED-TTL:PT1H 187 | // BEGIN:VEVENT 188 | // UID:123@ics.com 189 | // SUMMARY:test here 190 | // DTSTAMP:20230917T142621Z 191 | // DTSTART:20230917T152600 192 | // DTEND:20230917T175600 193 | // BEGIN:VALARM 194 | // ACTION:AUDIO 195 | // REPEAT:2 196 | // DESCRIPTION:Reminder 197 | // ATTACH;VALUE=URI:Glass 198 | // TRIGGER:-PT2H30M\nEND:VALARM 199 | // END:VEVENT 200 | // END:VCALENDAR 201 | ``` 202 | 203 | #### Using ESModules & in the browser 204 | 205 | ```javascript 206 | import { createEvent} from 'ics'; 207 | 208 | const event = { 209 | ... 210 | } 211 | 212 | async function handleDownload() { 213 | const filename = 'ExampleEvent.ics' 214 | const file = await new Promise((resolve, reject) => { 215 | createEvent(event, (error, value) => { 216 | if (error) { 217 | reject(error) 218 | } 219 | 220 | resolve(new File([value], filename, { type: 'text/calendar' })) 221 | }) 222 | }) 223 | const url = URL.createObjectURL(file); 224 | 225 | // trying to assign the file URL to a window could cause cross-site 226 | // issues so this is a workaround using HTML5 227 | const anchor = document.createElement('a'); 228 | anchor.href = url; 229 | anchor.download = filename; 230 | 231 | document.body.appendChild(anchor); 232 | anchor.click(); 233 | document.body.removeChild(anchor); 234 | 235 | URL.revokeObjectURL(url); 236 | } 237 | ``` 238 | 239 | ## API 240 | 241 | ### `createEvent(attributes[, callback])` 242 | 243 | Generates an iCal-compliant VCALENDAR string with one VEVENT. 244 | If a callback is not provided, returns an object having the form `{ error, value }`, 245 | where `value` contains an iCal-compliant string if there are no errors. 246 | If a callback is provided, returns a Node-style callback. 247 | 248 | #### `attributes` 249 | 250 | Object literal containing event information. 251 | Only the `start` property is required. 252 | 253 | 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`). 254 | 255 | The following properties are accepted: 256 | 257 | | Property | Description | Example | 258 | | ------------- | ------------- | ---------- 259 | | start | **Required**. Date and time at which the event begins. | `[2000, 1, 5, 10, 0]` (January 5, 2000) or a `number` 260 | | startInputType | Type of the date/time data in `start`:
`local` (default): passed data is in local time.
`utc`: passed data is UTC | 261 | | 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)) | 262 | | 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` 263 | | 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` | 264 | | 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` | 265 | | 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) 266 | | title | Title of event. | `'Code review'` 267 | | description | Description of event. | `'A constructive roasting of those seeking to merge into master branch'` 268 | | location | Intended venue | `Mountain Sun Pub and Brewery` 269 | | geo | Geographic coordinates (lat/lon) | `{ lat: 38.9072, lon: 77.0369 }` 270 | | url | URL associated with event | `'http://www.mountainsunpub.com/'` 271 | | status | Three statuses are allowed: `TENTATIVE`, `CONFIRMED`, `CANCELLED` | `CONFIRMED` 272 | | organizer | Person organizing the event | `{ name: 'Adam Gibbons', email: 'adam@example.com', dir: 'https://linkedin.com/in/adamgibbons', sentBy: 'test@example.com' }` 273 | | attendees | Persons invited to the event | `[{ name: 'Mo', email: 'mo@foo.com', rsvp: true }, { name: 'Bo', email: 'bo@bar.biz', dir: 'https://twitter.com/bo1234', partstat: 'ACCEPTED', role: 'REQ-PARTICIPANT' }]` 274 | | categories | Categories associated with the event | `['hacknight', 'stout month']` 275 | | alarms | Alerts that can be set to trigger before, during, or after the event. The following `attach` properties work on Mac OS: Basso, Blow, Bottle, Frog, Funk, Glass, Hero, Morse, Ping, Pop, Purr, Sousumi, Submarine, Tink | `{ action: 'display', description: 'Reminder', trigger: [2000, 1, 4, 18, 30] }` OR `{ action: 'display', description: 'Reminder', trigger: { hours: 2, minutes: 30, before: true } }` OR `{ action: 'display', description: 'Reminder', trigger: { hours: 2, minutes: 30, before: false }` OR `{ action: 'audio', description: 'Reminder', trigger: { hours: 2, minutes: 30, before: true }, repeat: 2, attachType: 'VALUE=URI', attach: 'Glass' }` 276 | | productId | Product which created ics, `PRODID` field | `'adamgibbons/ics'` 277 | | 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'` 278 | | 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` 279 | | 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` 280 | | 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]` 281 | | sequence | For sending an update for an event (with the same uid), defines the revision sequence number. | `2` 282 | | 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'` 283 | | 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'` 284 | | 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 285 | | created | Date-time representing event's creation date. Provide a date-time in local time | `[2000, 1, 5, 10, 0]` or a `number` 286 | | 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` 287 | | 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'` | 288 | | htmlContent | Used to include HTML markup in an event's description. Standard DESCRIPTION tag should contain non-HTML version. | `

This is
test
html code.

` | 289 | 290 | To create an **all-day** event, pass only three values (`year`, `month`, and `date`) to the `start` and `end` properties. 291 | The date of the `end` property should be the day *after* your all-day event. 292 | For example, in order to create an all-day event occuring on October 15, 2018: 293 | ```javascript 294 | const eventAttributes = { 295 | start: [2018, 10, 15], 296 | end: [2018, 10, 16], 297 | /* rest of attributes */ 298 | } 299 | ``` 300 | 301 | #### `callback` 302 | 303 | Optional. 304 | Node-style callback. 305 | 306 | ```javascript 307 | function (err, value) { 308 | if (err) { 309 | // if iCal generation fails, err is an object containing the reason 310 | // if iCal generation succeeds, err is null 311 | } 312 | 313 | console.log(value) // iCal-compliant text string 314 | } 315 | ``` 316 | 317 | ### `createEvents(events[, headerParams, callback])` 318 | 319 | Generates an iCal-compliant VCALENDAR string with multiple VEVENTS. 320 | 321 | `headerParams` may be omitted, and in this case they will be read from the first event. 322 | 323 | If a callback is not provided, returns an object having the form `{ error, value }`, where value is an iCal-compliant text string 324 | if `error` is `null`. 325 | 326 | If a callback is provided, returns a Node-style callback. 327 | 328 | #### `events` 329 | 330 | Array of `attributes` objects (as described in `createEvent`). 331 | 332 | #### `callback` 333 | 334 | Optional. 335 | Node-style callback. 336 | 337 | ```javascript 338 | function (err, value) { 339 | if (err) { 340 | // if iCal generation fails, err is an object containing the reason 341 | // if iCal generation succeeds, err is null 342 | } 343 | 344 | console.log(value) // iCal-compliant text string 345 | } 346 | ``` 347 | 348 | ## Develop 349 | 350 | Run mocha tests and watch for changes: 351 | ``` 352 | npm start 353 | ``` 354 | 355 | Run tests once and exit: 356 | ``` 357 | npm test 358 | ``` 359 | 360 | Build the project, compiling all ES6 files within the `src` directory into vanilla JavaScript in the `dist` directory. 361 | ``` 362 | npm run build 363 | ``` 364 | 365 | ## References 366 | 367 | - [RFC 5545: Internet Calendaring and Scheduling Core Object Specification (iCalendar)](http://tools.ietf.org/html/rfc5545) 368 | - [iCalendar Validator](http://icalendar.org/validator.html#results) 369 | -------------------------------------------------------------------------------- /test/pipeline/format.spec.js: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import { expect } from 'chai' 3 | import { 4 | formatEvent, 5 | buildEvent, 6 | formatHeader, 7 | buildHeader 8 | } from '../../src/pipeline' 9 | import {foldLine} from "../../src/utils"; 10 | 11 | describe('pipeline.formatHeader', () => { 12 | it('writes default values when no attributes passed', () => { 13 | const header = buildHeader() 14 | const formattedHeader = formatHeader(header) 15 | expect(formattedHeader).to.contain('BEGIN:VCALENDAR') 16 | expect(formattedHeader).to.contain('VERSION:2.0') 17 | expect(formattedHeader).to.contain('PRODID:adamgibbons/ics') 18 | }) 19 | it('writes a product id', () => { 20 | const header = buildHeader({ productId: 'productId'}) 21 | const formattedHeader = formatHeader(header) 22 | expect(formattedHeader).to.contain('PRODID:productId') 23 | }) 24 | it('writes a method', () => { 25 | const header = buildHeader({ method: 'method'}) 26 | const formattedHeader = formatHeader(header) 27 | expect(formattedHeader).to.contain('METHOD:method') 28 | }) 29 | it('writes a calName', () => { 30 | const header = buildHeader({ calName: 'calName'}) 31 | const formattedHeader = formatHeader(header) 32 | expect(formattedHeader).to.contain('X-WR-CALNAME:calName') 33 | }) 34 | }) 35 | 36 | describe('pipeline.formatEvent', () => { 37 | it('writes default values when no attributes passed', () => { 38 | const event = buildEvent() 39 | const formattedEvent = formatEvent(event) 40 | expect(formattedEvent).to.contain('BEGIN:VEVENT') 41 | expect(formattedEvent).to.contain('SUMMARY:Untitled event') 42 | expect(formattedEvent).to.contain('UID:') 43 | expect(formattedEvent).to.not.contain('SEQUENCE:') 44 | expect(formattedEvent).to.contain('DTSTART:') 45 | expect(formattedEvent).to.contain('DTSTAMP:20') 46 | expect(formattedEvent).to.contain('END:VEVENT') 47 | }) 48 | it('writes a title', () => { 49 | const event = buildEvent({ title: 'foo bar' }) 50 | const formattedEvent = formatEvent(event) 51 | expect(formattedEvent).to.contain('SUMMARY:foo bar') 52 | }) 53 | it('writes a start date-time', () => { 54 | const event = buildEvent({ start: [2017, 5, 15, 10, 0] }) 55 | const formattedEvent = formatEvent(event) 56 | expect(formattedEvent).to.contain('DTSTART:2017051') 57 | }) 58 | it('writes an end date-time', () => { 59 | const event = buildEvent({ end: [2017, 5, 15, 11, 0] }) 60 | const formattedEvent = formatEvent(event) 61 | expect(formattedEvent).to.contain('DTEND:2017051') 62 | }) 63 | it('writes a start date-time, taking the given date as local by default and outputting is as UTC by default', () => { 64 | const event = buildEvent({ start: [2017, 5, 15, 10, 0] }) 65 | const formattedEvent = formatEvent(event) 66 | const now = dayjs(new Date(2017, 5 - 1, 15, 10, 0)).utc().format('YYYYMMDDTHHmm00') 67 | expect(formattedEvent).to.contain('DTSTART:'+now+'Z') 68 | }) 69 | it('writes a start date-time, taking the given date as local by default and outputting is as UTC if requested', () => { 70 | const event = buildEvent({ start: [2017, 5, 15, 10, 0], startOutputType: 'utc' }) 71 | const formattedEvent = formatEvent(event) 72 | const now = dayjs(new Date(2017, 5 - 1, 15, 10, 0)).utc().format('YYYYMMDDTHHmm00') 73 | expect(formattedEvent).to.contain('DTSTART:'+now+'Z') 74 | }) 75 | it('writes a start date-time, taking the given date as local by default and outputting is as Local (floating) if requested', () => { 76 | const event = buildEvent({ start: [2017, 5, 15, 10, 0], startOutputType: 'local' }) 77 | const formattedEvent = formatEvent(event) 78 | expect(formattedEvent).to.contain('DTSTART:20170515T100000') 79 | expect(formattedEvent).to.not.contain('DTSTART:20170515T100000Z') 80 | }) 81 | it('writes a start date-time, taking the given date as local if requested and outputting is as UTC by default', () => { 82 | const event = buildEvent({ start: [2017, 5, 15, 10, 0], startInputType: 'local' }) 83 | const formattedEvent = formatEvent(event) 84 | const now = dayjs(new Date(2017, 5 - 1, 15, 10, 0)).utc().format('YYYYMMDDTHHmm00') 85 | expect(formattedEvent).to.contain('DTSTART:'+now+'Z') 86 | }) 87 | it('writes a start date-time, taking the given date as local if requested and outputting is as UTC if requested', () => { 88 | const event = buildEvent({ start: [2017, 5, 15, 10, 0], startInputType: 'local', startOutputType: 'utc' }) 89 | const formattedEvent = formatEvent(event) 90 | const now = dayjs(new Date(2017, 5 - 1, 15, 10, 0)).utc().format('YYYYMMDDTHHmm00') 91 | expect(formattedEvent).to.contain('DTSTART:'+now+'Z') 92 | }) 93 | it('writes a start date-time, taking the given date as local if requested and outputting is as Local (floating) if requested', () => { 94 | const event = buildEvent({ start: [2017, 5, 15, 10, 0], startInputType: 'local', startOutputType: 'local' }) 95 | const formattedEvent = formatEvent(event) 96 | expect(formattedEvent).to.contain('DTSTART:20170515T100000') 97 | expect(formattedEvent).to.not.contain('DTSTART:20170515T100000Z') 98 | }) 99 | it('writes a start date-time, taking the given date as UTC if requested and outputting is as UTC by default', () => { 100 | const event = buildEvent({ start: [2017, 5, 15, 10, 0], startInputType: 'utc' }) 101 | const formattedEvent = formatEvent(event) 102 | expect(formattedEvent).to.contain('DTSTART:20170515T100000Z') 103 | }) 104 | it('writes a start date-time, taking the given date as UTC if requested and outputting is as UTC if requested', () => { 105 | const event = buildEvent({ start: [2017, 5, 15, 10, 0], startInputType: 'utc', startOutputType: 'utc' }) 106 | const formattedEvent = formatEvent(event) 107 | expect(formattedEvent).to.contain('DTSTART:20170515T100000Z') 108 | }) 109 | it('writes a start date-time, taking the given date as UTC if requested and outputting is as Local (floating) if requested', () => { 110 | const event = buildEvent({ start: [2017, 5, 15, 10, 0], startInputType: 'utc', startOutputType: 'local' }) 111 | const formattedEvent = formatEvent(event) 112 | const now = dayjs(Date.UTC(2017, 5 - 1, 15, 10, 0)).format('YYYYMMDDTHHmm00') 113 | expect(formattedEvent).to.contain('DTSTART:'+now) 114 | expect(formattedEvent).to.not.contain('DTSTART:'+now+'Z') 115 | }) 116 | it('writes a created timestamp', () => { 117 | const event = buildEvent({ created: [2017, 5, 15, 10, 0] }) 118 | const formattedEvent = formatEvent(event) 119 | expect(formattedEvent).to.contain('CREATED:20170515') 120 | }) 121 | it('writes a lastModified timestamp', () => { 122 | const event = buildEvent({ lastModified: [2017, 5, 15, 10, 0] }) 123 | const formattedEvent = formatEvent(event) 124 | expect(formattedEvent).to.contain('LAST-MODIFIED:20170515') 125 | }) 126 | it('writes a html content and folds correctly', () => { 127 | const event = buildEvent({ htmlContent: '

This is
test
html code.

' }) 128 | const formattedEvent = formatEvent(event) 129 | expect(formattedEvent).to.contain(`X-ALT-DESC;FMTTYPE=text/html:

This is
test<\r\n\tbr>html code.

`) 130 | }) 131 | it('writes a sequence', () => { 132 | const event = buildEvent({ sequence: 8 }) 133 | const formattedEvent = formatEvent(event) 134 | expect(formattedEvent).to.contain('SEQUENCE:8') 135 | }) 136 | it('writes a description', () => { 137 | const event = buildEvent({ description: 'bar baz' }) 138 | const formattedEvent = formatEvent(event) 139 | expect(formattedEvent).to.contain('DESCRIPTION:bar baz') 140 | }) 141 | it('escapes characters in text types', () => { 142 | const event = buildEvent({ title: 'colon: semi; comma, period. slash\\', description: 'colon: semi; comma, period. slash\\' }) 143 | const formattedEvent = formatEvent(event) 144 | expect(formattedEvent).to.contain('DESCRIPTION:colon: semi\\; comma\\, period. slash\\\\') 145 | expect(formattedEvent).to.contain('SUMMARY:colon: semi\\; comma\\, period. slash\\\\') 146 | }) 147 | it('folds a long description', () => { 148 | const event = buildEvent({ description: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.' }) 149 | const formattedEvent = formatEvent(event) 150 | expect(formattedEvent).to.contain('DESCRIPTION:Lorem ipsum dolor sit amet\\, consectetur adipiscing elit\\, sed \r\n\tdo eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad \r\n\tminim veniam\\, quis nostrud exercitation ullamco laboris nisi ut aliquip e\r\n\tx ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptat\r\n\te velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaec\r\n\tat cupidatat non proident\\, sunt in culpa qui officia deserunt mollit anim\r\n\t id est laborum.') 151 | }) 152 | it('writes a url', () => { 153 | const event = buildEvent({ url: 'http://www.example.com/' }) 154 | const formattedEvent = formatEvent(event) 155 | expect(formattedEvent).to.contain('URL:http://www.example.com/') 156 | }) 157 | it('writes a geo', () => { 158 | const event = buildEvent({ geo: { lat: 1.234, lon: -9.876 } }) 159 | const formattedEvent = formatEvent(event) 160 | expect(formattedEvent).to.contain('GEO:1.234;-9.876') 161 | }) 162 | it('writes a location', () => { 163 | const event = buildEvent({ location: 'Folsom Field, University of Colorado at Boulder' }) 164 | const formattedEvent = formatEvent(event) 165 | expect(formattedEvent).to.contain('LOCATION:Folsom Field\\, University of Colorado at Boulder') 166 | }) 167 | it('writes a status', () => { 168 | const event = buildEvent({ status: 'tentative' }) 169 | const formattedEvent = formatEvent(event) 170 | expect(formattedEvent).to.contain('STATUS:tentative') 171 | }) 172 | it('writes categories', () => { 173 | const event = buildEvent({ categories: ['boulder', 'running'] }) 174 | const formattedEvent = formatEvent(event) 175 | expect(formattedEvent).to.contain('CATEGORIES:boulder,running') 176 | }) 177 | 178 | it('writes all-day events', () => { 179 | const eventWithOnlyStart = buildEvent({ start: [2017, 5, 15] }) 180 | const formattedStartEvent = formatEvent(eventWithOnlyStart) 181 | expect(formattedStartEvent).to.contain('DTSTART;VALUE=DATE:20170515') 182 | expect(formattedStartEvent).to.not.contain('DTEND') 183 | 184 | const eventWithStartAndEnd = buildEvent({ start: [2017, 5, 15], end: [2017, 5, 18] }) 185 | const formattedStartEndEvent = formatEvent(eventWithStartAndEnd) 186 | expect(formattedStartEndEvent).to.contain('DTSTART;VALUE=DATE:20170515') 187 | expect(formattedStartEndEvent).to.contain('DTEND;VALUE=DATE:20170518') 188 | }) 189 | 190 | it('writes attendees', () => { 191 | const event = buildEvent({ attendees: [ 192 | {name: 'Adam Gibbons', email: 'adam@example.com'}, 193 | {name: 'Brittany Seaton', email: 'brittany@example.com', rsvp: true } 194 | ]}) 195 | const formattedEvent = formatEvent(event) 196 | expect(formattedEvent).to.contain('ATTENDEE;CN="Adam Gibbons":mailto:adam@example.com') 197 | expect(formattedEvent).to.contain('ATTENDEE;RSVP=TRUE;CN="Brittany Seaton":mailto:brittany@example.com') 198 | }) 199 | it('writes a busystatus', () => { 200 | const eventFree = buildEvent({ busyStatus: "FREE" }) 201 | const eventBusy = buildEvent({ busyStatus: "BUSY"}) 202 | const eventTent = buildEvent({ busyStatus: "TENTATIVE"}) 203 | const eventOOF = buildEvent({ busyStatus: "OOF" }) 204 | const formattedEventFree = formatEvent(eventFree) 205 | const formattedEventBusy = formatEvent(eventBusy) 206 | const formattedEventTent = formatEvent(eventTent) 207 | const formattedEventOOF = formatEvent(eventOOF) 208 | expect(formattedEventFree).to.contain('X-MICROSOFT-CDO-BUSYSTATUS:FREE') 209 | expect(formattedEventBusy).to.contain('X-MICROSOFT-CDO-BUSYSTATUS:BUSY') 210 | expect(formattedEventTent).to.contain('X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE') 211 | expect(formattedEventOOF).to.contain('X-MICROSOFT-CDO-BUSYSTATUS:OOF') 212 | }) 213 | it('writes a transp', () => { 214 | const eventFree = buildEvent({ transp: "TRANSPARENT" }) 215 | const eventBusy = buildEvent({ transp: "OPAQUE"}) 216 | const formattedEventFree = formatEvent(eventFree) 217 | const formattedEventBusy = formatEvent(eventBusy) 218 | expect(formattedEventFree).to.contain('TRANSP:TRANSPARENT') 219 | expect(formattedEventBusy).to.contain('TRANSP:OPAQUE') 220 | }) 221 | it('writes a access classification', () => { 222 | const eventPublic = buildEvent({ classification: "PUBLIC" }) 223 | const eventPrivate = buildEvent({ classification: "PRIVATE"}) 224 | const eventConfidential = buildEvent({ classification: "CONFIDENTIAL"}) 225 | const eventAnyClass = buildEvent({ classification: "non-standard-property" }) 226 | const formattedEventPublic = formatEvent(eventPublic) 227 | const formattedEventPrivate = formatEvent(eventPrivate) 228 | const formattedEventConfidential = formatEvent(eventConfidential) 229 | const formattedEventAnyClass = formatEvent(eventAnyClass) 230 | expect(formattedEventPublic).to.contain('CLASS:PUBLIC') 231 | expect(formattedEventPrivate).to.contain('CLASS:PRIVATE') 232 | expect(formattedEventConfidential).to.contain('CLASS:CONFIDENTIAL') 233 | expect(formattedEventAnyClass).to.contain('CLASS:non-standard-property') 234 | }) 235 | it('writes an organizer', () => { 236 | const formattedEvent = formatEvent({ 237 | productId: 'productId', 238 | method: 'method', 239 | uid: 'uid', 240 | timestamp: 'timestamp', 241 | organizer: { 242 | name: 'Adam Gibbons', 243 | email: 'adam@example.com', 244 | dir: 'test-dir-value', 245 | sentBy: 'test@example.com', 246 | } 247 | }) 248 | expect(formattedEvent).to.contain(foldLine('ORGANIZER;DIR="test-dir-value";SENT-BY="MAILTO:test@example.com";CN="Adam Gibbons":MAILTO:adam@example.com')) 249 | }) 250 | it('writes an alarm', () => { 251 | const formattedEvent = formatEvent({ 252 | productId: 'productId', 253 | method: 'method', 254 | uid: 'uid', 255 | timestamp: 'timestamp', 256 | alarms: [{ 257 | action: 'audio', 258 | trigger: [1997, 2, 17, 1, 30], 259 | repeat: 4, 260 | duration: { minutes: 15 }, 261 | attach: 'ftp://example.com/pub/sounds/bell-01.aud' 262 | }] 263 | }) 264 | 265 | expect(formattedEvent).to.contain('BEGIN:VALARM') 266 | expect(formattedEvent).to.contain('TRIGGER;VALUE=DATE-TIME:199702') 267 | expect(formattedEvent).to.contain('REPEAT:4') 268 | expect(formattedEvent).to.contain('DURATION:PT15M') 269 | expect(formattedEvent).to.contain('ACTION:AUDIO') 270 | expect(formattedEvent).to.contain('ATTACH;FMTTYPE=audio/basic:ftp://example.com/pub/sounds/bell-01.aud') 271 | expect(formattedEvent).to.contain('END:VALARM') 272 | }) 273 | it('never writes lines longer than 75 characters, excluding CRLF', () => { 274 | const formattedEvent = formatEvent({ 275 | productId: '*'.repeat(1000), 276 | method: '*'.repeat(1000), 277 | timestamp: '*'.repeat(1000), 278 | uid: '*'.repeat(1000), 279 | title: '*'.repeat(1000), 280 | description: '*'.repeat(1000), 281 | url: '*'.repeat(1000), 282 | geo: '*'.repeat(1000), 283 | location: '*'.repeat(1000), 284 | status: '*'.repeat(1000), 285 | categories: ['*'.repeat(1000)], 286 | organizer: '*'.repeat(1000), 287 | attendees: [ 288 | {name: '*'.repeat(1000), email: '*'.repeat(1000)}, 289 | {name: '*'.repeat(1000), email: '*'.repeat(1000), rsvp: true} 290 | ] 291 | }) 292 | const max = Math.max(...formattedEvent.split('\r\n').map(line => line.length)) 293 | expect(max).to.be.at.most(75) 294 | }) 295 | it('writes a recurrence rule', () => { 296 | const formattedEvent = formatEvent({ 297 | productId: 'productId', 298 | method: 'method', 299 | uid: 'uid', 300 | timestamp: 'timestamp', 301 | recurrenceRule: 'FREQ=DAILY' 302 | }) 303 | 304 | expect(formattedEvent).to.contain('RRULE:FREQ=DAILY') 305 | }) 306 | it('writes exception date-time', () => { 307 | const date1 = new Date(0); 308 | date1.setUTCFullYear(2000); 309 | date1.setUTCMonth(6); 310 | date1.setUTCDate(20); 311 | date1.setUTCHours(1); 312 | date1.setUTCMinutes(0); 313 | date1.setUTCSeconds(0); 314 | 315 | const date2 = new Date(date1); 316 | date2.setUTCDate(21); 317 | 318 | const formattedEvent = formatEvent({ 319 | productId: 'productId', 320 | method: 'method', 321 | uid: 'uid', 322 | timestamp: 'timestamp', 323 | exclusionDates: [ 324 | [date1.getFullYear(), date1.getMonth(), date1.getDate(), date1.getHours(), date1.getMinutes(), date1.getSeconds()], 325 | [date2.getFullYear(), date2.getMonth(), date2.getDate(), date2.getHours(), date2.getMinutes(), date2.getSeconds()] 326 | ] 327 | }) 328 | expect(formattedEvent).to.contain('EXDATE:20000620T010000Z,20000621T010000Z') 329 | }) 330 | }) 331 | --------------------------------------------------------------------------------