diff --git a/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs b/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs index be4a6221..1302fb23 100644 --- a/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs +++ b/DiscordChatExporter.Cli.Tests/Infra/ExportWrapper.cs @@ -59,7 +59,9 @@ public static class ExportWrapper Token = Secrets.DiscordToken, ChannelIds = new[] { channelId }, ExportFormat = format, - OutputPath = filePath + OutputPath = filePath, + Locale = "en-US", + IsUtcNormalizationEnabled = true }.ExecuteAsync(console); } diff --git a/DiscordChatExporter.Cli.Tests/Specs/HtmlMarkdownSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/HtmlMarkdownSpecs.cs index 99f917e3..7340467c 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/HtmlMarkdownSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/HtmlMarkdownSpecs.cs @@ -1,8 +1,6 @@ -using System; -using System.Threading.Tasks; +using System.Threading.Tasks; using AngleSharp.Dom; using DiscordChatExporter.Cli.Tests.Infra; -using DiscordChatExporter.Cli.Tests.Utils; using DiscordChatExporter.Core.Discord; using FluentAssertions; using Xunit; @@ -14,218 +12,128 @@ public class HtmlMarkdownSpecs [Fact] public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker() { - // Date formatting code relies on the local time zone, so we need to set it to a fixed value - TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); + // Act + var message = await ExportWrapper.GetMessageAsHtmlAsync( + ChannelIds.MarkdownTestCases, + Snowflake.Parse("1074323136411078787") + ); - try - { - // Act - var message = await ExportWrapper.GetMessageAsHtmlAsync( - ChannelIds.MarkdownTestCases, - Snowflake.Parse("1074323136411078787") - ); - - // Assert - message.Text().Should().Contain("Default timestamp: 02/12/2023 3:36 PM"); - message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM"); - } - finally - { - TimeZoneInfo.ClearCachedData(); - } + // Assert + message.Text().Should().Contain("Default timestamp: 2/12/2023 1:36 PM"); + message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); } [Fact] public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_short_format() { - // Date formatting code relies on the local time zone, so we need to set it to a fixed value - TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); + // Act + var message = await ExportWrapper.GetMessageAsHtmlAsync( + ChannelIds.MarkdownTestCases, + Snowflake.Parse("1074323205268967596") + ); - try - { - // Act - var message = await ExportWrapper.GetMessageAsHtmlAsync( - ChannelIds.MarkdownTestCases, - Snowflake.Parse("1074323205268967596") - ); - - // Assert - message.Text().Should().Contain("Short time timestamp: 3:36 PM"); - message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM"); - } - finally - { - TimeZoneInfo.ClearCachedData(); - } + // Assert + message.Text().Should().Contain("Short time timestamp: 1:36 PM"); + message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); } [Fact] public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_long_format() { - // Date formatting code relies on the local time zone, so we need to set it to a fixed value - TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); + // Act + var message = await ExportWrapper.GetMessageAsHtmlAsync( + ChannelIds.MarkdownTestCases, + Snowflake.Parse("1074323235342139483") + ); - try - { - // Act - var message = await ExportWrapper.GetMessageAsHtmlAsync( - ChannelIds.MarkdownTestCases, - Snowflake.Parse("1074323235342139483") - ); - - // Assert - message.Text().Should().Contain("Long time timestamp: 3:36:12 PM"); - message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM"); - } - finally - { - TimeZoneInfo.ClearCachedData(); - } + // Assert + message.Text().Should().Contain("Long time timestamp: 1:36:12 PM"); + message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); } [Fact] public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_short_date_format() { - // Date formatting code relies on the local time zone, so we need to set it to a fixed value - TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); + // Act + var message = await ExportWrapper.GetMessageAsHtmlAsync( + ChannelIds.MarkdownTestCases, + Snowflake.Parse("1074323326727634984") + ); - try - { - // Act - var message = await ExportWrapper.GetMessageAsHtmlAsync( - ChannelIds.MarkdownTestCases, - Snowflake.Parse("1074323326727634984") - ); - - // Assert - message.Text().Should().Contain("Short date timestamp: 02/12/2023"); - message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM"); - } - finally - { - TimeZoneInfo.ClearCachedData(); - } + // Assert + message.Text().Should().Contain("Short date timestamp: 2/12/2023"); + message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); } [Fact] public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_long_date_format() { - // Date formatting code relies on the local time zone, so we need to set it to a fixed value - TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); + // Act + var message = await ExportWrapper.GetMessageAsHtmlAsync( + ChannelIds.MarkdownTestCases, + Snowflake.Parse("1074323350731640863") + ); - try - { - // Act - var message = await ExportWrapper.GetMessageAsHtmlAsync( - ChannelIds.MarkdownTestCases, - Snowflake.Parse("1074323350731640863") - ); - - // Assert - message.Text().Should().Contain("Long date timestamp: February 12, 2023"); - message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM"); - } - finally - { - TimeZoneInfo.ClearCachedData(); - } + // Assert + message.Text().Should().Contain("Long date timestamp: Sunday, February 12, 2023"); + message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); } [Fact] public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_full_format() { - // Date formatting code relies on the local time zone, so we need to set it to a fixed value - TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); + // Act + var message = await ExportWrapper.GetMessageAsHtmlAsync( + ChannelIds.MarkdownTestCases, + Snowflake.Parse("1074323374379118593") + ); - try - { - // Act - var message = await ExportWrapper.GetMessageAsHtmlAsync( - ChannelIds.MarkdownTestCases, - Snowflake.Parse("1074323374379118593") - ); - - // Assert - message.Text().Should().Contain("Full timestamp: February 12, 2023 3:36 PM"); - message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM"); - } - finally - { - TimeZoneInfo.ClearCachedData(); - } + // Assert + message.Text().Should().Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM"); + message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); } [Fact] public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_full_long_format() { - // Date formatting code relies on the local time zone, so we need to set it to a fixed value - TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); + // Act + var message = await ExportWrapper.GetMessageAsHtmlAsync( + ChannelIds.MarkdownTestCases, + Snowflake.Parse("1074323409095376947") + ); - try - { - // Act - var message = await ExportWrapper.GetMessageAsHtmlAsync( - ChannelIds.MarkdownTestCases, - Snowflake.Parse("1074323409095376947") - ); - - // Assert - message - .Text() - .Should() - .Contain("Full long timestamp: Sunday, February 12, 2023 3:36 PM"); - message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM"); - } - finally - { - TimeZoneInfo.ClearCachedData(); - } + // Assert + message + .Text() + .Should() + .Contain("Full long timestamp: Sunday, February 12, 2023 1:36:12 PM"); + message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); } [Fact] public async Task I_can_export_a_channel_that_contains_a_message_with_a_timestamp_marker_in_the_relative_format() { - // Date formatting code relies on the local time zone, so we need to set it to a fixed value - TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); + // Act + var message = await ExportWrapper.GetMessageAsHtmlAsync( + ChannelIds.MarkdownTestCases, + Snowflake.Parse("1074323436853285004") + ); - try - { - // Act - var message = await ExportWrapper.GetMessageAsHtmlAsync( - ChannelIds.MarkdownTestCases, - Snowflake.Parse("1074323436853285004") - ); - - // Assert - message.Text().Should().Contain("Relative timestamp: 02/12/2023 3:36 PM"); - message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM"); - } - finally - { - TimeZoneInfo.ClearCachedData(); - } + // Assert + message.Text().Should().Contain("Relative timestamp: 2/12/2023 1:36 PM"); + message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM"); } [Fact] public async Task I_can_export_a_channel_that_contains_a_message_with_an_invalid_timestamp_marker() { - // Date formatting code relies on the local time zone, so we need to set it to a fixed value - TimeZoneInfoEx.SetLocal(TimeSpan.FromHours(+2)); + // Act + var message = await ExportWrapper.GetMessageAsHtmlAsync( + ChannelIds.MarkdownTestCases, + Snowflake.Parse("1074328534409019563") + ); - try - { - // Act - var message = await ExportWrapper.GetMessageAsHtmlAsync( - ChannelIds.MarkdownTestCases, - Snowflake.Parse("1074328534409019563") - ); - - // Assert - message.Text().Should().Contain("Invalid timestamp: Invalid date"); - } - finally - { - TimeZoneInfo.ClearCachedData(); - } + // Assert + message.Text().Should().Contain("Invalid timestamp: Invalid date"); } } diff --git a/DiscordChatExporter.Cli.Tests/Utils/TimeZoneInfoEx.cs b/DiscordChatExporter.Cli.Tests/Utils/TimeZoneInfoEx.cs deleted file mode 100644 index b93f8ec3..00000000 --- a/DiscordChatExporter.Cli.Tests/Utils/TimeZoneInfoEx.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using ReflectionMagic; - -namespace DiscordChatExporter.Cli.Tests.Utils; - -internal static class TimeZoneInfoEx -{ - // https://stackoverflow.com/a/63700512/2205454 - public static void SetLocal(TimeZoneInfo timeZone) => - typeof(TimeZoneInfo).AsDynamicType().s_cachedData._localTimeZone = timeZone; - - public static void SetLocal(TimeSpan offset) => - SetLocal(TimeZoneInfo.CreateCustomTimeZone("test-tz", offset, "test-tz", "test-tz")); -} diff --git a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs index 5b0647c8..79c5c673 100644 --- a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -88,7 +89,7 @@ public abstract class ExportCommandBase : DiscordCommandBase "media", Description = "Download assets referenced by the export (user avatars, attached files, embedded images, etc.)." )] - public bool ShouldDownloadAssets { get; init; } = false; + public bool ShouldDownloadAssets { get; init; } [CommandOption( "reuse-media", @@ -111,9 +112,19 @@ public abstract class ExportCommandBase : DiscordCommandBase init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null; } - [CommandOption("dateformat", Description = "Format used when writing dates.")] + [Obsolete("This option doesn't do anything. Kept for backwards compatibility.")] + [CommandOption( + "dateformat", + Description = "This option doesn't do anything. Kept for backwards compatibility." + )] public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt"; + [CommandOption("locale", Description = "Locale to use when formatting dates and numbers.")] + public string Locale { get; init; } = CultureInfo.CurrentCulture.Name; + + [CommandOption("utc", Description = "Normalize all timestamps to UTC+0.")] + public bool IsUtcNormalizationEnabled { get; init; } = false; + [CommandOption( "fuck-russia", EnvironmentVariable = "FUCK_RUSSIA", @@ -210,7 +221,8 @@ public abstract class ExportCommandBase : DiscordCommandBase ShouldFormatMarkdown, ShouldDownloadAssets, ShouldReuseAssets, - DateFormat + Locale, + IsUtcNormalizationEnabled ); await Exporter.ExportChannelAsync( diff --git a/DiscordChatExporter.Core/Discord/Data/Common/ImageCdn.cs b/DiscordChatExporter.Core/Discord/Data/Common/ImageCdn.cs index eb7ff114..4c44341e 100644 --- a/DiscordChatExporter.Core/Discord/Data/Common/ImageCdn.cs +++ b/DiscordChatExporter.Core/Discord/Data/Common/ImageCdn.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Linq; using DiscordChatExporter.Core.Utils.Extensions; @@ -19,7 +20,10 @@ public static class ImageCdn ? runes : runes.Where(r => r.Value != 0xfe0f); - var twemojiId = string.Join("-", filteredRunes.Select(r => r.Value.ToString("x"))); + var twemojiId = string.Join( + "-", + filteredRunes.Select(r => r.Value.ToString("x", CultureInfo.InvariantCulture)) + ); return $"https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/{twemojiId}.svg"; } diff --git a/DiscordChatExporter.Core/Exporting/ExportContext.cs b/DiscordChatExporter.Core/Exporting/ExportContext.cs index 800e7208..072ccaf2 100644 --- a/DiscordChatExporter.Core/Exporting/ExportContext.cs +++ b/DiscordChatExporter.Core/Exporting/ExportContext.cs @@ -34,6 +34,12 @@ internal class ExportContext ); } + public DateTimeOffset NormalizeDate(DateTimeOffset instant) => + Request.IsUtcNormalizationEnabled ? instant.ToUniversalTime() : instant.ToLocalTime(); + + public string FormatDate(DateTimeOffset instant, string format = "g") => + NormalizeDate(instant).ToString(format, Request.CultureInfo); + public async ValueTask PopulateChannelsAndRolesAsync( CancellationToken cancellationToken = default ) @@ -41,10 +47,14 @@ internal class ExportContext await foreach ( var channel in Discord.GetGuildChannelsAsync(Request.Guild.Id, cancellationToken) ) + { _channelsById[channel.Id] = channel; + } await foreach (var role in Discord.GetGuildRolesAsync(Request.Guild.Id, cancellationToken)) + { _rolesById[role.Id] = role; + } } // Because members cannot be pulled in bulk, we need to populate them on demand @@ -84,14 +94,6 @@ internal class ExportContext CancellationToken cancellationToken = default ) => await PopulateMemberAsync(user.Id, user, cancellationToken); - public string FormatDate(DateTimeOffset instant) => - Request.DateFormat switch - { - "unix" => instant.ToUnixTimeSeconds().ToString(), - "unixms" => instant.ToUnixTimeMilliseconds().ToString(), - var format => instant.ToLocalString(format) - }; - public Member? TryGetMember(Snowflake id) => _membersById.GetValueOrDefault(id); public Channel? TryGetChannel(Snowflake id) => _channelsById.GetValueOrDefault(id); diff --git a/DiscordChatExporter.Core/Exporting/ExportRequest.cs b/DiscordChatExporter.Core/Exporting/ExportRequest.cs index 26c5a54c..62e6e551 100644 --- a/DiscordChatExporter.Core/Exporting/ExportRequest.cs +++ b/DiscordChatExporter.Core/Exporting/ExportRequest.cs @@ -39,7 +39,11 @@ public partial class ExportRequest public bool ShouldReuseAssets { get; } - public string DateFormat { get; } + public string Locale { get; } + + public CultureInfo CultureInfo { get; } + + public bool IsUtcNormalizationEnabled { get; } public ExportRequest( Guild guild, @@ -54,7 +58,8 @@ public partial class ExportRequest bool shouldFormatMarkdown, bool shouldDownloadAssets, bool shouldReuseAssets, - string dateFormat + string locale, + bool isUtcNormalizationEnabled ) { Guild = guild; @@ -67,7 +72,8 @@ public partial class ExportRequest ShouldFormatMarkdown = shouldFormatMarkdown; ShouldDownloadAssets = shouldDownloadAssets; ShouldReuseAssets = shouldReuseAssets; - DateFormat = dateFormat; + Locale = locale; + IsUtcNormalizationEnabled = isUtcNormalizationEnabled; OutputFilePath = GetOutputBaseFilePath(Guild, Channel, outputPath, Format, After, Before); @@ -76,6 +82,8 @@ public partial class ExportRequest AssetsDirPath = !string.IsNullOrWhiteSpace(assetsDirPath) ? FormatPath(assetsDirPath, Guild, Channel, After, Before) : $"{OutputFilePath}_Files{Path.DirectorySeparatorChar}"; + + CultureInfo = CultureInfo.GetCultureInfo(Locale); } } diff --git a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs index bb0ed3e8..ee9e724d 100644 --- a/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs +++ b/DiscordChatExporter.Core/Exporting/HtmlMarkdownVisitor.cs @@ -317,12 +317,12 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor ) { var formatted = timestamp.Instant is not null - ? !string.IsNullOrWhiteSpace(timestamp.Format) - ? timestamp.Instant.Value.ToLocalString(timestamp.Format) - : _context.FormatDate(timestamp.Instant.Value) + ? _context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g") : "Invalid date"; - var formattedLong = timestamp.Instant?.ToLocalString("dddd, MMMM d, yyyy h:mm tt") ?? ""; + var formattedLong = timestamp.Instant is not null + ? _context.FormatDate(timestamp.Instant.Value, "f") + : ""; _buffer.Append( // lang=html diff --git a/DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs b/DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs index 26521400..7c79a6aa 100644 --- a/DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/HtmlMessageWriter.cs @@ -139,7 +139,7 @@ internal class HtmlMessageWriter : MessageWriter Minify( await new PostambleTemplate { - ExportContext = Context, + Context = Context, MessagesWritten = MessagesWritten }.RenderAsync(cancellationToken) ) diff --git a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs index b2303366..e8c59bc4 100644 --- a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; using System.Text.Encodings.Web; using System.Text.Json; @@ -196,7 +197,7 @@ internal class JsonMessageWriter : MessageWriter await FormatMarkdownAsync(embed.Title ?? "", cancellationToken) ); _writer.WriteString("url", embed.Url); - _writer.WriteString("timestamp", embed.Timestamp); + _writer.WriteString("timestamp", embed.Timestamp?.Pipe(Context.NormalizeDate)); _writer.WriteString( "description", await FormatMarkdownAsync(embed.Description ?? "", cancellationToken) @@ -292,12 +293,12 @@ internal class JsonMessageWriter : MessageWriter // Date range _writer.WriteStartObject("dateRange"); - _writer.WriteString("after", Context.Request.After?.ToDate()); - _writer.WriteString("before", Context.Request.Before?.ToDate()); + _writer.WriteString("after", Context.Request.After?.ToDate().Pipe(Context.NormalizeDate)); + _writer.WriteString("before", Context.Request.Before?.ToDate().Pipe(Context.NormalizeDate)); _writer.WriteEndObject(); // Timestamp - _writer.WriteString("exportedAt", System.DateTimeOffset.UtcNow); + _writer.WriteString("exportedAt", Context.NormalizeDate(DateTimeOffset.UtcNow)); // Message array (start) _writer.WriteStartArray("messages"); @@ -316,9 +317,15 @@ internal class JsonMessageWriter : MessageWriter // Metadata _writer.WriteString("id", message.Id.ToString()); _writer.WriteString("type", message.Kind.ToString()); - _writer.WriteString("timestamp", message.Timestamp); - _writer.WriteString("timestampEdited", message.EditedTimestamp); - _writer.WriteString("callEndedTimestamp", message.CallEndedTimestamp); + _writer.WriteString("timestamp", Context.NormalizeDate(message.Timestamp)); + _writer.WriteString( + "timestampEdited", + message.EditedTimestamp?.Pipe(Context.NormalizeDate) + ); + _writer.WriteString( + "callEndedTimestamp", + message.CallEndedTimestamp?.Pipe(Context.NormalizeDate) + ); _writer.WriteBoolean("isPinned", message.IsPinned); // Content diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index 3a72cd8f..4a9a1255 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -1,6 +1,5 @@ @using System @using System.Collections.Generic -@using System.Globalization @using System.Linq @using System.Threading.Tasks @using DiscordChatExporter.Core.Discord.Data @@ -20,8 +19,8 @@ ValueTask ResolveAssetUrlAsync(string url) => Context.ResolveAssetUrlAsync(url, CancellationToken); - string FormatDate(DateTimeOffset instant) => - Context.FormatDate(instant); + string FormatDate(DateTimeOffset instant, string format = "g") => + Context.FormatDate(instant, format); async ValueTask FormatMarkdownAsync(string markdown) => Context.Request.ShouldFormatMarkdown @@ -100,7 +99,7 @@ } else if (message.Kind == MessageKind.Call) { - started a call that lasted @(((message.CallEndedTimestamp ?? message.Timestamp) - message.Timestamp).TotalMinutes.ToString("n0", CultureInfo.InvariantCulture)) minutes + started a call that lasted @(((message.CallEndedTimestamp ?? message.Timestamp) - message.Timestamp).TotalMinutes.ToString("n0", Context.Request.CultureInfo)) minutes } else if (message.Kind == MessageKind.ChannelNameChange) { @@ -132,7 +131,7 @@ @* Timestamp *@ - + @FormatDate(message.Timestamp) @@ -154,7 +153,7 @@ } else { -
@message.Timestamp.ToLocalString("t")
+
@FormatDate(message.Timestamp, "t")
} @@ -194,7 +193,7 @@ @if (message.ReferencedMessage.EditedTimestamp is not null) { - (edited) + (edited) } } @@ -241,7 +240,7 @@ } @* Timestamp *@ - @FormatDate(message.Timestamp) + @FormatDate(message.Timestamp) } @@ -258,7 +257,7 @@ @* Edited timestamp *@ @if (message.EditedTimestamp is not null) { - (edited) + (edited) } } diff --git a/DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs b/DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs index cbc2e013..eafd06e6 100644 --- a/DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs +++ b/DiscordChatExporter.Core/Exporting/PlainTextMarkdownVisitor.cs @@ -91,9 +91,7 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor { _buffer.Append( timestamp.Instant is not null - ? !string.IsNullOrWhiteSpace(timestamp.Format) - ? timestamp.Instant.Value.ToLocalString(timestamp.Format) - : _context.FormatDate(timestamp.Instant.Value) + ? _context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g") : "Invalid date" ); diff --git a/DiscordChatExporter.Core/Exporting/PostambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/PostambleTemplate.cshtml index 73d7d2d6..0bea929f 100644 --- a/DiscordChatExporter.Core/Exporting/PostambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/PostambleTemplate.cshtml @@ -1,7 +1,9 @@ -@inherits RazorBlade.HtmlTemplate +@using System + +@inherits RazorBlade.HtmlTemplate @functions { - public required ExportContext ExportContext { get; init; } + public required ExportContext Context { get; init; } public required long MessagesWritten { get; init; } } @@ -14,7 +16,8 @@
-
Exported @MessagesWritten.ToString("n0") message(s)
+
Exported @MessagesWritten.ToString("n0", Context.Request.CultureInfo) message(s)
+
Timezone: UTC@((Context.Request.IsUtcNormalizationEnabled ? 0 : TimeZoneInfo.Local.BaseUtcOffset.TotalHours).ToString("+#.#;-#.#;+0", Context.Request.CultureInfo))
diff --git a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml index b1742f75..05305890 100644 --- a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml @@ -21,8 +21,8 @@ ValueTask ResolveAssetUrlAsync(string url) => Context.ResolveAssetUrlAsync(url, CancellationToken); - string FormatDate(DateTimeOffset instant) => - Context.FormatDate(instant); + string FormatDate(DateTimeOffset instant, string format = "g") => + Context.FormatDate(instant, format); async ValueTask FormatMarkdownAsync(string markdown) => Context.Request.ShouldFormatMarkdown diff --git a/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs b/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs index 36d6e5aa..d1321538 100644 --- a/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs +++ b/DiscordChatExporter.Core/Markdown/Parsing/MarkdownParser.cs @@ -340,16 +340,13 @@ internal static partial class MarkdownParser ) ); - var format = m.Groups[2].Value switch + var format = m.Groups[2].Value.NullIfWhiteSpace() switch { - "t" => "h:mm tt", - "T" => "h:mm:ss tt", - "d" => "MM/dd/yyyy", - "D" => "MMMM dd, yyyy", - "f" => "MMMM dd, yyyy h:mm tt", - "F" => "dddd, MMMM dd, yyyy h:mm tt", - // Relative format is ignored because it doesn't make much sense in a static export - _ => null + // Ignore the 'relative' format because it doesn't make sense in a static export + "r" => null, + "R" => null, + // Discord's date formats are (mostly) compatible with .NET's date formats + var f => f }; return new TimestampNode(instant, format); diff --git a/DiscordChatExporter.Core/Utils/Extensions/DateExtensions.cs b/DiscordChatExporter.Core/Utils/Extensions/DateExtensions.cs deleted file mode 100644 index 702a2903..00000000 --- a/DiscordChatExporter.Core/Utils/Extensions/DateExtensions.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Globalization; - -namespace DiscordChatExporter.Core.Utils.Extensions; - -public static class DateExtensions -{ - public static string ToLocalString(this DateTimeOffset instant, string format) => - instant.ToLocalTime().ToString(format, CultureInfo.InvariantCulture); -} diff --git a/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameConverter.cs b/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameConverter.cs new file mode 100644 index 00000000..bfae7467 --- /dev/null +++ b/DiscordChatExporter.Gui/Converters/LocaleToDisplayNameConverter.cs @@ -0,0 +1,21 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace DiscordChatExporter.Gui.Converters; + +[ValueConversion(typeof(string), typeof(string))] +public class LocaleToDisplayNameConverter : IValueConverter +{ + public static LocaleToDisplayNameConverter Instance { get; } = new(); + + public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) => + value is string locale ? CultureInfo.GetCultureInfo(locale).DisplayName : null; + + public object ConvertBack( + object value, + Type targetType, + object parameter, + CultureInfo culture + ) => throw new NotSupportedException(); +} diff --git a/DiscordChatExporter.Gui/Services/SettingsService.cs b/DiscordChatExporter.Gui/Services/SettingsService.cs index 6662b410..526548d7 100644 --- a/DiscordChatExporter.Gui/Services/SettingsService.cs +++ b/DiscordChatExporter.Gui/Services/SettingsService.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.IO; using Cogwheel; using DiscordChatExporter.Core.Exporting; @@ -19,7 +20,9 @@ public partial class SettingsService : SettingsBase public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None; - public string DateFormat { get; set; } = "MM/dd/yyyy h:mm tt"; + public string Locale { get; set; } = CultureInfo.CurrentCulture.Name; + + public bool IsUtcNormalizationEnabled { get; set; } public int ParallelLimit { get; set; } = 1; diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs index 8cd12177..2b99a3ab 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -78,6 +78,11 @@ public class DashboardViewModel : PropertyChangedBase // due to the channels being asynchronously loaded. AvailableChannels = null; SelectedChannels = null; + + // Pull channels for the selected guild + // (ideally this should be called inside `PullGuilds()`, + // but Stylet doesn't support async commands) + PullChannels(); } ); } @@ -88,14 +93,14 @@ public class DashboardViewModel : PropertyChangedBase Token = _settingsService.LastToken; } - public async ValueTask ShowSettingsAsync() => + public async void ShowSettings() => await _dialogManager.ShowDialogAsync(_viewModelFactory.CreateSettingsViewModel()); public void ShowHelp() => ProcessEx.StartShellExecute(App.DocumentationUrl); - public bool CanPullGuildsAsync => !IsBusy && !string.IsNullOrWhiteSpace(Token); + public bool CanPullGuilds => !IsBusy && !string.IsNullOrWhiteSpace(Token); - public async ValueTask PullGuildsAsync() + public async void PullGuilds() { IsBusy = true; var progress = _progressMuxer.CreateInput(); @@ -118,9 +123,6 @@ public class DashboardViewModel : PropertyChangedBase AvailableGuilds = guilds; SelectedGuild = guilds.FirstOrDefault(); - - // Pull channels for the selected guild - await PullChannelsAsync(); } catch (DiscordChatExporterException ex) when (!ex.IsFatal) { @@ -142,10 +144,9 @@ public class DashboardViewModel : PropertyChangedBase } } - public bool CanPullChannelsAsync => - !IsBusy && _discord is not null && SelectedGuild is not null; + public bool CanPullChannels => !IsBusy && _discord is not null && SelectedGuild is not null; - public async ValueTask PullChannelsAsync() + public async void PullChannels() { IsBusy = true; var progress = _progressMuxer.CreateInput(); @@ -206,13 +207,13 @@ public class DashboardViewModel : PropertyChangedBase } } - public bool CanExportAsync => + public bool CanExport => !IsBusy && _discord is not null && SelectedGuild is not null && SelectedChannels?.Any() is true; - public async ValueTask ExportAsync() + public async void Export() { IsBusy = true; @@ -267,7 +268,8 @@ public class DashboardViewModel : PropertyChangedBase dialog.ShouldFormatMarkdown, dialog.ShouldDownloadAssets, dialog.ShouldReuseAssets, - _settingsService.DateFormat + _settingsService.Locale, + _settingsService.IsUtcNormalizationEnabled ); await exporter.ExportChannelAsync(request, progress, cancellationToken); diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs index 0d22736c..d24d60bd 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Linq; using DiscordChatExporter.Gui.Models; using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.ViewModels.Framework; @@ -37,10 +39,52 @@ public class SettingsViewModel : DialogScreen set => _settingsService.ThreadInclusionMode = value; } - public string DateFormat + public IReadOnlyList AvailableLocales { get; } = new[] + { + // Current locale + CultureInfo.CurrentCulture.Name, + // Locales supported by the Discord app + "da-DK", + "de-DE", + "en-GB", + "en-US", + "es-ES", + "fr-FR", + "hr-HR", + "it-IT", + "lt-LT", + "hu-HU", + "nl-NL", + "no-NO", + "pl-PL", + "pt-BR", + "ro-RO", + "fi-FI", + "sv-SE", + "vi-VN", + "tr-TR", + "cs-CZ", + "el-GR", + "bg-BG", + "ru-RU", + "uk-UA", + "th-TH", + "zh-CN", + "ja-JP", + "zh-TW", + "ko-KR" + }.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + public string Locale { - get => _settingsService.DateFormat; - set => _settingsService.DateFormat = value; + get => _settingsService.Locale; + set => _settingsService.Locale = value; + } + + public bool IsUtcNormalizationEnabled + { + get => _settingsService.IsUtcNormalizationEnabled; + set => _settingsService.IsUtcNormalizationEnabled = value; } public int ParallelLimit diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml b/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml index 253f6c11..0e06b748 100644 --- a/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml +++ b/DiscordChatExporter.Gui/Views/Components/DashboardView.xaml @@ -105,7 +105,7 @@ Grid.Column="2" Margin="0,6,6,6" Padding="4" - Command="{s:Action PullGuildsAsync}" + Command="{s:Action PullGuilds}" IsDefault="True" Style="{DynamicResource MaterialDesignFlatButton}" ToolTip="Pull available guilds and channels (Enter)"> @@ -122,7 +122,7 @@ Grid.Column="1" Margin="6" Padding="4" - Command="{s:Action ShowSettingsAsync}" + Command="{s:Action ShowSettings}" Foreground="{DynamicResource MaterialDesignDarkForeground}" Style="{DynamicResource MaterialDesignFlatButton}" ToolTip="Settings"> @@ -274,7 +274,7 @@ Margin="-8" Background="Transparent" Cursor="Hand" - MouseLeftButtonUp="{s:Action PullChannelsAsync}" + MouseLeftButtonUp="{s:Action PullChannels}" ToolTip="{Binding Name}"> - + @@ -412,9 +412,9 @@ Margin="32,24" HorizontalAlignment="Right" VerticalAlignment="Bottom" - Command="{s:Action ExportAsync}" + Command="{s:Action Export}" Style="{DynamicResource MaterialDesignFloatingActionAccentButton}" - Visibility="{Binding CanExportAsync, Converter={x:Static s:BoolToVisibilityConverter.Instance}}"> + Visibility="{Binding CanExport, Converter={x:Static s:BoolToVisibilityConverter.Instance}}"> - + + ToolTip="Locale to use when formatting dates and numbers"> - + + ItemsSource="{Binding AvailableLocales}" + SelectedItem="{Binding Locale}"> + + + + + + + + + + + + @@ -139,9 +164,13 @@