Skip to content

Commit

Permalink
Add support for floating date-time and date values
Browse files Browse the repository at this point in the history
  • Loading branch information
simonratner committed Aug 27, 2019
1 parent 18142da commit b231ec3
Show file tree
Hide file tree
Showing 12 changed files with 241 additions and 44 deletions.
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
40 changes: 33 additions & 7 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 Down Expand Up @@ -60,15 +60,35 @@ export function optionsToString (options: Partial<Options>) {
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:
Expand Down Expand Up @@ -97,10 +117,16 @@ export function optionsToString (options: Partial<Options>) {
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<Options> = {}, 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()
}
96 changes: 82 additions & 14 deletions src/parsestring.ts
Original file line number Diff line number Diff line change
@@ -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<Options> {
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<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')
} 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<Options> {
const options: Partial<Options> = {}

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 = !tzid && !dt.endsWith('Z')
}
}

return options
}

Expand All @@ -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}`)
}
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions 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 @@ -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,
Expand Down Expand Up @@ -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))
}

/**
Expand Down
8 changes: 6 additions & 2 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 Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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}`
Expand Down
Loading

0 comments on commit b231ec3

Please sign in to comment.