UNPKG

tell-me-when

Version:
1,548 lines (1,435 loc) 41.1 kB
import { AddFn, DateFn } from './util/DateFn' import { GrammarNode } from './util/GrammarNode' import { ParseNode } from './util/ParseNode' import { ParseRootNode } from './util/ParseRootNode' import * as base from './util/parse' const { token, group, named, oneOf, longestOf, negativeLookahead } = GrammarNode export const space = token(/\s+/) export class FullYearNode extends ParseNode { constructor(public wrapped: ParseNode) { super('FullYear', wrapped.from, wrapped.to) } year(input: string) { return parseInt(this.substringOf(input)) } dateFns(input: string): DateFn[] { return [['setYear', this.year(input)]] } } const FullYear = token(/\d{4}/).parseAs(FullYearNode) export class TwoDigitYearNode extends ParseNode { constructor(public wrapped: ParseNode) { super('TwoDigitYear', wrapped.from, wrapped.to) } year(input: string) { const digits = parseInt(this.substringOf(input).replace(/^'/, '')) return digits >= 70 ? 1900 + digits : 2000 + digits } dateFns(input: string): DateFn[] { return [['setYear', this.year(input)]] } } const TwoDigitYear = token(/'?\d\d/).parseAs(TwoDigitYearNode) const YearNum = FullYear.or(TwoDigitYear) const YearNumNotHour = oneOf( FullYear, token(/'\d\d/).parseAs(TwoDigitYearNode), group( token(/\d\d/).parseAs(TwoDigitYearNode), negativeLookahead(/:|\s*[ap](m|\s)/i) ) ) export class MonthNumNode extends ParseNode { constructor(public wrapped: ParseNode) { super('MonthNum', wrapped.from, wrapped.to) } month(input: string) { return parseInt(this.substringOf(input)) - 1 } dateFns(input: string): DateFn[] { return [ ['setMonth', this.month(input)], [ 'closestToNow', [['if', { afterNow: [['addYears', -1]] }]], [['if', { beforeNow: [['addYears', 1]] }]], ], ['startOfMonth'], ['makeInterval', ['addMonths', 1]], ] } } const MonthNum = token(/1[0-2]|0?[1-9]/).parseAs(MonthNumNode) export class MonthNameNode extends ParseNode { static months = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, } month(input: string) { return MonthNameNode.months[ input .substring(this.from, this.from + 3) .toLowerCase() as keyof (typeof MonthNameNode)['months'] ] } dateFns(input: string): DateFn[] { const month = this.month(input) return [ ['setMonth', month], [ 'closestToNow', [['if', { afterNow: [['addYears', -1]] }]], [['if', { beforeNow: [['addYears', 1]] }]], ], ['startOfMonth'], ['makeInterval', ['addMonths', 1]], ] } } const MonthNameFull = named( 'MonthNameFull', /(january|february|march|may|april|june|july|august|september|october|november|december)(?![a-z])/i ).parseAs(MonthNameNode) const MonthNameAbbrev = named( 'MonthNameAbbrev', /(jan|feb|mar|apr|may|jun|jul|aug|sept?|oct|nov|dec)(?![a-z])/i ).parseAs(MonthNameNode) const MonthName = MonthNameFull.or(group(MonthNameAbbrev, group('.').maybe())) export class RelativeMonthNameNode extends ParseNode { dateFns(input: string): DateFn[] { const month = this.find(MonthNameNode)?.month(input) if (month == null) throw new Error(`failed to find month name node`) if (this.find('Next')) { return [ ['setMonth', month], ['startOfMonth'], ['if', { beforeNow: [['addYears', 1]] }], ['makeInterval', ['addMonths', 1]], ] } if (this.find('AfterNext')) { return [ ['setMonth', month], ['startOfMonth'], ['if', { beforeNow: [['addYears', 1]] }], ['addYears', 1], ['makeInterval', ['addMonths', 1]], ] } if (this.find('Last')) { return [ ['setMonth', month], ['startOfMonth'], ['addMonths', 1], ['if', { afterNow: [['addYears', -1]] }], ['addMonths', -1], ['makeInterval', ['addMonths', 1]], ] } if (this.find('BeforeLast')) { return [ ['setMonth', month], ['startOfMonth'], ['addMonths', 1], ['if', { afterNow: [['addYears', -1]] }], ['addMonths', -1], ['addYears', -1], ['makeInterval', ['addMonths', 1]], ] } return [] } } export const RelativeMonthName = named( 'RelativeMonthName', oneOf( group(named('Last', /last/i), space, MonthName), group(named('Next', /next/i), space, MonthName), group(MonthName, space, named('BeforeLast', /before\s+last/i)), group(MonthName, space, named('AfterNext', /after\s+next/i)) ) ).parseAs(RelativeMonthNameNode) const MonthNameNoDot = MonthNameFull.or(MonthNameAbbrev) const Month = MonthName.or(MonthNum) const MonthNoDot = MonthNameNoDot.or(MonthNum) export class DayOfMonthNumNode extends ParseNode { constructor(public wrapped: ParseNode) { super('DayOfMonthNum', wrapped.from, wrapped.to) } dayOfMonth(input: string) { return parseInt(this.substringOf(input)) } } const DayOfMonthNum = token(/[12][0-9]|3[01]|0?[1-9]/).parseAs( DayOfMonthNumNode ) export class NthDayOfMonthNode extends ParseNode { constructor(public wrapped: ParseNode) { super('NthDayOfMonth', wrapped.from, wrapped.to) } dayOfMonth(input: string) { const value = this.substringOf(input).toLowerCase() switch (value) { case '1st': case 'first': return 1 case '2nd': case 'second': return 2 case '3rd': case 'third': return 3 case '4th': case 'fourth': return 4 case '5th': case 'fifth': return 5 case '6th': case 'sixth': return 6 case '7th': case 'seventh': return 7 case '8th': case 'eighth': return 8 case '9th': case 'ninth': return 9 case '10th': case 'tenth': return 10 case '11th': case 'eleventh': return 11 case '12th': case 'twelfth': return 12 case '13th': case 'thirteenth': return 13 case '14th': case 'fourteenth': return 14 case '15th': case 'fifteenth': return 15 case '16th': case 'sixteenth': return 16 case '17th': case 'seventeenth': return 17 case '18th': case 'eighteenth': return 18 case '19th': case 'ninteenth': return 19 case '20th': case 'twentieth': return 20 case '21st': case 'twenty-first': return 21 case '22nd': case 'twenty-second': return 22 case '23rd': case 'twenty-third': return 23 case '24th': case 'twenty-fourth': return 24 case '25th': case 'twenty-fifth': return 25 case '26th': case 'twenty-sixth': return 26 case '27th': case 'twenty-seventh': return 27 case '28th': case 'twenty-eighth': return 28 case '29th': case 'twenty-ninthy': return 29 case '30th': case 'thirtieth': return 30 case '31st': case 'thirty-first': return 31 } } } const NthDayOfMonth = token( /(1st|first|2nd|second|3rd|third|4th|fourth|5th|fifth|6th|sixth|7th|seventh|8th|eighth|9th|ninth|10th|tenth|11th|eleventh|12th|twelfth|13th|thirteenth|14th|fourteenth|15th|fifteenth|16th|sixteenth|17th|seventeenth|18th|eighteenth|19th|ninteenth|20th|twentieth|21st|twenty-first|22nd|twenty-second|23rd|twenty-third|24th|twenty-fourth|25th|twenty-fifth|26th|twenty-sixth|27th|twenty-seventh|28th|twenty-eighth|29th|twenty-ninthy|30th|thirtieth|31st|thirty-first)(?![a-z])/i ).parseAs(NthDayOfMonthNode) const DayOfMonth = NthDayOfMonth.or(DayOfMonthNum) type RelativeIntervalType = | 'Second' | 'Minute' | 'Day' | 'Hour' | 'Week' | 'Month' | 'Year' export abstract class RelativeIntervalNode extends ParseNode { abstract get intervalName(): RelativeIntervalType dateFns(): DateFn[] { const { intervalName } = this const offset = this.find(`Next${intervalName}`) ? 1 : this.find(`Last${intervalName}`) ? -1 : this.find(`${intervalName}BeforeLast`) ? -2 : this.find(`${intervalName}AfterNext`) ? 2 : 0 return [ ...(offset ? ([[`add${intervalName}s`, offset]] as DateFn[]) : []), [`startOf${intervalName}`], [`makeInterval`, [`add${intervalName}s`, 1]], ] } } export class RelativeSecondNode extends RelativeIntervalNode { get intervalName(): 'Second' { return 'Second' } } export class RelativeMinuteNode extends RelativeIntervalNode { get intervalName(): 'Minute' { return 'Minute' } } export class RelativeHourNode extends RelativeIntervalNode { get intervalName(): 'Hour' { return 'Hour' } } export class RelativeWeekNode extends RelativeIntervalNode { get intervalName(): 'Week' { return 'Week' } } export class RelativeMonthNode extends RelativeIntervalNode { get intervalName(): 'Month' { return 'Month' } } export class RelativeYearNode extends RelativeIntervalNode { get intervalName(): 'Year' { return 'Year' } } const RelativeIntervalNodes = { Second: RelativeSecondNode, Minute: RelativeMinuteNode, Hour: RelativeHourNode, Week: RelativeWeekNode, Month: RelativeMonthNode, Year: RelativeYearNode, } const RelativeIntervalBase = (intervalName: RelativeIntervalType) => oneOf( named(`This${intervalName}`, new RegExp(`this\\s+${intervalName}`, 'i')), named(`Last${intervalName}`, new RegExp(`last\\s+${intervalName}`, 'i')), named(`Next${intervalName}`, new RegExp(`next\\s+${intervalName}`, 'i')), named( `${intervalName}BeforeLast`, new RegExp(`(the\\s+)?${intervalName}\\s+before\\s+last`, 'i') ), named( `${intervalName}AfterNext`, new RegExp(`(the\\s+)?${intervalName}\\s+after\\s+next`, 'i') ) ) const RelativeInterval = (intervalName: Exclude<RelativeIntervalType, 'Day'>) => named(`Relative${intervalName}`, RelativeIntervalBase(intervalName)).parseAs( RelativeIntervalNodes[intervalName] ) export const RelativeSecond = RelativeInterval('Second') export const RelativeMinute = RelativeInterval('Minute') export const RelativeHour = RelativeInterval('Hour') export const RelativeWeek = RelativeInterval('Week') export const RelativeMonth = RelativeInterval('Month') export const RelativeYear = RelativeInterval('Year') export class DateNode extends ParseNode { yearFns(input: string): DateFn[] | undefined { return ( (this.find(FullYearNode) || this.find(TwoDigitYearNode))?.dateFns( input ) || this.find(RelativeYearNode) ?.dateFns() .filter((fn) => fn[0] === 'addYears') ) } monthFns(input: string): DateFn[] | undefined { const month = ( this?.find(MonthNumNode) || this?.find(MonthNameNode) )?.month(input) return month != null ? [['setMonth', month]] : undefined } relativeMonthFns(input: string): DateFn[] | undefined { return this.find(RelativeMonthNameNode) ?.dateFns(input) .filter((fn) => fn[0] !== 'makeInterval') } day(input: string) { return ( this?.find(DayOfMonthNumNode) || this?.find(NthDayOfMonthNode) )?.dayOfMonth(input) } dateFns(input: string): DateFn[] { const year = this.yearFns(input) const relativeMonth = this.relativeMonthFns(input) const month = relativeMonth || this.monthFns(input) const day = this.day(input) if (year == null) { return [ ...(month || []), ...(day != null ? ([['setDate', day]] satisfies DateFn[]) : []), ...((relativeMonth ? day != null ? [['startOfDay']] : [] : [ [ day != null ? 'startOfDay' : month != null ? 'startOfMonth' : 'startOfYear', ], [ 'closestToNow', [ [ 'if', { afterNow: [ [month != null ? 'addYears' : 'addMonths', -1], ], }, ], ], [ [ 'if', { beforeNow: [ [month != null ? 'addYears' : 'addMonths', 1], ], }, ], ], ], ]) satisfies DateFn[]), ['makeInterval', [day != null ? 'addDays' : 'addMonths', 1]], ] } return [ ...(year || []), ...(month || []), ...(day != null ? ([['setDate', day]] as DateFn[]) : []), [ day != null ? 'startOfDay' : month != null ? 'startOfMonth' : 'startOfYear', ], [ 'makeInterval', [day != null ? 'addDays' : month != null ? 'addMonths' : 'addYears', 1], ], ] } } const Date = named( 'Date', longestOf( group( FullYear, oneOf( group(MonthNameNoDot, DayOfMonth.maybe()), group('.', MonthNoDot, group('.', DayOfMonth).maybe()), group('-', MonthNoDot, group('-', DayOfMonth).maybe()), group('_', MonthNoDot, group('_', DayOfMonth).maybe()), group('/', MonthNoDot, group('/', DayOfMonth).maybe()), group(space, Month, group(space, DayOfMonth).maybe()) ).maybe() ), group( RelativeYear, group(space, Month, group(space, DayOfMonth).maybe()).maybe() ), group( MonthName, longestOf( group( space, DayOfMonth, group( space.or(group(space.maybe(), ',', space.maybe())), oneOf(YearNumNotHour, RelativeYear) ).maybe() ), group( space.or(group(space.maybe(), ',', space.maybe())), oneOf(YearNum, RelativeYear) ) ).maybe() ), group(RelativeMonthName, group(space, DayOfMonth).maybe()), group( MonthNameNoDot, oneOf( group('.', DayOfMonth, group('.', YearNum).maybe()), group(NthDayOfMonth, YearNumNotHour.maybe()), DayOfMonth, group('-', DayOfMonth, group('-', YearNum).maybe()), group('_', DayOfMonth, group('_', YearNum).maybe()), group('/', DayOfMonth, group('/', YearNum).maybe()) ).maybe() ), group( MonthNum, longestOf( group(/[- ._/]/, FullYear), group(NthDayOfMonth, YearNumNotHour.maybe()), group('.', DayOfMonth, group('.', YearNum).maybe()), group('-', DayOfMonth, group('-', YearNum).maybe()), group('_', DayOfMonth, group('_', YearNum).maybe()), group('/', DayOfMonth, group('/', YearNum).maybe()), group(space, DayOfMonth, group(space, YearNumNotHour).maybe()) ) ), group( group('the', space).maybe(), NthDayOfMonth, oneOf( group(MonthNameNoDot, YearNumNotHour.maybe()), group('.', MonthNoDot, group('.', YearNum).maybe()), group('-', MonthNoDot, group('-', YearNum).maybe()), group('_', MonthNoDot, group('_', YearNum).maybe()), group('/', MonthNoDot, group('/', YearNum).maybe()), group( space, group(group('day', space).maybe(), 'of', space).maybe(), MonthName, group( space.or(group(space.maybe(), ',', space.maybe())), oneOf(YearNumNotHour, RelativeYear) ).maybe() ) ).maybe() ), group( DayOfMonthNum, oneOf( group(MonthNameNoDot, YearNumNotHour.maybe()), group('.', MonthNoDot, group('.', YearNum).maybe()), group('-', MonthNoDot, group('-', YearNum).maybe()), group('_', MonthNoDot, group('_', YearNum).maybe()), group('/', MonthNoDot, group('/', YearNum).maybe()), group(space, MonthName, group(space, RelativeYear).maybe()), group(space, Month, group(space, YearNumNotHour).maybe()) ) ) ) ).parseAs(DateNode) const DayDate = named( 'DayDate', longestOf( group( FullYear, oneOf( group(MonthNameNoDot, DayOfMonth), group('.', MonthNoDot, group('.', DayOfMonth)), group('-', MonthNoDot, group('-', DayOfMonth)), group('_', MonthNoDot, group('_', DayOfMonth)), group('/', MonthNoDot, group('/', DayOfMonth)), group(space, Month, group(space, DayOfMonth)) ).maybe() ), group(RelativeYear, space, Month, group(space, DayOfMonth)), group( MonthName, group( space, DayOfMonth, group( space.or(group(space.maybe(), ',', space.maybe())), oneOf(YearNumNotHour, RelativeYear) ).maybe() ) ), group(RelativeMonthName, group(space, DayOfMonth)), group( MonthNameNoDot, oneOf( group('.', DayOfMonth, group('.', YearNum).maybe()), group(NthDayOfMonth, YearNumNotHour.maybe()), DayOfMonth, group('-', DayOfMonth, group('-', YearNum).maybe()), group('_', DayOfMonth, group('_', YearNum).maybe()), group('/', DayOfMonth, group('/', YearNum).maybe()) ) ), group( MonthNum, oneOf( group(NthDayOfMonth, YearNumNotHour.maybe()), group('.', DayOfMonth, group('.', YearNum).maybe()), group('-', DayOfMonth, group('-', YearNum).maybe()), group('_', DayOfMonth, group('_', YearNum).maybe()), group('/', DayOfMonth, group('/', YearNum).maybe()), group( space, DayOfMonth, group(space, oneOf(YearNumNotHour, RelativeYear)).maybe() ) ) ), group( group('the', space).maybe(), NthDayOfMonth, oneOf( group(MonthNameNoDot, YearNumNotHour.maybe()), group('.', MonthNoDot, group('.', YearNum).maybe()), group('-', MonthNoDot, group('-', YearNum).maybe()), group('_', MonthNoDot, group('_', YearNum).maybe()), group('/', MonthNoDot, group('/', YearNum).maybe()), group( space, group(group('day', space).maybe(), 'of', space).maybe(), MonthName, group( space.or(group(space.maybe(), ',', space.maybe())), oneOf(YearNumNotHour, RelativeYear) ).maybe() ) ).maybe() ), group( DayOfMonthNum, oneOf( group(MonthNameNoDot, YearNumNotHour.maybe()), group('.', MonthNoDot, group('.', YearNum).maybe()), group('-', MonthNoDot, group('-', YearNum).maybe()), group('_', MonthNoDot, group('_', YearNum).maybe()), group('/', MonthNoDot, group('/', YearNum).maybe()), group( space, Month, group(space, oneOf(YearNumNotHour, RelativeYear)).maybe() ) ) ) ) ).parseAs(DateNode) export class HoursNode extends ParseNode { hours(input: string) { return parseInt(this.substringOf(input)) } } const Hours = named('Hours', /2[0-3]|[01]?[0-9]/).parseAs(HoursNode) export class MinutesNode extends ParseNode { minutes(input: string) { return parseInt(this.substringOf(input)) } } const Minutes = named('Minutes', /[0-5][0-9]/).parseAs(MinutesNode) export class SecondsNode extends ParseNode { seconds(input: string) { return parseInt(this.substringOf(input)) } } const Seconds = named('Seconds', /[0-5][0-9]/).parseAs(SecondsNode) export class MillisecondsNode extends ParseNode { milliseconds(input: string) { return parseInt(this.substringOf(input).padEnd(3, '0')) } } const Milliseconds = named('Milliseconds', /\d{1,3}/).parseAs(MillisecondsNode) export enum AmPmValue { AM, PM, } export class AmPmNode extends ParseNode { amPm(input: string) { switch (input.substring(this.from, this.from + 1)) { case 'a': case 'A': return AmPmValue.AM case 'p': case 'P': return AmPmValue.PM default: throw new Error(`unexpected`) } } } const AmPm = named('AmPm', /[ap]m?(?!\w)/i).parseAs(AmPmNode) export class TimeNode extends ParseNode { hours(input: string) { return this.find(HoursNode)?.hours(input) } minutes(input: string) { return this.find(MinutesNode)?.minutes(input) } seconds(input: string) { return this.find(SecondsNode)?.seconds(input) } milliseconds(input: string) { return this.find(MillisecondsNode)?.milliseconds(input) } amPm(input: string) { return this.find(AmPmNode)?.amPm(input) } dateFns(input: string): DateFn[] { let hours = this.hours(input) const minutes = this.minutes(input) const seconds = this.seconds(input) const milliseconds = this.milliseconds(input) const amPm = this.amPm(input) if (hours != null && amPm != null) { if (hours < 0 || hours > 12) { throw new Error('hour out of range') } if (amPm === AmPmValue.PM && hours !== 12) hours += 12 else if (amPm === AmPmValue.AM && hours === 12) hours = 0 } return [ ...(hours != undefined ? ([['setHours', hours]] as DateFn[]) : []), ...(minutes != undefined ? ([['setMinutes', minutes]] as DateFn[]) : []), ...(seconds != undefined ? ([['setSeconds', seconds]] as DateFn[]) : []), ...(milliseconds != undefined ? ([['setMilliseconds', milliseconds]] as DateFn[]) : ([ [ seconds != undefined ? 'startOfSecond' : minutes != undefined ? 'startOfMinute' : 'startOfHour', ], ] as DateFn[])), ] } } const AtTime = named( 'AtTime', Hours, group( ':', Minutes, group(':', Seconds, group('.', Milliseconds).maybe()).maybe() ).maybe(), group(space.maybe(), AmPm).maybe() ).parseAs(TimeNode) const Time = named( 'Time', longestOf( group( Hours, group( ':', Minutes, group(':', Seconds, group('.', Milliseconds).maybe()).maybe() ), group(space.maybe(), AmPm).maybe() ), group( Hours, group( ':', Minutes, group(':', Seconds, group('.', Milliseconds).maybe()).maybe() ).maybe(), group(space.maybe(), AmPm) ) ) ).parseAs(TimeNode) export class NowNode extends ParseNode { dateFns(): DateFn[] { return [['now']] } } const Now = named('Now', /now|(the\s+)?present(\s+time)?/).parseAs(NowNode) export class QuantityNumNode extends ParseNode { quantity(input: string) { return parseInt(this.substringOf(input)) } } export const QuantityNum = named('QuantityNum', /\d+/).parseAs(QuantityNumNode) export class QuantityWordNode extends ParseNode { static quantities = { zero: 0, an: 1, a: 1, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, } quantity(input: string) { return QuantityWordNode.quantities[ this.substringOf( input ).toLowerCase() as keyof typeof QuantityWordNode.quantities ] } } export const QuantityWord = named( 'QuantityWord', new RegExp(Object.keys(QuantityWordNode.quantities).join('|'), 'i') ).parseAs(QuantityWordNode) export class QuantityNode extends ParseNode { quantity(input: string) { return ( this.find(QuantityNumNode) || this.find(QuantityWordNode) )?.quantity(input) } } export const Quantity = named( 'Quantity', oneOf(QuantityNum, QuantityWord) ).parseAs(QuantityNode) type DateTimeUnit = | 'years' | 'months' | 'weeks' | 'days' | 'hours' | 'minutes' | 'seconds' | 'milliseconds' export class DateTimeUnitNode extends ParseNode { unit(input: string): DateTimeUnit { switch (this.substringOf(input).toLowerCase()) { case 'y': case 'yr': case 'year': case 'years': return 'years' case 'mo': case 'mos': case 'month': case 'months': return 'months' case 'w': case 'wk': case 'wks': case 'week': case 'weeks': return 'weeks' case 'd': case 'day': case 'days': return 'days' case 'h': case 'hr': case 'hrs': case 'hour': case 'hours': return 'hours' case 'm': case 'min': case 'mins': case 'minute': case 'minutes': return 'minutes' case 's': case 'sec': case 'secs': case 'second': case 'seconds': return 'seconds' case 'ms': case 'milli': case 'millis': case 'millisecond': case 'milliseconds': return 'milliseconds' default: throw new Error('unexpected') } } dateFnName(input: string): DateFn[0] { switch (this.unit(input)) { case 'years': return 'addYears' case 'months': return 'addMonths' case 'weeks': return 'addWeeks' case 'days': return 'addDays' case 'hours': return 'addHours' case 'minutes': return 'addMinutes' case 'seconds': return 'addSeconds' case 'milliseconds': return 'addMilliseconds' default: throw new Error('unexpected') } } } export const DateTimeUnit = named( 'DateTimeUnit', /years?|yrs?|y|months?|mos?|weeks?|wks?|w|days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s|milliseconds?|millis?|ms/i ).parseAs(DateTimeUnitNode) export class DateTimeIntervalPartNode extends ParseNode { dateFns(input: string): AddFn[] { const quantity = this.find(QuantityNode)?.quantity(input) const dateFnName = this.find(DateTimeUnitNode)?.dateFnName(input) if (quantity == null || dateFnName == null) throw new Error(`unexpected`) return [[dateFnName, quantity]] as any } } export const DateTimeIntervalPart = named( 'DateTimeIntervalPart', Quantity, space.maybe(), DateTimeUnit ).parseAs(DateTimeIntervalPartNode) type DateTimeInterval = { [U in DateTimeUnit]?: number } export class DateTimeIntervalNode extends ParseNode { dateFns(input: string): AddFn[] { const fns: AddFn[] = [] for (const node of this.findAll(DateTimeIntervalPartNode)) { fns.push(...node.dateFns(input)) } return fns } } const DateTimeInterval = named( 'DateTimeInterval', DateTimeIntervalPart, group( space.maybe(), group(',', space.maybe()).maybe(), group('and', space).maybe(), DateTimeIntervalPart ).repeat(0, Infinity) ).parseAs(DateTimeIntervalNode) export class DateTimeOffsetNode extends ParseNode { dateFns(input: string): DateFn[] { const fns: AddFn[] = this.find(DateTimeIntervalNode)?.dateFns(input) || [] if (this.find('BeforeNow')) { for (const fn of fns) fn[1] = -fn[1] } return fns } } export const BeforeNow = named( 'BeforeNow', oneOf('ago', /in\s+the\s+past/i, group('before', space, Now)) ) export const AfterNow = named( 'AfterNow', oneOf(group(/after|from/i, space, Now), /in\s+the\s+future/i) ) export const DateTimeOffset = named( 'DateTimeOffset', DateTimeInterval, space.maybe(), oneOf(BeforeNow, AfterNow) ).parseAs(DateTimeOffsetNode) export class RangeEndDateTimeOffsetNode extends DateTimeOffsetNode { dateFns(input: string): DateFn[] { const fns: DateFn[] = super.dateFns(input) if (this.find('Later')) return fns return [['now'], ...fns] } } export const RangeEndDateTimeOffset = named( 'RangeEndDateTimeOffset', DateTimeInterval, space.maybe(), oneOf( BeforeNow, AfterNow, named('Later', /after\s+(then|that)|thereafter|later/) ) ).parseAs(RangeEndDateTimeOffsetNode) export class RelativeDayNode extends ParseNode { dateFns(input: string): DateFn[] { const quantity = this.find(QuantityNode)?.quantity(input) if (quantity != null) { return [ ['addDays', this.find('BeforeNow') ? -quantity : quantity], ] as DateFn[] } const offset = this.find('Tomorrow') ? 1 : this.find('Yesterday') ? -1 : this.find('DayBeforeYesterday') ? -2 : this.find('DayAfterTomorrow') ? 2 : 0 return [ ...(offset ? ([['addDays', offset]] as DateFn[]) : []), ['startOfDay'], ['makeInterval', ['addDays', 1]], ] } } const RelativeDayBase = oneOf( named('Today', group('today')), named('Yesterday', group('yesterday')), named('Tomorrow', group('tomorrow')), group( group('the', space).maybe(), group('day', space), oneOf( named('DayBeforeYesterday', group('before', space, /yesterday|last/)), named('DayAfterTomorrow', group('after', space, /tomorrow|next/)) ) ) ) export const RelativeDay = named('RelativeDay', RelativeDayBase).parseAs( RelativeDayNode ) export class RangeEndRelativeDayNode extends RelativeDayNode { dateFns(input: string): DateFn[] { return [['now'], ...super.dateFns(input)] } } export const RangeEndRelativeDay = named( 'RangeEndRelativeDay', RelativeDayBase ).parseAs(RangeEndRelativeDayNode) export class DayOfWeekNode extends ParseNode { dayOfWeek(input: string): number { switch (this.substringOf(input).toLowerCase()) { case 'sunday': case 'sun': return 0 case 'monday': case 'mon': return 1 case 'tuesday': case 'tues': case 'tue': return 2 case 'wednesday': case 'wed': return 3 case 'thursday': case 'thurs': case 'thur': case 'thu': return 4 case 'friday': case 'fri': return 5 case 'saturday': case 'sat': return 6 } throw new Error(`invalid day of week: ${this.substringOf(input)}`) } dateFns(input: string): DateFn[] { const dayOfWeek = this.dayOfWeek(input) return [ ['setDay', dayOfWeek], [ 'closestToNow', [['if', { afterNow: [['addWeeks', -1]] }]], [['if', { beforeNow: [['addWeeks', 1]] }]], ], ['startOfDay'], ['makeInterval', ['addDays', 1]], ] } } export const DayOfWeek = named( 'DayOfWeek', /sun(day)?|tue(s(day)?)?|wed(nesday)?|thu(r(s(day)?)?)?|fri(day)?|sat(urday)?/i ).parseAs(DayOfWeekNode) export class RelativeDayOfWeekNode extends ParseNode { dateFns(input: string): DateFn[] { const dayOfWeek = this.find(DayOfWeekNode)?.dayOfWeek(input) if (dayOfWeek == null) throw new Error(`failed to find DayOfWeekNode`) if (this.find('Next')) { return [ ['setDay', dayOfWeek], ['startOfDay'], ['if', { beforeNow: [['addWeeks', 1]] }], ['makeInterval', ['addDays', 1]], ] } if (this.find('AfterNext')) { return [ ['setDay', dayOfWeek], ['startOfDay'], ['if', { beforeNow: [['addWeeks', 1]] }], ['addWeeks', 1], ['makeInterval', ['addDays', 1]], ] } if (this.find('Last')) { return [ ['setDay', dayOfWeek], ['startOfDay'], ['addDays', 1], ['if', { afterNow: [['addWeeks', -1]] }], ['addDays', -1], ['makeInterval', ['addDays', 1]], ] } if (this.find('BeforeLast')) { return [ ['setDay', dayOfWeek], ['startOfDay'], ['addDays', 1], ['if', { afterNow: [['addWeeks', -1]] }], ['addDays', -1], ['addWeeks', -1], ['makeInterval', ['addDays', 1]], ] } return [] } } export const RelativeDayOfWeek = named( 'RelativeDayOfWeek', oneOf( group(named('Last', 'last'), space, DayOfWeek), group(named('Next', 'next'), space, DayOfWeek), group(DayOfWeek, space, named('BeforeLast', /before\s+last/i)), group(DayOfWeek, space, named('AfterNext', /after\s+next/i)) ) ).parseAs(RelativeDayOfWeekNode) const SpecificDay = oneOf(RelativeDay, DayDate, RelativeDayOfWeek, DayOfWeek) const RangeEndSpecificDay = oneOf( RangeEndRelativeDay, DayDate, RelativeDayOfWeek, DayOfWeek ) export abstract class RangeEndRelativeIntervalNode extends RelativeIntervalNode { dateFns(): DateFn[] { return [['now'], ...super.dateFns()] } } export class RangeEndRelativeSecondNode extends RangeEndRelativeIntervalNode { get intervalName(): 'Second' { return 'Second' } } export class RangeEndRelativeMinuteNode extends RangeEndRelativeIntervalNode { get intervalName(): 'Minute' { return 'Minute' } } export class RangeEndRelativeHourNode extends RangeEndRelativeIntervalNode { get intervalName(): 'Hour' { return 'Hour' } } export class RangeEndRelativeWeekNode extends RangeEndRelativeIntervalNode { get intervalName(): 'Week' { return 'Week' } } export class RangeEndRelativeMonthNode extends RangeEndRelativeIntervalNode { get intervalName(): 'Month' { return 'Month' } } export class RangeEndRelativeYearNode extends RangeEndRelativeIntervalNode { get intervalName(): 'Year' { return 'Year' } } const RangeEndRelativeIntervalNodes = { Second: RangeEndRelativeSecondNode, Minute: RangeEndRelativeMinuteNode, Hour: RangeEndRelativeHourNode, Week: RangeEndRelativeWeekNode, Month: RangeEndRelativeMonthNode, Year: RangeEndRelativeYearNode, } export const RangeEndRelativeInterval = ( intervalName: Exclude<RelativeIntervalType, 'Day'> ) => named( `RangeEndRelative${intervalName}`, RelativeIntervalBase(intervalName) ).parseAs(RangeEndRelativeIntervalNodes[intervalName]) export const RangeEndRelativeSecond = RangeEndRelativeInterval('Second') export const RangeEndRelativeMinute = RangeEndRelativeInterval('Minute') export const RangeEndRelativeHour = RangeEndRelativeInterval('Hour') export const RangeEndRelativeWeek = RangeEndRelativeInterval('Week') export const RangeEndRelativeMonth = RangeEndRelativeInterval('Month') export const RangeEndRelativeYear = RangeEndRelativeInterval('Year') function negateDateFns(dateFns: DateFn[]): DateFn[] { return dateFns.map((fn) => { switch (fn[0]) { case 'addDays': case 'addHours': case 'addMilliseconds': case 'addMinutes': case 'addMonths': case 'addSeconds': case 'addWeeks': case 'addYears': return [fn[0], -fn[1]] } return fn }) } class DateTimeOffsetIntervalUnitNode extends ParseNode { dateFns(input: string): DateFn[] { switch (this.substringOf(input).toLowerCase()) { case 'year': case 'yr': return [['addYears', 1]] case 'month': case 'mon': return [['addMonths', 1]] case 'week': case 'wk': return [['addWeeks', 1]] case 'day': return [['addDays', 1]] case 'hour': case 'hr': return [['addHours', 1]] case 'minute': case 'min': return [['addMinutes', 1]] case 'second': case 'sec': return [['addSeconds', 1]] } throw new Error(`invalid input`) } } const DateTimeOffsetIntervalUnit = named( 'DateTimeOffsetIntervalUnit', /year|yr|month|mon|week|wk|day|hour|hr|minute|min|second|sec/i ).parseAs(DateTimeOffsetIntervalUnitNode) export class DateTimeOffsetIntervalNode extends ParseNode { dateFns(input: string): DateFn[] { const fns = this.find(DateTimeIntervalNode)?.dateFns(input) ?? this.find(DateTimeOffsetIntervalUnitNode)?.dateFns(input) if (!fns) throw new Error(`expected to find a DateTimeIntervalNode`) return this.find('Past') ? [...negateDateFns(fns), ['makeInterval', ['now']]] : [['makeInterval', ...fns]] } } export class RangeEndDateTimeOffsetIntervalNode extends DateTimeOffsetIntervalNode { dateFns(input: string): DateFn[] { return [['now'], ...super.dateFns(input)] } } export const DateTimeOffsetIntervalBase = group( oneOf( named('Past', /(the\s+)?(past|last)/i), named('Future', /(the\s+)?(next|coming)/i) ), space, oneOf(DateTimeInterval, DateTimeOffsetIntervalUnit) ) export const DateTimeOffsetInterval = named( 'DateTimeOffsetInterval', DateTimeOffsetIntervalBase ).parseAs(DateTimeOffsetIntervalNode) export const RangeEndDateTimeOffsetInterval = named( 'RangeEndDateTimeOffsetInterval', DateTimeOffsetIntervalBase ).parseAs(RangeEndDateTimeOffsetIntervalNode) export class DateTimeNode extends ParseNode { date(input: string): DateFn[] | undefined { return ( this.find(DateNode) || this.find(RelativeDayNode) || this.find(RelativeDayOfWeekNode) || this.find(DayOfWeekNode) || this.find(RelativeIntervalNode) || this.find(RelativeMonthNameNode) || this.find(MonthNameNode) || this.find(DateTimeOffsetNode) || this.find(DateTimeOffsetIntervalNode) || this.find(NowNode) )?.dateFns(input) } time(input: string): DateFn[] | undefined { return this.find(TimeNode)?.dateFns(input) } dateFns(input: string): DateFn[] { const Time = this.time(input) const Date = this.date(input) if (Date && Time) { const lastIfIndex = Date.findIndex((op) => op[0] === 'if') return [ ...Date.filter( (op, index) => op[0] !== 'makeInterval' && (index < lastIfIndex || !op[0].startsWith('startOf')) ), ...Time, ] } return Date || Time || [] } } export const DateTime = named( 'DateTime', longestOf( Date, RelativeSecond, RelativeMinute, RelativeHour, RelativeWeek, RelativeMonthName, MonthName, RelativeMonth, DateTimeOffsetInterval, group( oneOf(DateTimeOffset, SpecificDay), group(/\s+(at\s+)?|\s*,\s*|\s+/i, AtTime).maybe() ), group(Time, group(/\s+(on\s+)?|\s*,\s*|\s+/i, SpecificDay).maybe()), group(AtTime, group(/\s+(on\s+)?|\s*,\s*/i, SpecificDay)), Now ) ).parseAs(DateTimeNode) export const RangeEndDateTime = named( 'RangeEndDateTime', longestOf( Date, RangeEndRelativeSecond, RangeEndRelativeMinute, RangeEndRelativeHour, RangeEndRelativeWeek, RangeEndRelativeMonth, RangeEndDateTimeOffsetInterval, group( oneOf(RangeEndDateTimeOffset, RangeEndSpecificDay), group(space, group('at', space).maybe(), AtTime).maybe() ), group( Time, group(space, group('on', space).maybe(), RangeEndSpecificDay).maybe() ), group(AtTime, group(space, group('on', space), RangeEndSpecificDay)), Now ) ).parseAs(DateTimeNode) export class RangeNode extends ParseNode { dateFns(input: string): DateFn[] { const RangeStart = this.find('RangeStart')?.find(DateTimeNode) if (!RangeStart) throw new Error('unexpected') let start = RangeStart.dateFns(input) const RangeEnd = this.find('RangeEnd')?.find(DateTimeNode) if (!RangeEnd) throw new Error('unexpected') let end = RangeEnd.dateFns(input) if ( !start.some((f) => f[0] === 'setYear' || f[0] === 'addYears') && end.some((f) => f[0] === 'setYear' || f[0] === 'addYears') ) { start = [ ...end.filter( (f) => f[0] === 'setYear' || f[0] === 'addYears' || f[0] === 'startOfYear' ), ...start.filter((f) => f[0] !== 'closestToNow'), ] end = end.filter( (f) => f[0] !== 'setYear' && f[0] !== 'addYears' && f[0] !== 'startOfYear' ) } const through = this.find('Through') != null const endFns = end.flatMap((fn) => fn[0] === 'makeInterval' ? (fn.slice(1) as DateFn[]) : [fn] ) if ( !through && end.find((fn) => fn[0] === 'makeInterval') && endFns[endFns.length - 1]?.[0]?.startsWith('add') ) { endFns.pop() } return [ ...start.filter((fn) => fn[0] !== 'makeInterval'), ['makeInterval', ...endFns], ] } } export const Range = named( 'Range', group( group('from', space).maybe(), named('RangeStart', DateTime), oneOf( group(space, oneOf('to', named('Through', 'through'), 'until'), space), /\s*-\s*/ ), named('RangeEnd', RangeEndDateTime) ) ).parseAs(RangeNode) export class RootNode extends ParseRootNode { dateFns(input: string): DateFn[] { return ( (this.find(RangeNode) || this.find(DateTimeNode))?.dateFns(input) || [] ) } } export const Root = group( space.maybe(), oneOf(Range, DateTime), space.maybe() ).parseAs(RootNode) export function parse(input: string) { return base.parse(input, { grammar: Root }) } export function tellMeWhen(when: string, options?: { now?: Date }) { return base.tellMeWhen(when, { ...options, grammar: Root }) }