Replace the date format option with a locale option (#1130)

pull/1134/head
Oleksii Holub 1 year ago committed by GitHub
parent 53b11d6c49
commit 59344cedbe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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);
}

@ -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));
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();
}
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323136411078787")
);
// 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));
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();
}
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323205268967596")
);
// 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));
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();
}
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323235342139483")
);
// 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));
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();
}
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323326727634984")
);
// 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));
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();
}
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323350731640863")
);
// 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));
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();
}
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323374379118593")
);
// 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));
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();
}
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323409095376947")
);
// 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));
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();
}
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074323436853285004")
);
// 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));
try
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074328534409019563")
);
// Assert
message.Text().Should().Contain("Invalid timestamp: Invalid date");
}
finally
{
TimeZoneInfo.ClearCachedData();
}
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.MarkdownTestCases,
Snowflake.Parse("1074328534409019563")
);
// Assert
message.Text().Should().Contain("Invalid timestamp: Invalid date");
}
}

@ -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"));
}

@ -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(

@ -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";
}

@ -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);

@ -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);
}
}

@ -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

@ -139,7 +139,7 @@ internal class HtmlMessageWriter : MessageWriter
Minify(
await new PostambleTemplate
{
ExportContext = Context,
Context = Context,
MessagesWritten = MessagesWritten
}.RenderAsync(cancellationToken)
)

@ -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

@ -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<string> 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<string> FormatMarkdownAsync(string markdown) =>
Context.Request.ShouldFormatMarkdown
@ -100,7 +99,7 @@
}
else if (message.Kind == MessageKind.Call)
{
<span>started a call that lasted @(((message.CallEndedTimestamp ?? message.Timestamp) - message.Timestamp).TotalMinutes.ToString("n0", CultureInfo.InvariantCulture)) minutes</span>
<span>started a call that lasted @(((message.CallEndedTimestamp ?? message.Timestamp) - message.Timestamp).TotalMinutes.ToString("n0", Context.Request.CultureInfo)) minutes</span>
}
else if (message.Kind == MessageKind.ChannelNameChange)
{
@ -132,7 +131,7 @@
</span>
@* Timestamp *@
<span class="chatlog__system-notification-timestamp">
<span class="chatlog__system-notification-timestamp" title="@FormatDate(message.Timestamp, "f")">
<a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
</span>
</div>
@ -154,7 +153,7 @@
}
else
{
<div class="chatlog__short-timestamp" title="@FormatDate(message.Timestamp)">@message.Timestamp.ToLocalString("t")</div>
<div class="chatlog__short-timestamp" title="@FormatDate(message.Timestamp, "f")">@FormatDate(message.Timestamp, "t")</div>
}
</div>
@ -194,7 +193,7 @@
@if (message.ReferencedMessage.EditedTimestamp is not null)
{
<span class="chatlog__reply-edited-timestamp" title="@FormatDate(message.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
<span class="chatlog__reply-edited-timestamp" title="@FormatDate(message.ReferencedMessage.EditedTimestamp.Value, "f")">(edited)</span>
}
</div>
}
@ -241,7 +240,7 @@
}
@* Timestamp *@
<span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span>
<span class="chatlog__timestamp" title="@FormatDate(message.Timestamp, "f")"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span>
</div>
}
@ -258,7 +257,7 @@
@* Edited timestamp *@
@if (message.EditedTimestamp is not null)
{
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value, "f")">(edited)</span>
}
</div>
}

@ -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"
);

@ -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 @@
<!--/wmm:ignore-->
<div class="postamble">
<div class="postamble__entry">Exported @MessagesWritten.ToString("n0") message(s)</div>
<div class="postamble__entry">Exported @MessagesWritten.ToString("n0", Context.Request.CultureInfo) message(s)</div>
<div class="postamble__entry">Timezone: UTC@((Context.Request.IsUtcNormalizationEnabled ? 0 : TimeZoneInfo.Local.BaseUtcOffset.TotalHours).ToString("+#.#;-#.#;+0", Context.Request.CultureInfo))</div>
</div>
</body>

@ -21,8 +21,8 @@
ValueTask<string> 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<string> FormatMarkdownAsync(string markdown) =>
Context.Request.ShouldFormatMarkdown

@ -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);

@ -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);
}

@ -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();
}

@ -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;

@ -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);

@ -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<string> 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.Locale;
set => _settingsService.Locale = value;
}
public bool IsUtcNormalizationEnabled
{
get => _settingsService.DateFormat;
set => _settingsService.DateFormat = value;
get => _settingsService.IsUtcNormalizationEnabled;
set => _settingsService.IsUtcNormalizationEnabled = value;
}
public int ParallelLimit

@ -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}">
<!-- Guild icon placeholder -->
<Ellipse
@ -350,7 +350,7 @@
<DataTemplate DataType="{x:Type data:Channel}">
<Grid Margin="-8" Background="Transparent">
<Grid.InputBindings>
<MouseBinding Command="{s:Action ExportAsync}" MouseAction="LeftDoubleClick" />
<MouseBinding Command="{s:Action Export}" MouseAction="LeftDoubleClick" />
</Grid.InputBindings>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
@ -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}}">
<materialDesign:PackIcon
Width="32"
Height="32"

@ -2,6 +2,7 @@
x:Class="DiscordChatExporter.Gui.Views.Dialogs.SettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@ -93,27 +94,51 @@
DockPanel.Dock="Left"
Text="Show threads" />
<ComboBox
Width="150"
VerticalAlignment="Center"
DockPanel.Dock="Right"
ItemsSource="{Binding AvailableThreadInclusions}"
SelectedItem="{Binding ThreadInclusionMode}" />
</DockPanel>
<!-- Date format -->
<!-- Locale -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Format used when writing dates (uses .NET date formatting rules)">
ToolTip="Locale to use when formatting dates and numbers">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Date format" />
<TextBox
Text="Locale" />
<ComboBox
Width="150"
VerticalAlignment="Center"
DockPanel.Dock="Right"
Text="{Binding DateFormat}" />
ItemsSource="{Binding AvailableLocales}"
SelectedItem="{Binding Locale}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={x:Static converters:LocaleToDisplayNameConverter.Instance}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</DockPanel>
<!-- UTC normalization -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Normalize all timestamps to UTC+0">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Normalize to UTC" />
<ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding IsUtcNormalizationEnabled}" />
</DockPanel>
<!-- Parallel limit -->
@ -139,9 +164,13 @@
<Slider
Width="150"
VerticalAlignment="Center"
IsSnapToTickEnabled="True"
LargeChange="1"
Maximum="10"
Minimum="1"
SmallChange="1"
Style="{DynamicResource MaterialDesignThinSlider}"
TickFrequency="1"
Value="{Binding ParallelLimit}" />
</StackPanel>
</DockPanel>

Loading…
Cancel
Save