Skip to content
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