diff --git a/package-lock.json b/package-lock.json index f5649fd7f..28329f45d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,6 @@ "requires": true, "packages": { "": { - "name": "ical-generator", "version": "v2.0.0-develop", "license": "MIT", "dependencies": { @@ -34,6 +33,7 @@ "moment-timezone": "^0.5.33", "nyc": "^15.1.0", "portfinder": "^1.0.28", + "rrule": "^2.6.8", "semantic-release": "^17.4.0", "ts-node": "^9.1.1", "typedoc": "^0.20.30", @@ -12613,6 +12613,25 @@ "rimraf": "bin.js" } }, + "node_modules/rrule": { + "version": "2.6.8", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.6.8.tgz", + "integrity": "sha512-cUaXuUPrz9d1wdyzHsBfT1hptKlGgABeCINFXFvulEPqh9Np9BnF3C3lrv9uO54IIr8VDb58tsSF3LhsW+4VRw==", + "dev": true, + "dependencies": { + "luxon": "^1.21.3", + "tslib": "^1.10.0" + }, + "optionalDependencies": { + "luxon": "^1.21.3" + } + }, + "node_modules/rrule/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + }, "node_modules/run-parallel": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.10.tgz", @@ -24182,6 +24201,24 @@ "glob": "^7.1.3" } }, + "rrule": { + "version": "2.6.8", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.6.8.tgz", + "integrity": "sha512-cUaXuUPrz9d1wdyzHsBfT1hptKlGgABeCINFXFvulEPqh9Np9BnF3C3lrv9uO54IIr8VDb58tsSF3LhsW+4VRw==", + "dev": true, + "requires": { + "luxon": "^1.21.3", + "tslib": "^1.10.0" + }, + "dependencies": { + "tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true + } + } + }, "run-parallel": { "version": "1.1.10", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.1.10.tgz", diff --git a/package.json b/package.json index 8cd8c0e15..1e85fa53c 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "moment-timezone": "^0.5.33", "nyc": "^15.1.0", "portfinder": "^1.0.28", + "rrule": "^2.6.8", "semantic-release": "^17.4.0", "ts-node": "^9.1.1", "typedoc": "^0.20.30", diff --git a/src/event.ts b/src/event.ts index 3241a11a0..ec49d9376 100755 --- a/src/event.ts +++ b/src/event.ts @@ -1,5 +1,6 @@ 'use strict'; +import type {RRule} from 'rrule'; import uuid from 'uuid-random'; import { addOrGetCustomAttributes, @@ -9,7 +10,7 @@ import { escape, formatDate, formatDateTZ, - generateCustomAttributes, + generateCustomAttributes, isRRule, toDate, toJSON } from './tools'; @@ -56,7 +57,7 @@ export interface ICalEventData { stamp?: ICalDateTimeValue, allDay?: boolean, floating?: boolean, - repeating?: ICalRepeatingOptions | null, + repeating?: ICalRepeatingOptions | RRule | string | null, summary?: string, location?: ICalLocation | string | null, description?: ICalDescription | string | null, @@ -83,7 +84,7 @@ export interface ICalEventInternalData { stamp: ICalDateTimeValue, allDay: boolean, floating: boolean, - repeating: ICalEventInternalRepeatingData | null, + repeating: ICalEventInternalRepeatingData | RRule | string | null, summary: string, location: ICalLocation | null, description: ICalDescription | null, @@ -394,9 +395,9 @@ export default class ICalEvent { * Set/Get the event's repeating stuff * @since 0.2.0 */ - repeating(): ICalEventInternalRepeatingData | null; - repeating(repeating: ICalRepeatingOptions | null): this; - repeating(repeating?: ICalRepeatingOptions | null): this | ICalEventInternalRepeatingData | null { + repeating(): ICalEventInternalRepeatingData | RRule | string | null; + repeating(repeating: ICalRepeatingOptions | RRule | string | null): this; + repeating(repeating?: ICalRepeatingOptions | RRule | string | null): this | ICalEventInternalRepeatingData | RRule | string | null { if (repeating === undefined) { return this.data.repeating; } @@ -404,6 +405,10 @@ export default class ICalEvent { this.data.repeating = null; return this; } + if(isRRule(repeating) || typeof repeating === 'string') { + this.data.repeating = repeating; + return this; + } this.data.repeating = { freq: checkEnum(ICalEventRepeatingFreq, repeating.freq) as ICalEventRepeatingFreq @@ -814,6 +819,17 @@ export default class ICalEvent { * @since 0.2.4 */ toJSON(): ICalEventInternalData { + let repeating: ICalEventInternalRepeatingData | string | null = null; + if(isRRule(this.data.repeating) || typeof this.data.repeating === 'string') { + repeating = this.data.repeating.toString(); + } + else if(this.data.repeating) { + repeating = Object.assign({}, this.data.repeating, { + until: toJSON(this.data.repeating.until), + exclude: this.data.repeating.exclude?.map(d => toJSON(d)), + }); + } + return Object.assign({}, this.data, { start: toJSON(this.data.start) || null, end: toJSON(this.data.end) || null, @@ -821,10 +837,7 @@ export default class ICalEvent { stamp: toJSON(this.data.stamp) || null, created: toJSON(this.data.created) || null, lastModified: toJSON(this.data.lastModified) || null, - repeating: this.data.repeating ? Object.assign({}, this.data.repeating, { - until: toJSON(this.data.repeating.until), - exclude: this.data.repeating.exclude?.map(d => toJSON(d)), - }) : null, + repeating, x: this.x() }); } @@ -866,7 +879,15 @@ export default class ICalEvent { } // REPEATING - if (this.data.repeating) { + if(isRRule(this.data.repeating) || typeof this.data.repeating === 'string') { + g += this.data.repeating + .toString() + .replace(/\r\n/g, '\n') + .split('\n') + .filter(l => l && !l.startsWith('DTSTART:')) + .join('\r\n') + '\r\n'; + } + else if (this.data.repeating) { g += 'RRULE:FREQ=' + this.data.repeating.freq; if (this.data.repeating.count) { diff --git a/src/tools.ts b/src/tools.ts index b06ab04d7..349f176c9 100755 --- a/src/tools.ts +++ b/src/tools.ts @@ -4,6 +4,8 @@ import type {Moment, Duration} from 'moment'; import type {Moment as MomentTZ} from 'moment-timezone'; import type {Dayjs} from 'dayjs'; import type {DateTime as LuxonDateTime} from 'luxon'; +import type { RRule } from 'rrule'; + import {ICalDateTimeValue, ICalOrganizer} from './types'; export function formatDate (timezone: string | null, d: ICalDateTimeValue, dateonly?: boolean, floating?: boolean): string { @@ -326,6 +328,12 @@ export function isMomentDuration(value: unknown): value is Duration { return value !== null && typeof value === 'object' && typeof value.asSeconds === 'function'; } +export function isRRule(value: unknown): value is RRule { + + // @ts-ignore + return value !== null && typeof value === 'object' && typeof value.between === 'function' && typeof value.toString === 'function'; +} + export function toJSON(value: ICalDateTimeValue | null | undefined): string | null | undefined { if(!value) { return value; diff --git a/test/event.ts b/test/event.ts index 28857afa7..a9f7e3a4c 100644 --- a/test/event.ts +++ b/test/event.ts @@ -8,6 +8,8 @@ import {ICalEventRepeatingFreq, ICalWeekday} from '../src/types'; import ICalAttendee from '../src/attendee'; import ICalAlarm, {ICalAlarmType} from '../src/alarm'; import ICalCategory from '../src/category'; +import {isRRule} from '../src/tools'; +import {RRule} from 'rrule'; describe('ical-generator Event', function () { describe('constructor()', function () { @@ -538,7 +540,10 @@ describe('ical-generator Event', function () { const e = new ICalEvent({}, new ICalCalendar()); e.repeating({freq: ICalEventRepeatingFreq.MONTHLY}); - assert.strictEqual(e.repeating()?.freq, 'MONTHLY'); + + const result = e.repeating(); + assert.ok(result && !isRRule(result) && typeof result !== 'string'); + assert.strictEqual(result.freq, 'MONTHLY'); }); it('setter should throw error when repeating.count is not a number', function () { @@ -570,7 +575,10 @@ describe('ical-generator Event', function () { const e = new ICalEvent({}, new ICalCalendar()); e.repeating({freq: ICalEventRepeatingFreq.MONTHLY, count: 5}); - assert.strictEqual(e.repeating()?.count, 5); + + const result = e.repeating(); + assert.ok(result && !isRRule(result) && typeof result !== 'string'); + assert.strictEqual(result.count, 5); }); it('should throw error when repeating.interval is not a number', function () { @@ -602,7 +610,10 @@ describe('ical-generator Event', function () { const e = new ICalEvent({}, new ICalCalendar()); e.repeating({freq: ICalEventRepeatingFreq.MONTHLY, interval: 5}); - assert.strictEqual(e.repeating()?.interval, 5); + + const result = e.repeating(); + assert.ok(result && !isRRule(result) && typeof result !== 'string'); + assert.strictEqual(result.interval, 5); }); it('should throw error when repeating.until is not a date', function () { @@ -623,21 +634,30 @@ describe('ical-generator Event', function () { const event = new ICalEvent({}, new ICalCalendar()); const date = moment().add(1, 'week').toJSON(); event.repeating({freq: ICalEventRepeatingFreq.MONTHLY, until: date}); - assert.deepStrictEqual(event.repeating()?.until, date); + + const result = event.repeating(); + assert.ok(result && !isRRule(result) && typeof result !== 'string'); + assert.deepStrictEqual(result.until, date); }); it('setter should handle repeating.until Dates if required', function () { const event = new ICalEvent({}, new ICalCalendar()); const date = moment().add(1, 'week').toDate(); event.repeating({freq: ICalEventRepeatingFreq.MONTHLY, until: date}); - assert.deepStrictEqual(event.repeating()?.until, date); + + const result = event.repeating(); + assert.ok(result && !isRRule(result) && typeof result !== 'string'); + assert.deepStrictEqual(result.until, date); }); it('setter should handle repeating.until moments', function () { const event = new ICalEvent({}, new ICalCalendar()); const date = moment().add(1, 'week'); event.repeating({freq: ICalEventRepeatingFreq.MONTHLY, until: date}); - assert.deepStrictEqual(event.repeating()?.until, date); + + const result = event.repeating(); + assert.ok(result && !isRRule(result) && typeof result !== 'string'); + assert.deepStrictEqual(result.until, date); }); it('setter should throw error when repeating.until is not a Date', function () { @@ -708,7 +728,9 @@ describe('ical-generator Event', function () { byDay: [ICalWeekday.SU, ICalWeekday.WE, ICalWeekday.TH] }); - assert.deepStrictEqual(e.repeating()?.byDay, ['SU', 'WE', 'TH']); + const result = e.repeating(); + assert.ok(result && !isRRule(result) && typeof result !== 'string'); + assert.deepStrictEqual(result.byDay, ['SU', 'WE', 'TH']); }); it('should throw error when repeating.byMonth is not valid', function () { @@ -742,7 +764,10 @@ describe('ical-generator Event', function () { const e = new ICalEvent({}, new ICalCalendar()); e.repeating({freq: ICalEventRepeatingFreq.MONTHLY, byMonth: [1, 12, 7]}); - assert.deepStrictEqual(e.repeating()?.byMonth, [1, 12, 7]); + + const result = e.repeating(); + assert.ok(result && !isRRule(result) && typeof result !== 'string'); + assert.deepStrictEqual(result.byMonth, [1, 12, 7]); }); it('should throw error when repeating.byMonthDay is not valid', function () { @@ -778,7 +803,10 @@ describe('ical-generator Event', function () { const e = new ICalEvent({}, new ICalCalendar()); e.repeating({freq: ICalEventRepeatingFreq.MONTHLY, byMonthDay: [1, 15]}); - assert.deepStrictEqual(e.repeating()?.byMonthDay, [1, 15]); + + const result = e.repeating(); + assert.ok(result && !isRRule(result) && typeof result !== 'string'); + assert.deepStrictEqual(result.byMonthDay, [1, 15]); }); it('should throw error when repeating.bySetPos is not valid', function () { @@ -836,8 +864,10 @@ describe('ical-generator Event', function () { bySetPos: 2 }); - assert.strictEqual(e.repeating()?.byDay?.length, 1); - assert.strictEqual(e.repeating()?.bySetPos, 2); + const result = e.repeating(); + assert.ok(result && !isRRule(result) && typeof result !== 'string'); + assert.strictEqual(result.byDay?.length, 1); + assert.strictEqual(result.bySetPos, 2); }); it('should throw error when repeating.exclude is not valid', function () { @@ -902,13 +932,14 @@ describe('ical-generator Event', function () { ] }); - const result = e.repeating()?.exclude; - assert.ok(Array.isArray(result)); - assert.strictEqual(result.length, 3); + const result = e.repeating(); + assert.ok(result && !isRRule(result) && typeof result !== 'string'); + assert.ok(Array.isArray(result.exclude)); + assert.strictEqual(result.exclude.length, 3); - assert.deepStrictEqual(result[0], date.toJSON(), 'String'); - assert.deepStrictEqual(result[1], date.toDate(), 'Date'); - assert.deepStrictEqual(result[2], date, 'Moment'); + assert.deepStrictEqual(result.exclude[0], date.toJSON(), 'String'); + assert.deepStrictEqual(result.exclude[1], date.toDate(), 'Date'); + assert.deepStrictEqual(result.exclude[2], date, 'Moment'); }); it('should throw error when repeating.startOfWeek is not valid', function () { @@ -933,7 +964,38 @@ describe('ical-generator Event', function () { freq: ICalEventRepeatingFreq.MONTHLY, startOfWeek: ICalWeekday.SU }); - assert.deepStrictEqual(e.repeating()?.startOfWeek, 'SU'); + + const result = e.repeating(); + assert.ok(result && !isRRule(result) && typeof result !== 'string'); + assert.deepStrictEqual(result.startOfWeek, 'SU'); + }); + + it('should support RRules', function () { + const start = new Date(Date.UTC(2012, 1, 1, 10, 30)); + const e = new ICalEvent({start}, new ICalCalendar()); + const rule = new RRule({ + freq: RRule.WEEKLY, + interval: 5, + byweekday: [RRule.MO, RRule.FR], + dtstart: start, + until: new Date(Date.UTC(2012, 12, 31)) + }); + + e.repeating(rule); + + const result = e.repeating(); + assert.ok(isRRule(result)); + assert.deepStrictEqual(result, rule); + assert.ok(e.toString().includes('RRULE:FREQ=WEEKLY;INTERVAL=5;BYDAY=MO,FR;UNTIL=20130131T000000Z')); + }); + it('should support strings', function () { + const e = new ICalEvent({start: new Date()}, new ICalCalendar()); + const rule = 'RRULE:FREQ=WEEKLY;INTERVAL=5;BYDAY=MO,FR;UNTIL=20130131T000000Z'; + e.repeating(rule); + + const result = e.repeating(); + assert.deepStrictEqual(result, rule); + assert.ok(e.toString().includes('RRULE:FREQ=WEEKLY;INTERVAL=5;BYDAY=MO,FR;UNTIL=20130131T000000Z')); }); }); @@ -1529,6 +1591,25 @@ describe('ical-generator Event', function () { assert.deepStrictEqual(event.toJSON().start, date.toJSON(), 'start is okay'); assert.strictEqual(typeof event.toJSON().start, 'string', 'start is string'); }); + it('should stringify RRule objects', function() { + const date = new Date(); + const rule = new RRule({ + freq: RRule.WEEKLY, + interval: 5, + byweekday: [RRule.MO, RRule.FR], + dtstart: date, + until: new Date(Date.UTC(2012, 12, 31)) + }); + + const event = new ICalEvent({}, new ICalCalendar()).summary('foo').start(date).repeating(rule); + const json = event.toJSON(); + const before = event.toString(); + assert.ok(typeof json.repeating === 'string'); + + const event2 = new ICalEvent(event.toJSON(), new ICalCalendar()); + const after = event2.toString(); + assert.strictEqual(after, before); + }); }); describe('transparency()', function () {