Skip to content

Commit

Permalink
make new rrule props top-level on event object. add better rrule types
Browse files Browse the repository at this point in the history
  • Loading branch information
arshaw committed Dec 19, 2020
1 parent 3b1febc commit dd9d225
Show file tree
Hide file tree
Showing 3 changed files with 105 additions and 123 deletions.
38 changes: 19 additions & 19 deletions packages/__tests__/src/datelib/rrule.ts
Expand Up @@ -59,8 +59,8 @@ describe('rrule plugin', () => {
rrule: {
dtstart: '2020-12-01',
freq: 'weekly',
exdate: '2020-12-08',
},
exdate: '2020-12-08',
}],
})

Expand All @@ -81,8 +81,8 @@ describe('rrule plugin', () => {
rrule: {
dtstart: '2020-12-01',
freq: 'weekly',
exdate: ['2020-12-08', '2020-12-15'],
},
exdate: ['2020-12-08', '2020-12-15'],
}],
})

Expand All @@ -102,11 +102,11 @@ describe('rrule plugin', () => {
rrule: {
dtstart: '2020-12-01',
freq: 'weekly',
exrule: {
dtstart: '2020-12-08',
until: '2020-12-15', // will include this date for exclusion
freq: 'weekly',
},
},
exrule: {
dtstart: '2020-12-08',
until: '2020-12-15', // will include this date for exclusion
freq: 'weekly',
},
}],
})
Expand All @@ -127,19 +127,19 @@ describe('rrule plugin', () => {
rrule: {
dtstart: '2020-12-01',
freq: 'weekly',
exrule: [
{
dtstart: '2020-12-08',
until: '2020-12-15', // will include this date for exclusion
freq: 'weekly',
},
{
dtstart: '2020-12-22',
until: '2020-12-29', // will include this date for exclusion
freq: 'weekly',
},
],
},
exrule: [
{
dtstart: '2020-12-08',
until: '2020-12-15', // will include this date for exclusion
freq: 'weekly',
},
{
dtstart: '2020-12-22',
until: '2020-12-29', // will include this date for exclusion
freq: 'weekly',
},
],
}],
})

Expand Down
20 changes: 15 additions & 5 deletions packages/rrule/src/event-refiners.ts
@@ -1,10 +1,20 @@
import { createDuration, identity, Identity } from '@fullcalendar/common'
import { createDuration, DateInput, identity, Identity } from '@fullcalendar/common'
import { Options as RRuleOptions } from 'rrule'

// TODO: ask rrule maintainers to expose this
// NOTE: we added `exdate` and `exrule` to this
type RRuleOptions = any
export type RRuleInputObjectFull = Omit<RRuleOptions, 'dtstart' | 'until' | 'freq' | 'wkst' | 'byweekday'> & {
dtstart: RRuleOptions['dtstart'] | DateInput
until: RRuleOptions['until'] | DateInput
freq: RRuleOptions['until'] | string
wkst: RRuleOptions['wkst'] | string
byweekday: RRuleOptions['byweekday'] | string
}

export type RRuleInputObject = Partial<RRuleInputObjectFull>
export type RRuleInput = RRuleInputObject | string

export const RRULE_EVENT_REFINERS = {
rrule: identity as Identity<RRuleOptions>,
rrule: identity as Identity<RRuleInput>,
exrule: identity as Identity<RRuleInputObject | RRuleInputObject[]>,
exdate: identity as Identity<DateInput | DateInput[]>,
duration: createDuration,
}
170 changes: 71 additions & 99 deletions packages/rrule/src/main.ts
@@ -1,4 +1,4 @@
import { RRule, RRuleSet, rrulestr } from 'rrule'
import { RRule, RRuleSet, rrulestr, Options as RRuleOptions } from 'rrule'
import {
RecurringType,
EventRefined,
Expand All @@ -7,162 +7,135 @@ import {
DateMarker,
createPlugin,
parseMarker,
DateInput,
} from '@fullcalendar/common'
import { RRULE_EVENT_REFINERS } from './event-refiners'
import { RRULE_EVENT_REFINERS, RRuleInputObject } from './event-refiners'
import './event-declare'

interface RRuleParsed {
interface EventRRuleData {
rruleSet: RRuleSet
isTimeZoneSpecified: boolean
}

let recurring: RecurringType<RRuleParsed> = {
let recurring: RecurringType<EventRRuleData> = {
parse(eventProps: EventRefined, dateEnv: DateEnv) {
if (eventProps.rrule != null) {
let eventRRuleData = parseEventRRule(eventProps, dateEnv)

parse(refined: EventRefined, dateEnv: DateEnv) {
if (refined.rrule != null) {
let parsed = parseRRule(refined.rrule, dateEnv)

if (parsed) {
if (eventRRuleData) {
return {
typeData: { rruleSet: parsed.rruleSet, isTimeZoneSpecified: parsed.isTimeZoneSpecified },
allDayGuess: !parsed.isTimeSpecified,
duration: refined.duration,
typeData: { rruleSet: eventRRuleData.rruleSet, isTimeZoneSpecified: eventRRuleData.isTimeZoneSpecified },
allDayGuess: !eventRRuleData.isTimeSpecified,
duration: eventProps.duration,
}
}
}

return null
},

expand(parsed: RRuleParsed, framingRange: DateRange, dateEnv: DateEnv): DateMarker[] {
expand(eventRRuleData: EventRRuleData, framingRange: DateRange, dateEnv: DateEnv): DateMarker[] {
let dates: DateMarker[]

if (parsed.isTimeZoneSpecified) {
dates = parsed.rruleSet.between(
if (eventRRuleData.isTimeZoneSpecified) {
dates = eventRRuleData.rruleSet.between(
dateEnv.toDate(framingRange.start), // rrule lib will treat as UTC-zoned
dateEnv.toDate(framingRange.end), // (same)
true, // inclusive (will give extra events at start, see https://github.com/jakubroztocil/rrule/issues/84)
).map((date) => dateEnv.createMarker(date)) // convert UTC-zoned-date to locale datemarker
} else {
// when no timezone in given start/end, the rrule lib will assume UTC,
// which is same as our DateMarkers. no need to manipulate
dates = parsed.rruleSet.between(
dates = eventRRuleData.rruleSet.between(
framingRange.start,
framingRange.end,
true, // inclusive (will give extra events at start, see https://github.com/jakubroztocil/rrule/issues/84)
)
}
return dates
},

}

export default createPlugin({
recurringTypes: [recurring],
eventRefiners: RRULE_EVENT_REFINERS,
})

function parseRRule(input, dateEnv: DateEnv) {
if (typeof input === 'string') {
return parseRRuleSetString(input)
}
function parseEventRRule(eventProps: EventRefined, dateEnv: DateEnv) {
let rruleSet: RRuleSet
let isTimeSpecified = false
let isTimeZoneSpecified = false

if (typeof input === 'object' && input) { // non-null object
return parseRRuleSetObject(input, dateEnv)
if (typeof eventProps.rrule === 'string') {
let res = parseRRuleString(eventProps.rrule)
rruleSet = res.rruleSet
isTimeSpecified = res.isTimeSpecified
isTimeZoneSpecified = res.isTimeZoneSpecified
}

return null
}

function parseRRuleSetObject(input, dateEnv: DateEnv) {
let { rrule, isTimeSpecified, isTimeZoneSpecified } = parseRRuleObject(input, dateEnv)
let exdateInputs: any[] = [].concat(input.exdate || []) // convert to array
let exruleInputs: any[] = [].concat(input.exrule || []) // convert to array
let rruleSet = new RRuleSet()
if (typeof eventProps.rrule === 'object' && eventProps.rrule) { // non-null object
let res = parseRRuleObject(eventProps.rrule, dateEnv)
rruleSet = new RRuleSet()
rruleSet.rrule(res.rrule)
isTimeSpecified = res.isTimeSpecified
isTimeZoneSpecified = res.isTimeZoneSpecified
}

rruleSet.rrule(rrule)
// convery to arrays. TODO: general util?
let exdateInputs: DateInput[] = [].concat(eventProps.exdate || [])
let exruleInputs: RRuleInputObject[] = [].concat(eventProps.exrule || [])

for (let exdateInput of exdateInputs) {
let exdateRes = parseMarker(exdateInput)

// TODO: not DRY
isTimeSpecified = isTimeSpecified || !exdateRes.isTimeUnspecified
isTimeZoneSpecified = isTimeZoneSpecified || exdateRes.timeZoneOffset !== null
let res = parseMarker(exdateInput)
isTimeSpecified = isTimeSpecified || !res.isTimeUnspecified
isTimeZoneSpecified = isTimeZoneSpecified || res.timeZoneOffset !== null
rruleSet.exdate(
new Date(exdateRes.marker.valueOf() - (exdateRes.timeZoneOffset || 0) * 60 * 1000),
new Date(res.marker.valueOf() - (res.timeZoneOffset || 0) * 60 * 1000), // NOT DRY
)
}

// TODO: exrule is deprecated. what to do? (https://icalendar.org/iCalendar-RFC-5545/a-3-deprecated-features.html)
for (let exruleInput of exruleInputs) {
let exruleRes = parseRRuleObject(exruleInput, dateEnv)

isTimeSpecified = isTimeSpecified || exruleRes.isTimeSpecified
isTimeZoneSpecified = isTimeZoneSpecified || exruleRes.isTimeZoneSpecified

rruleSet.exrule(exruleRes.rrule)
let res = parseRRuleObject(exruleInput, dateEnv)
isTimeSpecified = isTimeSpecified || res.isTimeSpecified
isTimeZoneSpecified = isTimeZoneSpecified || res.isTimeZoneSpecified
rruleSet.exrule(res.rrule)
}

return { rruleSet, isTimeSpecified, isTimeZoneSpecified }
}

function parseRRuleObject(input, dateEnv: DateEnv) {
function parseRRuleObject(rruleInput: RRuleInputObject, dateEnv: DateEnv) {
let isTimeSpecified = false
let isTimeZoneSpecified = false
let refined = { ...input } // copy

// TODO: weird to blacklist these here
delete refined.exdate
delete refined.exrule

if (typeof refined.dtstart === 'string') {
let result = parseMarker(refined.dtstart)

if (result) {
// TODO: not DRY
isTimeSpecified = isTimeSpecified || !result.isTimeUnspecified
isTimeZoneSpecified = isTimeZoneSpecified || result.timeZoneOffset !== null
refined.dtstart = new Date(result.marker.valueOf() - (result.timeZoneOffset || 0) * 60 * 1000)
} else { // invalid
delete refined.dtstart // best idea?
}
}

if (typeof refined.until === 'string') {
let result = parseMarker(refined.until)

if (result) {
// TODO: not DRY
isTimeSpecified = isTimeSpecified || !result.isTimeUnspecified
isTimeZoneSpecified = isTimeZoneSpecified || result.timeZoneOffset !== null
refined.until = new Date(result.marker.valueOf() - (result.timeZoneOffset || 0) * 60 * 1000)
} else { // invalid
delete refined.until // best idea?
function processDateInput(dateInput: DateInput) {
if (typeof dateInput === 'string') {
let markerData = parseMarker(dateInput)
if (markerData) {
isTimeSpecified = isTimeSpecified || !markerData.isTimeUnspecified
isTimeZoneSpecified = isTimeZoneSpecified || markerData.timeZoneOffset !== null
return new Date(markerData.marker.valueOf() - (markerData.timeZoneOffset || 0) * 60 * 1000) // NOT DRY
}
return null
}
return dateInput as Date // TODO: what about number timestamps?
}

if (refined.freq != null) {
refined.freq = convertConstant(refined.freq)
let rruleOptions: Partial<RRuleOptions> = {
...rruleInput,
dtstart: processDateInput(rruleInput.dtstart),
until: processDateInput(rruleInput.until),
freq: convertConstant(rruleInput.freq),
wkst: rruleInput.wkst == null
? (dateEnv.weekDow - 1 + 7) % 7 // convert Sunday-first to Monday-first
: convertConstant(rruleInput.wkst),
byweekday: convertConstants(rruleInput.wkst),
}

if (refined.wkst != null) {
refined.wkst = convertConstant(refined.wkst)
} else {
refined.wkst = (dateEnv.weekDow - 1 + 7) % 7 // convert Sunday-first to Monday-first
}

if (refined.byweekday != null) {
refined.byweekday = convertConstants(refined.byweekday) // the plural version
}

return {
rrule: new RRule(refined),
isTimeSpecified,
isTimeZoneSpecified,
}
return { rrule: new RRule(rruleOptions), isTimeSpecified, isTimeZoneSpecified }
}

function parseRRuleSetString(str) {
function parseRRuleString(str) {
let rruleSet = rrulestr(str, { forceset: true }) as RRuleSet
let analysis = analyzeRRuleString(str)

Expand All @@ -173,28 +146,27 @@ function analyzeRRuleString(str) {
let isTimeSpecified = false
let isTimeZoneSpecified = false

function process(whole: string, introPart: string, datePart: string) {
// TODO: not DRY
function processMatch(whole: string, introPart: string, datePart: string) {
let result = parseMarker(datePart)
isTimeSpecified = isTimeSpecified || !result.isTimeUnspecified
isTimeZoneSpecified = isTimeZoneSpecified || result.timeZoneOffset !== null
}

str.replace(/\b(DTSTART:)([^\n]*)/, process)
str.replace(/\b(EXDATE:)([^\n]*)/, process)
str.replace(/\b(UNTIL=)([^;]*)/, process)
str.replace(/\b(DTSTART:)([^\n]*)/, processMatch)
str.replace(/\b(EXDATE:)([^\n]*)/, processMatch)
str.replace(/\b(UNTIL=)([^;]*)/, processMatch)

return { isTimeSpecified, isTimeZoneSpecified }
}

function convertConstants(input) {
function convertConstants(input): number | null | number[] | null[] {
if (Array.isArray(input)) {
return input.map(convertConstant)
}
return convertConstant(input)
}

function convertConstant(input) {
function convertConstant(input): number | null {
if (typeof input === 'string') {
return RRule[input.toUpperCase()]
}
Expand Down

0 comments on commit dd9d225

Please sign in to comment.