mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-02-10 20:02:31 +00:00
Replace the date format option with a locale option (#1130)
This commit is contained in:
@@ -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));
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user