Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add support for DTEND, data types, and zoneless local date and date-time values #421

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
32 changes: 28 additions & 4 deletions src/dateutil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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(
Expand All @@ -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
2 changes: 1 addition & 1 deletion src/datewithzone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
Expand Down
2 changes: 1 addition & 1 deletion src/nlp/totext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
52 changes: 43 additions & 9 deletions src/optionstostring.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -8,6 +8,7 @@ import { DateWithZone } from './datewithzone'
export function optionsToString (options: Partial<Options>) {
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)

Expand Down Expand Up @@ -56,14 +57,37 @@ export function optionsToString (options: Partial<Options>) {

return new Weekday(wday)
}).toString()

break

case 'DTSTART':
dtstart = buildDtstart(value, options.tzid)
dtstart = formatDateTime(value, options, DateTimeProperty.START)
break

case 'DTEND':
dtend = formatDateTime(value, options, DateTimeProperty.END)
break

case 'DTVALUE':
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.tzid)
}
break

default:
Expand All @@ -89,13 +113,23 @@ export function optionsToString (options: Partial<Options>) {
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 formatDateTime (dt?: number, options: Partial<Options> = {}, prop = DateTimeProperty.START) {
if (!dt) {
return ''
}

return 'DTSTART' + new DateWithZone(new Date(dtstart), tzid).toString()
let prefix = prop.toString()
if (options.dtvalue) {
prefix += ';VALUE=' + options.dtvalue.toString()
}
if (!options.tzid) {
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()
}
97 changes: 84 additions & 13 deletions src/parsestring.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,94 @@
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<Options> {
const options = rfcString.split('\n').map(parseLine).filter(x => x !== null)
return { ...options[0], ...options[1] }
/**
* 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<Options>, cur: Partial<Options>) => {
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')
}
return Object.assign(acc, cur)
}, {}) || {}
}

export function parseDtstart (line: string) {
export function parseDateTime (line: string, prop = DateTimeProperty.START): Partial<Options> {
const options: Partial<Options> = {}

const dtstartWithZone = /DTSTART(?:;TZID=([^:=]+?))?(?::|=)([^;\s]+)/i.exec(line)
const dtWithZone = new RegExp(
`${prop}(?:;TZID=([^:=]+?))?(?:;VALUE=(DATE|DATE-TIME))?(?::|=)([^;\\s]+)`, 'i'
).exec(line)

if (!dtstartWithZone) {
if (!dtWithZone) {
return options
}

const [ _, tzid, dtstart ] = dtstartWithZone
const [ _, tzid, dtvalue, dt ] = dtWithZone

if (tzid) {
if (dt.endsWith('Z')) {
throw new Error(`Invalid UTC date-time value with timezone: ${line}`)
}
options.tzid = tzid
} else if (dt.endsWith('Z')) {
options.tzid = 'UTC'
}
options.dtstart = dateutil.untilStringToDate(dtstart)

if (dtvalue === DateTimeValue.DATE) {
if (prop === DateTimeProperty.START) {
options.dtstart = dateutil.fromRfc5545Date(dt)
} else {
options.dtend = dateutil.fromRfc5545Date(dt)
}
options.dtvalue = DateTimeValue.DATE
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)
} else {
options.dtend = dateutil.fromRfc5545DateTime(dt)
}
if (dtvalue) {
options.dtvalue = DateTimeValue.DATE_TIME
}
}

return options
}

Expand All @@ -41,15 +107,17 @@ function parseLine (rfcString: string) {
case 'EXRULE':
return parseRrule(rfcString)
case 'DTSTART':
return parseDtstart(rfcString)
return parseDateTime(rfcString, DateTimeProperty.START)
case 'DTEND':
return parseDateTime(rfcString, DateTimeProperty.END)
default:
throw new Error(`Unsupported RFC prop ${key} in ${rfcString}`)
}
}

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(';')

Expand Down Expand Up @@ -84,12 +152,15 @@ function parseRrule (line: string) {
case 'DTSTART':
case 'TZID':
// for backwards compatibility
const dtstart = parseDtstart(line)
options.tzid = dtstart.tzid
options.dtstart = dtstart.dtstart
const parsed = parseDateTime(line)
options.tzid = parsed.tzid
options.dtstart = parsed.dtstart
if (parsed.dtvalue) {
options.dtvalue = parsed.dtvalue
}
break
case 'UNTIL':
options.until = dateutil.untilStringToDate(value)
options.until = dateutil.fromRfc5545DateTime(value)
break
case 'BYEASTER':
options.byeaster = Number(value)
Expand Down
15 changes: 14 additions & 1 deletion src/rrule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -43,6 +43,8 @@ export const Days = {
export const DEFAULT_OPTIONS: Options = {
freq: Frequency.YEARLY,
dtstart: null,
dtend: null,
dtvalue: DateTimeValue.DATE_TIME,
interval: 1,
wkst: Days.MO,
count: null,
Expand Down Expand Up @@ -124,6 +126,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)
}

Expand Down
11 changes: 8 additions & 3 deletions src/rruleset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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

/**
Expand All @@ -51,6 +52,7 @@ export default class RRuleSet extends RRule {
}

dtstart = createGetterSetter.apply(this, ['dtstart'])
dtend = createGetterSetter.apply(this, ['dtend'])
tzid = createGetterSetter.apply(this, ['tzid'])

_iter <M extends QueryMethodTypes> (iterResult: IterResult<M>): IterResultType<M> {
Expand Down Expand Up @@ -140,7 +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, tzid: this._tzid }))
}

this._rrule.forEach(function (rrule) {
Expand Down Expand Up @@ -220,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}`
Expand Down
Loading