Skip to content

Commit 3b7bf59

Browse files
committed
Add fromRfc2822 function
1 parent 23723d8 commit 3b7bf59

File tree

4 files changed

+326
-1
lines changed

4 files changed

+326
-1
lines changed

CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- `startOfYear`, `startOfMonth`, `startOfDay`, `startOfHour`, `startOfMinute`, `startOfSecond`, `endOfYear`, `endOfMonth`, `endOfDay`, `endOfHour`, `endOfMinute`, `endOfSecond` functions
12+
- `startOfYear`, `startOfMonth`, `startOfDay`, `startOfHour`, `startOfMinute`, `startOfSecond`, `endOfYear`, `endOfMonth`, `endOfDay`, `endOfHour`, `endOfMinute`, `endOfSecond`, `fromRfc2822` functions
1313

1414
### Changed
1515

src/datetime/fromRfc2822.test.ts

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { Temporal } from "@js-temporal/polyfill";
2+
import { expect, test } from "vitest";
3+
4+
import { fromRfc2822 } from "./fromRfc2822.js";
5+
6+
test("PlainDateTime", () => {
7+
expect(
8+
fromRfc2822("01 Jan 2024 01:23:45 +0900", Temporal.PlainDateTime),
9+
).toEqual(Temporal.PlainDateTime.from("2024-01-01T01:23:45"));
10+
});
11+
12+
test("Instant", () => {
13+
expect(fromRfc2822("01 Jan 2024 01:23:45 +0900", Temporal.Instant)).toEqual(
14+
Temporal.Instant.from("2024-01-01T01:23:45+09:00"),
15+
);
16+
});
17+
18+
test("ZonedDateTime", () => {
19+
expect(
20+
fromRfc2822("01 Jan 2024 01:23:45 +0900", Temporal.ZonedDateTime),
21+
).toEqual(Temporal.ZonedDateTime.from("2024-01-01T01:23:45+09:00[+09:00]"));
22+
});
23+
24+
test("day of week", () => {
25+
expect(
26+
fromRfc2822("Mon, 01 Jan 2024 01:23:45 +0900", Temporal.PlainDateTime),
27+
).toEqual(Temporal.PlainDateTime.from("2024-01-01T01:23:45"));
28+
});
29+
30+
test("wrong day of week", () => {
31+
expect(() => {
32+
fromRfc2822("Tue, 01 Jan 2024 01:23:45 +0900", Temporal.PlainDateTime);
33+
}).toThrowError(/Wrong day of week/);
34+
});
35+
36+
test("string without second", () => {
37+
expect(
38+
fromRfc2822("01 Jan 2024 01:23 +0900", Temporal.PlainDateTime),
39+
).toEqual(Temporal.PlainDateTime.from("2024-01-01T01:23:00"));
40+
});
41+
42+
test("obsolete 2-digit year", () => {
43+
expect(
44+
fromRfc2822("01 Jan 24 01:23:45 +0900", Temporal.PlainDateTime),
45+
).toEqual(Temporal.PlainDateTime.from("2024-01-01T01:23:45"));
46+
expect(
47+
fromRfc2822("01 Jan 56 01:23:45 +0900", Temporal.PlainDateTime),
48+
).toEqual(Temporal.PlainDateTime.from("1956-01-01T01:23:45"));
49+
});
50+
51+
test.each([
52+
["01 Jan 2024 01:23:45 UT"],
53+
["01 Jan 2024 01:23:45 GMT"],
54+
["01 Jan 2024 01:23:45 Z"],
55+
["01 Jan 2024 01:23:45 z"],
56+
])("time zones with zero offset", (rfc2822) => {
57+
expect(fromRfc2822(rfc2822, Temporal.Instant)).toEqual(
58+
Temporal.Instant.from("2024-01-01T01:23:45+00:00"),
59+
);
60+
expect(fromRfc2822(rfc2822, Temporal.ZonedDateTime)).toEqual(
61+
Temporal.ZonedDateTime.from("2024-01-01T01:23:45+00:00[+00:00]"),
62+
);
63+
});
64+
65+
test("time zone -0000", () => {
66+
expect(
67+
fromRfc2822("01 Jan 24 01:23:45 -0000", Temporal.PlainDateTime),
68+
).toEqual(Temporal.PlainDateTime.from("2024-01-01T01:23:45"));
69+
expect(() => {
70+
fromRfc2822("01 Jan 2024 01:23:45 -0000", Temporal.Instant);
71+
}).toThrowError();
72+
expect(() => {
73+
fromRfc2822("01 Jan 2024 01:23:45 -0000", Temporal.ZonedDateTime);
74+
}).toThrowError();
75+
});
76+
77+
test("militatry time zone", () => {
78+
expect(fromRfc2822("01 Jan 24 01:23:45 A", Temporal.PlainDateTime)).toEqual(
79+
Temporal.PlainDateTime.from("2024-01-01T01:23:45"),
80+
);
81+
expect(() => {
82+
fromRfc2822("01 Jan 2024 01:23:45 A", Temporal.Instant);
83+
}).toThrowError();
84+
expect(() => {
85+
fromRfc2822("01 Jan 2024 01:23:45 A", Temporal.ZonedDateTime);
86+
}).toThrowError();
87+
});
88+
89+
test.each([
90+
["01 Jan 2024 01:23:45 EST", "2024-01-01T01:23:45-05:00[-05:00]"],
91+
["01 Jan 2024 01:23:45 EDT", "2024-01-01T01:23:45-04:00[-04:00]"],
92+
["01 Jan 2024 01:23:45 CST", "2024-01-01T01:23:45-06:00[-06:00]"],
93+
["01 Jan 2024 01:23:45 CDT", "2024-01-01T01:23:45-05:00[-05:00]"],
94+
["01 Jan 2024 01:23:45 MST", "2024-01-01T01:23:45-07:00[-07:00]"],
95+
["01 Jan 2024 01:23:45 MDT", "2024-01-01T01:23:45-06:00[-06:00]"],
96+
["01 Jan 2024 01:23:45 PST", "2024-01-01T01:23:45-08:00[-08:00]"],
97+
["01 Jan 2024 01:23:45 PDT", "2024-01-01T01:23:45-07:00[-07:00]"],
98+
])("North American time zones", (rfc2822, iso8601) => {
99+
expect(fromRfc2822(rfc2822, Temporal.Instant)).toEqual(
100+
Temporal.Instant.from(iso8601),
101+
);
102+
expect(fromRfc2822(rfc2822, Temporal.ZonedDateTime)).toEqual(
103+
Temporal.ZonedDateTime.from(iso8601),
104+
);
105+
});
106+
107+
test("comment", () => {
108+
expect(
109+
fromRfc2822(
110+
"(day of week skipped)01 Jan 2024 01:23:45 (comment\\()(comment2) +0000 (GMT! (nested \\(comment))",
111+
Temporal.Instant,
112+
),
113+
).toEqual(Temporal.Instant.from("2024-01-01T01:23:45Z"));
114+
});

src/datetime/fromRfc2822.ts

+210
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import {
2+
isInstantConstructor,
3+
isPlainDateTimeConstructor,
4+
} from "../type-utils.js";
5+
import type { Temporal } from "../types.js";
6+
7+
// spec: https://datatracker.ietf.org/doc/html/rfc2822#section-3.3 https://datatracker.ietf.org/doc/html/rfc2822#section-4.3
8+
9+
function removeComment(str: string) {
10+
const r = /(?<!\\)[()]/g;
11+
let res = "";
12+
let commentNestLevel = 0;
13+
let lastNonCommentStarted = 0;
14+
for (const m of str.matchAll(r)) {
15+
if (m[0] === "(") {
16+
if (commentNestLevel === 0) {
17+
// comment started
18+
res += str.slice(lastNonCommentStarted, m.index);
19+
}
20+
commentNestLevel++;
21+
} else {
22+
commentNestLevel--;
23+
if (commentNestLevel === 0) {
24+
// comment ended
25+
lastNonCommentStarted = m.index + 1;
26+
}
27+
}
28+
}
29+
return res;
30+
}
31+
32+
const dateTimeFormatRegex =
33+
/^[ \t\r\n]*(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun),)?[ \t\r\n]*(\d\d)[ \t\r\n]+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[ \t\r\n]+(\d\d|\d\d\d\d)[ \t\r\n]+(\d\d):(\d\d)(?::(\d\d))?[ \t\r\n]+([+-]\d{4}|[A-Za-z]+)[ \t\r\n]*$/;
34+
35+
function fullYear(year: string) {
36+
const yearNum = parseInt(year, 10);
37+
if (year.length === 4) {
38+
return yearNum;
39+
}
40+
return yearNum >= 50 ? 1900 + yearNum : 2000 + yearNum;
41+
}
42+
43+
function getDayOfWeek(year: number, month: number, day: number) {
44+
const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
45+
const week = days[new Date(Date.UTC(year, month - 1, day)).getUTCDay()];
46+
if (week === undefined) {
47+
throw new Error("something wrong");
48+
}
49+
return week;
50+
}
51+
52+
function monthNumber(monthName: string) {
53+
const monthNum =
54+
[
55+
"Jan",
56+
"Feb",
57+
"Mar",
58+
"Apr",
59+
"May",
60+
"Jun",
61+
"Jul",
62+
"Aug",
63+
"Sep",
64+
"Oct",
65+
"Nov",
66+
"Dec",
67+
].indexOf(monthName) + 1;
68+
if (monthNum === 0) {
69+
throw new Error(`Invalid month name: ${monthName}`);
70+
}
71+
return monthNum;
72+
}
73+
74+
function getOffset(timeZone: string): string {
75+
if (["UT", "GMT", "z", "Z"].includes(timeZone)) {
76+
return "+00:00";
77+
}
78+
if (timeZone === "-0000" || /^[A-IK-Za-ik-z]$/.test(timeZone)) {
79+
// according to the spec, military zone except 'Z' should be considered equivalent to "-0000",
80+
// which means the date-time contains no information about the local time zone
81+
throw new Error("No offset info");
82+
}
83+
if (/^[+-]\d{4}$/.test(timeZone)) {
84+
return `${timeZone.slice(0, 3)}:${timeZone.slice(3)}`;
85+
}
86+
const table = Object.assign(Object.create(null) as Record<string, string>, {
87+
EDT: "-04:00",
88+
EST: "-05:00",
89+
CDT: "-05:00",
90+
CST: "-06:00",
91+
MDT: "-06:00",
92+
MST: "-07:00",
93+
PDT: "-07:00",
94+
PST: "-08:00",
95+
});
96+
if (table[timeZone] !== undefined) {
97+
return table[timeZone];
98+
}
99+
throw new Error("Unknown time zone");
100+
}
101+
102+
function parse(date: string) {
103+
const result = dateTimeFormatRegex.exec(date);
104+
if (result === null) {
105+
throw new Error(`Invalid date and time format: ${date}`);
106+
}
107+
const [
108+
,
109+
dayOfWeek,
110+
day,
111+
monthName,
112+
year,
113+
hour,
114+
minute,
115+
second = "00",
116+
timeZone,
117+
] = result;
118+
if (
119+
day === undefined ||
120+
monthName === undefined ||
121+
year === undefined ||
122+
hour === undefined ||
123+
minute === undefined ||
124+
timeZone === undefined
125+
) {
126+
throw new Error("something wrong");
127+
}
128+
return {
129+
year: fullYear(year),
130+
month: monthNumber(monthName),
131+
day: parseInt(day, 10),
132+
hour: parseInt(hour, 10),
133+
minute: parseInt(minute, 10),
134+
second: parseInt(second),
135+
dayOfWeek,
136+
timeZone,
137+
};
138+
}
139+
140+
function formatInstantIso(
141+
year: number,
142+
month: number,
143+
day: number,
144+
hour: number,
145+
minute: number,
146+
second: number,
147+
offsetString: string,
148+
) {
149+
const yearStr = year.toString();
150+
const monthStr = month.toString().padStart(2, "0");
151+
const dayStr = day.toString().padStart(2, "0");
152+
const hourStr = hour.toString().padStart(2, "0");
153+
const minuteStr = minute.toString().padStart(2, "0");
154+
const secondStr = second.toString().padStart(2, "0");
155+
return `${yearStr}-${monthStr}-${dayStr}T${hourStr}:${minuteStr}:${secondStr}${offsetString}`;
156+
}
157+
158+
/**
159+
* Creates Temporal object from datetime string in RFC 2822's format.
160+
*
161+
* @param date datetime string in RFC 2822's format
162+
* @param TemporalClass Temporal class (such as `Temporal.PlainDateTime` or `Temporal.Instant`) which will be returned
163+
* @returns an instance of Temporal class specified in `TemporalClass` argument
164+
*/
165+
export function fromRfc2822<
166+
TemporalClassType extends
167+
| typeof Temporal.Instant
168+
| typeof Temporal.ZonedDateTime
169+
| typeof Temporal.PlainDateTime,
170+
>(
171+
date: string,
172+
TemporalClass: TemporalClassType,
173+
): InstanceType<TemporalClassType> {
174+
const dateWithoutComment = date.includes("(") ? removeComment(date) : date;
175+
176+
const { year, month, day, hour, minute, second, dayOfWeek, timeZone } =
177+
parse(dateWithoutComment);
178+
if (dayOfWeek !== undefined && getDayOfWeek(year, month, day) !== dayOfWeek) {
179+
throw new Error(`Wrong day of week: ${dayOfWeek}`);
180+
}
181+
182+
if (isPlainDateTimeConstructor(TemporalClass)) {
183+
return TemporalClass.from({
184+
year,
185+
month,
186+
day,
187+
hour,
188+
minute,
189+
second,
190+
calendarId: "iso8601",
191+
}) as InstanceType<TemporalClassType>;
192+
}
193+
194+
const offsetIso = getOffset(timeZone);
195+
if (isInstantConstructor(TemporalClass)) {
196+
return TemporalClass.from(
197+
formatInstantIso(year, month, day, hour, minute, second, offsetIso),
198+
) as InstanceType<TemporalClassType>;
199+
}
200+
return TemporalClass.from({
201+
year,
202+
month,
203+
day,
204+
hour,
205+
minute,
206+
second,
207+
calendarId: "iso8601",
208+
timeZone: offsetIso,
209+
}) as InstanceType<TemporalClassType>;
210+
}

src/datetime/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export {
1919
formatWithoutLocale,
2020
type FormatWithoutLocaleOptions,
2121
} from "./formatWithoutLocale.js";
22+
export { fromRfc2822 } from "./fromRfc2822.js";
2223
export { isAfter } from "./isAfter.js";
2324
export { isBefore } from "./isBefore.js";
2425
export { isWithinInterval } from "./isWithinInterval.js";

0 commit comments

Comments
 (0)