diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e0309921..7873ee0c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/checkout@v3 - name: Test - run: dotnet test --configuration Release + run: dotnet test -c Release - name: Test - Compiled run: dotnet test --configuration Release /p:Compiled=true diff --git a/Fluid.Tests/Fluid.Tests.csproj b/Fluid.Tests/Fluid.Tests.csproj index cf7b91dd..bdb93e23 100644 --- a/Fluid.Tests/Fluid.Tests.csproj +++ b/Fluid.Tests/Fluid.Tests.csproj @@ -1,11 +1,9 @@  - netcoreapp3.1;net5.0;net6.0;net7.0 + net6.0;net7.0 latest $(DefineConstants);COMPILED - - NETSDK1138 diff --git a/Fluid.Tests/MiscFiltersTests.cs b/Fluid.Tests/MiscFiltersTests.cs index 948674aa..8430dfbc 100644 --- a/Fluid.Tests/MiscFiltersTests.cs +++ b/Fluid.Tests/MiscFiltersTests.cs @@ -1,9 +1,10 @@ -using System; +using Fluid.Filters; +using Fluid.Values; +using System; using System.Globalization; using System.Linq; using System.Threading.Tasks; -using Fluid.Filters; -using Fluid.Values; +using TimeZoneConverter; using Xunit; namespace Fluid.Tests @@ -302,6 +303,27 @@ public async Task ChangeTimeZone(string initialDateTime, string timeZone, string Assert.Equal(expected, ((DateTimeOffset)result.ToObjectValue()).ToString("yyyy-MM-ddTHH:mm:ssK")); } + [Theory] + [InlineData("2022-12-13T21:02:18.399+00:00", "utc", "2022-12-13T21:02:18.399+00:00")] + [InlineData("2022-12-13T21:02:18.399+00:00", "America/New_York", "2022-12-13T21:02:18.399+00:00")] + [InlineData("2022-12-13T21:02:18.399+00:00", "Australia/Adelaide", "2022-12-13T21:02:18.399+00:00")] + [InlineData("2022-12-13T21:02:18.399", "utc", "2022-12-13T21:02:18.399+00:00")] + [InlineData("2022-12-13T21:02:18.399", "America/New_York", "2022-12-13T21:02:18.399-05:00")] + [InlineData("2022-12-13T21:02:18.399", "Australia/Adelaide", "2022-12-13T21:02:18.399+10:30")] + [InlineData("2022-12-13T21:02:18.399+01:00", "utc", "2022-12-13T21:02:18.399+01:00")] // Parsed as UTC+1, converted to UTC + public async Task DateFilterUsesContextTimezone(string initialDateTime, string timeZone, string expected) + { + // - When a TZ is provided in the source string, the resulting DateTimeOffset uses it + // - When no TZ is provided, we assume the local offset (context.TimeZone) + + var input = new StringValue(initialDateTime); + var context = new TemplateContext { TimeZone = TZConvert.GetTimeZoneInfo(timeZone) }; + + var date = await MiscFilters.Date(input, new FilterArguments(new StringValue(RoundTripDateTimePattern)), context); + + Assert.Equal(expected, date.ToStringValue()); + } + [Theory] [InlineData("2020-05-18T02:13:09+00:00", "America/New_York", "%l:%M%P", "10:13pm")] [InlineData("2020-05-18T02:13:09+00:00", "Europe/London", "%l:%M%P", "3:13am")] diff --git a/Fluid/Values/FluidValueExtensions.cs b/Fluid/Values/FluidValueExtensions.cs index bae87503..659b7b73 100644 --- a/Fluid/Values/FluidValueExtensions.cs +++ b/Fluid/Values/FluidValueExtensions.cs @@ -1,5 +1,6 @@ using System; using System.Globalization; +using TimeZoneConverter; namespace Fluid.Values { @@ -8,10 +9,13 @@ public static class FluidValueExtensions private const string Now = "now"; private const string Today = "today"; + // The K specifier is optional when used in TryParseExact, so + // if a TZ is not specified, it will still match + private static readonly string[] DefaultFormats = { - "yyyy-MM-ddTHH:mm:ss.FFF", - "yyyy-MM-ddTHH:mm:ss", - "yyyy-MM-ddTHH:mm", + "yyyy-MM-ddTHH:mm:ss.FFFK", + "yyyy-MM-ddTHH:mm:ssK", + "yyyy-MM-ddTHH:mmK", "yyyy-MM-dd", "yyyy-MM", "yyyy" @@ -53,6 +57,8 @@ public static bool TryGetDateTimeInput(this FluidValue input, TemplateContext co if (input.Type == FluidValues.String) { + var timeZoneProvided = false; + var stringValue = input.ToStringValue(); if (stringValue == Now || stringValue == Today) @@ -63,30 +69,52 @@ public static bool TryGetDateTimeInput(this FluidValue input, TemplateContext co { var success = true; - if (!DateTime.TryParseExact(stringValue, DefaultFormats, context.CultureInfo, DateTimeStyles.None, out var dateTime)) + // Use DateTimeOffset.Parse to extract the TZ if it's specified. + // We then verify if a TZ was set in the source string by using DateTime.Parse's Kind which will return Unspecified if not set. + + if (!DateTimeOffset.TryParseExact(stringValue, DefaultFormats, context.CultureInfo, DateTimeStyles.AssumeUniversal, out result)) { - if (!DateTime.TryParseExact(stringValue, SecondaryFormats, context.CultureInfo, DateTimeStyles.None, out dateTime)) + if (!DateTimeOffset.TryParseExact(stringValue, SecondaryFormats, context.CultureInfo, DateTimeStyles.AssumeUniversal, out result)) { - if (!DateTime.TryParse(stringValue, context.CultureInfo, DateTimeStyles.None, out dateTime)) + if (!DateTimeOffset.TryParse(stringValue, context.CultureInfo, DateTimeStyles.AssumeUniversal, out result)) { - if (!DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out dateTime)) + if (!DateTimeOffset.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out result)) { success = false; } + else + { + timeZoneProvided = DateTime.TryParse(stringValue, CultureInfo.InvariantCulture, DateTimeStyles.None, out var timeZoneDateTime) && timeZoneDateTime.Kind != DateTimeKind.Unspecified; + } + } + else + { + timeZoneProvided = DateTime.TryParse(stringValue, context.CultureInfo, DateTimeStyles.None, out var timeZoneDateTime) && timeZoneDateTime.Kind != DateTimeKind.Unspecified; } } + else + { + timeZoneProvided = DateTime.TryParseExact(stringValue, SecondaryFormats, context.CultureInfo, DateTimeStyles.None, out var timeZoneDateTime) && timeZoneDateTime.Kind != DateTimeKind.Unspecified; + } + } + else + { + timeZoneProvided = DateTime.TryParseExact(stringValue, DefaultFormats, context.CultureInfo, DateTimeStyles.None, out var timeZoneDateTime) && timeZoneDateTime.Kind != DateTimeKind.Unspecified; } - // If no timezone is specified, assume local using the configured timezone if (success) { - if (dateTime.Kind == DateTimeKind.Unspecified) - { - result = new DateTimeOffset(dateTime, context.TimeZone.GetUtcOffset(dateTime)); - } - else + // If no timezone is specified in the source string, only use the date time part of the result + if (!timeZoneProvided) { - result = new DateTimeOffset(dateTime); + // A timezone is represented as a UTC offset, but this can vary based on daylight saving times. + // Hence we don't use context.TimeZone.BaseUtcOffset which is fixed, but TimeZone.GetUtcOffset + // to get the actual timezone offset at the moment of the parsed date and time + + var dateTime = result.DateTime; + var offset = context.TimeZone.GetUtcOffset(dateTime); + + result = new DateTimeOffset(dateTime, offset); } } diff --git a/README.md b/README.md index ba511e98..bf1501b5 100644 --- a/README.md +++ b/README.md @@ -372,10 +372,12 @@ Tuesday, August 1, 2017 ### System time zone -`TemplateOptions` and `TemplateContext` provides a property to define a default time zone to use when parsing date and times. The default value is the current system's time zone. -When dates and times are parsed and don't specify a time zone, the default one is assumed. Setting a custom one can also prevent different environments (data centers) from +`TemplateOptions` and `TemplateContext` provides a property to define a default time zone to use when parsing date and times. The default value is the current system's time zone. Setting a custom one can also prevent different environments (data centers) from generating different results. +- When dates and times are parsed and don't specify a time zone, the configured one is assumed. +- When a time zone is provided in the source string, the resulting date time uses it. + > Note: The `date` filter conforms to the Ruby date and time formats https://ruby-doc.org/core-3.0.0/Time.html#method-i-strftime. To use the .NET standard date formats, use the `format_date` filter. #### Source