diff --git a/src/fable-library-py/fable_library/date.py b/src/fable-library-py/fable_library/date.py index 319c11c934..8f05a96c9d 100644 --- a/src/fable-library-py/fable_library/date.py +++ b/src/fable-library-py/fable_library/date.py @@ -167,6 +167,48 @@ def to_long_time_string(date: datetime) -> str: return datetime.strftime(date, "%H:%M:%S") +def _to_plain_utc(date: datetime) -> datetime: + # Strip any datetime subclass (e.g. DateTimeOffset) so astimezone's internal + # reconstruction doesn't hit an incompatible __new__ signature. + plain = datetime( + date.year, date.month, date.day, date.hour, date.minute, date.second, date.microsecond, tzinfo=date.tzinfo + ) + return plain.astimezone(UTC) + + +def to_rfc1123_string(date: datetime) -> str: + """RFC 1123: "Thu, 01 Jan 2009 00:00:00 GMT" — always UTC""" + utc_date = _to_plain_utc(date) + return ( + short_days[day_of_week(utc_date)] + + ", " + + utc_date.strftime("%d ") + + short_months[utc_date.month - 1] + + utc_date.strftime(" %Y %H:%M:%S GMT") + ) + + +def to_sortable_string(date: datetime) -> str: + """Sortable ISO 8601, no timezone: "2009-06-15T13:45:30" """ + return date.strftime("%Y-%m-%dT%H:%M:%S") + + +def to_universal_sortable_string(date: datetime) -> str: + """Universal sortable: "2009-06-15 13:45:30Z" — always UTC""" + utc_date = _to_plain_utc(date) + return utc_date.strftime("%Y-%m-%d %H:%M:%SZ") + + +def to_month_day_string(date: datetime) -> str: + """Month/day (InvariantCulture "MMMM dd"): "June 15" """ + return long_months[date.month - 1] + " " + f"{date.day:02d}" + + +def to_year_month_string(date: datetime) -> str: + """Year/month (InvariantCulture "yyyy MMMM"): "2009 June" """ + return str(date.year) + " " + long_months[date.month - 1] + + def parse_repeat_token(format: str, pos: int, pattern_char: str) -> int: token_length = 0 internal_pos = pos @@ -426,6 +468,35 @@ def date_to_string_with_offset(date: datetime, format: str | None = None) -> str return date.strftime("%Y-%m-%dT%H:%M:%S.%f%z") case "O" | "o": return date.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + case "D": + return to_long_date_string(date) + case "d": + return to_short_date_string(date) + case "F": + return to_long_date_string(date) + " " + to_long_time_string(date) + case "f": + return to_long_date_string(date) + " " + to_short_time_string(date) + case "G": + return to_short_date_string(date) + " " + to_long_time_string(date) + case "g": + return to_short_date_string(date) + " " + to_short_time_string(date) + case "M" | "m": + return to_month_day_string(date) + case "R" | "r": + return to_rfc1123_string(date) + case "s": + return to_sortable_string(date) + case "T": + return to_long_time_string(date) + case "t": + return to_short_time_string(date) + case "u": + return to_universal_sortable_string(date) + case "U": + utc_date = _to_plain_utc(date) + return to_long_date_string(utc_date) + " " + to_long_time_string(utc_date) + case "Y" | "y": + return to_year_month_string(date) case _ if len(format) == 1: raise Exception("Unrecognized Date print format") case _: @@ -435,24 +506,44 @@ def date_to_string_with_offset(date: datetime, format: str | None = None) -> str def date_to_string_with_kind(date: datetime, format: str | None = None) -> str: utc = date.tzinfo == UTC - if not format: - return date.isoformat() if utc else str(date) - elif len(format) == 1: - if format == "d": + match format: + case None | "": + return date.isoformat() if utc else str(date) + case "d": return to_short_date_string(date) - elif format == "D": + case "D": return to_long_date_string(date) - elif format == "T": + case "F": + return to_long_date_string(date) + " " + to_long_time_string(date) + case "f": + return to_long_date_string(date) + " " + to_short_time_string(date) + case "G": + return to_short_date_string(date) + " " + to_long_time_string(date) + case "g": + return to_short_date_string(date) + " " + to_short_time_string(date) + case "M" | "m": + return to_month_day_string(date) + case "O" | "o": + return date.astimezone().isoformat(timespec="milliseconds") + case "R" | "r": + return to_rfc1123_string(date) + case "s": + return to_sortable_string(date) + case "T": return to_long_time_string(date) - elif format == "t": + case "t": return to_short_time_string(date) - elif format == "O" or format == "o": - return date.astimezone().isoformat(timespec="milliseconds") - else: + case "u": + return to_universal_sortable_string(date) + case "U": + utc_date = _to_plain_utc(date) + return to_long_date_string(utc_date) + " " + to_long_time_string(utc_date) + case "Y" | "y": + return to_year_month_string(date) + case _ if len(format) == 1: raise Exception("Unrecognized Date print format") - - else: - return date_to_string_with_custom_format(date, format, utc) + case _: + return date_to_string_with_custom_format(date, format, utc) def to_string(date: datetime, format: str | None = None, provider: Any | None = None) -> str: diff --git a/src/fable-library-ts/Date.ts b/src/fable-library-ts/Date.ts index 2970ba14fe..7dc81bece9 100644 --- a/src/fable-library-ts/Date.ts +++ b/src/fable-library-ts/Date.ts @@ -410,9 +410,28 @@ function dateToStringWithOffset(date: IDateTimeOffset, format?: string) { switch (format) { case "D": return dateToString_D(d); case "d": return dateToString_d(d); + case "F": return dateToString_D(d) + " " + dateToString_T(d); + case "f": return dateToString_D(d) + " " + dateToString_t(d); + case "G": return dateToString_d(d) + " " + dateToString_T(d); + case "g": return dateToString_d(d) + " " + dateToString_t(d); + case "M": case "m": return dateToString_M(d); + case "O": case "o": return dateToISOStringWithOffset(d, (date.offset ?? 0)); + case "R": case "r": { + const utcDate = DateTime(date.getTime(), DateTimeKind.Utc); + return dateToString_R(utcDate); + } + case "s": return dateToString_s(d); case "T": return dateToString_T(toUniversalTime(d)); case "t": return dateToString_t(toUniversalTime(d)); - case "O": case "o": return dateToISOStringWithOffset(d, (date.offset ?? 0)); + case "u": { + const utcDate = DateTime(date.getTime(), DateTimeKind.Utc); + return dateToString_u(utcDate); + } + case "U": { + const utcDate = DateTime(date.getTime(), DateTimeKind.Utc); + return dateToString_D(utcDate) + " " + dateToString_T(utcDate); + } + case "Y": case "y": return dateToString_Y(d); default: throw new Exception("Unrecognized Date print format"); } } else { @@ -444,6 +463,49 @@ function dateToString_t(date: IDateTime) { + ":" + padWithZeros(minute(date), 2); } +// RFC 1123: "Thu, 01 Jan 2009 00:00:00 GMT" — always UTC +function dateToString_R(date: IDateTime) { + const utcDate = toUniversalTime(date); + return shortDays[dayOfWeek(utcDate)] + ", " + + padWithZeros(day(utcDate), 2) + " " + + shortMonths[month(utcDate) - 1] + " " + + year(utcDate) + " " + + padWithZeros(hour(utcDate), 2) + ":" + + padWithZeros(minute(utcDate), 2) + ":" + + padWithZeros(second(utcDate), 2) + " GMT"; +} + +// Sortable ISO 8601, no timezone: "2009-06-15T13:45:30" +function dateToString_s(date: IDateTime) { + return padWithZeros(year(date), 4) + "-" + + padWithZeros(month(date), 2) + "-" + + padWithZeros(day(date), 2) + "T" + + padWithZeros(hour(date), 2) + ":" + + padWithZeros(minute(date), 2) + ":" + + padWithZeros(second(date), 2); +} + +// Universal sortable: "2009-06-15 13:45:30Z" — always UTC +function dateToString_u(date: IDateTime) { + const utcDate = toUniversalTime(date); + return padWithZeros(year(utcDate), 4) + "-" + + padWithZeros(month(utcDate), 2) + "-" + + padWithZeros(day(utcDate), 2) + " " + + padWithZeros(hour(utcDate), 2) + ":" + + padWithZeros(minute(utcDate), 2) + ":" + + padWithZeros(second(utcDate), 2) + "Z"; +} + +// Month/day (InvariantCulture "MMMM dd"): "June 15" +function dateToString_M(date: IDateTime) { + return longMonths[month(date) - 1] + " " + padWithZeros(day(date), 2); +} + +// Year/month (InvariantCulture "yyyy MMMM"): "2009 June" +function dateToString_Y(date: IDateTime) { + return year(date) + " " + longMonths[month(date) - 1]; +} + function dateToStringWithKind(date: IDateTime, format?: string) { const utc = date.kind === DateTimeKind.Utc; if (typeof format !== "string") { @@ -452,10 +514,19 @@ function dateToStringWithKind(date: IDateTime, format?: string) { switch (format) { case "D": return dateToString_D(date); case "d": return dateToString_d(date); + case "F": return dateToString_D(date) + " " + dateToString_T(date); + case "f": return dateToString_D(date) + " " + dateToString_t(date); + case "G": return dateToString_d(date) + " " + dateToString_T(date); + case "g": return dateToString_d(date) + " " + dateToString_t(date); + case "M": case "m": return dateToString_M(date); + case "O": case "o": return dateToISOString(date, utc); + case "R": case "r": return dateToString_R(date); + case "s": return dateToString_s(date); case "T": return dateToString_T(date); case "t": return dateToString_t(date); - case "O": case "o": - return dateToISOString(date, utc); + case "u": return dateToString_u(date); + case "U": return dateToString_D(toUniversalTime(date)) + " " + dateToString_T(toUniversalTime(date)); + case "Y": case "y": return dateToString_Y(date); default: throw new Exception("Unrecognized Date print format"); } diff --git a/tests/Js/Main/DateTimeOffsetTests.fs b/tests/Js/Main/DateTimeOffsetTests.fs index 7a00bdc98a..89c31edb1d 100644 --- a/tests/Js/Main/DateTimeOffsetTests.fs +++ b/tests/Js/Main/DateTimeOffsetTests.fs @@ -97,6 +97,21 @@ let tests = |> format |> equal "05:07" + testCase "DateTimeOffset.ToString('R') works" <| fun _ -> + // R always formats in UTC + DateTimeOffset(2014, 9, 1, 16, 37, 2, TimeSpan.FromHours 2).ToString("R", CultureInfo.InvariantCulture) + |> equal "Mon, 01 Sep 2014 14:37:02 GMT" + + testCase "DateTimeOffset.ToString('u') works" <| fun _ -> + // u always formats in UTC + DateTimeOffset(2014, 9, 1, 16, 37, 2, TimeSpan.FromHours 2).ToString("u", CultureInfo.InvariantCulture) + |> equal "2014-09-01 14:37:02Z" + + testCase "DateTimeOffset.ToString('s') works" <| fun _ -> + // s uses the offset-local time with no timezone designator + DateTimeOffset(2014, 9, 1, 16, 37, 2, TimeSpan.Zero).ToString("s", CultureInfo.InvariantCulture) + |> equal "2014-09-01T16:37:02" + testCase "DateTimeOffset from Year 1 to 99 works" <| fun () -> let date = DateTimeOffset(1, 1, 2, 0, 0, 0, TimeSpan.Zero) date.Year |> equal 1 diff --git a/tests/Js/Main/DateTimeTests.fs b/tests/Js/Main/DateTimeTests.fs index 3dd7a513a1..253830984b 100644 --- a/tests/Js/Main/DateTimeTests.fs +++ b/tests/Js/Main/DateTimeTests.fs @@ -503,6 +503,60 @@ let tests = // DateTime(2014, 9, 11, 16, 37, 2, DateTimeKind.Local).ToString("O") // |> equal "2014-09-11T16:37:02.000+02:00" // Here the time zone is Europe/Paris (GMT+2) + testCase "DateTime.ToString('R') works" <| fun _ -> + let format (d: DateTime) = d.ToString("R", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "Mon, 01 Sep 2014 16:37:02 GMT" + + testCase "DateTime.ToString('s') works" <| fun _ -> + let format (d: DateTime) = d.ToString("s", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "2014-09-01T16:37:02" + + testCase "DateTime.ToString('u') works" <| fun _ -> + let format (d: DateTime) = d.ToString("u", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "2014-09-01 16:37:02Z" + + testCase "DateTime.ToString('F') works" <| fun _ -> + let format (d: DateTime) = d.ToString("F", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "Monday, 01 September 2014 16:37:02" + + testCase "DateTime.ToString('f') works" <| fun _ -> + let format (d: DateTime) = d.ToString("f", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "Monday, 01 September 2014 16:37" + + testCase "DateTime.ToString('G') works" <| fun _ -> + let format (d: DateTime) = d.ToString("G", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "09/01/2014 16:37:02" + + testCase "DateTime.ToString('g') works" <| fun _ -> + let format (d: DateTime) = d.ToString("g", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "09/01/2014 16:37" + + testCase "DateTime.ToString('M') works" <| fun _ -> + let format (d: DateTime) = d.ToString("M", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "September 01" + + testCase "DateTime.ToString('Y') works" <| fun _ -> + let format (d: DateTime) = d.ToString("Y", CultureInfo.InvariantCulture) + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc) + |> format + |> equal "2014 September" + testCase "DateTime from Year 1 to 99 works" <| fun () -> let date = DateTime(1, 1, 2) date.Year |> equal 1 diff --git a/tests/Python/TestDateTime.fs b/tests/Python/TestDateTime.fs index e250ec145f..19212924a9 100644 --- a/tests/Python/TestDateTime.fs +++ b/tests/Python/TestDateTime.fs @@ -556,6 +556,51 @@ let ``test DateTime.ToString with Round-trip format works for Utc`` () = str.Replace("0000000Z", "000000Z") |> equal "2014-09-11T16:37:02.000000Z" +[] +let ``test DateTime.ToString("R") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("R", CultureInfo.InvariantCulture) + |> equal "Mon, 01 Sep 2014 16:37:02 GMT" + +[] +let ``test DateTime.ToString("s") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("s", CultureInfo.InvariantCulture) + |> equal "2014-09-01T16:37:02" + +[] +let ``test DateTime.ToString("u") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("u", CultureInfo.InvariantCulture) + |> equal "2014-09-01 16:37:02Z" + +[] +let ``test DateTime.ToString full date long time format works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("F", CultureInfo.InvariantCulture) + |> equal "Monday, 01 September 2014 16:37:02" + +[] +let ``test DateTime.ToString full date short time format works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("f", CultureInfo.InvariantCulture) + |> equal "Monday, 01 September 2014 16:37" + +[] +let ``test DateTime.ToString general long time format works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("G", CultureInfo.InvariantCulture) + |> equal "09/01/2014 16:37:02" + +[] +let ``test DateTime.ToString general short time format works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("g", CultureInfo.InvariantCulture) + |> equal "09/01/2014 16:37" + +[] +let ``test DateTime.ToString("M") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("M", CultureInfo.InvariantCulture) + |> equal "September 01" + +[] +let ``test DateTime.ToString("Y") works`` () = + DateTime(2014, 9, 1, 16, 37, 2, DateTimeKind.Utc).ToString("Y", CultureInfo.InvariantCulture) + |> equal "2014 September" + [] let ``test DateTime from Year 1 to 99 works`` () = let date = DateTime(1, 1, 2) diff --git a/tests/Python/TestDateTimeOffset.fs b/tests/Python/TestDateTimeOffset.fs index 4151101ae4..afb958b848 100644 --- a/tests/Python/TestDateTimeOffset.fs +++ b/tests/Python/TestDateTimeOffset.fs @@ -489,6 +489,24 @@ let ``test DateTimeOffset.ToString with custom format works`` () = DateTimeOffset(2014, 9, 11, 16, 37, 0, TimeSpan.Zero).ToString("HH:mm", CultureInfo.InvariantCulture) |> equal "16:37" +[] +let ``test DateTimeOffset.ToString("R") works`` () = + // R always formats in UTC + DateTimeOffset(2014, 9, 1, 16, 37, 2, TimeSpan.FromHours 2).ToString("R", CultureInfo.InvariantCulture) + |> equal "Mon, 01 Sep 2014 14:37:02 GMT" + +[] +let ``test DateTimeOffset.ToString("u") works`` () = + // u always formats in UTC + DateTimeOffset(2014, 9, 1, 16, 37, 2, TimeSpan.FromHours 2).ToString("u", CultureInfo.InvariantCulture) + |> equal "2014-09-01 14:37:02Z" + +[] +let ``test DateTimeOffset.ToString("s") works`` () = + // s uses the offset-local time with no timezone designator + DateTimeOffset(2014, 9, 1, 16, 37, 2, TimeSpan.Zero).ToString("s", CultureInfo.InvariantCulture) + |> equal "2014-09-01T16:37:02" + [] let ``test DateTimeOffset.LocalDateTime works`` () = let d = DateTimeOffset(2014, 10, 9, 13, 23, 30, TimeSpan.Zero)