mar-rrule
Version:
JavaScript library for working with recurrence rules for calendar dates.
979 lines (849 loc) • 26.2 kB
JavaScript
/*!
* rrule.js - Library for working with recurrence rules for calendar dates.
* https://github.com/jkbrzt/rrule
*
* Copyright 2010, Jakub Roztocil and Lars Schoning
* Licenced under the BSD licence.
* https://github.com/jkbrzt/rrule/blob/master/LICENCE
*
*/
/**
*
* Implementation of RRule.fromText() and RRule::toText().
*
*
* On the client side, this file needs to be included
* when those functions are used.
*
*/
/* global module, define */
// =============================================================================
// Helper functions
// =============================================================================
/**
* Return true if a value is in an array
*/
var contains = function (arr, val) {
return arr.indexOf(val) !== -1
}
module.exports = function (RRule) {
// =============================================================================
// ToText
// =============================================================================
/**
*
* @param {RRule} rrule
* Optional:
* @param {Function} gettext function
* @param {Object} language definition
* @constructor
*/
var ToText = function (rrule, gettext, language) {
this.text = ''
this.language = language || ENGLISH
this.gettext = gettext || function (id) {
return id
}
this.rrule = rrule
this.freq = rrule.options.freq
this.options = rrule.options
this.origOptions = rrule.origOptions
if (this.origOptions.bymonthday) {
var bymonthday = [].concat(this.options.bymonthday)
var bynmonthday = [].concat(this.options.bynmonthday)
bymonthday.sort()
bynmonthday.sort()
bynmonthday.reverse()
// 1, 2, 3, .., -5, -4, -3, ..
this.bymonthday = bymonthday.concat(bynmonthday)
if (!this.bymonthday.length) this.bymonthday = null
}
if (this.origOptions.byweekday) {
var byweekday = !(this.origOptions.byweekday instanceof Array)
? [this.origOptions.byweekday] : this.origOptions.byweekday
var days = String(byweekday)
this.byweekday = {
allWeeks: byweekday.filter(function (weekday) {
return !Boolean(weekday.n)
}),
someWeeks: byweekday.filter(function (weekday) {
return Boolean(weekday.n)
}),
isWeekdays: (
days.indexOf('MO') !== -1 &&
days.indexOf('TU') !== -1 &&
days.indexOf('WE') !== -1 &&
days.indexOf('TH') !== -1 &&
days.indexOf('FR') !== -1 &&
days.indexOf('SA') === -1 &&
days.indexOf('SU') === -1
)
}
var sortWeekDays = function (a, b) {
return a.weekday - b.weekday
}
this.byweekday.allWeeks.sort(sortWeekDays)
this.byweekday.someWeeks.sort(sortWeekDays)
if (!this.byweekday.allWeeks.length) this.byweekday.allWeeks = null
if (!this.byweekday.someWeeks.length) this.byweekday.someWeeks = null
} else {
this.byweekday = null
}
}
var common = [
'count', 'until', 'interval',
'byweekday', 'bymonthday', 'bymonth'
]
ToText.IMPLEMENTED = []
ToText.IMPLEMENTED[RRule.HOURLY] = common
ToText.IMPLEMENTED[RRule.DAILY] = ['byhour'].concat(common)
ToText.IMPLEMENTED[RRule.WEEKLY] = common
ToText.IMPLEMENTED[RRule.MONTHLY] = common
ToText.IMPLEMENTED[RRule.YEARLY] = ['byweekno', 'byyearday'].concat(common)
/**
* Test whether the rrule can be fully converted to text.
* @param {RRule} rrule
* @return {Boolean}
*/
ToText.isFullyConvertible = function (rrule) {
var canConvert = true
if (!(rrule.options.freq in ToText.IMPLEMENTED)) return false
if (rrule.origOptions.until && rrule.origOptions.count) return false
for (var key in rrule.origOptions) {
if (contains(['dtstart', 'wkst', 'freq'], key)) return true
if (!contains(ToText.IMPLEMENTED[rrule.options.freq], key)) return false
}
return canConvert
}
ToText.prototype = {
constructor: ToText,
isFullyConvertible: function () {
return ToText.isFullyConvertible(this.rrule)
},
/**
* Perform the conversion. Only some of the frequencies are supported.
* If some of the rrule's options aren't supported, they'll
* be omitted from the output an "(~ approximate)" will be appended.
* @return {*}
*/
toString: function () {
var gettext = this.gettext
if (!(this.options.freq in ToText.IMPLEMENTED)) {
return gettext('RRule error: Unable to fully convert this rrule to text')
}
this.text = [gettext('every')]
this[RRule.FREQUENCIES[this.options.freq]]()
if (this.options.until) {
this.add(gettext('until'))
var until = this.options.until
this.add(this.language.monthNames[until.getMonth()])
.add(until.getDate() + ',')
.add(until.getFullYear())
} else if (this.options.count) {
this.add(gettext('for'))
.add(this.options.count)
.add(this.plural(this.options.count)
? gettext('times') : gettext('time'))
}
if (!this.isFullyConvertible()) this.add(gettext('(~ approximate)'))
return this.text.join('')
},
HOURLY: function () {
var gettext = this.gettext
if (this.options.interval !== 1) this.add(this.options.interval)
this.add(this.plural(this.options.interval)
? gettext('hours') : gettext('hour'))
},
DAILY: function () {
var gettext = this.gettext
if (this.options.interval !== 1) this.add(this.options.interval)
if (this.byweekday && this.byweekday.isWeekdays) {
this.add(this.plural(this.options.interval)
? gettext('weekdays') : gettext('weekday'))
} else {
this.add(this.plural(this.options.interval)
? gettext('days') : gettext('day'))
}
if (this.origOptions.bymonth) {
this.add(gettext('in'))
this._bymonth()
}
if (this.bymonthday) {
this._bymonthday()
} else if (this.byweekday) {
this._byweekday()
} else if (this.origOptions.byhour) {
this._byhour()
}
},
WEEKLY: function () {
var gettext = this.gettext
if (this.options.interval !== 1) {
this.add(this.options.interval)
.add(this.plural(this.options.interval)
? gettext('weeks') : gettext('week'))
}
if (this.byweekday && this.byweekday.isWeekdays) {
if (this.options.interval === 1) {
this.add(this.plural(this.options.interval)
? gettext('weekdays') : gettext('weekday'))
} else {
this.add(gettext('on')).add(gettext('weekdays'))
}
} else {
if (this.options.interval === 1) this.add(gettext('week'))
if (this.origOptions.bymonth) {
this.add(gettext('in'))
this._bymonth()
}
if (this.bymonthday) {
this._bymonthday()
} else if (this.byweekday) {
this._byweekday()
}
}
},
MONTHLY: function () {
var gettext = this.gettext
if (this.origOptions.bymonth) {
if (this.options.interval !== 1) {
this.add(this.options.interval).add(gettext('months'))
if (this.plural(this.options.interval)) this.add(gettext('in'))
} else {
// this.add(gettext('MONTH'))
}
this._bymonth()
} else {
if (this.options.interval !== 1) this.add(this.options.interval)
this.add(this.plural(this.options.interval)
? gettext('months') : gettext('month'))
}
if (this.bymonthday) {
this._bymonthday()
} else if (this.byweekday && this.byweekday.isWeekdays) {
this.add(gettext('on')).add(gettext('weekdays'))
} else if (this.byweekday) {
this._byweekday()
}
},
YEARLY: function () {
var gettext = this.gettext
if (this.origOptions.bymonth) {
if (this.options.interval !== 1) {
this.add(this.options.interval)
this.add(gettext('years'))
} else {
// this.add(gettext('YEAR'))
}
this._bymonth()
} else {
if (this.options.interval !== 1) this.add(this.options.interval)
this.add(this.plural(this.options.interval)
? gettext('years') : gettext('year'))
}
if (this.bymonthday) {
this._bymonthday()
} else if (this.byweekday) {
this._byweekday()
}
if (this.options.byyearday) {
this.add(gettext('on the'))
.add(this.list(this.options.byyearday, this.nth, gettext('and')))
.add(gettext('day'))
}
if (this.options.byweekno) {
this.add(gettext('in'))
.add(this.plural(this.options.byweekno.length) ? gettext('weeks') : gettext('week'))
.add(this.list(this.options.byweekno, null, gettext('and')))
}
},
_bymonthday: function () {
var gettext = this.gettext
if (this.byweekday && this.byweekday.allWeeks) {
this.add(gettext('on'))
.add(this.list(this.byweekday.allWeeks, this.weekdaytext, gettext('or')))
.add(gettext('the'))
.add(this.list(this.bymonthday, this.nth, gettext('or')))
} else {
this.add(gettext('on the'))
.add(this.list(this.bymonthday, this.nth, gettext('and')))
}
// this.add(gettext('DAY'))
},
_byweekday: function () {
var gettext = this.gettext
if (this.byweekday.allWeeks && !this.byweekday.isWeekdays) {
this.add(gettext('on'))
.add(this.list(this.byweekday.allWeeks, this.weekdaytext))
}
if (this.byweekday.someWeeks) {
if (this.byweekday.allWeeks) this.add(gettext('and'))
this.add(gettext('on the'))
.add(this.list(this.byweekday.someWeeks, this.weekdaytext, gettext('and')))
}
},
_byhour: function () {
var gettext = this.gettext
this.add(gettext('at'))
.add(this.list(this.origOptions.byhour, null, gettext('and')))
},
_bymonth: function () {
this.add(this.list(this.options.bymonth, this.monthtext, this.gettext('and')))
},
nth: function (n) {
var nth, npos
var gettext = this.gettext
if (n === -1) return gettext('last')
npos = Math.abs(n)
switch (npos) {
case 1:
case 21:
case 31:
nth = npos + gettext('st')
break
case 2:
case 22:
nth = npos + gettext('nd')
break
case 3:
case 23:
nth = npos + gettext('rd')
break
default:
nth = npos + gettext('th')
}
return n < 0 ? nth + ' ' + gettext('last') : nth
},
monthtext: function (m) {
return this.language.monthNames[m - 1]
},
weekdaytext: function (wday) {
var weekday = typeof wday === 'number' ? wday : wday.getJsWeekday()
return (wday.n ? this.nth(wday.n) + ' ' : '') +
this.language.dayNames[weekday]
},
plural: function (n) {
return n % 100 !== 1
},
add: function (s) {
this.text.push(' ')
this.text.push(s)
return this
},
list: function (arr, callback, finalDelim, delim) {
var delimJoin = function (array, delimiter, finalDelimiter) {
var list = ''
for (var i = 0; i < array.length; i++) {
if (i !== 0) {
if (i === array.length - 1) {
list += ' ' + finalDelimiter + ' '
} else {
list += delimiter + ' '
}
}
list += array[i]
}
return list
}
delim = delim || ','
callback = callback || function (o) {
return o
}
var self = this
var realCallback = function (arg) {
return callback.call(self, arg)
}
if (finalDelim) {
return delimJoin(arr.map(realCallback), delim, finalDelim)
} else {
return arr.map(realCallback).join(delim + ' ')
}
}
}
// =============================================================================
// fromText
// =============================================================================
/**
* Will be able to convert some of the below described rules from
* text format to a rule object.
*
*
* RULES
*
* Every ([n])
* day(s)
* | [weekday], ..., (and) [weekday]
* | weekday(s)
* | week(s)
* | month(s)
* | [month], ..., (and) [month]
* | year(s)
*
*
* Plus 0, 1, or multiple of these:
*
* on [weekday], ..., (or) [weekday] the [monthday], [monthday], ... (or) [monthday]
*
* on [weekday], ..., (and) [weekday]
*
* on the [monthday], [monthday], ... (and) [monthday] (day of the month)
*
* on the [nth-weekday], ..., (and) [nth-weekday] (of the month/year)
*
*
* Plus 0 or 1 of these:
*
* for [n] time(s)
*
* until [date]
*
* Plus (.)
*
*
* Definitely no supported for parsing:
*
* (for year):
* in week(s) [n], ..., (and) [n]
*
* on the [yearday], ..., (and) [n] day of the year
* on day [yearday], ..., (and) [n]
*
*
* NON-TERMINALS
*
* [n]: 1, 2 ..., one, two, three ..
* [month]: January, February, March, April, May, ... December
* [weekday]: Monday, ... Sunday
* [nth-weekday]: first [weekday], 2nd [weekday], ... last [weekday], ...
* [monthday]: first, 1., 2., 1st, 2nd, second, ... 31st, last day, 2nd last day, ..
* [date]:
* [month] (0-31(,) ([year])),
* (the) 0-31.(1-12.([year])),
* (the) 0-31/(1-12/([year])),
* [weekday]
*
* [year]: 0000, 0001, ... 01, 02, ..
*
* Definitely not supported for parsing:
*
* [yearday]: first, 1., 2., 1st, 2nd, second, ... 366th, last day, 2nd last day, ..
*
* @param {String} text
* @return {Object, Boolean} the rule, or null.
*/
var fromText = function (text, language) {
return new RRule(parseText(text, language))
}
var parseText = function (text, language) {
var options = {}
var ttr = new Parser((language || ENGLISH).tokens)
if (!ttr.start(text)) return null
S()
return options
function S () {
// every [n]
var n
ttr.expect('every')
if ((n = ttr.accept('number'))) options.interval = parseInt(n[0], 10)
if (ttr.isDone()) throw new Error('Unexpected end')
switch (ttr.symbol) {
case 'day(s)':
options.freq = RRule.DAILY
if (ttr.nextSymbol()) {
AT()
F()
}
break
// FIXME Note: every 2 weekdays != every two weeks on weekdays.
// DAILY on weekdays is not a valid rule
case 'weekday(s)':
options.freq = RRule.WEEKLY
options.byweekday = [
RRule.MO,
RRule.TU,
RRule.WE,
RRule.TH,
RRule.FR
]
ttr.nextSymbol()
F()
break
case 'week(s)':
options.freq = RRule.WEEKLY
if (ttr.nextSymbol()) {
ON()
F()
}
break
case 'hour(s)':
options.freq = RRule.HOURLY
if (ttr.nextSymbol()) {
ON()
F()
}
break
case 'month(s)':
options.freq = RRule.MONTHLY
if (ttr.nextSymbol()) {
ON()
F()
}
break
case 'year(s)':
options.freq = RRule.YEARLY
if (ttr.nextSymbol()) {
ON()
F()
}
break
case 'monday':
case 'tuesday':
case 'wednesday':
case 'thursday':
case 'friday':
case 'saturday':
case 'sunday':
options.freq = RRule.WEEKLY
options.byweekday = [RRule[ttr.symbol.substr(0, 2).toUpperCase()]]
if (!ttr.nextSymbol()) return
// TODO check for duplicates
while (ttr.accept('comma')) {
if (ttr.isDone()) throw new Error('Unexpected end')
var wkd
if (!(wkd = decodeWKD())) {
throw new Error('Unexpected symbol ' + ttr.symbol + ', expected weekday')
}
options.byweekday.push(RRule[wkd])
ttr.nextSymbol()
}
MDAYs()
F()
break
case 'january':
case 'february':
case 'march':
case 'april':
case 'may':
case 'june':
case 'july':
case 'august':
case 'september':
case 'october':
case 'november':
case 'december':
options.freq = RRule.YEARLY
options.bymonth = [decodeM()]
if (!ttr.nextSymbol()) return
// TODO check for duplicates
while (ttr.accept('comma')) {
if (ttr.isDone()) throw new Error('Unexpected end')
var m
if (!(m = decodeM())) {
throw new Error('Unexpected symbol ' + ttr.symbol + ', expected month')
}
options.bymonth.push(m)
ttr.nextSymbol()
}
ON()
F()
break
default:
throw new Error('Unknown symbol')
}
}
function ON () {
var on = ttr.accept('on')
var the = ttr.accept('the')
if (!(on || the)) return
do {
var nth, wkd, m
// nth <weekday> | <weekday>
if ((nth = decodeNTH())) {
// ttr.nextSymbol()
if ((wkd = decodeWKD())) {
ttr.nextSymbol()
if (!options.byweekday) options.byweekday = []
options.byweekday.push(RRule[wkd].nth(nth))
} else {
if (!options.bymonthday) options.bymonthday = []
options.bymonthday.push(nth)
ttr.accept('day(s)')
}
// <weekday>
} else if ((wkd = decodeWKD())) {
ttr.nextSymbol()
if (!options.byweekday) options.byweekday = []
options.byweekday.push(RRule[wkd])
} else if (ttr.symbol === 'weekday(s)') {
ttr.nextSymbol()
if (!options.byweekday) options.byweekday = []
options.byweekday.push(RRule.MO)
options.byweekday.push(RRule.TU)
options.byweekday.push(RRule.WE)
options.byweekday.push(RRule.TH)
options.byweekday.push(RRule.FR)
} else if (ttr.symbol === 'week(s)') {
ttr.nextSymbol()
var n
if (!(n = ttr.accept('number'))) {
throw new Error('Unexpected symbol ' + ttr.symbol + ', expected week number')
}
options.byweekno = [n[0]]
while (ttr.accept('comma')) {
if (!(n = ttr.accept('number'))) {
throw new Error('Unexpected symbol ' + ttr.symbol + '; expected monthday')
}
options.byweekno.push(n[0])
}
} else if ((m = decodeM())) {
ttr.nextSymbol()
if (!options.bymonth) options.bymonth = []
options.bymonth.push(m)
} else {
return
}
} while (ttr.accept('comma') || ttr.accept('the') || ttr.accept('on'))
}
function AT () {
var at = ttr.accept('at')
if (!at) return
do {
var n
if (!(n = ttr.accept('number'))) {
throw new Error('Unexpected symbol ' + ttr.symbol + ', expected hour')
}
options.byhour = [n[0]]
while (ttr.accept('comma')) {
if (!(n = ttr.accept('number'))) {
throw new Error('Unexpected symbol ' + ttr.symbol + '; expected hour')
}
options.byhour.push(n[0])
}
} while (ttr.accept('comma') || ttr.accept('at'))
}
function decodeM () {
switch (ttr.symbol) {
case 'january':
return 1
case 'february':
return 2
case 'march':
return 3
case 'april':
return 4
case 'may':
return 5
case 'june':
return 6
case 'july':
return 7
case 'august':
return 8
case 'september':
return 9
case 'october':
return 10
case 'november':
return 11
case 'december':
return 12
default:
return false
}
}
function decodeWKD () {
switch (ttr.symbol) {
case 'monday':
case 'tuesday':
case 'wednesday':
case 'thursday':
case 'friday':
case 'saturday':
case 'sunday':
return ttr.symbol.substr(0, 2).toUpperCase()
default:
return false
}
}
function decodeNTH () {
switch (ttr.symbol) {
case 'last':
ttr.nextSymbol()
return -1
case 'first':
ttr.nextSymbol()
return 1
case 'second':
ttr.nextSymbol()
return ttr.accept('last') ? -2 : 2
case 'third':
ttr.nextSymbol()
return ttr.accept('last') ? -3 : 3
case 'nth':
var v = parseInt(ttr.value[1], 10)
if (v < -366 || v > 366) throw new Error('Nth out of range: ' + v)
ttr.nextSymbol()
return ttr.accept('last') ? -v : v
default:
return false
}
}
function MDAYs () {
ttr.accept('on')
ttr.accept('the')
var nth
if (!(nth = decodeNTH())) return
options.bymonthday = [nth]
ttr.nextSymbol()
while (ttr.accept('comma')) {
if (!(nth = decodeNTH())) {
throw new Error('Unexpected symbol ' + ttr.symbol + '; expected monthday')
}
options.bymonthday.push(nth)
ttr.nextSymbol()
}
}
function F () {
if (ttr.symbol === 'until') {
var date = Date.parse(ttr.text)
if (!date) throw new Error('Cannot parse until date:' + ttr.text)
options.until = new Date(date)
} else if (ttr.accept('for')) {
options.count = ttr.value[0]
ttr.expect('number')
// ttr.expect('times')
}
}
}
// =============================================================================
// Parser
// =============================================================================
var Parser = function (rules) {
this.rules = rules
}
Parser.prototype.start = function (text) {
this.text = text
this.done = false
return this.nextSymbol()
}
Parser.prototype.isDone = function () {
return this.done && this.symbol == null
}
Parser.prototype.nextSymbol = function () {
var best, bestSymbol
var p = this
this.symbol = null
this.value = null
do {
if (this.done) return false
var match, rule
best = null
for (var name in this.rules) {
rule = this.rules[name]
if ((match = rule.exec(p.text))) {
if (best == null || match[0].length > best[0].length) {
best = match
bestSymbol = name
}
}
}
if (best != null) {
this.text = this.text.substr(best[0].length)
if (this.text === '') this.done = true
}
if (best == null) {
this.done = true
this.symbol = null
this.value = null
return
}
} while (bestSymbol === 'SKIP')
this.symbol = bestSymbol
this.value = best
return true
}
Parser.prototype.accept = function (name) {
if (this.symbol === name) {
if (this.value) {
var v = this.value
this.nextSymbol()
return v
}
this.nextSymbol()
return true
}
return false
}
Parser.prototype.expect = function (name) {
if (this.accept(name)) return true
throw new Error('expected ' + name + ' but found ' + this.symbol)
}
// =============================================================================
// i18n
// =============================================================================
var ENGLISH = {
dayNames: [
'Sunday', 'Monday', 'Tuesday', 'Wednesday',
'Thursday', 'Friday', 'Saturday'
],
monthNames: [
'January', 'February', 'March', 'April', 'May',
'June', 'July', 'August', 'September', 'October',
'November', 'December'
],
tokens: {
'SKIP': /^[ \r\n\t]+|^\.$/,
'number': /^[1-9][0-9]*/,
'numberAsText': /^(one|two|three)/i,
'every': /^every/i,
'day(s)': /^days?/i,
'weekday(s)': /^weekdays?/i,
'week(s)': /^weeks?/i,
'hour(s)': /^hours?/i,
'month(s)': /^months?/i,
'year(s)': /^years?/i,
'on': /^(on|in)/i,
'at': /^(at)/i,
'the': /^the/i,
'first': /^first/i,
'second': /^second/i,
'third': /^third/i,
'nth': /^([1-9][0-9]*)(\.|th|nd|rd|st)/i,
'last': /^last/i,
'for': /^for/i,
'time(s)': /^times?/i,
'until': /^(un)?til/i,
'monday': /^mo(n(day)?)?/i,
'tuesday': /^tu(e(s(day)?)?)?/i,
'wednesday': /^we(d(n(esday)?)?)?/i,
'thursday': /^th(u(r(sday)?)?)?/i,
'friday': /^fr(i(day)?)?/i,
'saturday': /^sa(t(urday)?)?/i,
'sunday': /^su(n(day)?)?/i,
'january': /^jan(uary)?/i,
'february': /^feb(ruary)?/i,
'march': /^mar(ch)?/i,
'april': /^apr(il)?/i,
'may': /^may/i,
'june': /^june?/i,
'july': /^july?/i,
'august': /^aug(ust)?/i,
'september': /^sep(t(ember)?)?/i,
'october': /^oct(ober)?/i,
'november': /^nov(ember)?/i,
'december': /^dec(ember)?/i,
'comma': /^(,\s*|(and|or)\s*)+/i
}
}
// =============================================================================
// Export
// =============================================================================
return {
fromText: fromText,
parseText: parseText,
isFullyConvertible: ToText.isFullyConvertible,
toText: function (rrule, gettext, language) {
return new ToText(rrule, gettext, language).toString()
}
}
}