-
-
Notifications
You must be signed in to change notification settings - Fork 11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add support for parsing iso-8601 dates #215
base: main
Are you sure you want to change the base?
Changes from all commits
cb2b8ee
c4887e4
3fef9e0
3258293
e1bdde6
da16490
da6557c
3b2f6f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,97 @@ | ||
import { TZDate } from '@date-fns/tz'; | ||
import assert from 'assert'; | ||
|
||
import { InvalidIso8601Date, parseIso8601DateTime } from './iso8601-date'; | ||
|
||
describe('parseDateTime', () => { | ||
it.each([ | ||
['2020', new Date(2020, 0)], | ||
['2021-02', new Date(2021, 1)], | ||
['2022-10', new Date(2022, 9)], | ||
['2023-11-02', new Date(2023, 10, 2)], | ||
['2024-12T01', new Date(2024, 11, 1, 1)], | ||
['2025-01T02:01', new Date(2025, 0, 1, 2, 1)], | ||
['2026-02-02T03', new Date(2026, 1, 2, 3)], | ||
['2027-03-03T04:02', new Date(2027, 2, 3, 4, 2)], | ||
['2027-03-03T04:02:01', new Date(2027, 2, 3, 4, 2, 1)], | ||
['20280404', new Date(2028, 3, 4)], | ||
['20290505T05', new Date(2029, 4, 5, 5)], | ||
['20300606T0603', new Date(2030, 5, 6, 6, 3)], | ||
['20310707T070402', new Date(2031, 6, 7, 7, 4, 2)], | ||
])('parses %s local-time correctly', (testIso, expectedDate) => { | ||
const result = parseIso8601DateTime(testIso); | ||
|
||
expect(result).toBeInstanceOf(Date); | ||
expect(result.toISOString()).toStrictEqual(expectedDate.toISOString()); | ||
}); | ||
|
||
it.each([ | ||
['2023-04-04T04Z', new TZDate(2023, 3, 4, 4, '+00:00')], | ||
['2023-04-04T04-01', new TZDate(2023, 3, 4, 4, '-01:00')], | ||
['2023-04-04T04+02', new TZDate(2023, 3, 4, 4, '+02:00')], | ||
['2023-04-04T04-01:01', new TZDate(2023, 3, 4, 4, '-01:01')], | ||
['2023-04-04T04+02:02', new TZDate(2023, 3, 4, 4, '+02:02')], | ||
|
||
['2023-04-04T04:04Z', new TZDate(2023, 3, 4, 4, 4, '+00:00')], | ||
['2023-04-04T04:04-01', new TZDate(2023, 3, 4, 4, 4, '-01:00')], | ||
['2023-04-04T04:04+02', new TZDate(2023, 3, 4, 4, 4, '+02:00')], | ||
['2023-04-04T04:04-01:01', new TZDate(2023, 3, 4, 4, 4, '-01:01')], | ||
['2023-04-04T04:04+02:02', new TZDate(2023, 3, 4, 4, 4, '+02:02')], | ||
|
||
['2023-04-04T04:04:04Z', new TZDate(2023, 3, 4, 4, 4, 4, '+00:00')], | ||
['2023-04-04T04:04:04-01', new TZDate(2023, 3, 4, 4, 4, 4, '-01:00')], | ||
['2023-04-04T04:04:04+02', new TZDate(2023, 3, 4, 4, 4, 4, '+02:00')], | ||
['2023-04-04T04:04:04-01:01', new TZDate(2023, 3, 4, 4, 4, 4, '-01:01')], | ||
['2023-04-04T04:04:04+02:02', new TZDate(2023, 3, 4, 4, 4, 4, '+02:02')], | ||
])('parses %s with time-zone correctly', (testIso, expectedDate) => { | ||
const result = parseIso8601DateTime(testIso); | ||
|
||
assert(result instanceof TZDate); | ||
expect(result.timeZone).toStrictEqual(expectedDate.timeZone); | ||
expect(result.toISOString()).toStrictEqual(expectedDate.toISOString()); | ||
}); | ||
|
||
it.each([ | ||
'', | ||
'0', | ||
'00', | ||
'000', | ||
'0000a', | ||
'2020-0', | ||
'2020-00', | ||
'2020-01a', | ||
'202001', | ||
'2020-01-00', | ||
'2020-01-01a', | ||
'2020-0101', | ||
'202001-01', | ||
'2020-01-01T', | ||
'2020-01-01T0000', | ||
'2020-01-01T00:00a', | ||
'2020-01-01T00:0000', | ||
'00:00', | ||
'2020:01', | ||
'2020-01:01', | ||
'2020-01-01T00:00A', | ||
'2020-01-01T00:00Za', | ||
'2020-01-01T00:00+a', | ||
'2020-01-01T00:00-a', | ||
'2020-01-01T00:00+0', | ||
'2020-01-01T00:00-0', | ||
'2020-01-01T00:00+00a', | ||
'2020-01-01T00:00+00:0', | ||
'2020-01-01T00:00-00:0', | ||
'2020-01-01T00:00+00:00a', | ||
'2020-01-01T00:00-00:00a', | ||
'2020-', | ||
'2020-01-01T24', | ||
'2020-01-01T23:60', | ||
'2020-01-01T23:59:60', | ||
'2020-01-01T23:59:59-13', | ||
'2020-01-01T23:59:59+13', | ||
'2020-01-01T23:59:59-11:60', | ||
'2020-01-01T23:59:59a', | ||
])('throws on invalid date time "%s"', (testIso) => { | ||
expect(() => parseIso8601DateTime(testIso)).toThrow(InvalidIso8601Date); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change | ||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,256 @@ | ||||||||||||
import { TZDate } from '@date-fns/tz/date'; | ||||||||||||
|
||||||||||||
import { assert } from './assert'; | ||||||||||||
|
||||||||||||
enum ParseDateState { | ||||||||||||
Year = 1, | ||||||||||||
Month = 2, | ||||||||||||
CalendarDate = 3, | ||||||||||||
Hour = 4, | ||||||||||||
Minute = 5, | ||||||||||||
Second = 6, | ||||||||||||
Timezone = 7, | ||||||||||||
TimezoneHour = 8, | ||||||||||||
TimezoneMinute = 9, | ||||||||||||
End = 0, | ||||||||||||
} | ||||||||||||
const END = Symbol('END'); | ||||||||||||
|
||||||||||||
const RE_YEAR = /[0-9]{4}/u; | ||||||||||||
const RE_MONTH = /(?:0[1-9])|(?:1[0-2])/u; | ||||||||||||
const RE_DATE = /(?:[1-9])|(?:[1-2][0-9])|(?:3[0-1])/u; | ||||||||||||
const RE_HOUR = /(?:[0-1][0-9])|(?:2[0-3])/u; | ||||||||||||
const RE_MINUTE_SECOND = /[0-5][0-9]/u; | ||||||||||||
const RE_Z_HOUR = /(?:0[0-9])|(?:1[0-2])/u; | ||||||||||||
|
||||||||||||
export const InvalidIso8601Date = new Error('Invalid ISO-8601 date'); | ||||||||||||
|
||||||||||||
/** | ||||||||||||
* Parses ISO-8601 date time string. | ||||||||||||
* | ||||||||||||
* @throws {InvalidIso8601Date} Is the input value is not correct. | ||||||||||||
* @param value - An ISO-8601 formatted string. | ||||||||||||
* @returns A date if value is in local-time, a TZDate if timezone data provided. | ||||||||||||
*/ | ||||||||||||
export function parseIso8601DateTime(value: string): Date | TZDate { | ||||||||||||
let at = 0; | ||||||||||||
let hasSeparators: boolean | null = null; | ||||||||||||
let state: ParseDateState = ParseDateState.Year; | ||||||||||||
|
||||||||||||
const consume = () => { | ||||||||||||
if (at >= value.length) { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
const char = value[at] as string; | ||||||||||||
at += 1; | ||||||||||||
return char; | ||||||||||||
}; | ||||||||||||
const peek = () => { | ||||||||||||
Comment on lines
+47
to
+48
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||
if (at >= value.length) { | ||||||||||||
return END; | ||||||||||||
} | ||||||||||||
return value[at] as string; | ||||||||||||
}; | ||||||||||||
const skip = (count = 1) => { | ||||||||||||
Comment on lines
+53
to
+54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||
assert(at + count <= value.length, 'Invalid ISO-8601 parser state'); | ||||||||||||
at += count; | ||||||||||||
}; | ||||||||||||
const consumeSeparator = (sep: '-' | ':') => { | ||||||||||||
Comment on lines
+57
to
+58
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||
const next = peek(); | ||||||||||||
assert(next !== END, 'Invalid ISO-8601 parser state'); | ||||||||||||
if (next === '-' || next === ':') { | ||||||||||||
if (hasSeparators === false || next !== sep) { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
hasSeparators = true; | ||||||||||||
skip(); | ||||||||||||
} else { | ||||||||||||
if (hasSeparators === true) { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
hasSeparators = false; | ||||||||||||
} | ||||||||||||
}; | ||||||||||||
|
||||||||||||
let year: undefined | string; | ||||||||||||
let month: undefined | string; | ||||||||||||
let date: undefined | string; | ||||||||||||
let hours: undefined | string; | ||||||||||||
let minutes: undefined | string; | ||||||||||||
let seconds: undefined | string; | ||||||||||||
let timezone: null | string = null; // null, "Z", "+", "-" | ||||||||||||
let offsetHours: undefined | string; | ||||||||||||
let offsetMinutes: undefined | string; | ||||||||||||
|
||||||||||||
while (state !== ParseDateState.End) { | ||||||||||||
switch (state) { | ||||||||||||
case ParseDateState.Year: | ||||||||||||
year = ''; | ||||||||||||
for (let i = 0; i < 4; i++) { | ||||||||||||
year += consume(); | ||||||||||||
} | ||||||||||||
if (!RE_YEAR.test(year)) { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
if (peek() === END) { | ||||||||||||
state = ParseDateState.End; | ||||||||||||
} else { | ||||||||||||
consumeSeparator('-'); | ||||||||||||
state = ParseDateState.Month; | ||||||||||||
} | ||||||||||||
break; | ||||||||||||
case ParseDateState.Month: | ||||||||||||
month = consume() + consume(); | ||||||||||||
if (!RE_MONTH.test(month)) { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
|
||||||||||||
// YYYYMM is not a valid ISO-8601 | ||||||||||||
// it requires a separator: YYYY-MM | ||||||||||||
if (hasSeparators === false && (peek() === END || peek() === 'T')) { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} else if (peek() === END) { | ||||||||||||
state = ParseDateState.End; | ||||||||||||
} else if (peek() === 'T') { | ||||||||||||
state = ParseDateState.Hour; | ||||||||||||
} else { | ||||||||||||
consumeSeparator('-'); | ||||||||||||
state = ParseDateState.CalendarDate; | ||||||||||||
} | ||||||||||||
break; | ||||||||||||
case ParseDateState.CalendarDate: | ||||||||||||
date = consume() + consume(); | ||||||||||||
if (!RE_DATE.test(date)) { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
|
||||||||||||
if (peek() === END) { | ||||||||||||
state = ParseDateState.End; | ||||||||||||
} else { | ||||||||||||
state = ParseDateState.Hour; | ||||||||||||
} | ||||||||||||
break; | ||||||||||||
case ParseDateState.Hour: | ||||||||||||
if (consume() !== 'T') { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
|
||||||||||||
hours = consume() + consume(); | ||||||||||||
if (!RE_HOUR.test(hours)) { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
|
||||||||||||
if (peek() === END) { | ||||||||||||
state = ParseDateState.End; | ||||||||||||
} else if (['Z', '-', '+'].includes(peek() as string)) { | ||||||||||||
state = ParseDateState.Timezone; | ||||||||||||
} else { | ||||||||||||
consumeSeparator(':'); | ||||||||||||
state = ParseDateState.Minute; | ||||||||||||
} | ||||||||||||
break; | ||||||||||||
case ParseDateState.Minute: | ||||||||||||
minutes = consume() + consume(); | ||||||||||||
if (!RE_MINUTE_SECOND.test(minutes)) { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
|
||||||||||||
if (peek() === END) { | ||||||||||||
state = ParseDateState.End; | ||||||||||||
} else if (['Z', '-', '+'].includes(peek() as string)) { | ||||||||||||
state = ParseDateState.Timezone; | ||||||||||||
} else { | ||||||||||||
consumeSeparator(':'); | ||||||||||||
state = ParseDateState.Second; | ||||||||||||
} | ||||||||||||
break; | ||||||||||||
case ParseDateState.Second: | ||||||||||||
seconds = consume() + consume(); | ||||||||||||
if (!RE_MINUTE_SECOND.test(seconds)) { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
|
||||||||||||
if (peek() === END) { | ||||||||||||
state = ParseDateState.End; | ||||||||||||
} else { | ||||||||||||
state = ParseDateState.Timezone; | ||||||||||||
} | ||||||||||||
break; | ||||||||||||
case ParseDateState.Timezone: | ||||||||||||
timezone = consume(); | ||||||||||||
|
||||||||||||
if (timezone === 'Z') { | ||||||||||||
if (peek() !== END) { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
state = ParseDateState.End; | ||||||||||||
} else if (['-', '+'].includes(timezone)) { | ||||||||||||
state = ParseDateState.TimezoneHour; | ||||||||||||
} else { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
Comment on lines
+179
to
+191
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This logic should probably be updated in accordance with MetaMask/SIPs#152 |
||||||||||||
break; | ||||||||||||
case ParseDateState.TimezoneHour: | ||||||||||||
offsetHours = consume() + consume(); | ||||||||||||
if (!RE_Z_HOUR.test(offsetHours)) { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
|
||||||||||||
if (peek() === END) { | ||||||||||||
state = ParseDateState.End; | ||||||||||||
} else { | ||||||||||||
consumeSeparator(':'); | ||||||||||||
state = ParseDateState.TimezoneMinute; | ||||||||||||
} | ||||||||||||
break; | ||||||||||||
case ParseDateState.TimezoneMinute: | ||||||||||||
offsetMinutes = consume() + consume(); | ||||||||||||
if (!RE_MINUTE_SECOND.test(offsetMinutes)) { | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
|
||||||||||||
if (peek() === END) { | ||||||||||||
state = ParseDateState.End; | ||||||||||||
} else { | ||||||||||||
// Garbage at the end | ||||||||||||
throw InvalidIso8601Date; | ||||||||||||
} | ||||||||||||
break; | ||||||||||||
/* istanbul ignore next */ | ||||||||||||
default: | ||||||||||||
assert(false, 'Invalid ISO-8601 parser state'); | ||||||||||||
} | ||||||||||||
} | ||||||||||||
|
||||||||||||
assert(year !== undefined, 'Invalid ISO-8601 parser state'); | ||||||||||||
month = month ?? '01'; | ||||||||||||
date = date ?? '01'; | ||||||||||||
hours = hours ?? '00'; | ||||||||||||
minutes = minutes ?? '00'; | ||||||||||||
seconds = seconds ?? '00'; | ||||||||||||
offsetHours = offsetHours ?? '00'; | ||||||||||||
offsetMinutes = offsetMinutes ?? '00'; | ||||||||||||
Comment on lines
+225
to
+232
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mind leaving a comment about how this is accounting for partial dates as that is something that ISO-8601 allows? |
||||||||||||
|
||||||||||||
if (timezone !== null) { | ||||||||||||
if (timezone === 'Z') { | ||||||||||||
timezone = '+'; | ||||||||||||
} | ||||||||||||
return new TZDate( | ||||||||||||
parseInt(year, 10), | ||||||||||||
parseInt(month, 10) - 1, | ||||||||||||
parseInt(date, 10), | ||||||||||||
parseInt(hours, 10), | ||||||||||||
parseInt(minutes, 10), | ||||||||||||
parseInt(seconds, 10), | ||||||||||||
`${timezone}${offsetHours}:${offsetMinutes}`, | ||||||||||||
); | ||||||||||||
} | ||||||||||||
return new Date( | ||||||||||||
parseInt(year, 10), | ||||||||||||
parseInt(month, 10) - 1, | ||||||||||||
parseInt(date, 10), | ||||||||||||
parseInt(hours, 10), | ||||||||||||
parseInt(minutes, 10), | ||||||||||||
parseInt(seconds, 10), | ||||||||||||
); | ||||||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.