From 615b24ee743e3bc524ae14d37d410013ed9bcb50 Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Thu, 22 Aug 2024 14:07:42 +0200 Subject: [PATCH 1/2] feat: parse well known date strings --- src/dateTime/dateTime.ts | 84 ++++--- src/dateTime/dateTimeUtc.test.ts | 18 ++ src/dateTime/parse.ts | 232 +++++++++++++++++-- src/dateTime/regexParse.ts | 368 +++++++++++++++++++++++++++++++ src/locale/english.ts | 48 ++++ src/typings/dateTime.ts | 16 +- src/utils/locale.ts | 5 + src/utils/utils.ts | 6 + 8 files changed, 735 insertions(+), 42 deletions(-) create mode 100644 src/dateTime/regexParse.ts create mode 100644 src/locale/english.ts diff --git a/src/dateTime/dateTime.ts b/src/dateTime/dateTime.ts index 3bc4549..d24d662 100644 --- a/src/dateTime/dateTime.ts +++ b/src/dateTime/dateTime.ts @@ -31,9 +31,11 @@ import { weeksInWeekYear, } from '../utils'; import type {DateObject} from '../utils'; +import {getLocaleData, getLocaleWeekValues} from '../utils/locale'; import {formatDate} from './format'; import {getTimestampFromArray, getTimestampFromObject} from './parse'; +import {parseDateString} from './regexParse'; import {fromTo} from './relative'; const IS_DATE_TIME = Symbol('isDateTime'); @@ -281,7 +283,7 @@ class DateTimeImpl implements DateTime { } isSame(input?: DateTimeInput, granularity?: DurationUnit): boolean { - const [ts] = getTimestamp(input, 'system'); + const [ts] = getTimestamp(input, 'system', this._locale); if (!this.isValid() || isNaN(ts)) { return false; } @@ -289,7 +291,7 @@ class DateTimeImpl implements DateTime { } isBefore(input?: DateTimeInput, granularity?: DurationUnit): boolean { - const [ts] = getTimestamp(input, 'system'); + const [ts] = getTimestamp(input, 'system', this._locale); if (!this.isValid() || isNaN(ts)) { return false; } @@ -299,7 +301,7 @@ class DateTimeImpl implements DateTime { } isAfter(input?: DateTimeInput, granularity?: DurationUnit): boolean { - const [ts] = getTimestamp(input, 'system'); + const [ts] = getTimestamp(input, 'system', this._locale); if (!this.isValid() || isNaN(ts)) { return false; } @@ -323,7 +325,7 @@ class DateTimeImpl implements DateTime { const value = DateTimeImpl.isDateTime(amount) ? amount.timeZone(this._timeZone) : createDateTime({ - ts: getTimestamp(amount, 'system')[0], + ts: getTimestamp(amount, 'system', this._locale)[0], timeZone: this._timeZone, locale: this._locale, offset: this._offset, @@ -772,11 +774,6 @@ class DateTimeImpl implements DateTime { } } -function getLocaleWeekValues(localeData: {yearStart?: number; weekStart?: number}) { - const {weekStart, yearStart} = localeData; - return {startOfWeek: weekStart || 7, minDaysInFirstWeek: yearStart || 1}; -} - function absRound(v: number) { const sign = Math.sign(v); return Math.round(sign * v) * sign; @@ -817,20 +814,27 @@ function createDateTime({ locale: string; }): DateTime { const loc = locale || 'en'; - const localeData = dayjs.Ls[loc] as Locale; + const localeData = getLocaleData(loc); const isValid = !isNaN(Number(new Date(ts))); - return new DateTimeImpl({ts, timeZone, offset, locale: loc, localeData, isValid}); + return new DateTimeImpl({ + ts, + timeZone, + offset, + locale: loc, + localeData, + isValid, + }); } function getTimestamp( input: DateTimeInput, timezone: string, + locale: string, format?: string, - lang?: string, - utc = false, + fixedOffset?: number, ): [ts: number, offset: number] { let ts: number; - let offset: number | undefined; + let offset = fixedOffset; if ( isDateTime(input) || typeof input === 'number' || @@ -841,18 +845,35 @@ function getTimestamp( } else if (input === null || input === undefined) { ts = Date.now(); } else if (Array.isArray(input)) { - [ts, offset] = getTimestampFromArray(input, timezone); + [ts, offset] = getTimestampFromArray(input, timezone, fixedOffset); } else if (typeof input === 'object') { - [ts, offset] = getTimestampFromObject(input, timezone); - } else if (utc) { - ts = dayjs.utc(input, format, STRICT).valueOf(); - } else { - const locale = dayjs.locale(lang || settings.getLocale(), undefined, true); + [ts, offset] = getTimestampFromObject(input, timezone, fixedOffset); + } else if (format === undefined) { + const [dateObject, timezoneOrOffset] = parseDateString(input); + if (Object.keys(dateObject).length === 0) { + return [NaN, NaN]; + } + [ts] = getTimestampFromObject( + dateObject, + typeof timezoneOrOffset === 'string' ? timezoneOrOffset : 'system', + typeof timezoneOrOffset === 'number' ? timezoneOrOffset : fixedOffset, + ); + if ( + fixedOffset !== undefined && + timezoneOrOffset !== null && + timezoneOrOffset !== fixedOffset + ) { + ts -= fixedOffset * 60 * 1000; + } + } else if (fixedOffset === undefined) { const localDate = format ? dayjs(input, format, locale, STRICT) : dayjs(input, undefined, locale); ts = localDate.valueOf(); + } else { + ts = dayjs.utc(input, format, STRICT).valueOf(); + ts -= fixedOffset * 60 * 1000; } offset = offset ?? timeZoneOffset(timezone, ts); @@ -886,7 +907,7 @@ export function dateTime(opt?: { const timeZoneOrDefault = normalizeTimeZone(timeZone, settings.getDefaultTimeZone()); const locale = dayjs.locale(lang || settings.getLocale(), undefined, true); - const [ts, offset] = getTimestamp(input, timeZoneOrDefault, format, lang); + const [ts, offset] = getTimestamp(input, timeZoneOrDefault, locale, format); const date = createDateTime({ ts, @@ -898,17 +919,30 @@ export function dateTime(opt?: { return date; } -export function dateTimeUtc(opt?: {input?: DateTimeInput; format?: FormatInput; lang?: string}) { - const {input, format, lang} = opt || {}; +/** + * Creates a DateTime instance with fixed offset. + * @param [opt] + * @param {DateTimeInput=} [opt.input] - input to parse. + * @param {string=} [opt.format] - strict {@link https://dayjs.gitee.io/docs/en/display/format format} for parsing user's input. + * @param {number=} [opt.offset=0] - specified offset. + * @param {string=} [opt.lang] - specified locale. + */ +export function dateTimeUtc(opt?: { + input?: DateTimeInput; + format?: FormatInput; + lang?: string; + offset?: number; +}): DateTime { + const {input, format, lang, offset = 0} = opt || {}; const locale = dayjs.locale(lang || settings.getLocale(), undefined, true); - const [ts] = getTimestamp(input, UtcTimeZone, format, lang, true); + const [ts] = getTimestamp(input, UtcTimeZone, locale, format, offset); const date = createDateTime({ ts, timeZone: UtcTimeZone, - offset: 0, + offset, locale, }); diff --git a/src/dateTime/dateTimeUtc.test.ts b/src/dateTime/dateTimeUtc.test.ts index a02a81c..21840cb 100644 --- a/src/dateTime/dateTimeUtc.test.ts +++ b/src/dateTime/dateTimeUtc.test.ts @@ -59,5 +59,23 @@ describe('DateTimeUtc', () => { const date = dateTimeUtc({input, format}).toISOString(); expect(date).toEqual(expected); }); + + test.each<[string, string]>([ + ['2023-12-31', '2023-12-31T00:00:00.000+02:30'], + ['2023-12-31T01:00', '2023-12-31T01:00:00.000+02:30'], + ['2023-12-31T01:00Z', '2023-12-31T01:00:00.000+02:30'], + ['2023-12-31T03:00+02:00', '2023-12-31T01:00:00.000+02:30'], + ])('input option (%p) with offset', (input, expected) => { + const date = dateTimeUtc({input, offset: 150}).toISOString(true); + expect(date).toEqual(expected); + }); + + test.each<[string, string, string]>([ + ['31.12.2023', 'DD.MM.YYYY', '2023-12-31T00:00:00.000+02:30'], + ['31.12.2023 01:00', 'DD.MM.YYYY HH:mm', '2023-12-31T01:00:00.000+02:30'], + ])('input (%p) format (%p) with offset', (input, format, expected) => { + const date = dateTimeUtc({input, format, offset: 150}).toISOString(true); + expect(date).toEqual(expected); + }); }); }); diff --git a/src/dateTime/parse.ts b/src/dateTime/parse.ts index d1251cc..a1be594 100644 --- a/src/dateTime/parse.ts +++ b/src/dateTime/parse.ts @@ -1,53 +1,253 @@ import {fixOffset, timeZoneOffset} from '../timeZone'; import type {InputObject} from '../typings'; -import {normalizeComponent, normalizeDateComponents, objToTS, tsToObject} from '../utils'; +import { + daysInMonth, + daysInYear, + gregorianToOrdinal, + gregorianToWeek, + normalizeComponent, + normalizeDateComponents, + objToTS, + tsToObject, + uncomputeOrdinal, + weekToGregorian, + weeksInWeekYear, +} from '../utils'; import type {DateObject} from '../utils'; +import {getLocaleData, getLocaleWeekValues} from '../utils/locale'; -export function getTimestampFromArray(input: (number | string)[], timezone: string) { +export function getTimestampFromArray( + input: (number | string)[], + timezone: string, + offset?: number, +) { if (input.length === 0) { - return getTimestampFromObject({}, timezone); + return getTimestampFromObject({}, timezone, offset); } const dateParts = input.map(Number); const [year, month = 0, date = 1, hour = 0, minute = 0, second = 0, millisecond = 0] = dateParts; - return getTimestampFromObject({year, month, date, hour, minute, second, millisecond}, timezone); + return getTimestampFromObject( + {year, month, date, hour, minute, second, millisecond}, + timezone, + offset, + ); } const defaultUnitValues = { year: 1, - month: 1, + month: 0, date: 1, hour: 0, minute: 0, second: 0, millisecond: 0, } as const; +const defaultWeekUnitValues = { + weekYear: 1, + weekNumber: 1, + weekday: 1, + hour: 0, + minute: 0, + second: 0, + millisecond: 0, +} as const; +const defaultOrdinalUnitValues = { + year: 1, + dayOfYear: 1, + hour: 0, + minute: 0, + second: 0, + millisecond: 0, +} as const; const orderedUnits = ['year', 'month', 'date', 'hour', 'minute', 'second', 'millisecond'] as const; +const orderedWeekUnits = [ + 'weekYear', + 'weekNumber', + 'weekday', + 'hour', + 'minute', + 'second', + 'millisecond', +] as const; +const orderedOrdinalUnits = [ + 'year', + 'dayOfYear', + 'hour', + 'minute', + 'second', + 'millisecond', +] as const; export function getTimestampFromObject( input: InputObject, timezone: string, + offset?: number, ): [ts: number, offset: number] { - const normalized = normalizeDateComponents(input, normalizeComponent); + let normalized = normalizeDateComponents(input, normalizeComponent); normalized.date = normalized.day ?? normalized.date; - const objNow = tsToObject(Date.now(), timeZoneOffset(timezone, Date.now())); + const definiteWeekStuff = + normalized.weekNumber !== undefined || normalized.weekYear !== undefined; + + const containsDayOfYear = normalized.dayOfYear !== undefined; + const containsYear = normalized.year !== undefined; + const containsMonthOrDate = normalized.month !== undefined || normalized.date !== undefined; + const containsYearOrMonthDay = containsYear || containsMonthOrDate; + + if ((containsYearOrMonthDay || containsDayOfYear) && definiteWeekStuff) { + throw new Error("Can't mix weekYear/weekNumber units with year/month/day or ordinals"); + } + + if (containsMonthOrDate && containsDayOfYear) { + throw new Error("Can't mix ordinal dates with month/day"); + } + + const useWeekData = definiteWeekStuff || (normalized.weekday && !containsYearOrMonthDay); + const isFixedOffset = offset !== undefined; + const likelyOffset = isFixedOffset ? offset : timeZoneOffset(timezone, Date.now()); + + let objNow: DateObject & typeof normalized = tsToObject(Date.now(), likelyOffset); + + if (useWeekData) { + const localeData = getLocaleData('en'); // TODO: locale + const {minDaysInFirstWeek, startOfWeek} = getLocaleWeekValues(localeData); + objNow = {...objNow, ...gregorianToWeek(objNow, minDaysInFirstWeek, startOfWeek)}; + setDefaultValues(normalized, objNow, orderedWeekUnits, defaultWeekUnitValues); + + if (!isValidWeekData(normalized, minDaysInFirstWeek, startOfWeek)) { + return [NaN, NaN]; + } + + normalized = { + ...normalized, + ...weekToGregorian(normalized, minDaysInFirstWeek, startOfWeek), + }; + } else if (containsDayOfYear) { + objNow = {...objNow, ...gregorianToOrdinal(objNow)}; + setDefaultValues(normalized, objNow, orderedOrdinalUnits, defaultOrdinalUnitValues); + + if (!isValidOrdinalData(normalized)) { + return [NaN, NaN]; + } + + normalized = { + ...normalized, + ...uncomputeOrdinal({...normalized, ordinal: normalized.dayOfYear}), + }; + } else { + setDefaultValues(normalized, objNow, orderedUnits, defaultUnitValues); + } + + if (!isValidDateData(normalized) || !isValidTimeData(normalized)) { + return [NaN, NaN]; + } + + const ts = objToTS(normalized); + if (isFixedOffset) { + return [ts - offset * 60 * 1000, offset]; + } + + return fixOffset(ts, likelyOffset, timezone); +} + +function setDefaultValues( + value: Partial>, + now: Partial>, + units: readonly U[], + defaultValues: Record, +) { let foundFirst = false; - for (const unit of orderedUnits) { - if (normalized[unit] !== undefined) { + for (const unit of units) { + if (value[unit] !== undefined) { foundFirst = true; } else if (foundFirst) { - normalized[unit] = defaultUnitValues[unit]; + value[unit] = defaultValues[unit]; } else { - normalized[unit] = objNow[unit]; + value[unit] = now[unit]; } } - const [ts, offset] = fixOffset( - objToTS(normalized as DateObject), - timeZoneOffset(timezone, Date.now()), - timezone, +} + +interface WeekData { + weekYear: number; + weekNumber: number; + weekday: number; +} +function isValidWeekData( + weekData: any, + minDaysInFirstWeek = 4, + startOfWeek = 1, +): weekData is WeekData { + return ( + Number.isInteger(weekData.weekYear) && + Number.isInteger(weekData.weekNumber) && + weekData.weekNumber >= 1 && + weekData.weekNumber <= + weeksInWeekYear(weekData.weekYear, minDaysInFirstWeek, startOfWeek) && + Number.isInteger(weekData.weekday) && + weekData.weekday >= 1 && + weekData.weekday <= 7 + ); +} + +interface OrdinalData { + year: number; + dayOfYear: number; +} + +function isValidOrdinalData(ordinalData: any): ordinalData is OrdinalData { + return ( + Number.isInteger(ordinalData.year) && + Number.isInteger(ordinalData.dayOfYear) && + ordinalData.dayOfYear >= 1 && + ordinalData.dayOfYear <= daysInYear(ordinalData.year) + ); +} + +interface DateData { + year: number; + month: number; + date: number; +} + +function isValidDateData(dateData: any): dateData is DateData { + return ( + Number.isInteger(dateData.year) && + Number.isInteger(dateData.month) && + dateData.month >= 0 && + dateData.month <= 11 && + Number.isInteger(dateData.date) && + dateData.date >= 1 && + dateData.date <= daysInMonth(dateData.year, dateData.month) + ); +} + +interface TimeData { + hour: number; + minute: number; + second: number; + millisecond: number; +} + +function isValidTimeData(timeData: any): timeData is TimeData { + return ( + Number.isInteger(timeData.hour) && + Number.isInteger(timeData.minute) && + Number.isInteger(timeData.second) && + Number.isInteger(timeData.millisecond) && + ((timeData.hour >= 0 && timeData.hour <= 23) || + (timeData.hour === 24 && + timeData.minute === 0 && + timeData.second === 0 && + timeData.millisecond === 0)) && + timeData.minute >= 0 && + timeData.minute <= 59 && + timeData.second >= 0 && + timeData.second <= 59 && + timeData.millisecond >= 0 && + timeData.millisecond <= 999 ); - return [ts, offset]; } diff --git a/src/dateTime/regexParse.ts b/src/dateTime/regexParse.ts new file mode 100644 index 0000000..95dc616 --- /dev/null +++ b/src/dateTime/regexParse.ts @@ -0,0 +1,368 @@ +// Copyright 2019 JS Foundation and other contributors +// Copyright 2024 YANDEX LLC + +import {UtcTimeZone} from '../constants'; +import * as English from '../locale/english'; +import type {DateObject} from '../utils'; + +interface ExtractedDateObject extends Partial { + weekYear?: number; + weekNumber?: number; + weekday?: number; +} + +type Extractor = ( + m: RegExpExecArray, +) => [dateObj: ExtractedDateObject, timezoneOrOffset: string | number | null]; + +type PartialResult = [ + dateObj: ExtractedDateObject, + timezoneOrOffset: string | number | null, + cursor: number, +]; +type PartialExtractor = (m: RegExpExecArray, cursor: number) => PartialResult; + +function combineExtractors(...extractors: PartialExtractor[]): Extractor { + return (m) => { + const res = extractors.reduce( + ([dateObj, timezoneOffset, cursor], extractor) => { + const [nextDateObj, nextTimezoneOffset, nextCursor] = extractor(m, cursor); + return [ + {...dateObj, ...nextDateObj}, + nextTimezoneOffset ?? timezoneOffset, + nextCursor, + ]; + }, + [{}, null, 1], + ); + return [res[0], res[1]]; + }; +} + +function parse(input: string, ...patterns: [RegExp, Extractor][]) { + if (!input) { + return [null, null]; + } + + for (const [regex, extractor] of patterns) { + const match = regex.exec(input); + if (match) { + return extractor(match); + } + } + + return [null, null]; +} + +// IANA time zone format: https://en.wikipedia.org/wiki/List_of_IANA_time_zones +const ianaRegex = /[A-Za-z_+-]{1,256}(?::?\/[A-Za-z0-9_+-]{1,256}(?:\/[A-Za-z0-9_+-]{1,256})?)?/; +// Z or ±00:00 +const offsetRegex = /(?:(Z)|([+-]\d\d)(?::?(\d\d))?)/; +const isoExtendedZone = `(?:${offsetRegex.source}?(?:\\[(${ianaRegex.source})\\])?)?`; +// hh:mm:ss.sss +const isoTimeBaseRegex = /(\d\d)(?::?(\d\d)(?::?(\d\d)(?:[.,](\d{1,30}))?)?)?/; +const isoTimeRegex = RegExp(`${isoTimeBaseRegex.source}${isoExtendedZone}`); +const isoTimeOnly = RegExp(`^T?${isoTimeBaseRegex.source}$`); +// Thh:mm:ss.sss±00:00 +const isoTimeExtensionRegex = RegExp(`(?:T${isoTimeRegex.source})?`); +// YYYY-MM-DD +const isoYmdRegex = /([+-]\d{6}|\d{4})(?:-?(\d\d)(?:-?(\d\d))?)?/; +// YYYY-Www-D +const isoWeekRegex = /(\d{4})-?W(\d\d)(?:-?(\d))?/; +// YYYY-DDD +const isoOrdinalRegex = /(\d{4})-?(\d{3})/; + +const isoYmdWithTimeExtensionRegex = new RegExp( + `^${isoYmdRegex.source}${isoTimeExtensionRegex.source}$`, +); +const isoWeekWithTimeExtensionRegex = new RegExp( + `^${isoWeekRegex.source}${isoTimeExtensionRegex.source}$`, +); +const isoOrdinalWithTimeExtensionRegex = new RegExp( + `^${isoOrdinalRegex.source}${isoTimeExtensionRegex.source}$`, +); +const isoTimeFullRegex = new RegExp(`^${isoTimeRegex.source}$`); + +// https://datatracker.ietf.org/doc/html/rfc2822#section-4.3 +const obsOffsets = { + GMT: 0, + UT: 0, + EDT: -4 * 60, + EST: -5 * 60, + CDT: -5 * 60, + CST: -6 * 60, + MDT: -6 * 60, + MST: -7 * 60, + PDT: -7 * 60, + PST: -8 * 60, +}; + +// RFC 2822/5322 https://datatracker.ietf.org/doc/html/rfc2822 +// Fri, 19 Nov 82 16:14:55 GMT +const rfc2822 = + /^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s)?(\d{1,2})\s(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s(\d{2,4})\s(\d\d):(\d\d)(?::(\d\d))?\s(?:(UT|GMT|[ECMP][SD]T)|([Zz])|(?:([+-]\d\d)(\d\d)))$/; + +function preprocessRFC2822(s: string) { + // Remove comments and folding whitespace and replace multiple-spaces with a single space + return s + .replace(/\([^()]*\)|[\n\t]/g, ' ') + .replace(/(\s\s+)/g, ' ') + .trim(); +} + +function extractRfc2822( + match: RegExpExecArray, +): [dateObj: ExtractedDateObject, timezoneOffset: number] { + const [ + , + weekday, + day, + month, + year, + hour, + minute, + second, + obsOffset, + zeroOffset, + offsetHours, + offsetMinutes, + ] = match; + + const result = stringsToDateObject(weekday, year, month, day, hour, minute, second); + + let offset: number; + if (obsOffset) { + offset = obsOffsets[obsOffset as keyof typeof obsOffsets]; + } else if (zeroOffset) { + offset = 0; + } else { + const hours = parseInt(offsetHours, 10); + const sign = hours < 0 || Object.is(hours, -0) ? -1 : 1; + const minutes = parseInt(offsetMinutes, 10) || 0; + offset = (hours || 0) * 60 + sign * minutes; + } + return [result, offset]; +} + +function extractISOYmd(match: RegExpExecArray, cursor: number): PartialResult { + const item = { + year: parseInteger(match[cursor]), + month: parseInteger(match[cursor + 1], 1) - 1, + day: parseInteger(match[cursor + 2], 1), + }; + + return [item, null, cursor + 3]; +} +function extractISOTime(match: RegExpExecArray, cursor: number): PartialResult { + const item = { + hour: parseInteger(match[cursor], 0), + minute: parseInteger(match[cursor + 1], 0), + second: parseInteger(match[cursor + 2], 0), + millisecond: parseMilliseconds(match[cursor + 3]), + }; + + return [item, null, cursor + 4]; +} + +function extractISOOffset(match: RegExpExecArray, cursor: number): PartialResult { + const local = !match[cursor] && !match[cursor + 1]; + if (local) { + return [{}, null, cursor + 3]; + } + if (match[cursor]) { + return [{}, 0, cursor + 3]; + } + + const fullOffset = signedOffset(match[cursor + 1], match[cursor + 2]); + return [{}, fullOffset, cursor + 3]; +} + +function extractIANAZone(match: RegExpExecArray, cursor: number): PartialResult { + const zone = match[cursor] || null; + return [{}, zone, cursor + 1]; +} + +function extractISOWeekData(match: RegExpExecArray, cursor: number): PartialResult { + const item = { + weekYear: parseInteger(match[cursor]), + weekNumber: parseInteger(match[cursor + 1], 1), + weekday: parseInteger(match[cursor + 2], 1), + }; + + return [item, null, cursor + 3]; +} +function extractISOOrdinalData(match: RegExpExecArray, cursor: number): PartialResult { + const item = { + year: parseInteger(match[cursor]), + ordinal: parseInteger(match[cursor + 1], 1), + }; + + return [item, null, cursor + 2]; +} + +const extractISOYmdTimeAndOffset = combineExtractors( + extractISOYmd, + extractISOTime, + extractISOOffset, + extractIANAZone, +); + +const extractISOWeekTimeAndOffset = combineExtractors( + extractISOWeekData, + extractISOTime, + extractISOOffset, + extractIANAZone, +); + +const extractISOOrdinalDateAndTime = combineExtractors( + extractISOOrdinalData, + extractISOTime, + extractISOOffset, + extractIANAZone, +); + +const extractISOTimeAndOffset = combineExtractors( + extractISOTime, + extractISOOffset, + extractIANAZone, +); + +// https://datatracker.ietf.org/doc/html/rfc1123#page-55 +const rfc1123 = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d\d) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d\d):(\d\d):(\d\d) GMT$/; +// https://datatracker.ietf.org/doc/html/rfc850#section-2.1.4 +const rfc850 = + /^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d\d)-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d\d) (\d\d):(\d\d):(\d\d) GMT$/; +// Fri Nov 19 16:59:30 1982 +const ascii = + /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( \d|\d\d) (\d\d):(\d\d):(\d\d) (\d{4})$/; + +function extractRFC1123Or850( + match: RegExpExecArray, +): [dateObj: ExtractedDateObject, timezoneOffset: string] { + const [, weekdayStr, dayStr, monthStr, yearStr, hourStr, minuteStr, secondStr] = match, + result = stringsToDateObject( + weekdayStr, + yearStr, + monthStr, + dayStr, + hourStr, + minuteStr, + secondStr, + ); + return [result, UtcTimeZone]; +} + +function extractASCII( + match: RegExpExecArray, +): [dateObj: ExtractedDateObject, timezoneOffset: string] { + const [, weekdayStr, monthStr, dayStr, hourStr, minuteStr, secondStr, yearStr] = match, + result = stringsToDateObject( + weekdayStr, + yearStr, + monthStr, + dayStr, + hourStr, + minuteStr, + secondStr, + ); + return [result, UtcTimeZone]; +} + +function parseInteger(str: string | undefined | null, defaultValue: number): number; +function parseInteger(str: string | undefined | null, defaultValue?: number): number | undefined; +function parseInteger(str: string | undefined | null, defaultValue?: number) { + return str ? parseInt(str, 10) : defaultValue; +} + +function parseMilliseconds(str: string | undefined | null) { + return str ? Math.floor(parseFloat(`0.${str}`) * 1000) : undefined; +} + +function signedOffset(offsetHours: string, offsetMinutes: string) { + const hours = parseInt(offsetHours, 10); + const sign = hours < 0 || Object.is(hours, -0) ? -1 : 1; + const minutes = parseInt(offsetMinutes, 10) || 0; + const offset = (hours || 0) * 60 + sign * minutes; + + return offset; +} + +function stringsToDateObject( + weekdayStr: string | undefined, + yearStr: string, + monthStr: string, + dayStr: string, + hourStr: string | undefined, + minuteStr: string | undefined, + secondStr: string | undefined, +) { + const res: ExtractedDateObject = { + year: yearStr.length === 2 ? parseInteger(yearStr) : parseInteger(yearStr), + month: + monthStr.length > 3 + ? English.monthsLong.indexOf(monthStr) + : English.monthsShort.indexOf(monthStr), + date: parseInteger(dayStr), + hour: parseInteger(hourStr), + minute: parseInteger(minuteStr), + second: parseInteger(secondStr), + }; + + if (weekdayStr) { + res.weekday = + (weekdayStr.length > 3 + ? English.weekdaysLong.indexOf(weekdayStr) + : English.weekdaysShort.indexOf(weekdayStr)) + 1; + } + + return res; +} + +export function parseISODate(s: string) { + return parse( + s, + [isoYmdWithTimeExtensionRegex, extractISOYmdTimeAndOffset], + [isoWeekWithTimeExtensionRegex, extractISOWeekTimeAndOffset], + [isoOrdinalWithTimeExtensionRegex, extractISOOrdinalDateAndTime], + [isoTimeFullRegex, extractISOTimeAndOffset], + ); +} + +export function parseRFC2822Date(s: string) { + return parse(preprocessRFC2822(s), [rfc2822, extractRfc2822]); +} + +export function parseHTTPDate(s: string) { + return parse( + s, + [rfc1123, extractRFC1123Or850], + [rfc850, extractRFC1123Or850], + [ascii, extractASCII], + ); +} + +export function parseISOTimeOnly(s: string) { + return parse(s, [isoTimeOnly, combineExtractors(extractISOTime)]); +} + +export function parseDateString(input: string) { + let [obj, offset] = parseISODate(input); + if (obj !== null) { + return [obj, offset] as const; + } + [obj, offset] = parseRFC2822Date(input); + if (obj !== null) { + return [obj, offset] as const; + } + [obj, offset] = parseHTTPDate(input); + if (obj !== null) { + return [obj, offset] as const; + } + [obj, offset] = parseISOTimeOnly(input); + if (obj !== null) { + return [obj, offset] as const; + } + + return [{}, null] as const; +} diff --git a/src/locale/english.ts b/src/locale/english.ts new file mode 100644 index 0000000..307e368 --- /dev/null +++ b/src/locale/english.ts @@ -0,0 +1,48 @@ +export const monthsLong = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +export const monthsShort = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; +export const monthsNarrow = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; + +export const weekdaysLong = [ + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + 'Sunday', +]; +export const weekdaysShort = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; +export const weekdaysNarrow = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; + +export const meridiems = ['AM', 'PM']; + +export const erasLong = ['Before Christ', 'Anno Domini']; +export const erasShort = ['BC', 'AD']; +export const erasNarrow = ['B', 'A']; diff --git a/src/typings/dateTime.ts b/src/typings/dateTime.ts index fd9fb7f..e7548f5 100644 --- a/src/typings/dateTime.ts +++ b/src/typings/dateTime.ts @@ -56,7 +56,21 @@ export type AllUnit = | 'weekYear' | 'isoWeekYear'; -export type InputObject = Partial>; +export type InputObject = Partial< + Record< + | BaseUnit + | DateUnit + | WeekUnit + | 'weekday' + | 'weekdays' + | 'e' + | 'dayOfYear' + | 'dayOfYears' + | 'DDD' + | 'weekYear', + number + > +>; export type SetObject = Partial>; export interface DateTime { diff --git a/src/utils/locale.ts b/src/utils/locale.ts index 6c04d23..4197b70 100644 --- a/src/utils/locale.ts +++ b/src/utils/locale.ts @@ -44,3 +44,8 @@ export function getLocaleData(locale: string) { } return localeData as Locale; } + +export function getLocaleWeekValues(localeData: {yearStart?: number; weekStart?: number}) { + const {weekStart, yearStart} = localeData; + return {startOfWeek: weekStart || 7, minDaysInFirstWeek: yearStart || 1}; +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 1906c99..8c9624a 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -249,6 +249,12 @@ export function uncomputeOrdinal({year, ordinal}: {year: number; ordinal: number return {month, date: day}; } +export function gregorianToOrdinal(gregData: {year: number; month: number; date: number}) { + const {year, month, date} = gregData; + const ordinal = computeOrdinal({year, month, date}); + return {year, ordinal}; +} + export function isoWeekdayToLocal(isoWeekday: number, startOfWeek: number) { return ((isoWeekday - startOfWeek + 7) % 7) + 1; } From 0295b069bd3af565ab98eb6229176ee7cb5251a8 Mon Sep 17 00:00:00 2001 From: Valerii Sidorenko Date: Sat, 16 Nov 2024 01:01:50 +0100 Subject: [PATCH 2/2] fix: some fixes + tests --- src/dateTime/__tests__/regexParse.test.ts | 140 ++++++++++++++++++++++ src/dateTime/parse.ts | 22 +++- src/dateTime/regexParse.ts | 15 ++- src/utils/utils.ts | 18 +-- 4 files changed, 182 insertions(+), 13 deletions(-) create mode 100644 src/dateTime/__tests__/regexParse.test.ts diff --git a/src/dateTime/__tests__/regexParse.test.ts b/src/dateTime/__tests__/regexParse.test.ts new file mode 100644 index 0000000..0c7c9b7 --- /dev/null +++ b/src/dateTime/__tests__/regexParse.test.ts @@ -0,0 +1,140 @@ +import {dateTime} from '../dateTime'; + +test('DateTime from ISO parses as local by default', () => { + const dt = dateTime({input: '2016-05-25T09:08:34.123'}); + expect([ + dt.year(), + dt.month(), + dt.date(), + dt.hour(), + dt.minute(), + dt.second(), + dt.millisecond(), + ]).toEqual([2016, 4, 25, 9, 8, 34, 123]); +}); + +test('DateTime from ISO uses the offset provided, but keeps the dateTime as local', () => { + const dt = dateTime({input: '2016-05-25T09:08:34.123+06:00'}).utc(); + expect([ + dt.year(), + dt.month(), + dt.date(), + dt.hour(), + dt.minute(), + dt.second(), + dt.millisecond(), + ]).toEqual([2016, 4, 25, 3, 8, 34, 123]); +}); + +test('DateTime from ISO uses the Z if provided, but keeps the dateTime as local', () => { + const dt = dateTime({input: '2016-05-25T09:08:34.123Z'}).utc(); + expect([ + dt.year(), + dt.month(), + dt.date(), + dt.hour(), + dt.minute(), + dt.second(), + dt.millisecond(), + ]).toEqual([2016, 4, 25, 9, 8, 34, 123]); +}); + +test.each<[string, [number, number, number, number, number, number, number]]>([ + ['2016', [2016, 0, 1, 0, 0, 0, 0]], + ['2016-05', [2016, 4, 1, 0, 0, 0, 0]], + ['201605', [2016, 4, 1, 0, 0, 0, 0]], + ['2016-05-25', [2016, 4, 25, 0, 0, 0, 0]], + ['20160525', [2016, 4, 25, 0, 0, 0, 0]], + ['+002016-05-25', [2016, 4, 25, 0, 0, 0, 0]], + ['-002016-05-25', [-2016, 4, 25, 0, 0, 0, 0]], + ['2016-05-25T09', [2016, 4, 25, 9, 0, 0, 0]], + ['2016-05-25T09:08', [2016, 4, 25, 9, 8, 0, 0]], + ['2016-05-25T0908', [2016, 4, 25, 9, 8, 0, 0]], + ['2016-05-25T09:08:34', [2016, 4, 25, 9, 8, 34, 0]], + ['2016-05-25T090834', [2016, 4, 25, 9, 8, 34, 0]], + ['2016-05-25T09:08:34.123', [2016, 4, 25, 9, 8, 34, 123]], + ['2016-05-25T09:08:34.123999', [2016, 4, 25, 9, 8, 34, 123]], + ['2016-05-25T09:08:34,123', [2016, 4, 25, 9, 8, 34, 123]], + ['2016-05-25T090834.123', [2016, 4, 25, 9, 8, 34, 123]], + ['2016-05-25T09:08:34.023', [2016, 4, 25, 9, 8, 34, 23]], + ['2016-05-25T09:08:34.99999', [2016, 4, 25, 9, 8, 34, 999]], + ['2016-05-25T09:08:34.1', [2016, 4, 25, 9, 8, 34, 100]], + ['2016-W21', [2016, 4, 16, 0, 0, 0, 0]], + ['2016-W21-3', [2016, 4, 18, 0, 0, 0, 0]], + ['2016W213', [2016, 4, 18, 0, 0, 0, 0]], + ['2016-W21-3T09:24:15.123', [2016, 4, 18, 9, 24, 15, 123]], + ['2016W213T09:24:15.123', [2016, 4, 18, 9, 24, 15, 123]], + ['2016-200', [2016, 6, 18, 0, 0, 0, 0]], + ['2016200', [2016, 6, 18, 0, 0, 0, 0]], + ['2016-200T09:24:15.123', [2016, 6, 18, 9, 24, 15, 123]], + ['2016200T09:24:15.123', [2016, 6, 18, 9, 24, 15, 123]], + ['2016-002', [2016, 0, 2, 0, 0, 0, 0]], + ['2018-01-04T24:00', [2018, 0, 5, 0, 0, 0, 0]], +])('DateTime from ISO (%p)', (input, expected) => { + const dt = dateTime({input}); + expect([ + dt.year(), + dt.month(), + dt.date(), + dt.hour(), + dt.minute(), + dt.second(), + dt.millisecond(), + ]).toEqual(expected); +}); + +test("DateTime from ISO doesn't accept 24:23", () => { + expect(dateTime({input: '2018-05-25T24:23'}).isValid()).toBe(false); +}); + +test.each<[string, [number, number, number, number]]>([ + ['09:24:15.123', [9, 24, 15, 123]], + ['09:24:15,123', [9, 24, 15, 123]], + ['09:24:15', [9, 24, 15, 0]], + ['09:24', [9, 24, 0, 0]], +])('DateTime from ISO time (%p)', (input, expected) => { + const dt = dateTime({input}); + const now = dateTime(); + expect([ + dt.year(), + dt.month(), + dt.date(), + dt.hour(), + dt.minute(), + dt.second(), + dt.millisecond(), + ]).toEqual([now.year(), now.month(), now.date(), ...expected]); +}); + +test.each<[string, [number, number, number, number, number, number]]>([ + ['Sun, 12 Apr 2015 05:06:07 GMT', [2015, 3, 12, 5, 6, 7]], + ['Tue, 01 Nov 2016 01:23:45 +0000', [2016, 10, 1, 1, 23, 45]], + ['Tue, 01 Nov 16 04:23:45 Z', [2016, 10, 1, 4, 23, 45]], + ['01 Nov 2016 05:23:45 z', [2016, 10, 1, 5, 23, 45]], + ['01 Nov 2016 13:23 +0600', [2016, 10, 1, 7, 23, 0]], + ['Mon, 02 Jan 2017 06:00:00 -0800', [2017, 0, 2, 6 + 8, 0, 0]], + ['Mon, 02 Jan 2017 06:00:00 +0800', [2017, 0, 1, 22, 0, 0]], + ['Mon, 02 Jan 2017 06:00:00 +0330', [2017, 0, 2, 2, 30, 0]], + ['Mon, 02 Jan 2017 06:00:00 -0330', [2017, 0, 2, 9, 30, 0]], + ['Mon, 02 Jan 2017 06:00:00 PST', [2017, 0, 2, 6 + 8, 0, 0]], + ['Mon, 02 Jan 2017 06:00:00 PDT', [2017, 0, 2, 6 + 7, 0, 0]], +])('DateTime from RFC2822 (%p)', (input, expected) => { + const dt = dateTime({input}).utc(); + expect([dt.year(), dt.month(), dt.date(), dt.hour(), dt.minute(), dt.second()]).toEqual( + expected, + ); +}); + +test.each<[string, [number, number, number, number, number, number]]>([ + ['Fri, 19 Nov 82 16:14:55 GMT', [1982, 10, 19, 16, 14, 55]], + ['Sun, 06 Nov 1994 08:49:37 GMT', [1994, 10, 6, 8, 49, 37]], + ['Sunday, 06-Nov-94 08:49:37 GMT', [1994, 10, 6, 8, 49, 37]], + ['Wednesday, 29-Jun-22 08:49:37 GMT', [2022, 5, 29, 8, 49, 37]], + ['Sun Nov 6 08:49:37 1994', [1994, 10, 6, 8, 49, 37]], + ['Wed Nov 16 08:49:37 1994', [1994, 10, 16, 8, 49, 37]], +])('DateTime from HTTP (%p)', (input, expected) => { + const dt = dateTime({input}).utc(); + expect([dt.year(), dt.month(), dt.date(), dt.hour(), dt.minute(), dt.second()]).toEqual( + expected, + ); +}); diff --git a/src/dateTime/parse.ts b/src/dateTime/parse.ts index a1be594..aa9f8eb 100644 --- a/src/dateTime/parse.ts +++ b/src/dateTime/parse.ts @@ -81,6 +81,8 @@ const orderedOrdinalUnits = [ 'millisecond', ] as const; +type NormalizedInput = Partial, number>>; + export function getTimestampFromObject( input: InputObject, timezone: string, @@ -177,11 +179,14 @@ interface WeekData { weekday: number; } function isValidWeekData( - weekData: any, + weekData: NormalizedInput, minDaysInFirstWeek = 4, startOfWeek = 1, ): weekData is WeekData { return ( + weekData.weekYear !== undefined && + weekData.weekNumber !== undefined && + weekData.weekday !== undefined && Number.isInteger(weekData.weekYear) && Number.isInteger(weekData.weekNumber) && weekData.weekNumber >= 1 && @@ -198,8 +203,10 @@ interface OrdinalData { dayOfYear: number; } -function isValidOrdinalData(ordinalData: any): ordinalData is OrdinalData { +function isValidOrdinalData(ordinalData: NormalizedInput): ordinalData is OrdinalData { return ( + ordinalData.year !== undefined && + ordinalData.dayOfYear !== undefined && Number.isInteger(ordinalData.year) && Number.isInteger(ordinalData.dayOfYear) && ordinalData.dayOfYear >= 1 && @@ -213,8 +220,11 @@ interface DateData { date: number; } -function isValidDateData(dateData: any): dateData is DateData { +function isValidDateData(dateData: NormalizedInput): dateData is DateData { return ( + dateData.year !== undefined && + dateData.month !== undefined && + dateData.date !== undefined && Number.isInteger(dateData.year) && Number.isInteger(dateData.month) && dateData.month >= 0 && @@ -232,8 +242,12 @@ interface TimeData { millisecond: number; } -function isValidTimeData(timeData: any): timeData is TimeData { +function isValidTimeData(timeData: NormalizedInput): timeData is TimeData { return ( + timeData.hour !== undefined && + timeData.minute !== undefined && + timeData.second !== undefined && + timeData.millisecond !== undefined && Number.isInteger(timeData.hour) && Number.isInteger(timeData.minute) && Number.isInteger(timeData.second) && diff --git a/src/dateTime/regexParse.ts b/src/dateTime/regexParse.ts index 95dc616..d0c97a7 100644 --- a/src/dateTime/regexParse.ts +++ b/src/dateTime/regexParse.ts @@ -194,7 +194,7 @@ function extractISOWeekData(match: RegExpExecArray, cursor: number): PartialResu function extractISOOrdinalData(match: RegExpExecArray, cursor: number): PartialResult { const item = { year: parseInteger(match[cursor]), - ordinal: parseInteger(match[cursor + 1], 1), + dayOfYear: parseInteger(match[cursor + 1], 1), }; return [item, null, cursor + 2]; @@ -298,7 +298,10 @@ function stringsToDateObject( secondStr: string | undefined, ) { const res: ExtractedDateObject = { - year: yearStr.length === 2 ? parseInteger(yearStr) : parseInteger(yearStr), + year: + yearStr.length === 2 + ? fullYearFromTwoDigitYear(parseInteger(yearStr)) + : parseInteger(yearStr), month: monthStr.length > 3 ? English.monthsLong.indexOf(monthStr) @@ -318,6 +321,14 @@ function stringsToDateObject( return res; } +function fullYearFromTwoDigitYear(year: number | undefined) { + if (!year || year > 99) { + return year; + } + + // TODO: add two digit cutoff year to settings + return year > 49 ? 1900 + year : 2000 + year; +} export function parseISODate(s: string) { return parse( diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 8c9624a..5351609 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -160,9 +160,9 @@ const normalizedUnits = { weekday: 'weekday', weekdays: 'weekday', e: 'weekday', - dayOfYear: 'dayOfYear', - dayOfYears: 'dayOfYear', - DDD: 'dayOfYear', + dayofyear: 'dayOfYear', + dayofyears: 'dayOfYear', + ddd: 'dayOfYear', weekyear: 'weekYear', isoweekyear: 'isoWeekYear', } as const; @@ -243,10 +243,14 @@ export function computeOrdinal({year, month, date}: {year: number; month: number } export function uncomputeOrdinal({year, ordinal}: {year: number; ordinal: number}) { - const table = isLeapYear(year) ? leapLadder : nonLeapLadder, - month = table.findIndex((i) => i < ordinal), - day = ordinal - table[month]; - return {month, date: day}; + const table = isLeapYear(year) ? leapLadder : nonLeapLadder; + for (let i = table.length; i > 0; i--) { + const month = i - 1; + if (table[month] < ordinal) { + return {month, date: ordinal - table[month]}; + } + } + return {month: 0, date: ordinal}; } export function gregorianToOrdinal(gregData: {year: number; month: number; date: number}) {