repeating-interval
Version:
IS0 8601 repeating interval parsing and manipulation
447 lines (397 loc) • 14.4 kB
text/typescript
import * as moment from 'moment';
type Moment = moment.Moment;
const millisPerSecond = 1000;
const millisPerMinute = millisPerSecond * 60;
const millisPerHour = millisPerMinute * 60;
const millisPerDay = millisPerHour * 24;
const millisPerWeek = millisPerDay * 7;
const millisPerYear = millisPerDay * 365;
let recurrenceRegex = /^R(\d*)/;
let recurrenceIndex = {
repetitions: 1
};
// From https://gist.github.com/philipashlock/8830168
let dateRegex = /^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/;
let durationRegex = /^(R(\d*)?\/)?P(?:(\d+(?:\.\d+)?)?Y)?(?:(\d+(?:\.\d+)?)?M)?(?:(\d+(?:\.\d+)?)?W)?(?:(\d+(?:\.\d+)?)?D)?(?:T(?:(\d+(?:\.\d+)?)?H)?(?:(\d+(?:\.\d+)?)?M)?(?:(\d+(?:\.\d+)?)?S)?)?$/;
let durationIndex = {
hasRecurrence: 0,
repetitionCount: 1,
year: 2,
month: 3,
week: 4,
day: 5,
hour: 6,
minute: 7,
second: 8
};
let rangeDateDateRegex = /^([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?(\/)([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\3([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\17[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/;
let rangeDateDurationRegex = /^(R\d*\/)?([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\4([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\18[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?(\/)P(?:\d+(?:\.\d+)?Y)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?W)?(?:\d+(?:\.\d+)?D)?(?:T(?:\d+(?:\.\d+)?H)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?S)?)?$/;
let rangeDurationDateRegex = /^(R\d*\/)?P(?:\d+(?:\.\d+)?Y)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?W)?(?:\d+(?:\.\d+)?D)?(?:T(?:\d+(?:\.\d+)?H)?(?:\d+(?:\.\d+)?M)?(?:\d+(?:\.\d+)?S)?)?\/([\+-]?\d{4}(?!\d{2}\b))((-?)((0[1-9]|1[0-2])(\4([12]\d|0[1-9]|3[01]))?|W([0-4]\d|5[0-2])(-?[1-7])?|(00[1-9]|0[1-9]\d|[12]\d{2}|3([0-5]\d|6[1-6])))([T\s]((([01]\d|2[0-3])((:?)[0-5]\d)?|24\:?00)([\.,]\d+(?!:))?)?(\18[0-5]\d([\.,]\d+)?)?([zZ]|([\+-])([01]\d|2[0-3]):?([0-5]\d)?)?)?)?$/;
function isInfinite(s: string) {
return s.length === 0;
}
function isDuration(s: string) {
return s[0] === 'P';
}
function isRepeating(s: string) {
return s[0] === 'R';
}
function isTime(s: string) {
return moment(s).isValid();
}
enum String8601Type {
repeating,
time,
duration,
infinite
}
interface parsed8601String {
type: String8601Type;
value: any;
}
function parse8601String(s: string): parsed8601String {
if (isInfinite(s)) {
return {
type: String8601Type.infinite,
value: null
};
} else if (isDuration(s)) {
return {
type: String8601Type.duration,
value: moment.duration(s)
};
} else if (isRepeating(s)) {
let result = recurrenceRegex.exec(s);
return {
type: String8601Type.repeating,
value: typeof result[recurrenceIndex.repetitions] === 'string' ?
parseInt(result[recurrenceIndex.repetitions]) : Number.POSITIVE_INFINITY
};
} else if (isTime(s)) {
return {
type: String8601Type.time,
value: moment(s).valueOf()
};
} else {
throw new Error(`[${s}] Is not a valid part of an ISO8601 time string`);
}
}
/**
* Class to describe ISO 8601 time intervals, including the repeating functionality.
*
* The class is primarily backed by moment.js and contains the start, end, duration,
* and number of repetitions found in the ISO8601 string or overridden in the constructor.
* Typical usage of the class is to construct one with a ISO8601 string in the constructor.
*/
export class Interval {
// Start interval component as unix timestamp (ms)
private _start: number;
// End interval component as unix timestamp (ms)
private _end: number;
// Duration component (ms)
private _duration: moment.Duration;
private _recurs: boolean;
// 0 if no repetition, Number.POSITIVE_INFINITY if infinite repetition
private _repetitions: number;
// The index of the first occurrence
private _first: number;
// The index of the last occurrence
private _last: number;
// This is set to true if the span is of the form '/{8601 string}' or '{8601 string}/' denoting a single never
// ending span
private _infiniteSpan: boolean;
/**
* The start of all intervals, if this is a repeating interval
* then this is the start of the first repitition.
* Returns an invalid moment if there are infinite negative repetitions
*/
get start(): moment.Moment {
if (this.isInfiniteNegative) {
return moment.invalid();
} else {
return this.occurrence(this.first);
}
}
/**
* The end of all intervals, if this is a repeating interval
* then this is then end of the last repitition.
* Returns an invalid moment if there are infinite positive repetitions
*/
get end(): moment.Moment {
if (this.isInfinitePositive) {
return moment.invalid();
} else {
return this.occurrence(this.last);
}
}
/**
* The duration of a single repition of the interval
*/
get duration(): moment.Duration {
if (this._duration) {
return this._duration;
}
if (this._start && this._end) {
return moment.duration(this._end - this._start);
} else {
return moment.duration(0);
}
}
/**
* True if the schedule is infintely long
* @returns {boolean}
*/
get isInfinite(): boolean {
return !isFinite(this._repetitions) || this._infiniteSpan;
}
/**
* True if the schedule progresses infinitely in the positive
* @returns {boolean}
*/
get isInfinitePositive(): boolean {
return !isFinite(this.last) || (this._infiniteSpan && !this._end);
}
/**
* True if the schedule progresses infinitely in the negative
* @returns {boolean}
*/
get isInfiniteNegative(): boolean {
return !isFinite(this.first) || (this._infiniteSpan && !this._start);
}
/**
* True if the schedule repeats (has more than one occurrence)
* @returns {boolean}
*/
get isRepeating(): boolean {
return this._recurs;
}
/**
* The number of repetitions
* @returns {number}
*/
get repetitions(): number {
return this._repetitions;
}
/**
* Index of the first occurrence,
* Number.NEGATIVE_INFINITY if the interval is reverse repeating indefinitely
* @returns {number}
*/
get first(): number {
return this._first;
}
/**
* Index of the last occurrence,
* Number.POSITIVE_INFINITY if the interval is forward repeating indefinitely
* @returns {number}
*/
get last(): number {
return this._last;
}
/**
* @param interval either ISO8601 string or an instance to copy
* @param repetitions the number of times to repeat, null for infinite
*/
constructor(interval?: string | Interval, repetitions?: number) {
this._recurs = false;
this._repetitions = 0;
this._infiniteSpan = false;
if (typeof interval === 'string') {
// Parse out the ISO 8601 string
let split = interval.split('/');
if (split.length > 3) {
throw new Error(`Invalid ISO 8601 string[${interval}]`);
}
for (let i = 0; i < split.length; i++) {
let fragment = split[i];
let parsed = parse8601String(fragment);
switch (parsed.type) {
case String8601Type.repeating:
if (i !== 0) {
throw new Error(`Incorrect placement of recurrence`);
}
if (parsed.value !== 0) {
this._recurs = true;
this._repetitions = parsed.value;
}
break;
case String8601Type.time:
if (this._duration || this._start || this._infiniteSpan) {
this._end = parsed.value;
} else if (!this._start) {
this._start = parsed.value;
} else {
throw new Error(`Invalid interval[${interval}] end time must come after a duration, start time or be an infinite span`);
}
break;
case String8601Type.duration:
if (this._duration) {
throw new Error(`Invalid interval[${interval}] two durations found`);
}
this._duration = parsed.value;
break;
case String8601Type.infinite:
if (this._duration || this._recurs) {
throw new Error(`Invalid interval[${interval}] single span infinite not compatible with durations or recurrence`);
}
this._infiniteSpan = true;
break;
default:
throw new Error(`Experienced unhandled type`);
}
}
} else {
this._start = interval._start;
this._end = interval._end;
this._duration = moment.duration(interval._duration);
this._recurs = interval._recurs;
this._repetitions = interval._repetitions;
this._infiniteSpan = interval._infiniteSpan;
}
if (typeof repetitions === 'number') {
if (repetitions === 0) {
this._recurs = false;
}
this._repetitions = repetitions;
} else if (repetitions === null) {
this._recurs = true;
this._repetitions = repetitions;
}
// Throw an error if this this is just bunk
if (this._start && this._end && this._duration) {
throw new Error(`Error creating interval with args[${arguments}]`);
}
// Determine the first and last occurrence indexes
if (!this._recurs) {
// Non recurring case
this._first = 0;
if (this._end || this._duration) {
this._last = 1;
} else {
this._last = 0;
}
} else if (this._start && (this._end || this._duration)) {
// Forward progressing case
this._first = 0;
this._last = this._repetitions;
} else {
// Backward progressing case
this._last = 0;
this._first = -this._repetitions;
}
}
public occurrence(idx: number): moment.Moment {
if (idx > this.last || idx < this.first) {
return moment.invalid();
}
if (this.isInfiniteNegative) {
return moment(this._end).subtract(this.durationBetween(idx, this.last));
} else {
return moment(this._start).add(this.durationBetween(this.first, idx));
}
}
/**
* Get a list of the occurrences for an interval,
* if no parameters are supplied then all occurrences are returned.
*
* @param {number} [from] the starting occurrence index
* @param {number} [to] the ending occurrence index
* @returns {moment.Moment[]}
*/
public slice(from?: number, to?: number): moment.Moment[] {
// Check for being out of bounds
if (from > this.last || to < this.first) {
return [];
}
if (from == null) {
if (this.isInfiniteNegative) {
throw new Error(`Tried to get all occurrences with no lower bound[${this.toISOString()}]`);
}
from = this.first;
} else if (from < this.first) {
from = this.first;
}
if (to == null) {
if (this.isInfinitePositive) {
throw new Error(`Tried to get all occurrences with no upper bound[${this.toISOString()}]`);
}
to = this.last;
} else if (to > this.last ) {
to = this.last;
}
let count = to - from + 1;
let initialDuration = this.durationBetween(from, to);
let currentOccurrence;
if (this.isInfiniteNegative) {
currentOccurrence = moment(this._end).subtract(initialDuration);
} else {
currentOccurrence = moment(this._start).add(initialDuration);
}
let occurrences = [];
for (var i = 0; i < count; i++) {
occurrences.push(currentOccurrence);
currentOccurrence.add(this.duration);
}
return occurrences;
}
/**
* Get the recurrence (the index of the occurrence) after the supplied time
* @param after Moment or something that it parses (if a number, then in epoc ms)
*/
public indexAfter(after: string | number | moment.Moment): number {
let afterMs = moment(after).valueOf();
let deltaMs;
if (this._start) {
deltaMs = afterMs - this._start;
} else {
deltaMs = afterMs - this._end;
}
let durationMs = this.duration.asMilliseconds();
return Math.ceil(deltaMs / durationMs);
}
public indexBefore(before: string | number | moment.Moment) {
return this.indexAfter(before) - 1;
}
/**
* Get the occurrence happening after the supplied date.
* Throws Error if there is no occurrence after the supplied date
*/
public occurrenceAfter(after: string | number | moment.Moment): moment.Moment {
let afterMoment = moment(after);
let recurrence = this.indexAfter(afterMoment);
return this.occurrence(recurrence);
}
public durationBetween(from: number, to: number): moment.Duration {
return moment.duration(this.duration.asMilliseconds() * (to - from));
}
static extents(intervalList: Interval[]): Interval {
let start = moment.min(intervalList.map(s => s.start));
let end = moment.max(intervalList.map(s => s.end));
return new Interval(`${start.isValid() ? start.toISOString() : ''}/${end.isValid() ? end.toISOString() : ''}`);
}
toISOString(): string {
let parts = [];
if (this._recurs) {
parts.push(`R${isFinite(this._repetitions) ? this._repetitions.toString() : ''}`);
}
if (this._start) {
parts.push(moment(this._start).toISOString());
if (this._infiniteSpan) {
parts.push('');
}
}
if (this._duration) {
parts.push(this._duration.toISOString());
}
if (this._end) {
parts.push(moment(this._end).toISOString());
if (this._infiniteSpan) {
parts.push('');
}
}
return parts.join('/');
}
toString(): string {
return this.toISOString();
}
}