UNPKG

@0dep/piso

Version:

ISO 8601 interval, date, and duration parser

1,567 lines (1,305 loc) 44.1 kB
const FRACTIONS = ',.'; const UNICODE_MINUS = '\u2212'; const ISO_ZULU = 'Z'; const ISODATE_HYPHEN = '-'; const ISODATE_PREFIX = '+-' + UNICODE_MINUS; const ISODATE_TIMEINSTRUCTION = 'T'; const ISODATE_WEEKINSTRUCTION = 'W'; const ISODURATION_DATE_DESIGNATORS = 'YMWD'; const ISODURATION_TIME_DESIGNATORS = 'HMS'; const ISOINTERVAL_DURATION = 'P'; const ISOINTERVAL_REPEAT = 'R'; const ISOINTERVAL_SEPARATOR = '/'; const ISOTIME_OFFSET = ISODATE_PREFIX + ISO_ZULU; const ISOTIME_SEPARATOR = ':'; const ISOTIME_STARTHOUR = '012'; const ISOTIME_STARTPART = '012345'; const ISODATE_WEEKDAYS = '1234567'; const NUMBERS = '0123456789'; const MILLISECONDS_PER_HOUR = 3600000; const MILLISECONDS_PER_DAY = 24 * MILLISECONDS_PER_HOUR; const NONLEAPYEAR = new Date(Date.UTC(1971, 0, 1)); const kIsParsed = Symbol.for('is parsed'); const dateUTCFns = { Y: [Date.prototype.getUTCFullYear, Date.prototype.setUTCFullYear], M: [Date.prototype.getUTCMonth, Date.prototype.setUTCMonth], D: [Date.prototype.getUTCDate, Date.prototype.setUTCDate], }; const dateLocalFns = { Y: [Date.prototype.getFullYear, Date.prototype.setFullYear], M: [Date.prototype.getMonth, Date.prototype.setMonth], D: [Date.prototype.getDate, Date.prototype.setDate], }; /** @module piso */ /** * ISO 8601 interval parser * @param {string} source interval source string * @param {boolean} [enforceUTC] enforce UTC if source lacks timezone offset */ export function ISOInterval(source, enforceUTC = false) { if (!source || typeof source !== 'string') throw new TypeError('ISO 8601 interval source is required and must be a string'); /** @internal Interval source string */ this.source = source; /** @internal */ this.c = ''; /** @internal */ this.parsed = ''; /** @internal */ this.idx = -1; /** @type {number | undefined} */ this.repeat = undefined; /** @type {ISODate | undefined} */ this.start = undefined; /** @type {ISODuration | undefined} */ this.duration = undefined; /** @type {ISODate | undefined} */ this.end = undefined; /** @type {import('types').ISOIntervalType} */ this.type = 0; this.enforceUTC = enforceUTC; /** @internal */ this[kIsParsed] = false; } /** @name module:piso.ISOInterval#startDate */ Object.defineProperty(ISOInterval.prototype, 'startDate', { /** @returns {Date | null} */ get() { return this.start?.toDate() ?? null; }, }); /** @name module:piso.ISOInterval#endDate */ Object.defineProperty(ISOInterval.prototype, 'endDate', { /** @returns {Date | null} */ get() { return this.end?.toDate() ?? null; }, }); /** * ISO 8601 interval parser */ ISOInterval.prototype.parse = function parseInterval() { if (this[kIsParsed]) return this; let c = this.peek(); if (c === ISOINTERVAL_REPEAT) { this.read(); this.consumeRepeat(); c = this.peek(); } let start; if (NUMBERS.indexOf(c) > -1 || ISODATE_PREFIX.indexOf(c) > -1) { start = this.consumeStartDate(); } else if (c !== ISOINTERVAL_DURATION) { throw new RangeError(`Invalid ISO 8601 interval "${this.source}"`); } c = this.peek(); if (c === ISOINTERVAL_DURATION) { this.consumeDuration(); if (this.repeat && this.repeat !== 1) { this.type = this.type | 1; } } c = this.current(); if (c === ISOINTERVAL_SEPARATOR && !start && this.duration) { this.end = this.consumeDate(); this.parsed = this.end.parsed; this.type |= 8; } else if (c === ISOINTERVAL_SEPARATOR && start && !this.duration) { this.consumePartialEndDate(start); } else if (c) { throw new RangeError(`ISO 8601 interval "${this.source}" combination is not allowed`); } this[kIsParsed] = true; return this; }; /** * Get expire at * @param {Date} [compareDate] optional compare date, defaults to now * @param {Date} [startDate] optional start date, duration without start or end defaults to now * @param {boolean} [enforceUTC] enforce UTC if source lacks timezone offset */ ISOInterval.prototype.getExpireAt = function getExpireAt(compareDate, startDate, enforceUTC) { if (!this[kIsParsed]) this.parse(); const type = this.type; const repetitions = (type & 1) === 1 ? this.repeat : 1; const duration = (type & 4) === 4 && this.duration; const hasEndDate = (type & 8) === 8; const eUTC = enforceUTC ?? this.enforceUTC; if (repetitions === 1 && hasEndDate) return this.end.toDate(eUTC); const hasStartDate = (type & 2) === 2; compareDate = compareDate ?? new Date(); if (hasStartDate && duration) { const dateFns = new ISODateDurationFunctions(this.start.toDate(eUTC), duration, compareDate, !!this.start.result.Z || eUTC); return dateFns.addDuration(repetitions === -1 ? Number.MAX_VALUE : repetitions); } else if (hasEndDate && duration && repetitions) { const dateFns = new ISODateDurationFunctions(this.end.toDate(eUTC), duration, compareDate, !!this.end.result.Z || eUTC); return dateFns.reduceDuration(repetitions === -1 ? Number.MAX_VALUE : repetitions); } startDate = startDate ?? new Date(); const dateFns = new ISODateDurationFunctions(startDate, duration, compareDate, true); return dateFns.addDuration(repetitions === -1 ? Number.MAX_VALUE : repetitions); }; /** * Get start at date * @param {Date} [compareDate] optional compare date, defaults to now * @param {Date} [endDate] optional end date, defaults to now * @param {boolean} [enforceUTC] enforce UTC if source lacks timezone offset */ ISOInterval.prototype.getStartAt = function getStartAt(compareDate, endDate, enforceUTC) { if (!this[kIsParsed]) this.parse(); const type = this.type; const repetitions = (type & 1) === 1 ? this.repeat : 1; const duration = (type & 4) === 4 && this.duration; const hasStartDate = (type & 2) === 2; const eUTC = enforceUTC ?? this.enforceUTC; if (repetitions === 1 && hasStartDate) return this.start.toDate(eUTC); const hasEndDate = (type & 8) === 8; compareDate = compareDate ?? new Date(); if (hasStartDate && duration) { return duration.applyDuration(this.getExpireAt(undefined, compareDate, eUTC), -1, !!this.start.result.Z || eUTC); } else if (hasEndDate && duration) { return duration.applyDuration(this.getExpireAt(undefined, compareDate, eUTC), -1, !!this.end.result.Z || eUTC); } else if (endDate === undefined) { return duration.getStartAt(this.getExpireAt(undefined, compareDate, eUTC)); } else if (repetitions === 1) { return duration.getStartAt(endDate); } const dateFns = new ISODateDurationFunctions(endDate, duration, compareDate, true); const expireAt = dateFns.reduceDuration(repetitions === -1 ? Number.MAX_VALUE : repetitions); return duration.getStartAt(expireAt); }; ISOInterval.prototype.toJSON = function intervalToJSON() { try { return this.toISOString(); } catch { return null; } }; ISOInterval.prototype.toISOString = function intervalToISOString() { if (!this[kIsParsed]) this.parse(); const type = this.type; const repetitions = (type & 1) === 1; const hasDuration = (type & 4) === 4; const hasStartDate = (type & 2) === 2; const hasEndDate = (type & 8) === 8; const isoString = []; if (repetitions) { isoString.push('R' + this.repeat); } if (hasStartDate) { isoString.push(this.start.toISOString()); } if (hasDuration) { isoString.push(this.duration.toISOString()); } if (hasEndDate) { isoString.push(this.end.toISOString()); } return isoString.join(ISOINTERVAL_SEPARATOR); }; ISOInterval.prototype.toString = function intervalToString() { try { this.parse(); return this.source; } catch { return `Invalid ${this.constructor.name}`; } }; ISOInterval.prototype.consumeRepeat = function consumeRepeat() { /** @type { string | undefined } */ let c = this.read(); if (c === '-') { c = this.read(); if (c !== '1') throw new RangeError(`Unexpected ISO 8601 interval character "${this.parsed}[${c}]" at ${this.idx}`); this.repeat = -1; return this.read(); } let value = ''; while (c && NUMBERS.indexOf(c) > -1) { value += c; c = this.read(); } this.repeat = value ? Number(value) : -1; if (c !== ISOINTERVAL_SEPARATOR) throw new RangeError(`Unexpected ISO 8601 interval characted "${this.parsed}[${c}]" at ${this.idx}`); }; ISOInterval.prototype.consumeStartDate = function consumeStartDate() { const start = (this.start = this.consumeDate(undefined, ISOINTERVAL_SEPARATOR)); this.parsed = start.parsed; this.type |= 2; return start; }; ISOInterval.prototype.consumeDuration = function consumeDuration() { const duration = (this.duration = new ISODuration(this.source, this.idx).parse()); this.idx = duration.idx; this.parsed = duration.parsed; this.type |= 4; return duration; }; /** * Consume partial end date * @param {ISODate} start */ ISOInterval.prototype.consumePartialEndDate = function consumePartialEndDate(start) { const isoDate = new ISODate(this.source, { offset: this.idx, enforceSeparators: start.enforceSeparators, enforceUTC: this.enforceUTC }); const end = (this.end = isoDate.parsePartialDate(start.result.Y, start.result.M, start.result.D, start.result.W)); if (start.result.Z && !end.result.Z) { end.result.Z = start.result.Z; end.result.OH = start.result.OH; end.result.Om = start.result.Om; end.result.OS = start.result.OS; } this.idx = isoDate.idx; this.c = isoDate.c; this.parsed = isoDate.parsed; if (start.toDate() > isoDate.toDate()) { throw new RangeError('ISO 8601 interval end date occur before start date'); } this.type |= 8; return isoDate; }; /** * Consume date * @param {boolean} [enforceSeparators] * @param {string} [endChars] */ ISOInterval.prototype.consumeDate = function consumeDate(enforceSeparators, endChars) { const isoDate = new ISODate(this.source, { offset: this.idx, endChars, enforceSeparators, enforceUTC: this.enforceUTC }).parse(); this.idx = isoDate.idx; this.c = isoDate.c; this.parsed += isoDate.parsed; return isoDate; }; ISOInterval.prototype.read = function read() { this.parsed += this.c; return (this.c = this.source[++this.idx]); }; ISOInterval.prototype.current = function current() { return this.source[this.idx]; }; ISOInterval.prototype.peek = function peek() { return this.source[this.idx + 1]; }; /** * ISO 8601 date parser * @param {string} source ISO 8601 date time source * @param {import('types').ISODateOptions} [options] parse options */ export function ISODate(source, options) { this.source = source; /** @type {number} */ // @ts-ignore this.offset = options?.offset ?? -1; this.idx = this.offset > -1 ? Number(this.offset) : -1; this.enforceSeparators = options?.enforceSeparators; this.enforceUTC = options?.enforceUTC; this.c = ''; // @ts-ignore this.parsed = this.offset > 0 ? source.substring(0, this.offset + 1) : ''; this.endChars = options?.endChars; /** @type {Partial<import('types').ISODateParts>} */ this.result = {}; this[kIsParsed] = false; } /** * ISO Date to Date * @param {boolean} [enforceUTC] enforce UTC if source lacks timezone offset */ ISODate.prototype.toDate = function toDate(enforceUTC) { if (!this[kIsParsed]) this.parse(); /** @type {any} */ const result = this.result; const args = [result.Y, result.M, result.D]; if (result.W) { const wdate = getUTCDateFromWeek(result.Y, result.W, result.D); args[0] = wdate.getUTCFullYear(); args[1] = wdate.getUTCMonth(); args[2] = wdate.getUTCDate(); } else if (result.M === undefined) { const odate = getUTCDateFromOrdinalDate(result.Y, result.D); args[1] = odate.getUTCMonth(); args[2] = odate.getUTCDate(); } if ('H' in result) args.push(result.H, 0); if ('m' in result) args[4] = result.m; if ('S' in result) args.push(result.S); if ('F' in result) args.push(Math.round(result.F)); switch (result.Z ?? ((enforceUTC ?? this.enforceUTC) && 'Z')) { case ISO_ZULU: /** @ts-ignore */ return new Date(Date.UTC(...args)); case '-': case UNICODE_MINUS: { if (result.OH) args[3] += result.OH; if (result.Om) args[4] += result.Om; if (result.OS) args[5] = (args[5] ?? 0) + result.OS; /** @ts-ignore */ return new Date(Date.UTC(...args)); } case '+': { if (result.OH) args[3] -= result.OH; if (result.Om) args[4] -= result.Om; if (result.OS) args[5] = (args[5] ?? 0) - result.OS; /** @ts-ignore */ return new Date(Date.UTC(...args)); } } /** @ts-ignore */ return new Date(...args); }; /** * Parse passed source as ISO 8601 date time */ ISODate.prototype.parse = function parseISODate() { if (this[kIsParsed]) { if (!this.result?.isValid) throw new RangeError(`Invalid ${this.constructor.name}`); return this; } this[kIsParsed] = true; let c = this.peek(); let sign = ''; let dateChars = NUMBERS + ISODATE_HYPHEN; if (ISODATE_PREFIX.indexOf(c) > -1) { sign = c === UNICODE_MINUS ? ISODATE_HYPHEN : c; this.enforceSeparators = true; this.consume(); } else if (!this.enforceSeparators) { dateChars += ISODATE_TIMEINSTRUCTION + ISODATE_WEEKINSTRUCTION; } let value = ''; while ((c = this.consumeCharOrEnd(dateChars))) { if (c === ISODATE_HYPHEN) { this.enforceSeparators = true; break; } else if (c === ISODATE_TIMEINSTRUCTION) { break; } else if (c === ISODATE_WEEKINSTRUCTION) { break; } else { value += c; if (!sign && value.length > 8) throw this.createUnexpectedError(); else if (sign && value.length > 17) throw this.createUnexpectedError(); } } if (value.length < 4) throw this.createUnexpectedError(); if (c === ISODATE_TIMEINSTRUCTION || !c) { if (this.enforceSeparators) throw this.createUnexpectedError(); const Y = (this.result.Y = Number(value.substring(0, 4))); if (value.length === 7) { const D = (this.result.D = Number(value.substring(4))); this.continueOrdinalDatePrecision(Y, D, c); } else { const M = (this.result.M = Number(value.substring(4, 6)) - 1); const D = (this.result.D = Number(value.substring(6, 8))); if (!validateDate(Y, M, D)) throw new RangeError(`Invalid ISO 8601 date "${this.parsed}"`); if (c) this.continueFromTimeInstruction(); } } else if (c === ISODATE_WEEKINSTRUCTION) { const Y = (this.result.Y = Number(value)); this.continueFromWeekInstruction(Y); } else if (sign) { const Y = (this.result.Y = Number(sign + value)); this.continueDatePrecision(Y); } else { if (value.length > 4) throw this.createUnexpectedError(); const Y = (this.result.Y = Number(value)); this.continueDatePrecision(Y); } this.result.isValid = true; return this; }; /** * Get ISO date as string * @returns date as JSON string */ ISODate.prototype.toISOString = function isoDateToISOString() { return this.toDate().toISOString(); }; /** * Get ISO date as JSON * @returns {string|null} date as JSON string */ ISODate.prototype.toJSON = function isoDateToJSON() { try { return this.toDate().toJSON(); } catch { return null; } }; ISODate.prototype.toString = function isoDateToString() { try { this.parse(); const offset = this.offset; if (offset < 0) { return this.parsed; } return this.parsed.substring(offset + 1); } catch { return `Invalid ${this.constructor.name}`; } }; /** * Parse ISO 8601 date string * @param {string} source ISO 8601 duration * @param {number?} [offset] source column offset */ ISODate.parse = function staticParseISODate(source, offset) { return new this(source, { offset }).parse().result; }; /** * Parse partial relative date * @param {number} Y Year if year is not defined * @param {number} M JavaScript month if month is not defined * @param {number} [D] Date if date is not defined * @param {number} [W] Weeknumber */ ISODate.prototype.parsePartialDate = function parsePartialDate(Y, M, D, W) { if (this[kIsParsed]) return this; this._parseRelativeDate(Y, M, D, W); this.result.isValid = true; return this; }; /** * @internal Parse relative date * @param {number} Y Year if year is not defined * @param {number} M JavaScript month if month is not defined * @param {number} [D] Date if date is not defined * @param {number} [W] Weeknumber */ ISODate.prototype._parseRelativeDate = function parseRelativeDate(Y, M, D, W) { this.result.Y = Y; if (ISODATE_PREFIX.indexOf(this.peek()) > -1) { return this.parse(); } this[kIsParsed] = true; const c = this.consumeChar(NUMBERS + ISODATE_WEEKINSTRUCTION); let next = this.peek(); if (c === ISODATE_WEEKINSTRUCTION) { return this.continueFromWeekInstruction(Y); } else if (W && (!next || next === ISODATE_TIMEINSTRUCTION) && ISODATE_WEEKDAYS.indexOf(c) > -1) { this.result.W = W; this.result.D = Number(c); if (!next) return this; this.consume(); return this.continueFromTimeInstruction(); } this.result.M = M; this.result.D = D; let value = c + this.consumeChar(); next = this.peek(); if (!next) { this.consume(); const day = (this.result.D = Number(value)); if (!validateDate(Y, M, day)) throw new RangeError(`Invalid ISO 8601 partial date "${this.parsed}"`); return this; } else if (next === ISODATE_TIMEINSTRUCTION) { const day = (this.result.D = Number(value)); if (!validateDate(Y, M, day)) throw new RangeError(`Invalid ISO 8601 partial date "${this.parsed}"`); this.consume(); return this.continueFromTimeInstruction(); } else if (next === ISOTIME_SEPARATOR) { const hours = (this.result.H = Number(value)); if (!M) this.result.W = W; return this.continueTimePrecision(hours); } else if (NUMBERS.indexOf(next) > -1) { this.result = {}; value += this.consumeChar(); next = this.peek(); if (!next || next === ISODATE_TIMEINSTRUCTION) { return this.continueOrdinalDatePrecision(Y, Number(value), next && this.consumeCharOrEnd(ISODATE_TIMEINSTRUCTION)); } const year = (this.result.Y = Number(value + this.consumeChar())); if (this.enforceSeparators) this.consumeChar(ISODATE_HYPHEN); return this.continueDatePrecision(year); } else if (next === ISODATE_HYPHEN) { this.consume(); const month = (this.result.M = Number(value) - 1); const day = (this.result.D = Number(this.consumeChar('0123') + this.consumeChar())); if (!validateDate(Y, month, day)) throw new RangeError(`Invalid ISO 8601 partial date "${this.parsed}"`); const c = this.consumeCharOrEnd(ISODATE_TIMEINSTRUCTION); if (c) { return this.continueFromTimeInstruction(); } return this; } throw this.createUnexpectedError(); }; /** * Consume as ISO date * @param {number} Y year */ ISODate.prototype.continueDatePrecision = function continueDatePrecision(Y) { const dateSeparator = this.enforceSeparators ? ISODATE_HYPHEN : ''; const initNext = ISODATE_WEEKINSTRUCTION + '0123'; /** @type {string | undefined} */ let c = this.consumeChar(dateSeparator + initNext); if (c === ISODATE_WEEKINSTRUCTION) { return this.continueFromWeekInstruction(Y); } const instructions = ISODATE_TIMEINSTRUCTION + NUMBERS + dateSeparator; let numbers = c + this.consumeChar(); let separator = -1; for (let i = 0; i < 4; i++) { c = this.consumeCharOrEnd(instructions); if (!c || c === ISODATE_TIMEINSTRUCTION) break; if (c === dateSeparator) { if (i > 0) throw this.createUnexpectedError(); separator = i; continue; } numbers += c; } if (numbers.length === 3 && separator === -1) { return this.continueOrdinalDatePrecision(Y, Number(numbers), c); } if (numbers.length === 4 && dateSeparator && separator !== 0) { throw new RangeError('Unbalanced ISO 8601 date separator'); } else if (numbers.length === 2 && (!dateSeparator || separator === 0)) { throw new RangeError('Unbalanced ISO 8601 date separator'); } else if (numbers.length === 3) { throw this.createUnexpectedError(); } const M = (this.result.M = Number(numbers.substring(0, 2)) - 1); const D = (this.result.D = Number(numbers.substring(2) || 1)); if (!validateDate(Y, M, D)) throw new RangeError(`Invalid ISO 8601 date "${this.source}"`); if (!c) return this; return this.continueFromTimeInstruction(); }; /** * Continue ordinal date precision * @param {number} Y year * @param {number} D ordinal day * @param {string} [next] next char if any */ ISODate.prototype.continueOrdinalDatePrecision = function continueOrdinalDatePrecision(Y, D, next) { if (!validateOrdinalDate(Y, D)) throw new RangeError(`Invalid ISO 8601 ordinal date "${this.source}"`); this.result.Y = Y; this.result.D = D; if (!next) return this; return this.continueFromTimeInstruction(); }; /** * Continue from week instruciton * @param {number} Y year */ ISODate.prototype.continueFromWeekInstruction = function continueFromWeekInstruction(Y) { const W = (this.result.W = Number(this.consumeChar('012345') + this.consumeChar())); let c; if (this.enforceSeparators) { c = this.consumeCharOrEnd(ISODATE_HYPHEN); if (!c) { if (!validateWeek(Y, W)) throw new RangeError(`Invalid ISO 8601 week date "${this.source}"`); this.result.D = 1; return this; } } c = this.consumeCharOrEnd(ISODATE_WEEKDAYS); if (!c) { this.result.D = 1; } else { this.result.D = Number(c); } if (!validateWeek(Y, W)) throw new RangeError(`Invalid ISO 8601 week date "${this.source}"`); c = this.consumeCharOrEnd(ISODATE_TIMEINSTRUCTION); if (!c) return this; return this.continueFromTimeInstruction(); }; /** * Continue from time instruction */ ISODate.prototype.continueFromTimeInstruction = function continueFromTimeInstruction() { const H = (this.result.H = Number(this.consumeChar(ISOTIME_STARTHOUR) + this.consumeChar())); return this.continueTimePrecision(H); }; /** * Consume minutes and seconds and so forth * @param {number} H from hour */ ISODate.prototype.continueTimePrecision = function continueTimePrecision(H) { if (H > 24) throw new RangeError(`Invalid ISO 8601 hours "${this.parsed}[${this.c}]" at ${this.idx}`); const midnight = H === 24; const firstChars = midnight ? '0' : ISOTIME_STARTPART; const numberChars = midnight ? '0' : NUMBERS; const timeSeparator = this.enforceSeparators ? ISOTIME_SEPARATOR : ''; /** @type {string | undefined} */ let c = this.consumeChar(timeSeparator + firstChars); if (c === timeSeparator) { c = this.consumeChar(firstChars); } else if (this.enforceSeparators) { throw this.createUnexpectedError(); } this.result.m = Number(c + this.consumeChar(numberChars)); c = this.consumeCharOrEnd(timeSeparator + ISOTIME_OFFSET + numberChars); if (!c) { return this; } else if (c === ISOTIME_SEPARATOR) { c = this.consumeChar(ISOTIME_STARTPART); } else if (ISOTIME_OFFSET.indexOf(c) > -1) { return this.continueTimeZonePrecision(c); } else if (this.enforceSeparators) { throw this.createUnexpectedError(); } let value = c + this.consumeChar(numberChars); this.result.S = Number(value); c = this.consumeCharOrEnd(FRACTIONS + ISOTIME_OFFSET); if (!c) { return this; } if (FRACTIONS.indexOf(c) > -1) { value = this.consumeChar(numberChars); while ((c = this.consumeCharOrEnd(numberChars + ISOTIME_OFFSET))) { if (!c || NUMBERS.indexOf(c) === -1) break; if (value.length === 3) value += '.'; value += c; if (value.length > 18) throw this.createUnexpectedError(); } if (value.length < 3) value = (value + '000').slice(0, 3); this.result.F = Number(value); } if (!c) { return this; } return this.continueTimeZonePrecision(c); }; /** * Continue timezone offset parsing * @param {string} instruction timezone offset instruction */ ISODate.prototype.continueTimeZonePrecision = function continueTimeZonePrecision(instruction) { const z = (this.result.Z = instruction); let c = this.consumeCharOrEnd(ISOTIME_STARTHOUR); if (c && z === ISO_ZULU) throw this.createUnexpectedError(); else if (!c) return this; this.result.OH = Number(c + this.consumeChar(c === '2' ? '0123' : NUMBERS)); c = this.consumeCharOrEnd(ISOTIME_SEPARATOR + ISOTIME_STARTPART); if (!c) return this; if (c === ISOTIME_SEPARATOR) { c = this.consumeChar(ISOTIME_STARTPART); } this.result.Om = Number(c + this.consumeChar()); c = this.consumeCharOrEnd(ISOTIME_SEPARATOR + ISOTIME_STARTPART); if (!c) return this; if (c === ISOTIME_SEPARATOR) { c = this.consumeChar(ISOTIME_STARTPART); } this.result.OS = Number(c + this.consumeChar()); return this.end(); }; ISODate.prototype.consume = function consume() { this.parsed += this.c; const c = (this.c = this.source[++this.idx]); return c; }; /** * Consume next char * @param {string} [valid] defaults to number char */ ISODate.prototype.consumeChar = function consumeChar(valid = NUMBERS) { const c = this.consume(); if (valid.indexOf(c) === -1) throw this.createUnexpectedError(); return c; }; ISODate.prototype.peek = function peek() { return this.source[this.idx + 1]; }; ISODate.prototype.end = function end() { this.consumeCharOrEnd(''); return this; }; /** * Consume char or end * @param {string} [valid] Valid chars, defaults to 0-9 * @returns {string | undefined} */ ISODate.prototype.consumeCharOrEnd = function consumeCharOrEnd(valid = NUMBERS) { const c = this.consume(); if (c && this.endChars && this.endChars.indexOf(c) > -1) { return undefined; } else if (c && valid.indexOf(c) === -1) { throw this.createUnexpectedError(); } return c; }; ISODate.prototype.createUnexpectedError = function createUnexpectedError() { const c = this.c; return new RangeError(`Unexpected ISO 8601 date character "${this.parsed}[${c ? c : 'EOL'}]" at ${this.idx}`); }; /** * ISO 8601 duration parser * @param {string} source * @param {number} [offset] */ export function ISODuration(source, offset = -1) { this.source = source; this.idx = offset > -1 ? Number(offset) : -1; this.type = ''; this.parsed = offset > 0 ? source.substring(0, offset + 1) : ''; /** @type {keyof import('types').ISOParts | undefined} */ this.designator = undefined; this.value = ''; this.usedFractions = false; this.fractionedDesignator = undefined; this.designators = ISODURATION_DATE_DESIGNATORS; this.usedDesignators = ''; /** @type {Partial<import('types').ISOParts>} */ this.result = {}; this.isDateIndifferent = true; /** @internal */ this[kIsParsed] = false; } /** * Parse ISO 8601 duration string * @param {string} source ISO 8601 duration * @param {number} [offset] Column offset */ ISODuration.parse = function parseDuration(source, offset) { const writer = new this(source, offset); writer.parse(); return writer.result; }; ISODuration.prototype.parse = function parseDuration() { if (this[kIsParsed]) { if (!this.result?.isValid) throw new RangeError(`Invalid ${this.constructor.name}`); return this; } this[kIsParsed] = true; const source = this.source; if (typeof source !== 'string') throw new TypeError('ISO 8601 duration must be a string'); if (source.length > 255) throw new RangeError('ISO 8601 duration string is too long'); const start = this.idx + 1; if (source[start] !== ISOINTERVAL_DURATION) throw this.createUnexpectedError(source[start], start); for (const c of source.slice(start)) { if (c === ISOINTERVAL_SEPARATOR) break; this.write(c, ++this.idx); } this.end(this.idx++); this.result.isValid = true; return this; }; ISODuration.prototype.toISOString = function durationToISOString() { this.parse(); const result = this.result; let isoString = 'P'; for (const designator of ISODURATION_DATE_DESIGNATORS) { // @ts-ignore const v = result[designator]; if (v) { isoString += v.toString() + designator; } } let time = 'T'; for (const designator of ISODURATION_TIME_DESIGNATORS) { if (designator === 'M') { // eslint-disable-next-line no-var var v = result.m; } else { // @ts-ignore v = result[designator]; } if (v) { time += v.toString() + designator; } } if (time[1]) isoString += time; return isoString; }; ISODuration.prototype.toJSON = function durationToJSON() { try { return this.toISOString(); } catch { return null; } }; ISODuration.prototype.toString = function durationToString() { try { return this.toISOString(); } catch { return `Invalid ${this.constructor.name}`; } }; /** * Write * @param {string | undefined} c ISO 8601 character * @param {number} column Current column */ ISODuration.prototype.write = function writeDuration(c, column) { if (!c) { return this.end(column); } if (this.fractionedDesignator) { throw new RangeError( 'ISO 8601 duration fractions are allowed on the smallest unit in the string, e.g. P0.5D or PT1.001S but not P0.5DT1H', ); } let designatorIdx; if (NUMBERS.indexOf(c) > -1) { this.value += c; } else if ((designatorIdx = this.designators.indexOf(c)) > -1) { this.designators = this.designators.slice(designatorIdx + 1); // @ts-ignore this.designator = c; this.setDesignatorValue(c, this.value); } else if (FRACTIONS.indexOf(c) > -1) { this.usedFractions = true; this.value += '.'; } else if (c === ISOINTERVAL_DURATION && !this.type) { this.type = c; } else if (c === ISODATE_TIMEINSTRUCTION && this.type === ISOINTERVAL_DURATION) { this.designators = ISODURATION_TIME_DESIGNATORS; this.type = c; } else { throw this.createUnexpectedError(c, column); } this.parsed += c; }; /** * @internal * Set duration designator and value * @param {string} designator * @param {string} value */ ISODuration.prototype.setDesignatorValue = function setDesignatorValue(designator, value) { this.designator = undefined; this.value = ''; const designatorKey = designator === 'M' && this.type === ISODATE_TIMEINSTRUCTION ? 'm' : designator; // @ts-ignore this.result[designatorKey] = Number(value); this.usedDesignators += designatorKey; if (ISODURATION_DATE_DESIGNATORS.indexOf(designatorKey) > -1) { this.isDateIndifferent = false; } if (this.usedFractions) { this.fractionedDesignator = designatorKey; } }; /** * Parse completed, no more chars * @param {number} column Current column */ ISODuration.prototype.end = function end(column) { if (this.value || this.parsed === ISOINTERVAL_DURATION || this.parsed === ISOINTERVAL_DURATION + ISODATE_TIMEINSTRUCTION) { throw this.createUnexpectedError('', column); } }; /** * Get duration expire at date * @param {Date} [startDate] start ticking from date, defaults to now * @param {number} [repetition] repetition */ ISODuration.prototype.getExpireAt = function getExpireAt(startDate, repetition = 1) { return this.applyDuration(startDate, repetition, true); }; /** * Get duration start date * @param {Date} [endDate] optional end date, defaults to now * @param {number} [repetition] number of repetitions */ ISODuration.prototype.getStartAt = function getStartAt(endDate, repetition = 1) { return this.applyDuration(endDate, repetition ? -repetition : -1, true); }; /** * Get duration in milliseconds from optional start date * @param {Date} [startDate] start date, defaults to 1971-01-01T00:00:00Z since it's not a leap year * @param {number} [repetition] repetition * @returns duration in milliseconds from start date */ ISODuration.prototype.toMilliseconds = function toMilliseconds(startDate, repetition = 1) { startDate = startDate ?? NONLEAPYEAR; return this.getExpireAt(startDate, repetition).getTime() - startDate.getTime(); }; /** * Get duration in milliseconds until optional end date * @param {Date} [endDate] end date, defaults to epoch start 1970-01-01T00:00:00Z * @param {number} [repetition] repetition * @returns duration in milliseconds from end date */ ISODuration.prototype.untilMilliseconds = function untilMilliseconds(endDate, repetition = 1) { endDate = endDate ?? NONLEAPYEAR; return this.getStartAt(endDate, repetition).getTime() - endDate.getTime(); }; /** * Calculate date indifferent duration milliseconds * @param {number} [repetitions] repetitions * @returns number of date indifferent milliseconds */ ISODuration.prototype.getDateIndifferentMilliseconds = function getDateIndifferentMilliseconds(repetitions = 1) { /** @type {any} */ const { result, usedDesignators } = this; let ms = 0; for (const designator of usedDesignators) { const value = result[designator]; switch (designator) { case 'H': ms += value * MILLISECONDS_PER_HOUR * repetitions; break; case 'm': ms += value * 60000 * repetitions; break; case 'S': ms += value * 1000 * repetitions; break; } } return Math.round(ms); }; /** * Create unexpected error * @param {string | undefined} c * @param {number} column */ ISODuration.prototype.createUnexpectedError = function createUnexpectedError(c, column) { return new RangeError(`Unexpected ISO 8601 duration character "${this.parsed}[${c ? c : 'EOL'}]" at ${column}`); }; /** * * @param {Date} [date] * @param {number} [repetitions] * @param {boolean} [useUtc] UTC * @returns new date with applied duration */ ISODuration.prototype.applyDuration = function applyDuration(date, repetitions = 1, useUtc = false) { date = date ?? new Date(); const indifferentMs = this.getDateIndifferentMilliseconds(repetitions); const ms = date.getTime(); let nextDt = new Date(ms + indifferentMs); if (!this.isDateIndifferent) { nextDt = this.applyDateDuration(nextDt, repetitions, useUtc); } if (isNaN(nextDt.getTime())) throw new RangeError(`ISO duration rendered an invalid date`); return nextDt; }; /** * Apply date duration * @param {Date} fromDate apply to date * @param {number} [repetitions] repetitions * @param {boolean} [useUtc] UTC * @returns new date with applied duration */ ISODuration.prototype.applyDateDuration = function applyDateDuration(fromDate, repetitions = 1, useUtc = false) { let endTime = fromDate.getTime(); /** @type {any} */ const result = this.result; for (const designator of 'YMWD') { if (!(designator in result)) continue; let value = repetitions * result[designator]; let designatorKey = designator; if (designator === 'W') { designatorKey = 'D'; value = value * 7; } const fromDate = new Date(endTime); const toDate = new Date(endTime); // @ts-ignore const [getter, setter] = this._getDateFns(designatorKey, useUtc); const current = getter.call(toDate); if (this.fractionedDesignator !== designator) { setter.call(toDate, current + value); endTime += toDate.getTime() - fromDate.getTime(); } else { const fullValue = ~~value; if (fullValue) { setter.call(toDate, current + fullValue); endTime += toDate.getTime() - fromDate.getTime(); } const fraction = new Date(endTime); setter.call(fraction, getter.call(fraction) + repetitions); endTime += repetitions * (fraction.getTime() - toDate.getTime()) * (value - fullValue); } if (isNaN(endTime)) throw new RangeError(`ISO duration rendered an invalid date when applying ${designator}`); } return new Date(endTime); }; /** * Get date designator getter and setter; * @internal * @param {string} designator * @param {boolean} useUtc */ ISODuration.prototype._getDateFns = function getDateFns(designator, useUtc) { const fns = useUtc ? dateUTCFns : dateLocalFns; // @ts-ignore return fns[designator]; }; /** * * @param {Date} date * @param {Date} compareTo * @param {ISODuration} duration * @param {boolean} [enforceUTC] enforce UTC if source lacks timezone offset */ function ISODateDurationFunctions(date, duration, compareTo, enforceUTC) { this.date = date; this.duration = duration; this.compareTo = compareTo; this.enforceUTC = enforceUTC; } /** * Add duration to date * @param {number} [repetitions] repetition */ ISODateDurationFunctions.prototype.addDuration = function addDuration(repetitions = 1) { const diff = this.compareTo.getTime() - this.date.getTime(); const q = diff / this.duration.toMilliseconds(); if (q < 0) return this.applyDuration(this.date); let qs = ~~q; if (qs >= repetitions) return this.applyDuration(this.date, repetitions); let expireAt = this.applyDuration(this.date, ++qs); while (expireAt <= this.compareTo && qs < repetitions) { expireAt = this.applyDuration(this.date, ++qs); } return expireAt; }; /** * Reduce duration from date * @param {number} [repetitions] number of repetitions */ ISODateDurationFunctions.prototype.reduceDuration = function reduceDuration(repetitions = 0) { const endDate = this.date; const repeat = 1 - repetitions; const ms = this.date.getTime(); const now = this.compareTo; const diff = now.getTime() - ms; const q = diff / this.duration.toMilliseconds(); if (q >= 0) return endDate; const qs = ~~q; if (qs < repeat) return this.applyDuration(endDate, repeat); let expireAt = this.applyDuration(endDate, qs); let iter = qs; while (expireAt > now && iter > repeat) { expireAt = this.applyDuration(endDate, --iter); } if (expireAt <= now) { return this.applyDuration(endDate, ++iter); } return this.applyDuration(endDate, repeat); }; /** * * @param {Date} [date] * @param {number} [repetitions] * @returns new date with applied duration */ ISODateDurationFunctions.prototype.applyDuration = function applyDuration(date, repetitions = 1) { return this.duration.applyDuration(date, repetitions, this.enforceUTC); }; /** * Parse ISO 8601 interval * @param {string} isoInterval ISO 8601 interval * @param {boolean} [enforceUTC] enforce UTC if source lacks timezone offset */ export function parseInterval(isoInterval, enforceUTC) { return new ISOInterval(isoInterval, enforceUTC).parse(); } /** * Parse ISO 8601 duration or interval to get duration * @param {string} isoDuration ISO 8601 duration or interval */ export function parseDuration(isoDuration) { return new ISOInterval(isoDuration).parse().duration; } /** * Parse ISO 8601 date * @param {string | Date | number} isoDateSource ISO 8601 date * @param {boolean} [enforceUTC] enforce UTC if source lacks timezone offset */ export function getDate(isoDateSource, enforceUTC) { if (isoDateSource instanceof Date) return new Date(isoDateSource); else if (typeof isoDateSource === 'number') return new Date(isoDateSource); else if (!isoDateSource || typeof isoDateSource !== 'string') { throw new TypeError('ISO 8601 date source and must be a string'); } return new ISODate(isoDateSource, { enforceUTC }).toDate(); } /** * Interval expire at date * @param {string} isoInterval ISO 8601 interval * @param {Date} [compareDate] optional compare date, defaults to now * @param {Date} [startDate] optional start date for use when only duration is present * @param {boolean} [enforceUTC] enforce UTC if source lacks timezone offset */ export function getExpireAt(isoInterval, compareDate, startDate, enforceUTC) { return new ISOInterval(isoInterval, enforceUTC).getExpireAt(compareDate, startDate); } /** * Interval start at date * @param {string} isoInterval ISO 8601 interval * @param {Date} [compareDate] optional compare date, defaults to now * @param {Date} [endDate] optional end date for use when only duration is present * @param {boolean} [enforceUTC] enforce UTC if source lacks timezone offset */ export function getStartAt(isoInterval, compareDate, endDate, enforceUTC) { return new ISOInterval(isoInterval, enforceUTC).getStartAt(compareDate, endDate); } /** * Validate date parts * @param {number} Y year * @param {number} M javascript month * @param {number} D day of month */ function validateDate(Y, M, D) { if (!D) return false; switch (M) { case 1: return D - (isLeapYear(Y) ? 1 : 0) < 29; case 0: case 2: case 4: case 6: case 7: case 9: case 11: return D < 32; case 3: case 5: case 8: case 10: return D < 31; } return false; } /** * Validate date parts * @param {number} Y year * @param {number} D day of month */ function validateOrdinalDate(Y, D) { if (!D || D > 366) return false; if (D < 366) return true; return D === getOrdinalDayOfYear(Y, 11, 31); } /** * Validate week parts * @param {number} Y UTC full year * @param {number} W week */ function validateWeek(Y, W) { if (!W || W > 53) return false; if (W < 53) return true; return getUTCLastWeekOfYear(Y) === 53; } /** * Get last week of UTC year * @param {number} Y UTC full year */ export function getUTCLastWeekOfYear(Y) { const dec31 = new Date(Date.UTC(Y, 11, 31)); const weekdayDec31 = getUTCWeekday(dec31); if (weekdayDec31 < 4) { return 52; } const jan4 = new Date(Date.UTC(Y, 0, 4)); return 53 * 7 + weekdayDec31 - getUTCWeekday(jan4) + 3 > 372 ? 52 : 53; } /** * Get Monday week one date * @param {number} Y UTC full year */ export function getUTCWeekOneDate(Y) { const jan4 = new Date(Date.UTC(Y, 0, 4)); const weekdayJan4 = getUTCWeekday(jan4); return new Date(jan4.getTime() - (weekdayJan4 - 1) * MILLISECONDS_PER_DAY); } /** * Get UTC week from date * @param {Date|number|string} [date] * @returns {import('types').ISOWeekParts} */ export function getUTCWeekNumber(date) { const dt = new Date(date ?? Date.now()); let Y = dt.getUTCFullYear(); const M = dt.getUTCMonth(); const D = dt.getUTCDate(); const weekday = getUTCWeekday(dt); const doy = getOrdinalDayOfYear(Y, M, D); let W = ~~((10 + doy - weekday) / 7); if (W < 1) { W = getUTCLastWeekOfYear(--Y); } else if (W === 53 && getUTCLastWeekOfYear(Y) === 52) { Y++; W = 1; } return { Y, W, weekday }; } /** * Get date expressed as ISO week string * @param {Date|number|string} [date] */ export function getISOWeekString(date) { const dt = new Date(date ?? Date.now()); const { Y, W, weekday } = getUTCWeekNumber(dt); const iso = dt.toISOString(); const paddedW = W < 10 ? `0${W}` : W; return `${Y}-W${paddedW}-${weekday}T${iso.split('T').pop()}`; } /** * Get ISO weekday from date * 1 = Monday, 7 = Sunday * @param {Date} date * @returns {import('types').ISOWeekday} */ function getUTCWeekday(date) { const weekday = date.getUTCDay(); return !weekday ? 7 : weekday; } /** * Is leap year * @param {number} year */ function isLeapYear(year) { if (year % 4) return false; return year % 100 === 0 ? year % 400 === 0 : true; } /** * Get UTC date from week and weekday * @param {number} Y year * @param {number} W week number * @param {number} D weekday */ function getUTCDateFromWeek(Y, W, D) { const daysToAdd = (W - 1) * 7 + (D - 1); return new Date(getUTCWeekOneDate(Y).getTime() + daysToAdd * MILLISECONDS_PER_DAY); } /** * Get UTC date from week and weekday * @param {number} Y year * @param {number} D days from january first */ function getUTCDateFromOrdinalDate(Y, D) { return new Date(Date.UTC(Y, 0, D)); } /** * Get ordinal days, count number of days until date * @param {number} Y year * @param {number} M javascript month, 0 = January * @param {number} D day of month */ function getOrdinalDayOfYear(Y, M, D) { let doy = D; switch (M - 1) { case 10: doy += 30; case 9: doy += 31; case 8: doy += 30; case 7: doy += 31; case 6: doy += 31; case 5: doy += 30; case 4: doy += 31; case 3: doy += 30; case 2: doy += 31; case 1: { doy += 28; if (isLeapYear(Y)) { doy += 1; } } case 0: doy += 31; } return doy; }