From 788e51e911688c71137ad4d8be6eef86fe1cb9b2 Mon Sep 17 00:00:00 2001 From: Simon Ratner Date: Wed, 14 Aug 2019 00:53:09 -0700 Subject: [PATCH 1/9] Add support for dtend and duration --- src/nlp/totext.ts | 2 +- src/optionstostring.ts | 17 +++++--- src/parsestring.ts | 26 +++++++----- src/rrule.ts | 26 ++++++++++++ src/rruleset.ts | 1 + src/rrulestr.ts | 15 ++++++- src/types.ts | 2 + test/optionstostring.test.ts | 6 +++ test/parsestring.test.ts | 10 +++++ test/rrule.test.ts | 78 +++++++++++++++++++++++++++++++++++- 10 files changed, 163 insertions(+), 20 deletions(-) diff --git a/src/nlp/totext.ts b/src/nlp/totext.ts index f58e269d..98d5e3a6 100644 --- a/src/nlp/totext.ts +++ b/src/nlp/totext.ts @@ -129,7 +129,7 @@ export default class ToText { if (rrule.origOptions.until && rrule.origOptions.count) return false for (let key in rrule.origOptions) { - if (contains(['dtstart', 'wkst', 'freq'], key)) return true + if (contains(['dtstart', 'dtend', 'wkst', 'freq'], key)) return true if (!contains(ToText.IMPLEMENTED[rrule.options.freq], key)) return false } diff --git a/src/optionstostring.ts b/src/optionstostring.ts index 6321712e..8bc95488 100644 --- a/src/optionstostring.ts +++ b/src/optionstostring.ts @@ -8,6 +8,7 @@ import { DateWithZone } from './datewithzone' export function optionsToString (options: Partial) { let rrule: string[][] = [] let dtstart: string = '' + let dtend: string = '' const keys: (keyof Options)[] = Object.keys(options) as (keyof Options)[] const defaultKeys = Object.keys(DEFAULT_OPTIONS) @@ -56,10 +57,14 @@ export function optionsToString (options: Partial) { return new Weekday(wday) }).toString() - break + case 'DTSTART': - dtstart = buildDtstart(value, options.tzid) + dtstart = buildDateTime(value, options.tzid) + break + + case 'DTEND': + dtend = buildDateTime(value, options.tzid, true /* end */) break case 'UNTIL': @@ -89,13 +94,13 @@ export function optionsToString (options: Partial) { ruleString = `RRULE:${rules}` } - return [ dtstart, ruleString ].filter(x => !!x).join('\n') + return [ dtstart, dtend, ruleString ].filter(x => !!x).join('\n') } -function buildDtstart (dtstart?: number, tzid?: string | null) { - if (!dtstart) { +function buildDateTime (dt?: number, tzid?: string | null, end: boolean = false) { + if (!dt) { return '' } - return 'DTSTART' + new DateWithZone(new Date(dtstart), tzid).toString() + return 'DT' + (end ? 'END' : 'START') + new DateWithZone(new Date(dt), tzid).toString() } diff --git a/src/parsestring.ts b/src/parsestring.ts index b1b38e53..1daa99ab 100644 --- a/src/parsestring.ts +++ b/src/parsestring.ts @@ -5,24 +5,30 @@ import { Days } from './rrule' export function parseString (rfcString: string): Partial { const options = rfcString.split('\n').map(parseLine).filter(x => x !== null) - return { ...options[0], ...options[1] } + return options.reduce((acc, cur) => Object.assign(acc, cur)) } -export function parseDtstart (line: string) { +export function parseDateTime (line: string, end: boolean = false) { const options: Partial = {} - const dtstartWithZone = /DTSTART(?:;TZID=([^:=]+?))?(?::|=)([^;\s]+)/i.exec(line) + const dtWithZone = end + ? /DTEND(?:;TZID=([^:=]+?))?(?::|=)([^;\s]+)/i.exec(line) + : /DTSTART(?:;TZID=([^:=]+?))?(?::|=)([^;\s]+)/i.exec(line) - if (!dtstartWithZone) { + if (!dtWithZone) { return options } - const [ _, tzid, dtstart ] = dtstartWithZone + const [ _, tzid, dt ] = dtWithZone if (tzid) { options.tzid = tzid } - options.dtstart = dateutil.untilStringToDate(dtstart) + if (end) { + options.dtend = dateutil.untilStringToDate(dt) + } else { + options.dtstart = dateutil.untilStringToDate(dt) + } return options } @@ -41,7 +47,9 @@ function parseLine (rfcString: string) { case 'EXRULE': return parseRrule(rfcString) case 'DTSTART': - return parseDtstart(rfcString) + return parseDateTime(rfcString) + case 'DTEND': + return parseDateTime(rfcString, true /* end */) default: throw new Error(`Unsupported RFC prop ${key} in ${rfcString}`) } @@ -49,7 +57,7 @@ function parseLine (rfcString: string) { function parseRrule (line: string) { const strippedLine = line.replace(/^RRULE:/i, '') - const options = parseDtstart(strippedLine) + const options = parseDateTime(strippedLine) const attrs = line.replace(/^(?:RRULE|EXRULE):/i, '').split(';') @@ -84,7 +92,7 @@ function parseRrule (line: string) { case 'DTSTART': case 'TZID': // for backwards compatibility - const dtstart = parseDtstart(line) + const dtstart = parseDateTime(line) options.tzid = dtstart.tzid options.dtstart = dtstart.dtstart break diff --git a/src/rrule.ts b/src/rrule.ts index ccbac06c..fb6a995b 100644 --- a/src/rrule.ts +++ b/src/rrule.ts @@ -43,6 +43,7 @@ export const Days = { export const DEFAULT_OPTIONS: Options = { freq: Frequency.YEARLY, dtstart: null, + dtend: null, interval: 1, wkst: Days.MO, count: null, @@ -242,6 +243,31 @@ export default class RRule implements QueryMethods { return this.all().length } + /** + * Returns the duration of recurrence instances in this set, in milliseconds. + */ + duration (): number { + if (this.options.dtstart && this.options.dtend) { + return this.options.dtend.valueOf() - this.options.dtstart.valueOf() + } + return NaN + } + + /** + * Returns true iff the given date-time is included within the duration + * of any instance of this rule. + */ + includes (dt: Date): boolean { + const prev = this.before(dt) + if (!prev) { + return false + } + if (this.options.until && dt.valueOf() > this.options.until.valueOf()) { + return false + } + return dt.valueOf() >= prev.valueOf() && dt.valueOf() < (prev.valueOf() + this.duration()) + } + /** * Converts the rrule into its string representation * @see diff --git a/src/rruleset.ts b/src/rruleset.ts index 1e548fb9..a5ac0959 100644 --- a/src/rruleset.ts +++ b/src/rruleset.ts @@ -51,6 +51,7 @@ export default class RRuleSet extends RRule { } dtstart = createGetterSetter.apply(this, ['dtstart']) + dtend = createGetterSetter.apply(this, ['dtend']) tzid = createGetterSetter.apply(this, ['tzid']) _iter (iterResult: IterResult): IterResultType { diff --git a/src/rrulestr.ts b/src/rrulestr.ts index 967d20a1..fb4a3a6f 100644 --- a/src/rrulestr.ts +++ b/src/rrulestr.ts @@ -3,10 +3,11 @@ import RRuleSet from './rruleset' import dateutil from './dateutil' import { includes, split } from './helpers' import { Options } from './types' -import { parseString, parseDtstart } from './parsestring' +import { parseString, parseDateTime } from './parsestring' export interface RRuleStrOptions { dtstart: Date | null + dtend: Date | null cache: boolean unfold: boolean forceset: boolean @@ -20,6 +21,7 @@ export interface RRuleStrOptions { */ const DEFAULT_OPTIONS: RRuleStrOptions = { dtstart: null, + dtend: null, cache: false, unfold: false, forceset: false, @@ -33,7 +35,8 @@ export function parseInput (s: string, options: Partial) { let exrulevals: Partial[] = [] let exdatevals: Date[] = [] - let { dtstart, tzid } = parseDtstart(s) + let { dtstart, tzid } = parseDateTime(s) + let dtend const lines = splitIntoLines(s, options.unfold) @@ -73,6 +76,13 @@ export function parseInput (s: string, options: Partial) { case 'DTSTART': break + case 'DTEND': + let parsed = parseDateTime(s, true /* end */) + if (parsed) { + dtend = parsed.dtend + } + break + default: throw new Error('unsupported property: ' + name) } @@ -80,6 +90,7 @@ export function parseInput (s: string, options: Partial) { return { dtstart, + dtend, tzid, rrulevals, rdatevals, diff --git a/src/types.ts b/src/types.ts index bbf1d85a..538c5a29 100644 --- a/src/types.ts +++ b/src/types.ts @@ -27,6 +27,7 @@ export function freqIsDailyOrGreater (freq: Frequency): freq is Frequency.YEARLY export interface Options { freq: Frequency dtstart: Date | null + dtend: Date | null interval: number wkst: Weekday | number | null count: number | null @@ -48,6 +49,7 @@ export interface Options { export interface ParsedOptions extends Options { dtstart: Date + dtend: Date wkst: number bysetpos: number[] bymonth: number[] diff --git a/test/optionstostring.test.ts b/test/optionstostring.test.ts index 4eb5e94e..ffcf3c0e 100644 --- a/test/optionstostring.test.ts +++ b/test/optionstostring.test.ts @@ -17,6 +17,12 @@ describe('optionsToString', () => { { dtstart: new Date(Date.UTC(1997, 8, 2, 9, 0, 0)), tzid: 'America/New_York', freq: RRule.WEEKLY }, 'DTSTART;TZID=America/New_York:19970902T090000\n' + 'RRULE:FREQ=WEEKLY' + ], + [ + { dtstart: new Date(Date.UTC(1997, 8, 2, 9, 0, 0)), dtend: new Date(Date.UTC(1997, 8, 3, 9, 0, 0)), freq: RRule.WEEKLY }, + 'DTSTART:19970902T090000Z\n' + + 'DTEND:19970903T090000Z\n' + + 'RRULE:FREQ=WEEKLY' ] ] diff --git a/test/parsestring.test.ts b/test/parsestring.test.ts index ade2397e..a0123090 100644 --- a/test/parsestring.test.ts +++ b/test/parsestring.test.ts @@ -40,6 +40,16 @@ describe('parseString', () => { freq: RRule.YEARLY, count: 3 } + ], + [ + 'DTSTART:19970902T090000Z\n' + + 'DTEND:19970903T090000Z\n' + + 'RRULE:FREQ=WEEKLY\n', + { + dtstart: new Date(Date.UTC(1997, 8, 2, 9, 0, 0)), + dtend: new Date(Date.UTC(1997, 8, 3, 9, 0, 0)), + freq: RRule.WEEKLY + } ] ] diff --git a/test/rrule.test.ts b/test/rrule.test.ts index dbf90411..72181798 100644 --- a/test/rrule.test.ts +++ b/test/rrule.test.ts @@ -27,8 +27,8 @@ describe('RRule', function () { const s2 = rrulestr(s1).toString() expect(s1).equals(s2, s1 + ' => ' + s2) }) - - it('rrulestr itteration not infinite when interval 0', function () { + + it('rrulestr iteration not infinite when interval 0', function () { ['FREQ=YEARLY;INTERVAL=0;BYSETPOS=1;BYDAY=MO', 'FREQ=MONTHLY;INTERVAL=0;BYSETPOS=1;BYDAY=MO', 'FREQ=DAILY;INTERVAL=0;BYSETPOS=1;BYDAY=MO', @@ -38,6 +38,80 @@ describe('RRule', function () { .map((s) => expect(rrulestr(s).count()).to.equal(0)) }) + it('duration', function () { + const rule = new RRule({ + freq: RRule.WEEKLY, + dtstart: new Date('2010-01-02T00:00:00Z'), + dtend: new Date('2010-01-02T08:00:00Z') + }) + expect(rule.duration()).equals(8 * 60 * 60 * 1e3) + }) + + it('duration with timezone', function () { + const rule = new RRule({ + freq: RRule.WEEKLY, + dtstart: new Date('2010-01-02T00:00:00Z'), + dtend: new Date('2010-01-02T00:00:00-0800') + }) + expect(rule.duration()).equals(8 * 60 * 60 * 1e3) + }) + + it('includes', function () { + const rule = new RRule({ + freq: RRule.DAILY, + dtstart: new Date('2010-01-02T00:00:00Z'), + dtend: new Date('2010-01-02T08:00:00Z') + }) + expect(rule.includes(new Date('2010-01-02T01:00:00Z'))).equals(true) + }) + + it('includes recurrence', function () { + const rule = new RRule({ + freq: RRule.DAILY, + dtstart: new Date('2010-01-02T00:00:00Z'), + dtend: new Date('2010-01-02T08:00:00Z') + }) + expect(rule.includes(new Date('2010-01-03T01:00:00Z'))).equals(true) + }) + + it('does not include', function () { + const rule = new RRule({ + freq: RRule.DAILY, + dtstart: new Date('2010-01-02T00:00:00Z'), + dtend: new Date('2010-01-02T08:00:00Z') + }) + expect(rule.includes(new Date('2010-01-02T09:00:00Z'))).equals(false) + }) + + it('does not include after end', function () { + const rule = new RRule({ + freq: RRule.DAILY, + dtstart: new Date('2010-01-02T00:00:00Z'), + dtend: new Date('2010-01-02T08:00:00Z'), + until: new Date('2010-01-03T00:00:00Z') + }) + expect(rule.includes(new Date('2010-01-03T01:00:00Z'))).equals(false) + }) + + it('does not include after end with timezone', function () { + const rule = new RRule({ + freq: RRule.DAILY, + dtstart: new Date('2010-01-02T00:00:00Z'), + dtend: new Date('2010-01-02T08:00:00Z'), + until: new Date('2010-01-03T08:00:00+0800') + }) + expect(rule.includes(new Date('2010-01-03T01:00:00Z'))).equals(false) + }) + + it('does not include before start', function () { + const rule = new RRule({ + freq: RRule.DAILY, + dtstart: new Date('2010-01-02T00:00:00Z'), + dtend: new Date('2010-01-02T08:00:00Z') + }) + expect(rule.includes(new Date('2010-01-01T01:00:00Z'))).equals(false) + }) + it('does not mutate the passed-in options object', function () { const options = { freq: RRule.MONTHLY, From b7e785c93475fc8d37ef3310eb8e406e781aa3a2 Mon Sep 17 00:00:00 2001 From: Simon Ratner Date: Thu, 15 Aug 2019 00:08:17 -0700 Subject: [PATCH 2/9] Fix dtend parsing in rrulestr and add test --- src/rrulestr.ts | 9 ++++++--- test/rrule.test.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/rrulestr.ts b/src/rrulestr.ts index fb4a3a6f..1accd81e 100644 --- a/src/rrulestr.ts +++ b/src/rrulestr.ts @@ -106,6 +106,7 @@ function buildRule (s: string, options: Partial) { exrulevals, exdatevals, dtstart, + dtend, tzid } = parseInput(s, options) @@ -131,7 +132,7 @@ function buildRule (s: string, options: Partial) { rrulevals.forEach(val => { rset.rrule( new RRule( - groomRruleOptions(val, dtstart, tzid), + groomRruleOptions(val, dtstart, dtend, tzid), noCache ) ) @@ -144,7 +145,7 @@ function buildRule (s: string, options: Partial) { exrulevals.forEach(val => { rset.exrule( new RRule( - groomRruleOptions(val, dtstart, tzid), + groomRruleOptions(val, dtstart, dtend, tzid), noCache ) ) @@ -162,6 +163,7 @@ function buildRule (s: string, options: Partial) { return new RRule(groomRruleOptions( val, val.dtstart || options.dtstart || dtstart, + val.dtend || options.dtend || dtend, val.tzid || options.tzid || tzid ), noCache) } @@ -173,10 +175,11 @@ export function rrulestr ( return buildRule(s, initializeOptions(options)) } -function groomRruleOptions (val: Partial, dtstart?: Date | null, tzid?: string | null) { +function groomRruleOptions (val: Partial, dtstart?: Date | null, dtend?: Date | null, tzid?: string | null) { return { ...val, dtstart, + dtend, tzid } } diff --git a/test/rrule.test.ts b/test/rrule.test.ts index 72181798..cc17c4b2 100644 --- a/test/rrule.test.ts +++ b/test/rrule.test.ts @@ -2,6 +2,7 @@ import { parse, datetime, testRecurring, expectedDate } from './lib/utils' import { expect } from 'chai' import { RRule, rrulestr, Frequency } from '../src/index' import { DateTime } from 'luxon' +import { DateWithZone } from '../src/datewithzone' import { set as setMockDate, reset as resetMockDate } from 'mockdate' import { optionsToString } from '../src/optionstostring'; @@ -74,6 +75,21 @@ describe('RRule', function () { expect(rule.includes(new Date('2010-01-03T01:00:00Z'))).equals(true) }) + it('includes recurrence with DST', function () { + // This recurrence spans the DST transition in America/Los_Angeles, and is + // only 2 hours in duration on the first day (3 hours on subsequent days). + const rule = rrulestr([ + 'DTSTART;TZID=America/Los_Angeles:20190310T010000', + 'DTEND;TZID=America/Los_Angeles:20190310T040000', + 'RRULE:FREQ=DAILY' + ].join('\n')) + // Test dates are assumed to be in America/Los_Angeles, as all other input + // to rrule they are represented as zoneless UTC. + expect(rule.includes(new Date(Date.UTC(2019, 2, 10, 3, 30, 0)))).equals(true) + expect(rule.includes(new Date(Date.UTC(2019, 2, 11, 3, 30, 0)))).equals(true) + expect(rule.includes(new Date(Date.UTC(2019, 10, 11, 3, 30, 0)))).equals(true) + }) + it('does not include', function () { const rule = new RRule({ freq: RRule.DAILY, From d456286c6bb564df01c272d6b12b8714fd0369fa Mon Sep 17 00:00:00 2001 From: Simon Ratner Date: Fri, 16 Aug 2019 17:26:33 -0700 Subject: [PATCH 3/9] Fix reduce return type --- src/parsestring.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/parsestring.ts b/src/parsestring.ts index 1daa99ab..db539e7d 100644 --- a/src/parsestring.ts +++ b/src/parsestring.ts @@ -5,7 +5,7 @@ import { Days } from './rrule' export function parseString (rfcString: string): Partial { const options = rfcString.split('\n').map(parseLine).filter(x => x !== null) - return options.reduce((acc, cur) => Object.assign(acc, cur)) + return options.reduce((acc, cur) => Object.assign(acc, cur), {}) || {} } export function parseDateTime (line: string, end: boolean = false) { From 02c5e46682a4d2230f754e7efccb5bcd62f067d3 Mon Sep 17 00:00:00 2001 From: Simon Ratner Date: Mon, 19 Aug 2019 11:22:45 -0700 Subject: [PATCH 4/9] Add more DST tests --- test/rrule.test.ts | 59 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/test/rrule.test.ts b/test/rrule.test.ts index cc17c4b2..adbdefd2 100644 --- a/test/rrule.test.ts +++ b/test/rrule.test.ts @@ -75,19 +75,62 @@ describe('RRule', function () { expect(rule.includes(new Date('2010-01-03T01:00:00Z'))).equals(true) }) - it('includes recurrence with DST', function () { + it('includes recurrence for rule created during DST start', function () { // This recurrence spans the DST transition in America/Los_Angeles, and is - // only 2 hours in duration on the first day (3 hours on subsequent days). + // 2.5 hours in duration on the first day (3.5 hours on subsequent days). const rule = rrulestr([ - 'DTSTART;TZID=America/Los_Angeles:20190310T010000', + 'DTSTART;TZID=America/Los_Angeles:20190310T003000', 'DTEND;TZID=America/Los_Angeles:20190310T040000', 'RRULE:FREQ=DAILY' ].join('\n')) - // Test dates are assumed to be in America/Los_Angeles, as all other input - // to rrule they are represented as zoneless UTC. - expect(rule.includes(new Date(Date.UTC(2019, 2, 10, 3, 30, 0)))).equals(true) - expect(rule.includes(new Date(Date.UTC(2019, 2, 11, 3, 30, 0)))).equals(true) - expect(rule.includes(new Date(Date.UTC(2019, 10, 11, 3, 30, 0)))).equals(true) + // Test dates are assumed to be in America/Los_Angeles, as all inputs to rrule + // are represented as zoneless UTC. + expect(rule.includes(new Date(Date.UTC(2019, 2, 10, 3, 20, 0)))).equals(true) + expect(rule.includes(new Date(Date.UTC(2019, 2, 10, 4, 20, 0)))).equals(false) + expect(rule.includes(new Date(Date.UTC(2019, 2, 11, 3, 20, 0)))).equals(true) + expect(rule.includes(new Date(Date.UTC(2019, 2, 11, 4, 20, 0)))).equals(false) + expect(rule.includes(new Date(Date.UTC(2019, 10, 9, 3, 20, 0)))).equals(true) + }) + + it('includes recurrence for rule created during DST end', function () { + // This recurrence spans the DST transition in America/Los_Angeles, and is + // 4.5 hours in duration on the first day (3.5 hours on subsequent days). + const rule = rrulestr([ + 'DTSTART;TZID=America/Los_Angeles:20191103T003000', + 'DTEND;TZID=America/Los_Angeles:20191103T040000', + 'RRULE:FREQ=DAILY' + ].join('\n')) + // Test dates are assumed to be in America/Los_Angeles, as all inputs to rrule + // are represented as zoneless UTC. + expect(rule.includes(new Date(Date.UTC(2019, 10, 3, 3, 20, 0)))).equals(true) + expect(rule.includes(new Date(Date.UTC(2019, 10, 3, 4, 20, 0)))).equals(false) + expect(rule.includes(new Date(Date.UTC(2019, 10, 4, 3, 20, 0)))).equals(true) + expect(rule.includes(new Date(Date.UTC(2019, 10, 4, 4, 20, 0)))).equals(false) + expect(rule.includes(new Date(Date.UTC(2020, 2, 11, 3, 20, 0)))).equals(true) + }) + + it('includes recurrence spanning DST start', function () { + const rule = rrulestr([ + 'DTSTART;TZID=America/Los_Angeles:20190210T003000', + 'DTEND;TZID=America/Los_Angeles:20190210T040000', + 'RRULE:FREQ=DAILY' + ].join('\n')) + // Test dates are assumed to be in America/Los_Angeles, as all inputs to rrule + // are represented as zoneless UTC. + expect(rule.includes(new Date(Date.UTC(2019, 2, 10, 3, 20, 0)))).equals(true) + expect(rule.includes(new Date(Date.UTC(2019, 2, 10, 4, 20, 0)))).equals(false) + }) + + it('includes recurrence spanning DST end', function () { + const rule = rrulestr([ + 'DTSTART;TZID=America/Los_Angeles:20191003T003000', + 'DTEND;TZID=America/Los_Angeles:20191003T040000', + 'RRULE:FREQ=DAILY' + ].join('\n')) + // Test dates are assumed to be in America/Los_Angeles, as all inputs to rrule + // are represented as zoneless UTC. + expect(rule.includes(new Date(Date.UTC(2019, 10, 3, 3, 20, 0)))).equals(true) + expect(rule.includes(new Date(Date.UTC(2019, 10, 3, 4, 20, 0)))).equals(false) }) it('does not include', function () { From 368bcf82afd5f7fe75eeeaaee44e31507d161389 Mon Sep 17 00:00:00 2001 From: Simon Ratner Date: Mon, 26 Aug 2019 21:26:20 -0700 Subject: [PATCH 5/9] Add support for floating date-time and date values --- src/dateutil.ts | 32 ++++++++++++-- src/datewithzone.ts | 2 +- src/optionstostring.ts | 40 ++++++++++++++--- src/parsestring.ts | 96 ++++++++++++++++++++++++++++++++++------ src/rrule.ts | 6 ++- src/rruleset.ts | 8 +++- src/rrulestr.ts | 47 ++++++++++++++++---- src/types.ts | 12 +++++ test/dateutil.test.ts | 13 ++++-- test/parsestring.test.ts | 10 +++++ test/rruleset.test.ts | 17 ++++++- test/rrulestr.test.ts | 2 +- 12 files changed, 241 insertions(+), 44 deletions(-) diff --git a/src/dateutil.ts b/src/dateutil.ts index 9ed09b18..ecf1e874 100644 --- a/src/dateutil.ts +++ b/src/dateutil.ts @@ -161,7 +161,7 @@ export namespace dateutil { }) } - export const timeToUntilString = function (time: number, utc = true) { + export const toRfc5545DateTime = function (time: number, utc = true) { const date = new Date(time) return [ padStart(date.getUTCFullYear().toString(), 4, '0'), @@ -175,11 +175,20 @@ export namespace dateutil { ].join('') } - export const untilStringToDate = function (until: string) { + export const toRfc5545Date = function (time: number) { + const date = new Date(time) + return [ + padStart(date.getUTCFullYear().toString(), 4, '0'), + padStart(date.getUTCMonth() + 1, 2, '0'), + padStart(date.getUTCDate(), 2, '0') + ].join('') + } + + export const fromRfc5545DateTime = function (dt: string) { const re = /^(\d{4})(\d{2})(\d{2})(T(\d{2})(\d{2})(\d{2})Z?)?$/ - const bits = re.exec(until) + const bits = re.exec(dt) - if (!bits) throw new Error(`Invalid UNTIL value: ${until}`) + if (!bits) throw new Error(`Invalid date-time value: ${dt}`) return new Date( Date.UTC( @@ -193,6 +202,21 @@ export namespace dateutil { ) } + export const fromRfc5545Date = function (dt: string) { + const re = /^(\d{4})(\d{2})(\d{2})$/ + const bits = re.exec(dt) + + if (!bits) throw new Error(`Invalid date value: ${dt}`) + + return new Date( + Date.UTC( + parseInt(bits[1], 10), + parseInt(bits[2], 10) - 1, + parseInt(bits[3], 10) + ) + ) + } + } export default dateutil diff --git a/src/datewithzone.ts b/src/datewithzone.ts index 8ae3ed09..80247f60 100644 --- a/src/datewithzone.ts +++ b/src/datewithzone.ts @@ -15,7 +15,7 @@ export class DateWithZone { } public toString () { - const datestr = dateutil.timeToUntilString(this.date.getTime(), this.isUTC) + const datestr = dateutil.toRfc5545DateTime(this.date.getTime(), this.isUTC) if (!this.isUTC) { return `;TZID=${this.tzid}:${datestr}` } diff --git a/src/optionstostring.ts b/src/optionstostring.ts index 8bc95488..abf1f989 100644 --- a/src/optionstostring.ts +++ b/src/optionstostring.ts @@ -1,4 +1,4 @@ -import { Options } from './types' +import { Options, DateTimeProperty, DateTimeValue } from './types' import RRule, { DEFAULT_OPTIONS } from './rrule' import { includes, isPresent, isArray, isNumber, toArray } from './helpers' import { Weekday } from './weekday' @@ -60,15 +60,35 @@ export function optionsToString (options: Partial) { break case 'DTSTART': - dtstart = buildDateTime(value, options.tzid) + dtstart = formatDateTime(value, options, DateTimeProperty.START) break case 'DTEND': - dtend = buildDateTime(value, options.tzid, true /* end */) + dtend = formatDateTime(value, options, DateTimeProperty.END) + break + + case 'DTVALUE': + case 'DTFLOATING': break case 'UNTIL': - outValue = dateutil.timeToUntilString(value, !options.tzid) + /** + * From [RFC 5545](https://tools.ietf.org/html/rfc5545): + * + * 3.3.10. Recurrence Rule + * + * The value of the UNTIL rule part MUST have the same value type as the + * "DTSTART" property. Furthermore, if the "DTSTART" property is specified as + * a date with local time, then the UNTIL rule part MUST also be specified as + * a date with local time. If the "DTSTART" property is specified as a date + * with UTC time or a date with local time and time zone reference, then the + * UNTIL rule part MUST be specified as a date with UTC time. + */ + if (options.dtvalue === DateTimeValue.DATE) { + outValue = dateutil.toRfc5545Date(value) + } else { + outValue = dateutil.toRfc5545DateTime(value, !options.dtfloating) + } break default: @@ -97,10 +117,16 @@ export function optionsToString (options: Partial) { return [ dtstart, dtend, ruleString ].filter(x => !!x).join('\n') } -function buildDateTime (dt?: number, tzid?: string | null, end: boolean = false) { +function formatDateTime (dt?: number, options: Partial = {}, prop = DateTimeProperty.START) { if (!dt) { return '' } - - return 'DT' + (end ? 'END' : 'START') + new DateWithZone(new Date(dt), tzid).toString() + let prefix = prop.toString() + if (options.dtvalue) { + prefix += ';VALUE=' + options.dtvalue.toString() + } + if (options.dtfloating) { + return prefix + ':' + dateutil.toRfc5545DateTime(dt, false) + } + return prefix + new DateWithZone(new Date(dt), options.tzid).toString() } diff --git a/src/parsestring.ts b/src/parsestring.ts index db539e7d..032b8479 100644 --- a/src/parsestring.ts +++ b/src/parsestring.ts @@ -1,34 +1,96 @@ -import { Options, Frequency } from './types' +import { Options, Frequency, DateTimeProperty, DateTimeValue } from './types' import { Weekday } from './weekday' import dateutil from './dateutil' import { Days } from './rrule' export function parseString (rfcString: string): Partial { const options = rfcString.split('\n').map(parseLine).filter(x => x !== null) - return options.reduce((acc, cur) => Object.assign(acc, cur), {}) || {} + /** + * From [RFC 5545](https://tools.ietf.org/html/rfc5545): + * + * 3.8.2.2. Date-Time End ("DTEND") + * + * The value type of this property MUST be the same as the "DTSTART" property, and its + * value MUST be later in time than the value of the "DTSTART" property. Furthermore, + * this property MUST be specified as a date with local time if and only if the + * "DTSTART" property is also specified as a date with local time. + */ + return options.reduce((acc: Partial, cur: Partial) => { + let existing + if (cur.dtstart) { + if (acc.dtstart) { + throw new Error('Invalid rule: DTSTART must occur only once') + } + if (acc.dtend && acc.dtend.valueOf() <= cur.dtstart.valueOf()) { + throw new Error('Invalid rule: DTEND must be later than DTSTART') + } + existing = acc.dtend + } + if (cur.dtend) { + if (acc.dtend) { + throw new Error('Invalid rule: DTEND must occur only once') + } + if (acc.dtstart && acc.dtstart.valueOf() >= cur.dtend.valueOf()) { + throw new Error('Invalid rule: DTEND must be later than DTSTART') + } + existing = acc.dtstart + } + if (existing && acc.dtvalue !== cur.dtvalue) { + // Different value types. + throw new Error('Invalid rule: DTSTART and DTEND must have the same value type') + } else if (existing && acc.tzid !== cur.tzid) { + // Different timezones. + throw new Error('Invalid rule: DTSTART and DTEND must have the same timezone') + } else if (existing && acc.dtfloating !== cur.dtfloating) { + // Different floating types. + throw new Error('Invalid rule: DTSTART and DTEND must both be floating') + } + return Object.assign(acc, cur) + }, {}) || {} } -export function parseDateTime (line: string, end: boolean = false) { +export function parseDateTime (line: string, prop = DateTimeProperty.START): Partial { const options: Partial = {} - const dtWithZone = end - ? /DTEND(?:;TZID=([^:=]+?))?(?::|=)([^;\s]+)/i.exec(line) - : /DTSTART(?:;TZID=([^:=]+?))?(?::|=)([^;\s]+)/i.exec(line) + const dtWithZone = new RegExp( + `${prop}(?:;TZID=([^:=]+?))?(?:;VALUE=(DATE|DATE-TIME))?(?::|=)([^;\\s]+)`, 'i' + ).exec(line) if (!dtWithZone) { return options } - const [ _, tzid, dt ] = dtWithZone + const [ _, tzid, dtvalue, dt ] = dtWithZone if (tzid) { + if (dt.endsWith('Z')) { + throw new Error(`Invalid UTC date-time with timezone: ${line}`) + } options.tzid = tzid } - if (end) { - options.dtend = dateutil.untilStringToDate(dt) - } else { - options.dtstart = dateutil.untilStringToDate(dt) + + if (dtvalue === DateTimeValue.DATE) { + if (prop === DateTimeProperty.START) { + options.dtstart = dateutil.fromRfc5545Date(dt) + } else { + options.dtend = dateutil.fromRfc5545Date(dt) + } + options.dtvalue = DateTimeValue.DATE + options.dtfloating = true + } else { // Default value type is DATE-TIME + if (prop === DateTimeProperty.START) { + options.dtstart = dateutil.fromRfc5545DateTime(dt) + } else { + options.dtend = dateutil.fromRfc5545DateTime(dt) + } + if (dtvalue) { + options.dtvalue = DateTimeValue.DATE_TIME + } + if (!tzid && !dt.endsWith('Z')) { + options.dtfloating = true + } } + return options } @@ -47,9 +109,9 @@ function parseLine (rfcString: string) { case 'EXRULE': return parseRrule(rfcString) case 'DTSTART': - return parseDateTime(rfcString) + return parseDateTime(rfcString, DateTimeProperty.START) case 'DTEND': - return parseDateTime(rfcString, true /* end */) + return parseDateTime(rfcString, DateTimeProperty.END) default: throw new Error(`Unsupported RFC prop ${key} in ${rfcString}`) } @@ -95,9 +157,15 @@ function parseRrule (line: string) { const dtstart = parseDateTime(line) options.tzid = dtstart.tzid options.dtstart = dtstart.dtstart + if (dtstart.dtvalue) { + options.dtvalue = dtstart.dtvalue + } + if (dtstart.dtfloating) { + options.dtfloating = dtstart.dtfloating + } break case 'UNTIL': - options.until = dateutil.untilStringToDate(value) + options.until = dateutil.fromRfc5545DateTime(value) break case 'BYEASTER': options.byeaster = Number(value) diff --git a/src/rrule.ts b/src/rrule.ts index fb6a995b..11621454 100644 --- a/src/rrule.ts +++ b/src/rrule.ts @@ -5,7 +5,7 @@ import CallbackIterResult from './callbackiterresult' import { Language } from './nlp/i18n' import { Nlp } from './nlp/index' import { DateFormatter, GetText } from './nlp/totext' -import { ParsedOptions, Options, Frequency, QueryMethods, QueryMethodTypes, IterResultType } from './types' +import { ParsedOptions, Options, DateTimeValue, Frequency, QueryMethods, QueryMethodTypes, IterResultType } from './types' import { parseOptions, initializeOptions } from './parseoptions' import { parseString } from './parsestring' import { optionsToString } from './optionstostring' @@ -44,6 +44,8 @@ export const DEFAULT_OPTIONS: Options = { freq: Frequency.YEARLY, dtstart: null, dtend: null, + dtvalue: DateTimeValue.DATE_TIME, + dtfloating: false, interval: 1, wkst: Days.MO, count: null, @@ -265,7 +267,7 @@ export default class RRule implements QueryMethods { if (this.options.until && dt.valueOf() > this.options.until.valueOf()) { return false } - return dt.valueOf() >= prev.valueOf() && dt.valueOf() < (prev.valueOf() + this.duration()) + return dt.valueOf() >= prev.valueOf() && dt.valueOf() < (prev.valueOf() + (this.duration() || 0)) } /** diff --git a/src/rruleset.ts b/src/rruleset.ts index a5ac0959..fced1973 100644 --- a/src/rruleset.ts +++ b/src/rruleset.ts @@ -3,7 +3,7 @@ import dateutil from './dateutil' import { includes } from './helpers' import IterResult from './iterresult' import { iterSet } from './iterset' -import { QueryMethodTypes, IterResultType } from './types' +import { DateTimeProperty, QueryMethodTypes, IterResultType } from './types' import { rrulestr } from './rrulestr' import { optionsToString } from './optionstostring' @@ -33,6 +33,7 @@ export default class RRuleSet extends RRule { public readonly _exdate: Date[] private _dtstart?: Date | null | undefined + private _dtend?: Date | null | undefined private _tzid?: string /** @@ -143,6 +144,9 @@ export default class RRuleSet extends RRule { if (!this._rrule.length && this._dtstart) { result = result.concat(optionsToString({ dtstart: this._dtstart })) } + if (!this._rrule.length && this._dtend) { + result = result.concat(optionsToString({ dtend: this._dtend })) + } this._rrule.forEach(function (rrule) { result = result.concat(rrule.toString().split('\n')) @@ -221,7 +225,7 @@ function rdatesToString (param: string, rdates: Date[], tzid: string | undefined const header = isUTC ? `${param}:` : `${param};TZID=${tzid}:` const dateString = rdates - .map(rdate => dateutil.timeToUntilString(rdate.valueOf(), isUTC)) + .map(rdate => dateutil.toRfc5545DateTime(rdate.valueOf(), isUTC)) .join(',') return `${header}${dateString}` diff --git a/src/rrulestr.ts b/src/rrulestr.ts index 1accd81e..4c380842 100644 --- a/src/rrulestr.ts +++ b/src/rrulestr.ts @@ -2,12 +2,14 @@ import RRule from './rrule' import RRuleSet from './rruleset' import dateutil from './dateutil' import { includes, split } from './helpers' -import { Options } from './types' +import { Options, DateTimeProperty, DateTimeValue } from './types' import { parseString, parseDateTime } from './parsestring' export interface RRuleStrOptions { dtstart: Date | null dtend: Date | null + dtvalue: DateTimeValue | null + dtfloating: boolean | null cache: boolean unfold: boolean forceset: boolean @@ -22,6 +24,8 @@ export interface RRuleStrOptions { const DEFAULT_OPTIONS: RRuleStrOptions = { dtstart: null, dtend: null, + dtvalue: null, + dtfloating: false, cache: false, unfold: false, forceset: false, @@ -35,8 +39,8 @@ export function parseInput (s: string, options: Partial) { let exrulevals: Partial[] = [] let exdatevals: Date[] = [] - let { dtstart, tzid } = parseDateTime(s) - let dtend + let { dtstart, dtfloating, dtvalue, tzid } = parseDateTime(s) + let dtend: Date | null = null const lines = splitIntoLines(s, options.unfold) @@ -77,8 +81,24 @@ export function parseInput (s: string, options: Partial) { break case 'DTEND': - let parsed = parseDateTime(s, true /* end */) - if (parsed) { + let parsed: Partial = parseDateTime(s, DateTimeProperty.END) + if (parsed.dtend) { + if (dtend) { + throw new Error('Invalid rule: DTEND must occur only once') + } + if (dtstart && dtstart.valueOf() >= parsed.dtend.valueOf()) { + throw new Error('Invalid rule: DTEND must be later than DTSTART') + } + if (dtstart && dtvalue !== parsed.dtvalue) { + // Different value types. + throw new Error('Invalid rule: DTSTART and DTEND must have the same value type') + } else if (dtstart && tzid !== parsed.tzid) { + // Different timezones. + throw new Error('Invalid rule: DTSTART and DTEND must have the same timezone') + } else if (dtstart && dtfloating !== parsed.dtfloating) { + // Different floating types. + throw new Error('Invalid rule: DTSTART and DTEND must both be floating') + } dtend = parsed.dtend } break @@ -91,6 +111,8 @@ export function parseInput (s: string, options: Partial) { return { dtstart, dtend, + dtvalue, + dtfloating, tzid, rrulevals, rdatevals, @@ -107,6 +129,8 @@ function buildRule (s: string, options: Partial) { exdatevals, dtstart, dtend, + dtvalue, + dtfloating, tzid } = parseInput(s, options) @@ -127,12 +151,13 @@ function buildRule (s: string, options: Partial) { const rset = new RRuleSet(noCache) rset.dtstart(dtstart) + rset.dtend(dtend) rset.tzid(tzid || undefined) rrulevals.forEach(val => { rset.rrule( new RRule( - groomRruleOptions(val, dtstart, dtend, tzid), + groomRruleOptions(val, dtstart, dtend, dtvalue, dtfloating, tzid), noCache ) ) @@ -145,7 +170,7 @@ function buildRule (s: string, options: Partial) { exrulevals.forEach(val => { rset.exrule( new RRule( - groomRruleOptions(val, dtstart, dtend, tzid), + groomRruleOptions(val, dtstart, dtend, dtvalue, dtfloating, tzid), noCache ) ) @@ -164,6 +189,8 @@ function buildRule (s: string, options: Partial) { val, val.dtstart || options.dtstart || dtstart, val.dtend || options.dtend || dtend, + val.dtvalue || options.dtvalue || dtvalue, + val.dtfloating || options.dtfloating || dtfloating, val.tzid || options.tzid || tzid ), noCache) } @@ -175,11 +202,13 @@ export function rrulestr ( return buildRule(s, initializeOptions(options)) } -function groomRruleOptions (val: Partial, dtstart?: Date | null, dtend?: Date | null, tzid?: string | null) { +function groomRruleOptions (val: Partial, dtstart?: Date | null, dtend?: Date | null, dtvalue?: DateTimeValue | null, dtfloating?: boolean | null, tzid?: string | null) { return { ...val, dtstart, dtend, + dtvalue, + dtfloating, tzid } } @@ -270,5 +299,5 @@ function parseRDate (rdateval: string, parms: string[]) { return rdateval .split(',') - .map(datestr => dateutil.untilStringToDate(datestr)) + .map(datestr => dateutil.fromRfc5545DateTime(datestr)) } diff --git a/src/types.ts b/src/types.ts index 538c5a29..9c9514be 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,16 @@ export interface QueryMethods { export type QueryMethodTypes = keyof QueryMethods export type IterResultType = M extends 'all' | 'between' ? Date[] : (Date | null) +export enum DateTimeProperty { + START = 'DTSTART', + END = 'DTEND' +} + +export enum DateTimeValue { + DATE = 'DATE', + DATE_TIME = 'DATE-TIME' +} + export enum Frequency { YEARLY = 0, MONTHLY = 1, @@ -28,6 +38,8 @@ export interface Options { freq: Frequency dtstart: Date | null dtend: Date | null + dtvalue: DateTimeValue | null + dtfloating: boolean | null interval: number wkst: Weekday | number | null count: number | null diff --git a/test/dateutil.test.ts b/test/dateutil.test.ts index d6bd196a..7f4b31fa 100644 --- a/test/dateutil.test.ts +++ b/test/dateutil.test.ts @@ -1,9 +1,16 @@ import { dateutil } from '../src/dateutil' import { expect } from 'chai' -describe('untilStringToDate', () => { - it('parses a date string', () => { - const date = dateutil.untilStringToDate('19970902T090000') +describe('fromRfc5545DateTime', () => { + it('parses a date-time string', () => { + const date = dateutil.fromRfc5545DateTime('19970902T090000') expect(date.getTime()).to.equal(new Date(Date.UTC(1997, 8, 2, 9, 0, 0, 0)).getTime()) }) }) + +describe('fromRfc5545Date', () => { + it('parses a date string', () => { + const date = dateutil.fromRfc5545Date('19970902') + expect(date.getTime()).to.equal(new Date(Date.UTC(1997, 8, 2, 0, 0, 0, 0)).getTime()) + }) +}) diff --git a/test/parsestring.test.ts b/test/parsestring.test.ts index a0123090..aedaadfe 100644 --- a/test/parsestring.test.ts +++ b/test/parsestring.test.ts @@ -50,6 +50,16 @@ describe('parseString', () => { dtend: new Date(Date.UTC(1997, 8, 3, 9, 0, 0)), freq: RRule.WEEKLY } + ], + [ + 'RRULE:FREQ=WEEKLY\n' + + 'DTEND:19970903T090000Z\n' + + 'DTSTART:19970902T090000Z\n', + { + dtstart: new Date(Date.UTC(1997, 8, 2, 9, 0, 0)), + dtend: new Date(Date.UTC(1997, 8, 3, 9, 0, 0)), + freq: RRule.WEEKLY + } ] ] diff --git a/test/rruleset.test.ts b/test/rruleset.test.ts index 2fe7f0da..e606925a 100644 --- a/test/rruleset.test.ts +++ b/test/rruleset.test.ts @@ -649,6 +649,21 @@ describe('RRuleSet', function () { expectRecurrence([original, legacy]).toBeUpdatedWithEndDate([ 'DTSTART;TZID=America/New_York:20171201T080000', + 'RRULE:FREQ=WEEKLY;UNTIL=20171224T235959Z', + ].join('\n')) + }) + + it('handles rule with floating time', () => { + const legacy = [ + 'RRULE:DTSTART=20171201T080000;FREQ=WEEKLY', + ] + const original = [ + 'DTSTART:20171201T080000', + 'RRULE:FREQ=WEEKLY', + ] + + expectRecurrence([original, legacy]).toBeUpdatedWithEndDate([ + 'DTSTART:20171201T080000', 'RRULE:FREQ=WEEKLY;UNTIL=20171224T235959', ].join('\n')) }) @@ -785,4 +800,4 @@ describe('RRuleSet', function () { expect(set.exdates()).eql([dt]); }); }); -}); \ No newline at end of file +}); diff --git a/test/rrulestr.test.ts b/test/rrulestr.test.ts index 1ae2d511..88d17af8 100644 --- a/test/rrulestr.test.ts +++ b/test/rrulestr.test.ts @@ -317,7 +317,7 @@ describe('rrulestr', function () { it('parses a DTSTART with a TZID inside an RRULE', () => { const rrule = rrulestr( - 'RRULE:UNTIL=19990404T110000Z;DTSTART;TZID=America/New_York:19990104T110000Z;FREQ=WEEKLY;BYDAY=TU,WE' + 'RRULE:UNTIL=19990404T110000Z;DTSTART;TZID=America/New_York:19990104T110000;FREQ=WEEKLY;BYDAY=TU,WE' ) expect(rrule.options).to.deep.include({ From 0f392afa4044cd5526ed022ed96981fc5bd3d9e9 Mon Sep 17 00:00:00 2001 From: Simon Ratner Date: Thu, 29 Aug 2019 18:57:35 -0700 Subject: [PATCH 6/9] Add inclusive option to includes method --- src/rrule.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/rrule.ts b/src/rrule.ts index 11621454..668eb782 100644 --- a/src/rrule.ts +++ b/src/rrule.ts @@ -127,6 +127,17 @@ export default class RRule implements QueryMethods { static parseString = parseString static fromString (str: string) { + /* From [RFC 5545](https://tools.ietf.org/html/rfc5545): + * + * 3.3.10. Recurrence Rule + * + * The BYSECOND, BYMINUTE, and BYHOUR rule parts MUST NOT be specified when the + * associated "DTSTART" property has a DATE value type. These rule parts MUST be + * ignored in RECUR value that violate the above requirement (e.g., generated by + * applications that pre-date this revision of iCalendar). + * + * TODO: ^^^ + */ return new RRule(RRule.parseString(str) || undefined) } @@ -259,8 +270,8 @@ export default class RRule implements QueryMethods { * Returns true iff the given date-time is included within the duration * of any instance of this rule. */ - includes (dt: Date): boolean { - const prev = this.before(dt) + includes (dt: Date, inc = false): boolean { + const prev = this.before(dt, inc) if (!prev) { return false } From a5dbd1fdb765f7938a92d22a21c65a32c12c6c6f Mon Sep 17 00:00:00 2001 From: Simon Ratner Date: Thu, 29 Aug 2019 18:58:35 -0700 Subject: [PATCH 7/9] Fix parsing of date-time values specified in UTC --- src/optionstostring.ts | 6 +++++- src/parsestring.ts | 7 ++++++- test/parsestring.test.ts | 3 +++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/optionstostring.ts b/src/optionstostring.ts index abf1f989..53cb7436 100644 --- a/src/optionstostring.ts +++ b/src/optionstostring.ts @@ -126,7 +126,11 @@ function formatDateTime (dt?: number, options: Partial = {}, prop = Dat prefix += ';VALUE=' + options.dtvalue.toString() } if (options.dtfloating) { - return prefix + ':' + dateutil.toRfc5545DateTime(dt, false) + if (options.dtvalue === DateTimeValue.DATE) { + return prefix + ':' + dateutil.toRfc5545Date(dt) + } else { + return prefix + ':' + dateutil.toRfc5545DateTime(dt, false) + } } return prefix + new DateWithZone(new Date(dt), options.tzid).toString() } diff --git a/src/parsestring.ts b/src/parsestring.ts index 032b8479..15ff3a63 100644 --- a/src/parsestring.ts +++ b/src/parsestring.ts @@ -64,9 +64,11 @@ export function parseDateTime (line: string, prop = DateTimeProperty.START): Par if (tzid) { if (dt.endsWith('Z')) { - throw new Error(`Invalid UTC date-time with timezone: ${line}`) + throw new Error(`Invalid UTC date-time value with timezone: ${line}`) } options.tzid = tzid + } else if (dt.endsWith('Z')) { + options.tzid = 'UTC' } if (dtvalue === DateTimeValue.DATE) { @@ -77,6 +79,9 @@ export function parseDateTime (line: string, prop = DateTimeProperty.START): Par } options.dtvalue = DateTimeValue.DATE options.dtfloating = true + if (options.tzid) { + throw new Error(`Invalid date value with timezone: ${line}`) + } } else { // Default value type is DATE-TIME if (prop === DateTimeProperty.START) { options.dtstart = dateutil.fromRfc5545DateTime(dt) diff --git a/test/parsestring.test.ts b/test/parsestring.test.ts index aedaadfe..5f1394c2 100644 --- a/test/parsestring.test.ts +++ b/test/parsestring.test.ts @@ -37,6 +37,7 @@ describe('parseString', () => { 'RRULE:FREQ=YEARLY;COUNT=3\n', { dtstart: new Date(Date.UTC(1997, 8, 2, 9, 0, 0)), + tzid: 'UTC', freq: RRule.YEARLY, count: 3 } @@ -48,6 +49,7 @@ describe('parseString', () => { { dtstart: new Date(Date.UTC(1997, 8, 2, 9, 0, 0)), dtend: new Date(Date.UTC(1997, 8, 3, 9, 0, 0)), + tzid: 'UTC', freq: RRule.WEEKLY } ], @@ -58,6 +60,7 @@ describe('parseString', () => { { dtstart: new Date(Date.UTC(1997, 8, 2, 9, 0, 0)), dtend: new Date(Date.UTC(1997, 8, 3, 9, 0, 0)), + tzid: 'UTC', freq: RRule.WEEKLY } ] From ed97a442a2fedd6b0623a2a9d3f37e4b0c4a218c Mon Sep 17 00:00:00 2001 From: Simon Ratner Date: Fri, 6 Sep 2019 16:50:29 +0200 Subject: [PATCH 8/9] Remove duration and includes methods, not useful --- src/rrule.ts | 25 --------- test/rrule.test.ts | 132 --------------------------------------------- 2 files changed, 157 deletions(-) diff --git a/src/rrule.ts b/src/rrule.ts index 668eb782..10186127 100644 --- a/src/rrule.ts +++ b/src/rrule.ts @@ -256,31 +256,6 @@ export default class RRule implements QueryMethods { return this.all().length } - /** - * Returns the duration of recurrence instances in this set, in milliseconds. - */ - duration (): number { - if (this.options.dtstart && this.options.dtend) { - return this.options.dtend.valueOf() - this.options.dtstart.valueOf() - } - return NaN - } - - /** - * Returns true iff the given date-time is included within the duration - * of any instance of this rule. - */ - includes (dt: Date, inc = false): boolean { - const prev = this.before(dt, inc) - if (!prev) { - return false - } - if (this.options.until && dt.valueOf() > this.options.until.valueOf()) { - return false - } - return dt.valueOf() >= prev.valueOf() && dt.valueOf() < (prev.valueOf() + (this.duration() || 0)) - } - /** * Converts the rrule into its string representation * @see diff --git a/test/rrule.test.ts b/test/rrule.test.ts index adbdefd2..3fe3681d 100644 --- a/test/rrule.test.ts +++ b/test/rrule.test.ts @@ -39,138 +39,6 @@ describe('RRule', function () { .map((s) => expect(rrulestr(s).count()).to.equal(0)) }) - it('duration', function () { - const rule = new RRule({ - freq: RRule.WEEKLY, - dtstart: new Date('2010-01-02T00:00:00Z'), - dtend: new Date('2010-01-02T08:00:00Z') - }) - expect(rule.duration()).equals(8 * 60 * 60 * 1e3) - }) - - it('duration with timezone', function () { - const rule = new RRule({ - freq: RRule.WEEKLY, - dtstart: new Date('2010-01-02T00:00:00Z'), - dtend: new Date('2010-01-02T00:00:00-0800') - }) - expect(rule.duration()).equals(8 * 60 * 60 * 1e3) - }) - - it('includes', function () { - const rule = new RRule({ - freq: RRule.DAILY, - dtstart: new Date('2010-01-02T00:00:00Z'), - dtend: new Date('2010-01-02T08:00:00Z') - }) - expect(rule.includes(new Date('2010-01-02T01:00:00Z'))).equals(true) - }) - - it('includes recurrence', function () { - const rule = new RRule({ - freq: RRule.DAILY, - dtstart: new Date('2010-01-02T00:00:00Z'), - dtend: new Date('2010-01-02T08:00:00Z') - }) - expect(rule.includes(new Date('2010-01-03T01:00:00Z'))).equals(true) - }) - - it('includes recurrence for rule created during DST start', function () { - // This recurrence spans the DST transition in America/Los_Angeles, and is - // 2.5 hours in duration on the first day (3.5 hours on subsequent days). - const rule = rrulestr([ - 'DTSTART;TZID=America/Los_Angeles:20190310T003000', - 'DTEND;TZID=America/Los_Angeles:20190310T040000', - 'RRULE:FREQ=DAILY' - ].join('\n')) - // Test dates are assumed to be in America/Los_Angeles, as all inputs to rrule - // are represented as zoneless UTC. - expect(rule.includes(new Date(Date.UTC(2019, 2, 10, 3, 20, 0)))).equals(true) - expect(rule.includes(new Date(Date.UTC(2019, 2, 10, 4, 20, 0)))).equals(false) - expect(rule.includes(new Date(Date.UTC(2019, 2, 11, 3, 20, 0)))).equals(true) - expect(rule.includes(new Date(Date.UTC(2019, 2, 11, 4, 20, 0)))).equals(false) - expect(rule.includes(new Date(Date.UTC(2019, 10, 9, 3, 20, 0)))).equals(true) - }) - - it('includes recurrence for rule created during DST end', function () { - // This recurrence spans the DST transition in America/Los_Angeles, and is - // 4.5 hours in duration on the first day (3.5 hours on subsequent days). - const rule = rrulestr([ - 'DTSTART;TZID=America/Los_Angeles:20191103T003000', - 'DTEND;TZID=America/Los_Angeles:20191103T040000', - 'RRULE:FREQ=DAILY' - ].join('\n')) - // Test dates are assumed to be in America/Los_Angeles, as all inputs to rrule - // are represented as zoneless UTC. - expect(rule.includes(new Date(Date.UTC(2019, 10, 3, 3, 20, 0)))).equals(true) - expect(rule.includes(new Date(Date.UTC(2019, 10, 3, 4, 20, 0)))).equals(false) - expect(rule.includes(new Date(Date.UTC(2019, 10, 4, 3, 20, 0)))).equals(true) - expect(rule.includes(new Date(Date.UTC(2019, 10, 4, 4, 20, 0)))).equals(false) - expect(rule.includes(new Date(Date.UTC(2020, 2, 11, 3, 20, 0)))).equals(true) - }) - - it('includes recurrence spanning DST start', function () { - const rule = rrulestr([ - 'DTSTART;TZID=America/Los_Angeles:20190210T003000', - 'DTEND;TZID=America/Los_Angeles:20190210T040000', - 'RRULE:FREQ=DAILY' - ].join('\n')) - // Test dates are assumed to be in America/Los_Angeles, as all inputs to rrule - // are represented as zoneless UTC. - expect(rule.includes(new Date(Date.UTC(2019, 2, 10, 3, 20, 0)))).equals(true) - expect(rule.includes(new Date(Date.UTC(2019, 2, 10, 4, 20, 0)))).equals(false) - }) - - it('includes recurrence spanning DST end', function () { - const rule = rrulestr([ - 'DTSTART;TZID=America/Los_Angeles:20191003T003000', - 'DTEND;TZID=America/Los_Angeles:20191003T040000', - 'RRULE:FREQ=DAILY' - ].join('\n')) - // Test dates are assumed to be in America/Los_Angeles, as all inputs to rrule - // are represented as zoneless UTC. - expect(rule.includes(new Date(Date.UTC(2019, 10, 3, 3, 20, 0)))).equals(true) - expect(rule.includes(new Date(Date.UTC(2019, 10, 3, 4, 20, 0)))).equals(false) - }) - - it('does not include', function () { - const rule = new RRule({ - freq: RRule.DAILY, - dtstart: new Date('2010-01-02T00:00:00Z'), - dtend: new Date('2010-01-02T08:00:00Z') - }) - expect(rule.includes(new Date('2010-01-02T09:00:00Z'))).equals(false) - }) - - it('does not include after end', function () { - const rule = new RRule({ - freq: RRule.DAILY, - dtstart: new Date('2010-01-02T00:00:00Z'), - dtend: new Date('2010-01-02T08:00:00Z'), - until: new Date('2010-01-03T00:00:00Z') - }) - expect(rule.includes(new Date('2010-01-03T01:00:00Z'))).equals(false) - }) - - it('does not include after end with timezone', function () { - const rule = new RRule({ - freq: RRule.DAILY, - dtstart: new Date('2010-01-02T00:00:00Z'), - dtend: new Date('2010-01-02T08:00:00Z'), - until: new Date('2010-01-03T08:00:00+0800') - }) - expect(rule.includes(new Date('2010-01-03T01:00:00Z'))).equals(false) - }) - - it('does not include before start', function () { - const rule = new RRule({ - freq: RRule.DAILY, - dtstart: new Date('2010-01-02T00:00:00Z'), - dtend: new Date('2010-01-02T08:00:00Z') - }) - expect(rule.includes(new Date('2010-01-01T01:00:00Z'))).equals(false) - }) - it('does not mutate the passed-in options object', function () { const options = { freq: RRule.MONTHLY, From 78b7d01de003474dfd76efae8e31a074e8aa1196 Mon Sep 17 00:00:00 2001 From: Simon Ratner Date: Mon, 16 Sep 2019 21:13:05 -0700 Subject: [PATCH 9/9] Remove floating flag, use absense of timezone instead --- src/optionstostring.ts | 5 ++--- src/parsestring.ts | 20 +++++--------------- src/rrule.ts | 1 - src/rruleset.ts | 4 ++-- src/rrulestr.ts | 17 ++++------------- src/types.ts | 1 - test/optionstostring.test.ts | 11 +++++++++-- test/rrule.test.ts | 3 ++- test/rruleset.test.ts | 6 ++++-- 9 files changed, 28 insertions(+), 40 deletions(-) diff --git a/src/optionstostring.ts b/src/optionstostring.ts index 53cb7436..7657f3fc 100644 --- a/src/optionstostring.ts +++ b/src/optionstostring.ts @@ -68,7 +68,6 @@ export function optionsToString (options: Partial) { break case 'DTVALUE': - case 'DTFLOATING': break case 'UNTIL': @@ -87,7 +86,7 @@ export function optionsToString (options: Partial) { if (options.dtvalue === DateTimeValue.DATE) { outValue = dateutil.toRfc5545Date(value) } else { - outValue = dateutil.toRfc5545DateTime(value, !options.dtfloating) + outValue = dateutil.toRfc5545DateTime(value, !!options.tzid) } break @@ -125,7 +124,7 @@ function formatDateTime (dt?: number, options: Partial = {}, prop = Dat if (options.dtvalue) { prefix += ';VALUE=' + options.dtvalue.toString() } - if (options.dtfloating) { + if (!options.tzid) { if (options.dtvalue === DateTimeValue.DATE) { return prefix + ':' + dateutil.toRfc5545Date(dt) } else { diff --git a/src/parsestring.ts b/src/parsestring.ts index 15ff3a63..86f1691a 100644 --- a/src/parsestring.ts +++ b/src/parsestring.ts @@ -41,9 +41,6 @@ export function parseString (rfcString: string): Partial { } else if (existing && acc.tzid !== cur.tzid) { // Different timezones. throw new Error('Invalid rule: DTSTART and DTEND must have the same timezone') - } else if (existing && acc.dtfloating !== cur.dtfloating) { - // Different floating types. - throw new Error('Invalid rule: DTSTART and DTEND must both be floating') } return Object.assign(acc, cur) }, {}) || {} @@ -78,7 +75,6 @@ export function parseDateTime (line: string, prop = DateTimeProperty.START): Par options.dtend = dateutil.fromRfc5545Date(dt) } options.dtvalue = DateTimeValue.DATE - options.dtfloating = true if (options.tzid) { throw new Error(`Invalid date value with timezone: ${line}`) } @@ -91,9 +87,6 @@ export function parseDateTime (line: string, prop = DateTimeProperty.START): Par if (dtvalue) { options.dtvalue = DateTimeValue.DATE_TIME } - if (!tzid && !dt.endsWith('Z')) { - options.dtfloating = true - } } return options @@ -159,14 +152,11 @@ function parseRrule (line: string) { case 'DTSTART': case 'TZID': // for backwards compatibility - const dtstart = parseDateTime(line) - options.tzid = dtstart.tzid - options.dtstart = dtstart.dtstart - if (dtstart.dtvalue) { - options.dtvalue = dtstart.dtvalue - } - if (dtstart.dtfloating) { - options.dtfloating = dtstart.dtfloating + const parsed = parseDateTime(line) + options.tzid = parsed.tzid + options.dtstart = parsed.dtstart + if (parsed.dtvalue) { + options.dtvalue = parsed.dtvalue } break case 'UNTIL': diff --git a/src/rrule.ts b/src/rrule.ts index 10186127..475c3105 100644 --- a/src/rrule.ts +++ b/src/rrule.ts @@ -45,7 +45,6 @@ export const DEFAULT_OPTIONS: Options = { dtstart: null, dtend: null, dtvalue: DateTimeValue.DATE_TIME, - dtfloating: false, interval: 1, wkst: Days.MO, count: null, diff --git a/src/rruleset.ts b/src/rruleset.ts index fced1973..b4c96c3c 100644 --- a/src/rruleset.ts +++ b/src/rruleset.ts @@ -142,10 +142,10 @@ export default class RRuleSet extends RRule { let result: string[] = [] if (!this._rrule.length && this._dtstart) { - result = result.concat(optionsToString({ dtstart: this._dtstart })) + result = result.concat(optionsToString({ dtstart: this._dtstart, tzid: this._tzid })) } if (!this._rrule.length && this._dtend) { - result = result.concat(optionsToString({ dtend: this._dtend })) + result = result.concat(optionsToString({ dtend: this._dtend, tzid: this._tzid })) } this._rrule.forEach(function (rrule) { diff --git a/src/rrulestr.ts b/src/rrulestr.ts index 4c380842..9495fc12 100644 --- a/src/rrulestr.ts +++ b/src/rrulestr.ts @@ -9,7 +9,6 @@ export interface RRuleStrOptions { dtstart: Date | null dtend: Date | null dtvalue: DateTimeValue | null - dtfloating: boolean | null cache: boolean unfold: boolean forceset: boolean @@ -25,7 +24,6 @@ const DEFAULT_OPTIONS: RRuleStrOptions = { dtstart: null, dtend: null, dtvalue: null, - dtfloating: false, cache: false, unfold: false, forceset: false, @@ -39,7 +37,7 @@ export function parseInput (s: string, options: Partial) { let exrulevals: Partial[] = [] let exdatevals: Date[] = [] - let { dtstart, dtfloating, dtvalue, tzid } = parseDateTime(s) + let { dtstart, dtvalue, tzid } = parseDateTime(s) let dtend: Date | null = null const lines = splitIntoLines(s, options.unfold) @@ -95,9 +93,6 @@ export function parseInput (s: string, options: Partial) { } else if (dtstart && tzid !== parsed.tzid) { // Different timezones. throw new Error('Invalid rule: DTSTART and DTEND must have the same timezone') - } else if (dtstart && dtfloating !== parsed.dtfloating) { - // Different floating types. - throw new Error('Invalid rule: DTSTART and DTEND must both be floating') } dtend = parsed.dtend } @@ -112,7 +107,6 @@ export function parseInput (s: string, options: Partial) { dtstart, dtend, dtvalue, - dtfloating, tzid, rrulevals, rdatevals, @@ -130,7 +124,6 @@ function buildRule (s: string, options: Partial) { dtstart, dtend, dtvalue, - dtfloating, tzid } = parseInput(s, options) @@ -157,7 +150,7 @@ function buildRule (s: string, options: Partial) { rrulevals.forEach(val => { rset.rrule( new RRule( - groomRruleOptions(val, dtstart, dtend, dtvalue, dtfloating, tzid), + groomRruleOptions(val, dtstart, dtend, dtvalue, tzid), noCache ) ) @@ -170,7 +163,7 @@ function buildRule (s: string, options: Partial) { exrulevals.forEach(val => { rset.exrule( new RRule( - groomRruleOptions(val, dtstart, dtend, dtvalue, dtfloating, tzid), + groomRruleOptions(val, dtstart, dtend, dtvalue, tzid), noCache ) ) @@ -190,7 +183,6 @@ function buildRule (s: string, options: Partial) { val.dtstart || options.dtstart || dtstart, val.dtend || options.dtend || dtend, val.dtvalue || options.dtvalue || dtvalue, - val.dtfloating || options.dtfloating || dtfloating, val.tzid || options.tzid || tzid ), noCache) } @@ -202,13 +194,12 @@ export function rrulestr ( return buildRule(s, initializeOptions(options)) } -function groomRruleOptions (val: Partial, dtstart?: Date | null, dtend?: Date | null, dtvalue?: DateTimeValue | null, dtfloating?: boolean | null, tzid?: string | null) { +function groomRruleOptions (val: Partial, dtstart?: Date | null, dtend?: Date | null, dtvalue?: DateTimeValue | null, tzid?: string | null) { return { ...val, dtstart, dtend, dtvalue, - dtfloating, tzid } } diff --git a/src/types.ts b/src/types.ts index 9c9514be..25e77730 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,7 +39,6 @@ export interface Options { dtstart: Date | null dtend: Date | null dtvalue: DateTimeValue | null - dtfloating: boolean | null interval: number wkst: Weekday | number | null count: number | null diff --git a/test/optionstostring.test.ts b/test/optionstostring.test.ts index ffcf3c0e..e4d25e6c 100644 --- a/test/optionstostring.test.ts +++ b/test/optionstostring.test.ts @@ -6,10 +6,17 @@ import { expect } from "chai"; describe('optionsToString', () => { it('serializes valid single lines of rrules', function () { const expectations: ([ Partial, string ][]) = [ - [{ freq: RRule.WEEKLY, until: new Date(Date.UTC(2010, 0, 1, 0, 0, 0)) }, 'RRULE:FREQ=WEEKLY;UNTIL=20100101T000000Z' ], + [{ freq: RRule.WEEKLY, until: new Date(Date.UTC(2010, 0, 1, 0, 0, 0)) }, 'RRULE:FREQ=WEEKLY;UNTIL=20100101T000000' ], + [{ freq: RRule.WEEKLY, until: new Date(Date.UTC(2010, 0, 1, 0, 0, 0)), tzid: 'UTC' }, 'RRULE:FREQ=WEEKLY;UNTIL=20100101T000000Z' ], + [{ dtstart: new Date(Date.UTC(1997, 8, 2, 9, 0, 0)) }, 'DTSTART:19970902T090000' ], [{ dtstart: new Date(Date.UTC(1997, 8, 2, 9, 0, 0)), tzid: 'America/New_York' }, 'DTSTART;TZID=America/New_York:19970902T090000' ], [ { dtstart: new Date(Date.UTC(1997, 8, 2, 9, 0, 0)), freq: RRule.WEEKLY }, + 'DTSTART:19970902T090000\n' + + 'RRULE:FREQ=WEEKLY' + ], + [ + { dtstart: new Date(Date.UTC(1997, 8, 2, 9, 0, 0)), tzid: 'UTC', freq: RRule.WEEKLY }, 'DTSTART:19970902T090000Z\n' + 'RRULE:FREQ=WEEKLY' ], @@ -19,7 +26,7 @@ describe('optionsToString', () => { 'RRULE:FREQ=WEEKLY' ], [ - { dtstart: new Date(Date.UTC(1997, 8, 2, 9, 0, 0)), dtend: new Date(Date.UTC(1997, 8, 3, 9, 0, 0)), freq: RRule.WEEKLY }, + { dtstart: new Date(Date.UTC(1997, 8, 2, 9, 0, 0)), dtend: new Date(Date.UTC(1997, 8, 3, 9, 0, 0)), tzid: 'UTC', freq: RRule.WEEKLY }, 'DTSTART:19970902T090000Z\n' + 'DTEND:19970903T090000Z\n' + 'RRULE:FREQ=WEEKLY' diff --git a/test/rrule.test.ts b/test/rrule.test.ts index 3fe3681d..ce1ca589 100644 --- a/test/rrule.test.ts +++ b/test/rrule.test.ts @@ -3505,6 +3505,7 @@ describe('RRule', function () { [6, RRule.SU].forEach(function (wkst) { const rr = new RRule({ dtstart: new Date(Date.UTC(2017, 9, 17, 0, 30, 0, 0)), + tzid: 'UTC', until: new Date(Date.UTC(2017, 11, 22, 1, 30, 0, 0)), freq: RRule.MONTHLY, interval: 1, @@ -3643,7 +3644,7 @@ describe('RRule', function () { const ruleString = rrule.toString() const rrule2 = RRule.fromString(ruleString) - expect(ruleString).to.equal('DTSTART:09900101T000000Z\nRRULE:COUNT=1') + expect(ruleString).to.equal('DTSTART:09900101T000000\nRRULE:COUNT=1') expect(rrule2.count()).to.equal(1) expect(rrule2.all()).to.deep.equal([ new Date(Date.UTC(990, 0, 1, 0, 0, 0)) diff --git a/test/rruleset.test.ts b/test/rruleset.test.ts index e606925a..317aa19d 100644 --- a/test/rruleset.test.ts +++ b/test/rruleset.test.ts @@ -366,7 +366,8 @@ describe('RRuleSet', function () { set.rrule(new RRule({ freq: RRule.YEARLY, count: 2, - dtstart: parse('19600101T090000') + dtstart: parse('19600101T090000'), + tzid: 'UTC' })) expect(set.valueOf()).to.deep.equal([ @@ -381,7 +382,8 @@ describe('RRuleSet', function () { set.rrule(new RRule({ freq: RRule.YEARLY, count: 2, - dtstart: parse('19600101T090000') + dtstart: parse('19600101T090000'), + tzid: 'UTC' })) set.rrule(new RRule({