pull/1037/head
Tyrrrz 1 year ago
parent 03c5c1bc5e
commit 31c7ae9312

@ -51,7 +51,7 @@ public static class ExportWrapper
// Lock separately for each channel and format
using (await Locker.LockAsync(filePath))
{
// Perform export only if it hasn't been done before
// Perform the export only if it hasn't been done before
if (!File.Exists(filePath))
{
await new ExportChannelsCommand
@ -94,8 +94,7 @@ public static class ExportWrapper
public static async ValueTask<IElement> GetMessageAsHtmlAsync(Snowflake channelId, Snowflake messageId)
{
var message = (await GetMessagesAsHtmlAsync(channelId))
.SingleOrDefault(e =>
var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(e =>
string.Equals(
e.GetAttribute("data-message-id"),
messageId.ToString(),
@ -115,8 +114,7 @@ public static class ExportWrapper
public static async ValueTask<JsonElement> GetMessageAsJsonAsync(Snowflake channelId, Snowflake messageId)
{
var message = (await GetMessagesAsJsonAsync(channelId))
.SingleOrDefault(j =>
var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(j =>
string.Equals(
j.GetProperty("id").GetString(),
messageId.ToString(),

@ -8,7 +8,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class CsvContentSpecs
{
[Fact]
public async Task Messages_are_exported_correctly()
public async Task I_can_export_a_channel_in_the_CSV_format()
{
// Act
var document = await ExportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases);

@ -17,7 +17,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class DateRangeSpecs
{
[Fact]
public async Task Messages_filtered_after_specific_date_only_include_messages_sent_after_that_date()
public async Task I_can_filter_the_export_to_only_include_messages_sent_after_the_specified_date()
{
// Arrange
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
@ -61,7 +61,7 @@ public class DateRangeSpecs
}
[Fact]
public async Task Messages_filtered_before_specific_date_only_include_messages_sent_before_that_date()
public async Task I_can_filter_the_export_to_only_include_messages_sent_before_the_specified_date()
{
// Arrange
var before = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
@ -103,7 +103,7 @@ public class DateRangeSpecs
}
[Fact]
public async Task Messages_filtered_between_specific_dates_only_include_messages_sent_between_those_dates()
public async Task I_can_filter_the_export_to_only_include_messages_sent_between_the_specified_dates()
{
// Arrange
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);

@ -16,7 +16,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class FilterSpecs
{
[Fact]
public async Task Messages_filtered_by_text_only_include_messages_that_contain_that_text()
public async Task I_can_filter_the_export_to_only_include_messages_that_contain_the_specified_text()
{
// Arrange
using var file = TempFile.Create();
@ -42,7 +42,7 @@ public class FilterSpecs
}
[Fact]
public async Task Messages_filtered_by_author_only_include_messages_sent_by_that_author()
public async Task I_can_filter_the_export_to_only_include_messages_that_were_sent_by_the_specified_author()
{
// Arrange
using var file = TempFile.Create();
@ -68,7 +68,7 @@ public class FilterSpecs
}
[Fact]
public async Task Messages_filtered_by_content_only_include_messages_that_have_that_content()
public async Task I_can_filter_the_export_to_only_include_messages_that_contain_the_specified_content()
{
// Arrange
using var file = TempFile.Create();
@ -94,7 +94,7 @@ public class FilterSpecs
}
[Fact]
public async Task Messages_filtered_by_pin_only_include_messages_that_have_been_pinned()
public async Task I_can_filter_the_export_to_only_include_messages_that_have_been_pinned()
{
// Arrange
using var file = TempFile.Create();
@ -120,7 +120,7 @@ public class FilterSpecs
}
[Fact]
public async Task Messages_filtered_by_mention_only_include_messages_that_have_that_mention()
public async Task I_can_filter_the_export_to_only_include_messages_that_contain_the_specified_mention()
{
// Arrange
using var file = TempFile.Create();

@ -11,7 +11,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlAttachmentSpecs
{
[Fact]
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_generic_attachment()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -36,7 +36,7 @@ public class HtmlAttachmentSpecs
}
[Fact]
public async Task Message_with_an_image_attachment_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_an_image_attachment()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -57,7 +57,7 @@ public class HtmlAttachmentSpecs
}
[Fact]
public async Task Message_with_a_video_attachment_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_video_attachment()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/333
@ -77,7 +77,7 @@ public class HtmlAttachmentSpecs
}
[Fact]
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_an_audio_attachment()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/333

@ -11,7 +11,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlContentSpecs
{
[Fact]
public async Task Messages_are_exported_correctly()
public async Task I_can_export_a_channel_in_the_HTML_format()
{
// Act
var messages = await ExportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases);

@ -12,7 +12,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlEmbedSpecs
{
[Fact]
public async Task Message_with_an_embed_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_rich_embed()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -33,7 +33,7 @@ public class HtmlEmbedSpecs
}
[Fact]
public async Task Message_with_an_image_link_is_rendered_with_an_image_embed()
public async Task I_can_export_a_channel_that_contains_a_message_with_an_image_embed()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/537
@ -54,7 +54,7 @@ public class HtmlEmbedSpecs
}
[Fact]
public async Task Message_with_an_image_link_and_nothing_else_is_rendered_without_text_content()
public async Task I_can_export_a_channel_that_contains_a_message_with_an_image_embed_and_the_text_is_hidden_if_it_only_contains_the_image_link()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
@ -70,7 +70,7 @@ public class HtmlEmbedSpecs
}
[Fact]
public async Task Message_with_a_video_link_is_rendered_with_a_video_embed()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_video_embed()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -89,7 +89,7 @@ public class HtmlEmbedSpecs
}
[Fact]
public async Task Message_with_a_GIFV_link_is_rendered_with_a_video_embed()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_GIFV_embed()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -108,7 +108,7 @@ public class HtmlEmbedSpecs
}
[Fact]
public async Task Message_with_a_GIFV_link_and_nothing_else_is_rendered_without_text_content()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_GIFV_embed_and_the_text_is_hidden_if_it_only_contains_the_video_link()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -122,7 +122,7 @@ public class HtmlEmbedSpecs
}
[Fact]
public async Task Message_with_a_Spotify_track_link_is_rendered_with_a_track_embed()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_Spotify_track_embed()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/657
@ -138,7 +138,7 @@ public class HtmlEmbedSpecs
}
[Fact]
public async Task Message_with_a_YouTube_video_link_is_rendered_with_a_video_embed()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_YouTube_video_embed()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/570
@ -154,7 +154,7 @@ public class HtmlEmbedSpecs
}
[Fact]
public async Task Message_with_a_Twitter_post_link_with_multiple_images_is_rendered_as_a_single_embed()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_Twitter_post_embed_that_includes_multiple_images()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/695
@ -180,7 +180,7 @@ public class HtmlEmbedSpecs
}
[Fact]
public async Task Message_with_a_guild_invite_link_is_rendered_with_a_widget()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_guild_invite()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/649

@ -15,7 +15,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlGroupingSpecs
{
[Fact]
public async Task Messages_are_grouped_correctly()
public async Task I_can_export_a_channel_and_the_messages_are_grouped_according_to_their_author_and_timestamps()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/152

@ -12,7 +12,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlMarkdownSpecs
{
[Fact]
public async Task Message_with_a_timestamp_is_rendered_correctly()
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));
@ -36,7 +36,7 @@ public class HtmlMarkdownSpecs
}
[Fact]
public async Task Message_with_a_short_time_timestamp_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_short_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));
@ -60,7 +60,7 @@ public class HtmlMarkdownSpecs
}
[Fact]
public async Task Message_with_a_long_time_timestamp_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_long_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));
@ -84,7 +84,7 @@ public class HtmlMarkdownSpecs
}
[Fact]
public async Task Message_with_a_short_date_timestamp_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_short_date_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));
@ -108,7 +108,7 @@ public class HtmlMarkdownSpecs
}
[Fact]
public async Task Message_with_a_long_date_timestamp_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_long_date_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));
@ -132,7 +132,7 @@ public class HtmlMarkdownSpecs
}
[Fact]
public async Task Message_with_a_full_timestamp_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_full_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));
@ -156,7 +156,7 @@ public class HtmlMarkdownSpecs
}
[Fact]
public async Task Message_with_a_full_long_timestamp_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_full_long_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));
@ -180,7 +180,7 @@ public class HtmlMarkdownSpecs
}
[Fact]
public async Task Message_with_a_relative_timestamp_is_rendered_as_the_default_timestamp()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_relative_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));
@ -204,7 +204,7 @@ public class HtmlMarkdownSpecs
}
[Fact]
public async Task Message_with_an_invalid_timestamp_is_rendered_correctly()
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));

@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlMentionSpecs
{
[Fact]
public async Task Message_with_a_user_mention_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_user_mention()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -24,7 +24,7 @@ public class HtmlMentionSpecs
}
[Fact]
public async Task Message_with_a_text_channel_mention_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_text_channel_mention()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -37,7 +37,7 @@ public class HtmlMentionSpecs
}
[Fact]
public async Task Message_with_a_voice_channel_mention_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_voice_channel_mention()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -50,7 +50,7 @@ public class HtmlMentionSpecs
}
[Fact]
public async Task Message_with_a_role_mention_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_role_mention()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(

@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlReplySpecs
{
[Fact]
public async Task Message_with_a_reply_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_another_message()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -24,7 +24,7 @@ public class HtmlReplySpecs
}
[Fact]
public async Task Message_with_a_reply_to_a_deleted_message_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_a_deleted_message()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/645
@ -42,7 +42,7 @@ public class HtmlReplySpecs
}
[Fact]
public async Task Message_with_a_reply_to_an_empty_message_with_attachment_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_an_empty_message_with_an_attachment()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/634
@ -58,7 +58,7 @@ public class HtmlReplySpecs
}
[Fact]
public async Task Message_with_a_reply_to_an_interaction_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_an_interaction()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/569

@ -9,7 +9,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlStickerSpecs
{
[Fact]
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_PNG_sticker()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
@ -23,7 +23,7 @@ public class HtmlStickerSpecs
}
[Fact]
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_Lottie_sticker()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(

@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonAttachmentSpecs
{
[Fact]
public async Task Message_with_a_generic_attachment_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_generic_attachment()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
@ -23,15 +23,16 @@ public class JsonAttachmentSpecs
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
attachments.Should().HaveCount(1);
attachments.Single().GetProperty("url").GetString().Should().Be(
attachments[0].GetProperty("url").GetString().Should().Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
);
attachments.Single().GetProperty("fileName").GetString().Should().Be("Test.txt");
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(11);
attachments[0].GetProperty("fileName").GetString().Should().Be("Test.txt");
attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(11);
}
[Fact]
public async Task Message_with_an_image_attachment_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_an_image_attachment()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
@ -44,15 +45,16 @@ public class JsonAttachmentSpecs
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
attachments.Should().HaveCount(1);
attachments.Single().GetProperty("url").GetString().Should().Be(
attachments[0].GetProperty("url").GetString().Should().Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
);
attachments.Single().GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png");
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(466335);
attachments[0].GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png");
attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(466335);
}
[Fact]
public async Task Message_with_a_video_attachment_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_video_attachment()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
@ -65,15 +67,16 @@ public class JsonAttachmentSpecs
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
attachments.Should().HaveCount(1);
attachments.Single().GetProperty("url").GetString().Should().Be(
attachments[0].GetProperty("url").GetString().Should().Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
);
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP4_640_3MG.mp4");
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(3114374);
attachments[0].GetProperty("fileName").GetString().Should().Be("file_example_MP4_640_3MG.mp4");
attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(3114374);
}
[Fact]
public async Task Message_with_an_audio_attachment_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_an_audio_attachment()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
@ -86,10 +89,11 @@ public class JsonAttachmentSpecs
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
attachments.Should().HaveCount(1);
attachments.Single().GetProperty("url").GetString().Should().Be(
attachments[0].GetProperty("url").GetString().Should().Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
);
attachments.Single().GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
attachments.Single().GetProperty("fileSizeBytes").GetInt64().Should().Be(1087849);
attachments[0].GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(1087849);
}
}

@ -9,7 +9,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonContentSpecs
{
[Fact]
public async Task Messages_are_exported_correctly()
public async Task I_can_export_a_channel_in_the_JSON_format()
{
// Act
var messages = await ExportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases);

@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonEmbedSpecs
{
[Fact]
public async Task Message_with_an_embed_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_rich_embed()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(

@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonMentionSpecs
{
[Fact]
public async Task Message_with_a_user_mention_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_user_mention()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
@ -30,7 +30,7 @@ public class JsonMentionSpecs
}
[Fact]
public async Task Message_with_a_text_channel_mention_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_text_channel_mention()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
@ -43,7 +43,7 @@ public class JsonMentionSpecs
}
[Fact]
public async Task Message_with_a_voice_channel_mention_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_voice_channel_mention()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
@ -56,7 +56,7 @@ public class JsonMentionSpecs
}
[Fact]
public async Task Message_with_a_role_mention_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_role_mention()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(

@ -10,7 +10,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonStickerSpecs
{
[Fact]
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_PNG_sticker()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
@ -31,7 +31,7 @@ public class JsonStickerSpecs
}
[Fact]
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
public async Task I_can_export_a_channel_that_contains_a_message_with_a_Lottie_sticker()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(

@ -14,7 +14,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class PartitioningSpecs
{
[Fact]
public async Task Messages_partitioned_by_count_are_split_into_a_corresponding_number_of_files()
public async Task I_can_export_a_channel_with_partitioning_based_on_message_count()
{
// Arrange
using var dir = TempDir.Create();
@ -37,7 +37,7 @@ public class PartitioningSpecs
}
[Fact]
public async Task Messages_partitioned_by_file_size_are_split_into_a_corresponding_number_of_files()
public async Task I_can_export_a_channel_with_partitioning_based_on_file_size()
{
// Arrange
using var dir = TempDir.Create();

@ -8,7 +8,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class PlainTextContentSpecs
{
[Fact]
public async Task Messages_are_exported_correctly()
public async Task I_can_export_a_channel_in_the_TXT_format()
{
// Act
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases);

@ -14,7 +14,7 @@ namespace DiscordChatExporter.Cli.Tests.Specs;
public class SelfContainedSpecs
{
[Fact]
public async Task Messages_in_self_contained_export_only_reference_local_file_resources()
public async Task I_can_export_a_channel_and_download_all_referenced_assets()
{
// Arrange
using var dir = TempDir.Create();

@ -30,8 +30,8 @@ public abstract class ExportCommandBase : DiscordCommandBase
'o',
Description =
"Output file or directory path. " +
"If a directory is specified, file names will be generated automatically based on the channel names and other parameters. " +
"Directory path should end with a slash to avoid ambiguity. " +
"Directory path must end with a slash to avoid ambiguity. " +
"If a directory is specified, file names will be generated automatically. " +
"Supports template tokens, see the documentation for more info."
)]
public string OutputPath
@ -65,13 +65,16 @@ public abstract class ExportCommandBase : DiscordCommandBase
"partition",
'p',
Description =
"Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb')."
"Split the output into partitions, each limited to the specified " +
"number of messages (e.g. '100') or file size (e.g. '10mb')."
)]
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
[CommandOption(
"filter",
Description = "Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')."
Description =
"Only include messages that satisfy this filter. " +
"See the documentation for more info."
)]
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
@ -103,7 +106,9 @@ public abstract class ExportCommandBase : DiscordCommandBase
[CommandOption(
"media-dir",
Description = "Download assets to this directory. If not specified, the asset directory path will be derived from the output path."
Description =
"Download assets to this directory. " +
"If not specified, the asset directory path will be derived from the output path."
)]
public string? AssetsDirPath
{
@ -123,6 +128,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
"fuck-russia",
EnvironmentVariable = "FUCK_RUSSIA",
Description = "Don't print the Support Ukraine message to the console.",
// Use a converter to accept '1' as 'true' to reuse the existing environment variable
Converter = typeof(TruthyBooleanBindingConverter)
)]
public bool IsUkraineSupportMessageDisabled { get; init; }
@ -132,7 +138,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels)
{
// Reuse assets option should only be used when the download assets option is set.
// Asset reuse can only be enabled if the download assets option is set
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
if (ShouldReuseAssets && !ShouldDownloadAssets)
{
@ -141,7 +147,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
);
}
// Assets directory should only be specified when the download assets option is set
// Assets directory can only be specified if the download assets option is set
if (!string.IsNullOrWhiteSpace(AssetsDirPath) && !ShouldDownloadAssets)
{
throw new CommandException(
@ -149,8 +155,8 @@ public abstract class ExportCommandBase : DiscordCommandBase
);
}
// Make sure the user does not try to export all channels into a single file.
// Output path must either be a directory, or contain template tokens.
// Make sure the user does not try to export multiple channels into one file.
// Output path must either be a directory or contain template tokens for this to work.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
var isValidOutputPath =
@ -225,7 +231,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
);
});
// Print result
// Print the result
using (console.WithForegroundColor(ConsoleColor.White))
{
await console.Output.WriteLineAsync(
@ -240,29 +246,27 @@ public abstract class ExportCommandBase : DiscordCommandBase
using (console.WithForegroundColor(ConsoleColor.Red))
{
await console.Output.WriteLineAsync(
await console.Error.WriteLineAsync(
$"Failed to export {errors.Count} channel(s):"
);
}
foreach (var (channel, error) in errors)
{
await console.Output.WriteAsync($"{channel.Category.Name} / {channel.Name}: ");
await console.Error.WriteAsync($"{channel.Category.Name} / {channel.Name}: ");
using (console.WithForegroundColor(ConsoleColor.Red))
await console.Output.WriteLineAsync(error);
await console.Error.WriteLineAsync(error);
}
await console.Output.WriteLineAsync();
await console.Error.WriteLineAsync();
}
// Fail the command only if ALL channels failed to export.
// Having some of the channels fail to export is expected.
// If only some channels failed to export, it's okay.
if (errors.Count >= channels.Count)
{
throw new CommandException("Export failed.");
}
}
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Snowflake> channelIds)
{

@ -13,7 +13,7 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportall", Description = "Export all accessible channels.")]
[Command("exportall", Description = "Exports all accessible channels.")]
public class ExportAllCommand : ExportCommandBase
{
[CommandOption(
@ -30,7 +30,9 @@ public class ExportAllCommand : ExportCommandBase
[CommandOption(
"data-package",
Description = "Path to the personal data package (ZIP file) requested from Discord. If provided, only channels referenced in the dump will be exported."
Description =
"Path to the personal data package (ZIP file) requested from Discord. " +
"If provided, only channels referenced in the dump will be exported."
)]
public string? DataPackageFilePath { get; init; }
@ -62,7 +64,7 @@ public class ExportAllCommand : ExportCommandBase
var entry = archive.GetEntry("messages/index.json");
if (entry is null)
throw new CommandException("Cannot find channel index inside the data package.");
throw new CommandException("Could not find channel index inside the data package.");
await using var stream = entry.Open();
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
@ -85,7 +87,7 @@ public class ExportAllCommand : ExportCommandBase
}
catch (DiscordChatExporterException)
{
await console.Output.WriteLineAsync($"Channel '{channelName}' ({channelId}) is inaccessible.");
await console.Error.WriteLineAsync($"Channel '{channelName}' ({channelId}) is inaccessible.");
}
}
}

@ -7,14 +7,16 @@ using DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Cli.Commands;
[Command("export", Description = "Export one or multiple channels.")]
[Command("export", Description = "Exports one or multiple channels.")]
public class ExportChannelsCommand : ExportCommandBase
{
// TODO: change this to plural (breaking change)
[CommandOption(
"channel",
'c',
Description = "Channel ID(s). If provided with category IDs, all channels inside those categories will be exported."
Description =
"Channel ID(s). " +
"If provided with category ID(s), all channels inside those categories will be exported."
)]
public required IReadOnlyList<Snowflake> ChannelIds { get; init; }

@ -7,7 +7,7 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportdm", Description = "Export all direct message channels.")]
[Command("exportdm", Description = "Exports all direct message channels.")]
public class ExportDirectMessagesCommand : ExportCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)

@ -9,7 +9,7 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportguild", Description = "Export all channels within specified guild.")]
[Command("exportguild", Description = "Exports all channels within the specified guild.")]
public class ExportGuildCommand : ExportCommandBase
{
[CommandOption(

@ -22,7 +22,7 @@ public class GetChannelsCommand : DiscordCommandBase
[CommandOption(
"include-threads",
Description = "Display threads alongside channels."
Description = "Include threads in the output."
)]
public bool IncludeThreads { get; init; }

@ -9,7 +9,7 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("dm", Description = "Get the list of direct message channels.")]
[Command("dm", Description = "Gets the list of all direct message channels.")]
public class GetDirectChannelsCommand : DiscordCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)

@ -9,7 +9,7 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("guilds", Description = "Get the list of accessible guilds.")]
[Command("guilds", Description = "Gets the list of accessible guilds.")]
public class GetGuildsCommand : DiscordCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)

@ -6,7 +6,7 @@ using CliFx.Infrastructure;
namespace DiscordChatExporter.Cli.Commands;
[Command("guide", Description = "Explains how to obtain token, guild or channel ID.")]
[Command("guide", Description = "Explains how to obtain the token, guild or channel ID.")]
public class GuideCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console)

@ -1,7 +1,6 @@
namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/channel#channel-object-channel-types
// Order of enum fields needs to match the order in the docs.
public enum ChannelKind
{
GuildTextChat = 0,

@ -1,5 +1,4 @@
using System.Linq;
using System.Text.Json;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;

@ -1,12 +0,0 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Discord.Data.Common;
public class IdBasedEqualityComparer : IEqualityComparer<IHasId>
{
public static IdBasedEqualityComparer Instance { get; } = new();
public bool Equals(IHasId? x, IHasId? y) => x?.Id == y?.Id;
public int GetHashCode(IHasId obj) => obj.Id.GetHashCode();
}

@ -34,8 +34,7 @@ public partial record Emoji
if (!string.IsNullOrWhiteSpace(name))
return ImageCdn.GetStandardEmojiUrl(name);
// Either ID or name should be set
throw new ApplicationException("Emoji has neither ID nor name set.");
throw new InvalidOperationException("Either the emoji ID or name should be provided.");
}
public static Emoji Parse(JsonElement json)

@ -17,5 +17,5 @@ public enum MessageKind
public static class MessageKindExtensions
{
public static bool IsSystemNotification(this MessageKind c) => (int)c is >= 1 and <= 18;
public static bool IsSystemNotification(this MessageKind kind) => (int)kind is >= 1 and <= 18;
}

@ -238,11 +238,8 @@ public class DiscordClient
? categories.GetValueOrDefault(parentId)
: null;
var channel = Channel.Parse(channelJson, category, position);
yield return Channel.Parse(channelJson, category, position);
position++;
yield return channel;
}
}
}
@ -288,8 +285,8 @@ public class DiscordClient
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
return ChannelCategory.Parse(response);
}
// In some cases, the Discord API returns an empty body when requesting a channel.
// Return an empty channel category as fallback in these cases.
// In some cases, Discord API returns an empty body when requesting a channel.
// Use an empty channel category as fallback for these cases.
catch (DiscordChatExporterException)
{
return new ChannelCategory(channelId, "Unknown Category", 0);
@ -371,8 +368,9 @@ public class DiscordClient
if (lastMessage is null || lastMessage.Timestamp < after?.ToDate())
yield break;
// Keep track of the first message in range in order to calculate progress
// Keep track of the first message in range in order to calculate the progress
var firstMessage = default(Message);
var currentAfter = after ?? Snowflake.Zero;
while (true)
{

@ -29,15 +29,11 @@ public partial record struct Snowflake
// As number
if (ulong.TryParse(str, NumberStyles.None, formatProvider, out var value))
{
return new Snowflake(value);
}
// As date
if (DateTimeOffset.TryParse(str, formatProvider, DateTimeStyles.None, out var instant))
{
return FromDate(instant);
}
return null;
}

@ -89,11 +89,15 @@ internal partial class CsvMessageWriter : MessageWriter
// Message content
if (message.Kind.IsSystemNotification())
{
await _writer.WriteAsync(CsvEncode(message.GetFallbackContent()));
await _writer.WriteAsync(CsvEncode(
message.GetFallbackContent()
));
}
else
{
await _writer.WriteAsync(CsvEncode(await FormatMarkdownAsync(message.Content, cancellationToken)));
await _writer.WriteAsync(CsvEncode(
await FormatMarkdownAsync(message.Content, cancellationToken)
));
}
await _writer.WriteAsync(',');

@ -75,9 +75,8 @@ internal partial class ExportAssetDownloader
catch
{
// This can apparently fail for some reason.
// Updating the file date is not a critical task, so we'll just ignore exceptions thrown here.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
// Updating file dates is not a critical task, so we'll just
// ignore exceptions thrown here.
}
});
@ -98,7 +97,7 @@ internal partial class ExportAssetDownloader
{
var urlHash = GetUrlHash(url);
// Try to extract file name from URL
// Try to extract the file name from URL
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
// If it's not there, just use the URL hash as the file name
@ -110,7 +109,7 @@ internal partial class ExportAssetDownloader
var fileExtension = Path.GetExtension(fileName);
// Probably not a file extension, just a dot in a long file name
// https://github.com/Tyrrrz/DiscordChatExporter/issues/708
// https://github.com/Tyrrrz/DiscordChatExporter/pull/812
if (fileExtension.Length > 41)
{
fileNameWithoutExtension = fileName;

@ -55,10 +55,13 @@ internal class ExportContext
var member = await Discord.TryGetGuildMemberAsync(Request.Guild.Id, id, cancellationToken);
// User may have left the guild since they were mentioned
// User may have left the guild since they were mentioned.
// Create a dummy member object based on the user info.
if (member is null)
{
var user = fallbackUser ?? await Discord.TryGetUserAsync(id, cancellationToken);
// User may have been deleted since they were mentioned
if (user is not null)
member = Member.CreateDefault(user);
}
@ -114,7 +117,7 @@ internal class ExportContext
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
// Prefer relative paths so that the output files can be copied around without breaking references.
// If the assets path is outside of the export directory, use the absolute path instead.
// If the assets path is outside of the export directory, use an absolute path instead.
var optimalFilePath =
relativeFilePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
relativeFilePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)
@ -135,8 +138,8 @@ internal class ExportContext
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
{
// TODO: add logging so we can be more liberal with catching exceptions
// We don't want this to crash the exporting process in case of failure
// We don't want this to crash the exporting process in case of failure.
// TODO: add logging so we can be more liberal with catching exceptions.
return url;
}
}

@ -52,12 +52,12 @@ internal class HtmlMessageWriter : MessageWriter
if ((message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes > 7)
return false;
// Messages must be from the same author
// Messages must be sent by the same author
if (message.Author.Id != lastMessage.Author.Id)
return false;
// If the user changed their name after the last message, their new messages
// cannot join an existing group.
// If the author changed their name after the last message, their new messages
// cannot join the existing group.
if (!string.Equals(message.Author.FullName, lastMessage.Author.FullName, StringComparison.Ordinal))
return false;
}

@ -39,7 +39,7 @@ internal partial class MessageExporter : IAsyncDisposable
private async ValueTask<MessageWriter> GetWriterAsync(CancellationToken cancellationToken = default)
{
// Ensure partition limit has not been reached
// Ensure that the partition limit has not been reached
if (_writer is not null &&
_context.Request.PartitionLimit.IsReached(_writer.MessagesWritten, _writer.BytesWritten))
{
@ -74,11 +74,11 @@ internal partial class MessageExporter
{
private static string GetPartitionFilePath(string baseFilePath, int partitionIndex)
{
// First partition, don't change file name
// First partition, don't change the file name
if (partitionIndex <= 0)
return baseFilePath;
// Inject partition index into file name
// Inject partition index into the file name
var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath);
var fileExt = Path.GetExtension(baseFilePath);
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";

@ -16,7 +16,7 @@ public static class Http
private static bool IsRetryableStatusCode(HttpStatusCode statusCode) =>
statusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout ||
// Treat all server-side errors as retryable.
// Treat all server-side errors as retryable
// https://github.com/Tyrrrz/DiscordChatExporter/issues/908
(int)statusCode >= 500;

@ -17,8 +17,8 @@ public class Bootstrapper : Bootstrapper<RootViewModel>
{
base.OnStart();
// Set default theme
// (preferred theme will be set later, once the settings are loaded)
// Set the default theme.
// Preferred theme will be set later, once the settings are loaded.
App.SetLightTheme();
}
@ -26,10 +26,7 @@ public class Bootstrapper : Bootstrapper<RootViewModel>
{
base.ConfigureIoC(builder);
// Bind settings as singleton
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
// Bind view model factory
builder.Bind<IViewModelFactory>().ToAbstractFactory();
}

@ -45,7 +45,7 @@ public partial class SettingsService : SettingsBase
public override void Save()
{
// Clear token if it's not supposed to be persisted
// Clear the token if it's not supposed to be persisted
if (!IsTokenPersisted)
LastToken = null;

@ -6,13 +6,15 @@ internal static class ProcessEx
{
public static void StartShellExecute(string path)
{
var startInfo = new ProcessStartInfo(path)
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = path,
UseShellExecute = true
}
};
using (Process.Start(startInfo))
{
}
process.Start();
}
}

@ -156,14 +156,11 @@ public class DashboardViewModel : PropertyChangedBase
var exporter = new ChannelExporter(_discord);
var channelProgressPairs = dialog
.Channels!
.Select(c => new
var channelProgressPairs = dialog.Channels!.Select(c => new
{
Channel = c,
Progress = _progressMuxer.CreateInput()
})
.ToArray();
}).ToArray();
var successfulExportCount = 0;
@ -213,7 +210,7 @@ public class DashboardViewModel : PropertyChangedBase
}
);
// Notify of overall completion
// Notify of the overall completion
if (successfulExportCount > 0)
{
_eventAggregator.Publish(

@ -25,7 +25,7 @@ public class DialogManager : IDisposable
void OnDialogOpened(object? openSender, DialogOpenedEventArgs openArgs)
{
void OnScreenClosed(object? closeSender, EventArgs args)
void OnScreenClosed(object? closeSender, EventArgs closeArgs)
{
try
{

@ -1,8 +1,3 @@
namespace DiscordChatExporter.Gui.ViewModels.Messages;
public class NotificationMessage
{
public string Text { get; }
public NotificationMessage(string text) => Text = text;
}
public record NotificationMessage(string Text);

@ -62,10 +62,8 @@ public class RootViewModel : Screen, IHandle<NotificationMessage>, IDisposable
_settingsService.Save();
if (await _dialogManager.ShowDialogAsync(dialog) == true)
{
ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter");
}
}
private async ValueTask CheckForUpdatesAsync()
{
@ -106,7 +104,7 @@ public class RootViewModel : Screen, IHandle<NotificationMessage>, IDisposable
_settingsService.Load();
// Sync theme with settings
// Sync the theme with settings
if (_settingsService.IsDarkModeEnabled)
{
App.SetDarkTheme();
@ -116,7 +114,7 @@ public class RootViewModel : Screen, IHandle<NotificationMessage>, IDisposable
App.SetLightTheme();
}
// App has just been updated, display changelog
// App has just been updated, display the changelog
if (_settingsService.LastAppVersion is not null && _settingsService.LastAppVersion != App.Version)
{
Notifications.Enqueue(
@ -137,8 +135,7 @@ public class RootViewModel : Screen, IHandle<NotificationMessage>, IDisposable
_updateService.FinalizeUpdate(false);
}
public void Handle(NotificationMessage message) =>
Notifications.Enqueue(message.Text);
public void Handle(NotificationMessage message) => Notifications.Enqueue(message.Text);
public void Dispose() => Notifications.Dispose();
}

@ -221,7 +221,7 @@
materialDesign:HintAssist.IsFloating="True"
Style="{DynamicResource MaterialDesignOutlinedTextBox}"
Text="{Binding PartitionLimitValue}"
ToolTip="Split output into partitions, each limited to this number of messages (e.g. '100') or file size (e.g. '10mb')" />
ToolTip="Split the output into partitions, each limited to the specified number of messages (e.g. '100') or file size (e.g. '10mb')" />
<!-- Filtering -->
<TextBox

Loading…
Cancel
Save