calendar/src/models/recurrenceRule.js

504 lines
15 KiB
JavaScript

/**
* @copyright Copyright (c) 2020 Georg Ehrke
*
* @author Georg Ehrke <oc.list@georgehrke.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { getWeekDayFromDate } from '../utils/recurrence.js'
import { getDateFromDateTimeValue } from '../utils/date.js'
/**
* Creates a complete recurrence-rule-object based on given props
*
* @param {Object} props Recurrence-rule-object-props already provided
* @returns {Object}
*/
const getDefaultRecurrenceRuleObject = (props = {}) => Object.assign({}, {
// The calendar-js recurrence-rule value
recurrenceRuleValue: null,
// The frequency of the recurrence-rule (DAILY, WEEKLY, ...)
frequency: 'NONE',
// The interval of the recurrence-rule, must be a positive integer
interval: 1,
// Positive integer if recurrence-rule limited by count, null otherwise
count: null,
// Date if recurrence-rule limited by date, null otherwise
// We do not store a timezone here, since we only care about the date part
until: null,
// List of byDay components to limit/expand the recurrence-rule
byDay: [],
// List of byMonth components to limit/expand the recurrence-rule
byMonth: [],
// List of byMonthDay components to limit/expand the recurrence-rule
byMonthDay: [],
// A position to limit the recurrence-rule (e.g. -1 for last Friday)
bySetPosition: null,
// Whether or not the rule is not supported for editing
isUnsupported: false,
}, props)
/**
* Maps a calendar-js recurrence-rule-value to an recurrence-rule-object
*
* @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value
* @param {DateTimeValue} baseDate The base-date used to fill unset values
* @returns {Object}
*/
const mapRecurrenceRuleValueToRecurrenceRuleObject = (recurrenceRuleValue, baseDate) => {
switch (recurrenceRuleValue.frequency) {
case 'DAILY':
return mapDailyRuleValueToRecurrenceRuleObject(recurrenceRuleValue)
case 'WEEKLY':
return mapWeeklyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate)
case 'MONTHLY':
return mapMonthlyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate)
case 'YEARLY':
return mapYearlyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate)
default: // SECONDLY, MINUTELY, HOURLY
return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, {
isUnsupported: true,
})
}
}
const FORBIDDEN_BY_PARTS_DAILY = [
'BYSECOND',
'BYMINUTE',
'BYHOUR',
'BYDAY',
'BYMONTHDAY',
'BYYEARDAY',
'BYWEEKNO',
'BYMONTH',
'BYSETPOS',
]
const FORBIDDEN_BY_PARTS_WEEKLY = [
'BYSECOND',
'BYMINUTE',
'BYHOUR',
'BYMONTHDAY',
'BYYEARDAY',
'BYWEEKNO',
'BYMONTH',
'BYSETPOS',
]
const FORBIDDEN_BY_PARTS_MONTHLY = [
'BYSECOND',
'BYMINUTE',
'BYHOUR',
'BYYEARDAY',
'BYWEEKNO',
'BYMONTH',
]
const FORBIDDEN_BY_PARTS_YEARLY = [
'BYSECOND',
'BYMINUTE',
'BYHOUR',
'BYMONTHDAY',
'BYYEARDAY',
'BYWEEKNO',
]
const SUPPORTED_BY_DAY_WEEKLY = [
'SU',
'MO',
'TU',
'WE',
'TH',
'FR',
'SA',
]
/**
* Get all numbers between start and end as strings
*
* @param {Number} start Lower end of range
* @param {Number} end Upper end of range
* @returns {string[]}
*/
const getRangeAsStrings = (start, end) => {
return Array
.apply(null, Array((end - start) + 1))
.map((_, n) => n + start)
.map((s) => s.toString())
}
const SUPPORTED_BY_MONTHDAY_MONTHLY = getRangeAsStrings(1, 31)
const SUPPORTED_BY_MONTH_YEARLY = getRangeAsStrings(1, 12)
/**
* Maps a daily calendar-js recurrence-rule-value to an recurrence-rule-object
*
* @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value
* @returns {Object}
*/
const mapDailyRuleValueToRecurrenceRuleObject = (recurrenceRuleValue) => {
/**
* We only support DAILY rules without any by-parts in the editor.
* If the recurrence-rule contains any by-parts, mark it as unsupported.
*/
const isUnsupported = containsRecurrenceComponent(recurrenceRuleValue, FORBIDDEN_BY_PARTS_DAILY)
return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, {
isUnsupported,
})
}
/**
* Maps a weekly calendar-js recurrence-rule-value to an recurrence-rule-object
*
* @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value
* @param {DateTimeValue} baseDate The base-date used to fill unset values
* @returns {Object}
*/
const mapWeeklyRuleValueToRecurrenceRuleObject = (recurrenceRuleValue, baseDate) => {
/**
* For WEEKLY recurrences, our editor only allows BYDAY
*
* As defined in RFC5545 3.3.10. Recurrence Rule:
* > Each BYDAY value can also be preceded by a positive (+n) or
* > negative (-n) integer. If present, this indicates the nth
* > occurrence of a specific day within the MONTHLY or YEARLY "RRULE".
*
* RFC 5545 specifies other components, which can be used along WEEKLY.
* Among them are BYMONTH and BYSETPOS. We don't support those.
*/
const containsUnsupportedByParts = containsRecurrenceComponent(recurrenceRuleValue, FORBIDDEN_BY_PARTS_WEEKLY)
const containsInvalidByDayPart = recurrenceRuleValue.getComponent('BYDAY')
.some((weekday) => !SUPPORTED_BY_DAY_WEEKLY.includes(weekday))
const isUnsupported = containsUnsupportedByParts || containsInvalidByDayPart
const byDay = recurrenceRuleValue.getComponent('BYDAY')
.filter((weekday) => SUPPORTED_BY_DAY_WEEKLY.includes(weekday))
// If the BYDAY is empty, add the day that the event occurs in
// E.g. if the event is on a Wednesday, automatically set BYDAY:WE
if (byDay.length === 0) {
byDay.push(getWeekDayFromDate(baseDate.jsDate))
}
return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, {
byDay,
isUnsupported,
})
}
/**
* Maps a monthly calendar-js recurrence-rule-value to an recurrence-rule-object
*
* @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value
* @param {DateTimeValue} baseDate The base-date used to fill unset values
* @returns {Object}
*/
const mapMonthlyRuleValueToRecurrenceRuleObject = (recurrenceRuleValue, baseDate) => {
/**
* We only supports BYMONTHDAY, BYDAY, BYSETPOS in order to expand the monthly rule.
* It supports either BYMONTHDAY or the combination of BYDAY and BYSETPOS. They have to be used exclusively
* and cannot be combined.
*
* We do not support other BY-parts like BYMONTH
*
* For monthly recurrence-rules, BYDAY components are allowed to be preceded by positive or negative integers.
* The Nextcloud-editor supports at most one BYDAY component with an integer.
* If it's presented with such a BYDAY component, it will internally be converted to BYDAY without integer and BYSETPOS.
* e.g.
* BYDAY=3WE => BYDAY=WE,BYSETPOS=3
*
* BYSETPOS is limited to -2, -1, 1, 2, 3, 4, 5
* Other values are not supported
*
* BYDAY is limited to "MO", "TU", "WE", "TH", "FR", "SA", "SU",
* "MO,TU,WE,TH,FR,SA,SU", "MO,TU,WE,TH,FR", "SA,SU"
*
* BYMONTHDAY is limited to "1", "2", ..., "31"
*/
let isUnsupported = containsRecurrenceComponent(recurrenceRuleValue, FORBIDDEN_BY_PARTS_MONTHLY)
let byDay = []
let bySetPosition = null
let byMonthDay = []
// This handles the first case, where we have a BYMONTHDAY rule
if (containsRecurrenceComponent(recurrenceRuleValue, ['BYMONTHDAY'])) {
// verify there is no BYDAY or BYSETPOS at the same time
if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY', 'BYSETPOS'])) {
isUnsupported = true
}
const containsInvalidByMonthDay = recurrenceRuleValue.getComponent('BYMONTHDAY')
.some((monthDay) => !SUPPORTED_BY_MONTHDAY_MONTHLY.includes(monthDay.toString()))
isUnsupported = isUnsupported || containsInvalidByMonthDay
byMonthDay = recurrenceRuleValue.getComponent('BYMONTHDAY')
.filter((monthDay) => SUPPORTED_BY_MONTHDAY_MONTHLY.includes(monthDay.toString()))
.map((monthDay) => monthDay.toString())
// This handles cases where we have both BYDAY and BYSETPOS
} else if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY']) && containsRecurrenceComponent(recurrenceRuleValue, ['BYSETPOS'])) {
if (isAllowedByDay(recurrenceRuleValue.getComponent('BYDAY'))) {
byDay = recurrenceRuleValue.getComponent('BYDAY')
} else {
byDay = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
isUnsupported = true
}
const setPositionArray = recurrenceRuleValue.getComponent('BYSETPOS')
if (setPositionArray.length === 1 && isAllowedBySetPos(setPositionArray[0])) {
bySetPosition = setPositionArray[0]
} else {
bySetPosition = 1
isUnsupported = true
}
// This handles cases where we only have a BYDAY
} else if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY'])) {
const byDayArray = recurrenceRuleValue.getComponent('BYDAY')
if (byDayArray.length > 1) {
byMonthDay.push(baseDate.day.toString())
isUnsupported = true
} else {
const firstElement = byDayArray[0]
const match = /^(-?\d)([A-Z]{2})$/.exec(firstElement)
if (match) {
const matchedBySetPosition = match[1]
const matchedByDay = match[2]
if (isAllowedBySetPos(matchedBySetPosition)) {
byDay = [matchedByDay]
bySetPosition = parseInt(matchedBySetPosition, 10)
} else {
byDay = [matchedByDay]
bySetPosition = 1
isUnsupported = true
}
} else {
byMonthDay.push(baseDate.day.toString())
isUnsupported = true
}
}
// This is a fallback where we just default BYMONTHDAY to the start date of the event
} else {
byMonthDay.push(baseDate.day.toString())
}
return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, {
byDay,
bySetPosition,
byMonthDay,
isUnsupported,
})
}
/**
* Maps a yearly calendar-js recurrence-rule-value to an recurrence-rule-object
*
* @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value
* @param {DateTimeValue} baseDate The base-date used to fill unset values
* @returns {Object}
*/
const mapYearlyRuleValueToRecurrenceRuleObject = (recurrenceRuleValue, baseDate) => {
/**
* We only supports BYMONTH, BYDAY, BYSETPOS in order to expand the yearly rule.
* It supports a combination of them.
*
* We do not support other BY-parts.
*
* For yearly recurrence-rules, BYDAY components are allowed to be preceded by positive or negative integers.
* The Nextcloud-editor supports at most one BYDAY component with an integer.
* If it's presented with such a BYDAY component, it will internally be converted to BYDAY without integer and BYSETPOS.
* e.g.
* BYDAY=3WE => BYDAY=WE,BYSETPOS=3
*
* BYSETPOS is limited to -2, -1, 1, 2, 3, 4, 5
* Other values are not supported
*
* BYDAY is limited to "MO", "TU", "WE", "TH", "FR", "SA", "SU",
* "MO,TU,WE,TH,FR,SA,SU", "MO,TU,WE,TH,FR", "SA,SU"
*/
let isUnsupported = containsRecurrenceComponent(recurrenceRuleValue, FORBIDDEN_BY_PARTS_YEARLY)
let byDay = []
let bySetPosition = null
let byMonth = []
if (containsRecurrenceComponent(recurrenceRuleValue, ['BYMONTH'])) {
const containsInvalidByMonthDay = recurrenceRuleValue.getComponent('BYMONTH')
.some((month) => !SUPPORTED_BY_MONTH_YEARLY.includes(month.toString()))
isUnsupported = isUnsupported || containsInvalidByMonthDay
byMonth = recurrenceRuleValue.getComponent('BYMONTH')
.filter((monthDay) => SUPPORTED_BY_MONTH_YEARLY.includes(monthDay.toString()))
.map((month) => month.toString())
} else {
byMonth.push(baseDate.month.toString())
}
if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY']) && containsRecurrenceComponent(recurrenceRuleValue, ['BYSETPOS'])) {
if (isAllowedByDay(recurrenceRuleValue.getComponent('BYDAY'))) {
byDay = recurrenceRuleValue.getComponent('BYDAY')
} else {
byDay = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
isUnsupported = true
}
const setPositionArray = recurrenceRuleValue.getComponent('BYSETPOS')
if (setPositionArray.length === 1 && isAllowedBySetPos(setPositionArray[0])) {
bySetPosition = setPositionArray[0]
} else {
bySetPosition = 1
isUnsupported = true
}
} else if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY'])) {
const byDayArray = recurrenceRuleValue.getComponent('BYDAY')
if (byDayArray.length > 1) {
isUnsupported = true
} else {
const firstElement = byDayArray[0]
const match = /^(-?\d)([A-Z]{2})$/.exec(firstElement)
if (match) {
const matchedBySetPosition = match[1]
const matchedByDay = match[2]
if (isAllowedBySetPos(matchedBySetPosition)) {
byDay = [matchedByDay]
bySetPosition = parseInt(matchedBySetPosition, 10)
} else {
byDay = [matchedByDay]
bySetPosition = 1
isUnsupported = true
}
} else {
isUnsupported = true
}
}
}
return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, {
byDay,
bySetPosition,
byMonth,
isUnsupported,
})
}
/**
* Checks if the given parameter is a supported BYDAY value
*
* @param {String[]} byDay The byDay component to check
* @returns {Boolean}
*/
const isAllowedByDay = (byDay) => {
return [
'MO',
'TU',
'WE',
'TH',
'FR',
'SA',
'SU',
'FR,MO,SA,SU,TH,TU,WE',
'FR,MO,TH,TU,WE',
'SA,SU',
].includes(byDay.slice().sort().join(','))
}
/**
* Checks if the given parameter is a supported BYSETPOS value
*
* @param {String} bySetPos The bySetPos component to check
* @returns {Boolean}
*/
const isAllowedBySetPos = (bySetPos) => {
return [
'-2',
'-1',
'1',
'2',
'3',
'4',
'5',
].includes(bySetPos.toString())
}
/**
* Checks if the recurrence-rule contains any of the given components
*
* @param {RecurValue} recurrenceRule The recurrence-rule value to check for the given components
* @param {String[]} components List of components to check for
* @returns {Boolean}
*/
const containsRecurrenceComponent = (recurrenceRule, components) => {
for (const component of components) {
const componentValue = recurrenceRule.getComponent(component)
if (componentValue.length > 0) {
return true
}
}
return false
}
/**
* Returns a full recurrence-rule-object with default values derived from recurrenceRuleValue
* and additional props
*
* @param {RecurValue} recurrenceRuleValue The recurrence-rule value to get default values from
* @param {Object} props The properties to provide on top of default one
* @returns {Object}
*/
const getDefaultRecurrenceRuleObjectForRecurrenceValue = (recurrenceRuleValue, props) => {
const isUnsupported = recurrenceRuleValue.count !== null && recurrenceRuleValue.until !== null
let isUnsupportedProps = {}
if (isUnsupported) {
isUnsupportedProps = {
isUnsupported,
}
}
return getDefaultRecurrenceRuleObject(Object.assign({}, {
recurrenceRuleValue,
frequency: recurrenceRuleValue.frequency,
interval: parseInt(recurrenceRuleValue.interval, 10) || 1,
count: recurrenceRuleValue.count,
until: recurrenceRuleValue.until
? getDateFromDateTimeValue(recurrenceRuleValue.until)
: null,
}, props, isUnsupportedProps))
}
export {
getDefaultRecurrenceRuleObject,
mapRecurrenceRuleValueToRecurrenceRuleObject,
}