Skip to content
This repository was archived by the owner on Nov 20, 2025. It is now read-only.

Commit b231ec3

Browse files
committed
Add support for floating date-time and date values
1 parent 18142da commit b231ec3

12 files changed

Lines changed: 241 additions & 44 deletions

src/dateutil.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ export namespace dateutil {
161161
})
162162
}
163163

164-
export const timeToUntilString = function (time: number, utc = true) {
164+
export const toRfc5545DateTime = function (time: number, utc = true) {
165165
const date = new Date(time)
166166
return [
167167
padStart(date.getUTCFullYear().toString(), 4, '0'),
@@ -175,11 +175,20 @@ export namespace dateutil {
175175
].join('')
176176
}
177177

178-
export const untilStringToDate = function (until: string) {
178+
export const toRfc5545Date = function (time: number) {
179+
const date = new Date(time)
180+
return [
181+
padStart(date.getUTCFullYear().toString(), 4, '0'),
182+
padStart(date.getUTCMonth() + 1, 2, '0'),
183+
padStart(date.getUTCDate(), 2, '0')
184+
].join('')
185+
}
186+
187+
export const fromRfc5545DateTime = function (dt: string) {
179188
const re = /^(\d{4})(\d{2})(\d{2})(T(\d{2})(\d{2})(\d{2})Z?)?$/
180-
const bits = re.exec(until)
189+
const bits = re.exec(dt)
181190

182-
if (!bits) throw new Error(`Invalid UNTIL value: ${until}`)
191+
if (!bits) throw new Error(`Invalid date-time value: ${dt}`)
183192

184193
return new Date(
185194
Date.UTC(
@@ -193,6 +202,21 @@ export namespace dateutil {
193202
)
194203
}
195204

205+
export const fromRfc5545Date = function (dt: string) {
206+
const re = /^(\d{4})(\d{2})(\d{2})$/
207+
const bits = re.exec(dt)
208+
209+
if (!bits) throw new Error(`Invalid date value: ${dt}`)
210+
211+
return new Date(
212+
Date.UTC(
213+
parseInt(bits[1], 10),
214+
parseInt(bits[2], 10) - 1,
215+
parseInt(bits[3], 10)
216+
)
217+
)
218+
}
219+
196220
}
197221

198222
export default dateutil

src/datewithzone.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export class DateWithZone {
1515
}
1616

1717
public toString () {
18-
const datestr = dateutil.timeToUntilString(this.date.getTime(), this.isUTC)
18+
const datestr = dateutil.toRfc5545DateTime(this.date.getTime(), this.isUTC)
1919
if (!this.isUTC) {
2020
return `;TZID=${this.tzid}:${datestr}`
2121
}

src/optionstostring.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Options } from './types'
1+
import { Options, DateTimeProperty, DateTimeValue } from './types'
22
import RRule, { DEFAULT_OPTIONS } from './rrule'
33
import { includes, isPresent, isArray, isNumber, toArray } from './helpers'
44
import { Weekday } from './weekday'
@@ -60,15 +60,35 @@ export function optionsToString (options: Partial<Options>) {
6060
break
6161

6262
case 'DTSTART':
63-
dtstart = buildDateTime(value, options.tzid)
63+
dtstart = formatDateTime(value, options, DateTimeProperty.START)
6464
break
6565

6666
case 'DTEND':
67-
dtend = buildDateTime(value, options.tzid, true /* end */)
67+
dtend = formatDateTime(value, options, DateTimeProperty.END)
68+
break
69+
70+
case 'DTVALUE':
71+
case 'DTFLOATING':
6872
break
6973

7074
case 'UNTIL':
71-
outValue = dateutil.timeToUntilString(value, !options.tzid)
75+
/**
76+
* From [RFC 5545](https://tools.ietf.org/html/rfc5545):
77+
*
78+
* 3.3.10. Recurrence Rule
79+
*
80+
* The value of the UNTIL rule part MUST have the same value type as the
81+
* "DTSTART" property. Furthermore, if the "DTSTART" property is specified as
82+
* a date with local time, then the UNTIL rule part MUST also be specified as
83+
* a date with local time. If the "DTSTART" property is specified as a date
84+
* with UTC time or a date with local time and time zone reference, then the
85+
* UNTIL rule part MUST be specified as a date with UTC time.
86+
*/
87+
if (options.dtvalue === DateTimeValue.DATE) {
88+
outValue = dateutil.toRfc5545Date(value)
89+
} else {
90+
outValue = dateutil.toRfc5545DateTime(value, !options.dtfloating)
91+
}
7292
break
7393

7494
default:
@@ -97,10 +117,16 @@ export function optionsToString (options: Partial<Options>) {
97117
return [ dtstart, dtend, ruleString ].filter(x => !!x).join('\n')
98118
}
99119

100-
function buildDateTime (dt?: number, tzid?: string | null, end: boolean = false) {
120+
function formatDateTime (dt?: number, options: Partial<Options> = {}, prop = DateTimeProperty.START) {
101121
if (!dt) {
102122
return ''
103123
}
104-
105-
return 'DT' + (end ? 'END' : 'START') + new DateWithZone(new Date(dt), tzid).toString()
124+
let prefix = prop.toString()
125+
if (options.dtvalue) {
126+
prefix += ';VALUE=' + options.dtvalue.toString()
127+
}
128+
if (options.dtfloating) {
129+
return prefix + ':' + dateutil.toRfc5545DateTime(dt, false)
130+
}
131+
return prefix + new DateWithZone(new Date(dt), options.tzid).toString()
106132
}

src/parsestring.ts

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,96 @@
1-
import { Options, Frequency } from './types'
1+
import { Options, Frequency, DateTimeProperty, DateTimeValue } from './types'
22
import { Weekday } from './weekday'
33
import dateutil from './dateutil'
44
import { Days } from './rrule'
55

66
export function parseString (rfcString: string): Partial<Options> {
77
const options = rfcString.split('\n').map(parseLine).filter(x => x !== null)
8-
return options.reduce((acc, cur) => Object.assign(acc, cur), {}) || {}
8+
/**
9+
* From [RFC 5545](https://tools.ietf.org/html/rfc5545):
10+
*
11+
* 3.8.2.2. Date-Time End ("DTEND")
12+
*
13+
* The value type of this property MUST be the same as the "DTSTART" property, and its
14+
* value MUST be later in time than the value of the "DTSTART" property. Furthermore,
15+
* this property MUST be specified as a date with local time if and only if the
16+
* "DTSTART" property is also specified as a date with local time.
17+
*/
18+
return options.reduce((acc: Partial<Options>, cur: Partial<Options>) => {
19+
let existing
20+
if (cur.dtstart) {
21+
if (acc.dtstart) {
22+
throw new Error('Invalid rule: DTSTART must occur only once')
23+
}
24+
if (acc.dtend && acc.dtend.valueOf() <= cur.dtstart.valueOf()) {
25+
throw new Error('Invalid rule: DTEND must be later than DTSTART')
26+
}
27+
existing = acc.dtend
28+
}
29+
if (cur.dtend) {
30+
if (acc.dtend) {
31+
throw new Error('Invalid rule: DTEND must occur only once')
32+
}
33+
if (acc.dtstart && acc.dtstart.valueOf() >= cur.dtend.valueOf()) {
34+
throw new Error('Invalid rule: DTEND must be later than DTSTART')
35+
}
36+
existing = acc.dtstart
37+
}
38+
if (existing && acc.dtvalue !== cur.dtvalue) {
39+
// Different value types.
40+
throw new Error('Invalid rule: DTSTART and DTEND must have the same value type')
41+
} else if (existing && acc.tzid !== cur.tzid) {
42+
// Different timezones.
43+
throw new Error('Invalid rule: DTSTART and DTEND must have the same timezone')
44+
} else if (existing && acc.dtfloating !== cur.dtfloating) {
45+
// Different floating types.
46+
throw new Error('Invalid rule: DTSTART and DTEND must both be floating')
47+
}
48+
return Object.assign(acc, cur)
49+
}, {}) || {}
950
}
1051

11-
export function parseDateTime (line: string, end: boolean = false) {
52+
export function parseDateTime (line: string, prop = DateTimeProperty.START): Partial<Options> {
1253
const options: Partial<Options> = {}
1354

14-
const dtWithZone = end
15-
? /DTEND(?:;TZID=([^:=]+?))?(?::|=)([^;\s]+)/i.exec(line)
16-
: /DTSTART(?:;TZID=([^:=]+?))?(?::|=)([^;\s]+)/i.exec(line)
55+
const dtWithZone = new RegExp(
56+
`${prop}(?:;TZID=([^:=]+?))?(?:;VALUE=(DATE|DATE-TIME))?(?::|=)([^;\\s]+)`, 'i'
57+
).exec(line)
1758

1859
if (!dtWithZone) {
1960
return options
2061
}
2162

22-
const [ _, tzid, dt ] = dtWithZone
63+
const [ _, tzid, dtvalue, dt ] = dtWithZone
2364

2465
if (tzid) {
66+
if (dt.endsWith('Z')) {
67+
throw new Error(`Invalid UTC date-time with timezone: ${line}`)
68+
}
2569
options.tzid = tzid
2670
}
27-
if (end) {
28-
options.dtend = dateutil.untilStringToDate(dt)
29-
} else {
30-
options.dtstart = dateutil.untilStringToDate(dt)
71+
72+
if (dtvalue === DateTimeValue.DATE) {
73+
if (prop === DateTimeProperty.START) {
74+
options.dtstart = dateutil.fromRfc5545Date(dt)
75+
} else {
76+
options.dtend = dateutil.fromRfc5545Date(dt)
77+
}
78+
options.dtvalue = DateTimeValue.DATE
79+
options.dtfloating = true
80+
} else { // Default value type is DATE-TIME
81+
if (prop === DateTimeProperty.START) {
82+
options.dtstart = dateutil.fromRfc5545DateTime(dt)
83+
} else {
84+
options.dtend = dateutil.fromRfc5545DateTime(dt)
85+
}
86+
if (dtvalue) {
87+
options.dtvalue = DateTimeValue.DATE_TIME
88+
}
89+
if (!tzid && !dt.endsWith('Z')) {
90+
options.dtfloating = !tzid && !dt.endsWith('Z')
91+
}
3192
}
93+
3294
return options
3395
}
3496

@@ -47,9 +109,9 @@ function parseLine (rfcString: string) {
47109
case 'EXRULE':
48110
return parseRrule(rfcString)
49111
case 'DTSTART':
50-
return parseDateTime(rfcString)
112+
return parseDateTime(rfcString, DateTimeProperty.START)
51113
case 'DTEND':
52-
return parseDateTime(rfcString, true /* end */)
114+
return parseDateTime(rfcString, DateTimeProperty.END)
53115
default:
54116
throw new Error(`Unsupported RFC prop ${key} in ${rfcString}`)
55117
}
@@ -95,9 +157,15 @@ function parseRrule (line: string) {
95157
const dtstart = parseDateTime(line)
96158
options.tzid = dtstart.tzid
97159
options.dtstart = dtstart.dtstart
160+
if (dtstart.dtvalue) {
161+
options.dtvalue = dtstart.dtvalue
162+
}
163+
if (dtstart.dtfloating) {
164+
options.dtfloating = dtstart.dtfloating
165+
}
98166
break
99167
case 'UNTIL':
100-
options.until = dateutil.untilStringToDate(value)
168+
options.until = dateutil.fromRfc5545DateTime(value)
101169
break
102170
case 'BYEASTER':
103171
options.byeaster = Number(value)

src/rrule.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import CallbackIterResult from './callbackiterresult'
55
import { Language } from './nlp/i18n'
66
import { Nlp } from './nlp/index'
77
import { DateFormatter, GetText } from './nlp/totext'
8-
import { ParsedOptions, Options, Frequency, QueryMethods, QueryMethodTypes, IterResultType } from './types'
8+
import { ParsedOptions, Options, DateTimeValue, Frequency, QueryMethods, QueryMethodTypes, IterResultType } from './types'
99
import { parseOptions, initializeOptions } from './parseoptions'
1010
import { parseString } from './parsestring'
1111
import { optionsToString } from './optionstostring'
@@ -44,6 +44,8 @@ export const DEFAULT_OPTIONS: Options = {
4444
freq: Frequency.YEARLY,
4545
dtstart: null,
4646
dtend: null,
47+
dtvalue: DateTimeValue.DATE_TIME,
48+
dtfloating: false,
4749
interval: 1,
4850
wkst: Days.MO,
4951
count: null,
@@ -265,7 +267,7 @@ export default class RRule implements QueryMethods {
265267
if (this.options.until && dt.valueOf() > this.options.until.valueOf()) {
266268
return false
267269
}
268-
return dt.valueOf() >= prev.valueOf() && dt.valueOf() < (prev.valueOf() + this.duration())
270+
return dt.valueOf() >= prev.valueOf() && dt.valueOf() < (prev.valueOf() + (this.duration() || 0))
269271
}
270272

271273
/**

src/rruleset.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import dateutil from './dateutil'
33
import { includes } from './helpers'
44
import IterResult from './iterresult'
55
import { iterSet } from './iterset'
6-
import { QueryMethodTypes, IterResultType } from './types'
6+
import { DateTimeProperty, QueryMethodTypes, IterResultType } from './types'
77
import { rrulestr } from './rrulestr'
88
import { optionsToString } from './optionstostring'
99

@@ -33,6 +33,7 @@ export default class RRuleSet extends RRule {
3333
public readonly _exdate: Date[]
3434

3535
private _dtstart?: Date | null | undefined
36+
private _dtend?: Date | null | undefined
3637
private _tzid?: string
3738

3839
/**
@@ -143,6 +144,9 @@ export default class RRuleSet extends RRule {
143144
if (!this._rrule.length && this._dtstart) {
144145
result = result.concat(optionsToString({ dtstart: this._dtstart }))
145146
}
147+
if (!this._rrule.length && this._dtend) {
148+
result = result.concat(optionsToString({ dtend: this._dtend }))
149+
}
146150

147151
this._rrule.forEach(function (rrule) {
148152
result = result.concat(rrule.toString().split('\n'))
@@ -221,7 +225,7 @@ function rdatesToString (param: string, rdates: Date[], tzid: string | undefined
221225
const header = isUTC ? `${param}:` : `${param};TZID=${tzid}:`
222226

223227
const dateString = rdates
224-
.map(rdate => dateutil.timeToUntilString(rdate.valueOf(), isUTC))
228+
.map(rdate => dateutil.toRfc5545DateTime(rdate.valueOf(), isUTC))
225229
.join(',')
226230

227231
return `${header}${dateString}`

0 commit comments

Comments
 (0)