Use CSharpier

pull/1125/head
Tyrrrz 1 year ago
parent c410e745b1
commit 20f58963a6

@ -8,11 +8,11 @@
<ItemGroup> <ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" /> <Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="*.secret" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.0.4" /> <PackageReference Include="AngleSharp" Version="1.0.4" />
<PackageReference Include="CSharpier.MsBuild" Version="0.25.0" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="6.11.0" /> <PackageReference Include="FluentAssertions" Version="6.11.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.2" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="2.3.2" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />

@ -23,4 +23,4 @@ public static class ChannelIds
public static Snowflake SelfContainedTestCases { get; } = Snowflake.Parse("887441432678379560"); public static Snowflake SelfContainedTestCases { get; } = Snowflake.Parse("887441432678379560");
public static Snowflake StickerTestCases { get; } = Snowflake.Parse("939668868253769729"); public static Snowflake StickerTestCases { get; } = Snowflake.Parse("939668868253769729");
} }

@ -19,14 +19,16 @@ namespace DiscordChatExporter.Cli.Tests.Infra;
public static class ExportWrapper public static class ExportWrapper
{ {
private static readonly AsyncKeyedLocker<string> Locker = new(o => private static readonly AsyncKeyedLocker<string> Locker =
{ new(o =>
o.PoolSize = 20; {
o.PoolInitialFill = 1; o.PoolSize = 20;
}); o.PoolInitialFill = 1;
});
private static readonly string DirPath = Path.Combine( private static readonly string DirPath = Path.Combine(
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? Directory.GetCurrentDirectory(), Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
?? Directory.GetCurrentDirectory(),
"ExportCache" "ExportCache"
); );
@ -36,9 +38,7 @@ public static class ExportWrapper
{ {
Directory.Delete(DirPath, true); Directory.Delete(DirPath, true);
} }
catch (DirectoryNotFoundException) catch (DirectoryNotFoundException) { }
{
}
Directory.CreateDirectory(DirPath); Directory.CreateDirectory(DirPath);
} }
@ -66,13 +66,11 @@ public static class ExportWrapper
return await File.ReadAllTextAsync(filePath); return await File.ReadAllTextAsync(filePath);
} }
public static async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId) => Html.Parse( public static async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId) =>
await ExportAsync(channelId, ExportFormat.HtmlDark) Html.Parse(await ExportAsync(channelId, ExportFormat.HtmlDark));
);
public static async ValueTask<JsonElement> ExportAsJsonAsync(Snowflake channelId) => Json.Parse( public static async ValueTask<JsonElement> ExportAsJsonAsync(Snowflake channelId) =>
await ExportAsync(channelId, ExportFormat.Json) Json.Parse(await ExportAsync(channelId, ExportFormat.Json));
);
public static async ValueTask<string> ExportAsPlainTextAsync(Snowflake channelId) => public static async ValueTask<string> ExportAsPlainTextAsync(Snowflake channelId) =>
await ExportAsync(channelId, ExportFormat.PlainText); await ExportAsync(channelId, ExportFormat.PlainText);
@ -80,25 +78,26 @@ public static class ExportWrapper
public static async ValueTask<string> ExportAsCsvAsync(Snowflake channelId) => public static async ValueTask<string> ExportAsCsvAsync(Snowflake channelId) =>
await ExportAsync(channelId, ExportFormat.Csv); await ExportAsync(channelId, ExportFormat.Csv);
public static async ValueTask<IReadOnlyList<IElement>> GetMessagesAsHtmlAsync(Snowflake channelId) => public static async ValueTask<IReadOnlyList<IElement>> GetMessagesAsHtmlAsync(
(await ExportAsHtmlAsync(channelId)) Snowflake channelId
.QuerySelectorAll("[data-message-id]") ) => (await ExportAsHtmlAsync(channelId)).QuerySelectorAll("[data-message-id]").ToArray();
.ToArray();
public static async ValueTask<IReadOnlyList<JsonElement>> GetMessagesAsJsonAsync(Snowflake channelId) => public static async ValueTask<IReadOnlyList<JsonElement>> GetMessagesAsJsonAsync(
(await ExportAsJsonAsync(channelId)) Snowflake channelId
.GetProperty("messages") ) => (await ExportAsJsonAsync(channelId)).GetProperty("messages").EnumerateArray().ToArray();
.EnumerateArray()
.ToArray();
public static async ValueTask<IElement> GetMessageAsHtmlAsync(Snowflake channelId, Snowflake messageId) public static async ValueTask<IElement> GetMessageAsHtmlAsync(
Snowflake channelId,
Snowflake messageId
)
{ {
var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(e => var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(
string.Equals( e =>
e.GetAttribute("data-message-id"), string.Equals(
messageId.ToString(), e.GetAttribute("data-message-id"),
StringComparison.OrdinalIgnoreCase messageId.ToString(),
) StringComparison.OrdinalIgnoreCase
)
); );
if (message is null) if (message is null)
@ -111,14 +110,18 @@ public static class ExportWrapper
return message; return message;
} }
public static async ValueTask<JsonElement> GetMessageAsJsonAsync(Snowflake channelId, Snowflake messageId) public static async ValueTask<JsonElement> GetMessageAsJsonAsync(
Snowflake channelId,
Snowflake messageId
)
{ {
var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(j => var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(
string.Equals( j =>
j.GetProperty("id").GetString(), string.Equals(
messageId.ToString(), j.GetProperty("id").GetString(),
StringComparison.OrdinalIgnoreCase messageId.ToString(),
) StringComparison.OrdinalIgnoreCase
)
); );
if (message.ValueKind == JsonValueKind.Undefined) if (message.ValueKind == JsonValueKind.Undefined)
@ -130,4 +133,4 @@ public static class ExportWrapper
return message; return message;
} }
} }

@ -12,6 +12,6 @@ internal static class Secrets
.Build(); .Build();
public static string DiscordToken => public static string DiscordToken =>
Configuration["DISCORD_TOKEN"] ?? Configuration["DISCORD_TOKEN"]
throw new InvalidOperationException("Discord token not provided for tests."); ?? throw new InvalidOperationException("Discord token not provided for tests.");
} }

@ -14,16 +14,18 @@ public class CsvContentSpecs
var document = await ExportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases); var document = await ExportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases);
// Assert // Assert
document.Should().ContainAll( document
"tyrrrz", .Should()
"Hello world", .ContainAll(
"Goodbye world", "tyrrrz",
"Foo bar", "Hello world",
"Hurdle Durdle", "Goodbye world",
"One", "Foo bar",
"Two", "Hurdle Durdle",
"Three", "One",
"Yeet" "Two",
); "Three",
"Yeet"
);
} }
} }

@ -34,8 +34,7 @@ public class DateRangeSpecs
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
var timestamps = Json var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages") .GetProperty("messages")
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset()) .Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
@ -43,21 +42,28 @@ public class DateRangeSpecs
timestamps.All(t => t > after).Should().BeTrue(); timestamps.All(t => t > after).Should().BeTrue();
timestamps.Should().BeEquivalentTo(new[] timestamps
{ .Should()
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero), .BeEquivalentTo(
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero), new[]
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero), {
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero) new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
}, o => new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
{ new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
return o new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero)
.Using<DateTimeOffset>(ctx => },
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) o =>
) {
.WhenTypeIs<DateTimeOffset>(); return o.Using<DateTimeOffset>(
}); ctx =>
ctx.Subject
.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
)
.WhenTypeIs<DateTimeOffset>();
}
);
} }
[Fact] [Fact]
@ -78,8 +84,7 @@ public class DateRangeSpecs
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
var timestamps = Json var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages") .GetProperty("messages")
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset()) .Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
@ -87,19 +92,26 @@ public class DateRangeSpecs
timestamps.All(t => t < before).Should().BeTrue(); timestamps.All(t => t < before).Should().BeTrue();
timestamps.Should().BeEquivalentTo(new[] timestamps
{ .Should()
new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero), .BeEquivalentTo(
new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero), new[]
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero) {
}, o => new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero),
{ new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero),
return o new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero)
.Using<DateTimeOffset>(ctx => },
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) o =>
) {
.WhenTypeIs<DateTimeOffset>(); return o.Using<DateTimeOffset>(
}); ctx =>
ctx.Subject
.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
)
.WhenTypeIs<DateTimeOffset>();
}
);
} }
[Fact] [Fact]
@ -122,8 +134,7 @@ public class DateRangeSpecs
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
var timestamps = Json var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages") .GetProperty("messages")
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset()) .Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
@ -131,19 +142,26 @@ public class DateRangeSpecs
timestamps.All(t => t < before && t > after).Should().BeTrue(); timestamps.All(t => t < before && t > after).Should().BeTrue();
timestamps.Should().BeEquivalentTo(new[] timestamps
{ .Should()
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero), .BeEquivalentTo(
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero), new[]
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero), {
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero) new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
}, o => new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
{ new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
return o new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero)
.Using<DateTimeOffset>(ctx => },
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) o =>
) {
.WhenTypeIs<DateTimeOffset>(); return o.Using<DateTimeOffset>(
}); ctx =>
ctx.Subject
.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
)
.WhenTypeIs<DateTimeOffset>();
}
);
} }
} }

@ -32,8 +32,7 @@ public class FilterSpecs
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
Json Json.Parse(await File.ReadAllTextAsync(file.Path))
.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages") .GetProperty("messages")
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("content").GetString()) .Select(j => j.GetProperty("content").GetString())
@ -58,8 +57,7 @@ public class FilterSpecs
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
Json Json.Parse(await File.ReadAllTextAsync(file.Path))
.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages") .GetProperty("messages")
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("author").GetProperty("name").GetString()) .Select(j => j.GetProperty("author").GetProperty("name").GetString())
@ -84,8 +82,7 @@ public class FilterSpecs
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
Json Json.Parse(await File.ReadAllTextAsync(file.Path))
.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages") .GetProperty("messages")
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("content").GetString()) .Select(j => j.GetProperty("content").GetString())
@ -110,8 +107,7 @@ public class FilterSpecs
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
Json Json.Parse(await File.ReadAllTextAsync(file.Path))
.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages") .GetProperty("messages")
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("content").GetString()) .Select(j => j.GetProperty("content").GetString())
@ -136,12 +132,11 @@ public class FilterSpecs
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
Json Json.Parse(await File.ReadAllTextAsync(file.Path))
.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages") .GetProperty("messages")
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("content").GetString()) .Select(j => j.GetProperty("content").GetString())
.Should() .Should()
.ContainSingle("This has mention"); .ContainSingle("This has mention");
} }
} }

@ -20,11 +20,7 @@ public class HtmlAttachmentSpecs
); );
// Assert // Assert
message.Text().Should().ContainAll( message.Text().Should().ContainAll("Generic file attachment", "Test.txt", "11 bytes");
"Generic file attachment",
"Test.txt",
"11 bytes"
);
message message
.QuerySelectorAll("a") .QuerySelectorAll("a")
@ -71,9 +67,11 @@ public class HtmlAttachmentSpecs
message.Text().Should().Contain("Video attachment"); message.Text().Should().Contain("Video attachment");
var videoUrl = message.QuerySelector("video source")?.GetAttribute("src"); var videoUrl = message.QuerySelector("video source")?.GetAttribute("src");
videoUrl.Should().Be( videoUrl
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4" .Should()
); .Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
);
} }
[Fact] [Fact]
@ -91,8 +89,10 @@ public class HtmlAttachmentSpecs
message.Text().Should().Contain("Audio attachment"); message.Text().Should().Contain("Audio attachment");
var audioUrl = message.QuerySelector("audio source")?.GetAttribute("src"); var audioUrl = message.QuerySelector("audio source")?.GetAttribute("src");
audioUrl.Should().Be( audioUrl
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3" .Should()
); .Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
);
} }
} }

@ -16,26 +16,32 @@ public class HtmlContentSpecs
var messages = await ExportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases); var messages = await ExportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases);
// Assert // Assert
messages.Select(e => e.GetAttribute("data-message-id")).Should().Equal( messages
"866674314627121232", .Select(e => e.GetAttribute("data-message-id"))
"866710679758045195", .Should()
"866732113319428096", .Equal(
"868490009366396958", "866674314627121232",
"868505966528835604", "866710679758045195",
"868505969821364245", "866732113319428096",
"868505973294268457", "868490009366396958",
"885169254029213696" "868505966528835604",
); "868505969821364245",
"868505973294268457",
"885169254029213696"
);
messages.SelectMany(e => e.Text()).Should().ContainInOrder( messages
"Hello world", .SelectMany(e => e.Text())
"Goodbye world", .Should()
"Foo bar", .ContainInOrder(
"Hurdle Durdle", "Hello world",
"One", "Goodbye world",
"Two", "Foo bar",
"Three", "Hurdle Durdle",
"Yeet" "One",
); "Two",
"Three",
"Yeet"
);
} }
} }

@ -21,15 +21,21 @@ public class HtmlEmbedSpecs
); );
// Assert // Assert
message.Text().Should().ContainAll( message
"Embed author", .Text()
"Embed title", .Should()
"Embed description", .ContainAll(
"Field 1", "Value 1", "Embed author",
"Field 2", "Value 2", "Embed title",
"Field 3", "Value 3", "Embed description",
"Embed footer" "Field 1",
); "Value 1",
"Field 2",
"Value 2",
"Field 3",
"Value 3",
"Embed footer"
);
} }
[Fact] [Fact]
@ -83,7 +89,12 @@ public class HtmlEmbedSpecs
.QuerySelectorAll("source") .QuerySelectorAll("source")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.WhereNotNull() .WhereNotNull()
.Where(s => s.EndsWith("i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4")) .Where(
s =>
s.EndsWith(
"i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4"
)
)
.Should() .Should()
.ContainSingle(); .ContainSingle();
} }
@ -193,4 +204,4 @@ public class HtmlEmbedSpecs
// Assert // Assert
message.Text().Should().Contain("DiscordChatExporter TestServer"); message.Text().Should().Contain("DiscordChatExporter TestServer");
} }
} }

@ -32,8 +32,7 @@ public class HtmlGroupingSpecs
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
var messageGroups = Html var messageGroups = Html.Parse(await File.ReadAllTextAsync(file.Path))
.Parse(await File.ReadAllTextAsync(file.Path))
.QuerySelectorAll(".chatlog__message-group"); .QuerySelectorAll(".chatlog__message-group");
messageGroups.Should().HaveCount(2); messageGroups.Should().HaveCount(2);
@ -59,12 +58,6 @@ public class HtmlGroupingSpecs
.QuerySelectorAll(".chatlog__content") .QuerySelectorAll(".chatlog__content")
.Select(e => e.Text()) .Select(e => e.Text())
.Should() .Should()
.ContainInOrder( .ContainInOrder("Eleventh", "Twelveth", "Thirteenth", "Fourteenth", "Fifteenth");
"Eleventh",
"Twelveth",
"Thirteenth",
"Fourteenth",
"Fifteenth"
);
} }
} }

@ -170,7 +170,10 @@ public class HtmlMarkdownSpecs
); );
// Assert // Assert
message.Text().Should().Contain("Full long timestamp: Sunday, February 12, 2023 3:36 PM"); 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"); message.InnerHtml.Should().Contain("Sunday, February 12, 2023 3:36 PM");
} }
finally finally
@ -225,4 +228,4 @@ public class HtmlMarkdownSpecs
TimeZoneInfo.ClearCachedData(); TimeZoneInfo.ClearCachedData();
} }
} }
} }

@ -61,4 +61,4 @@ public class HtmlMentionSpecs
// Assert // Assert
message.Text().Should().Contain("Role mention: @Role 1"); message.Text().Should().Contain("Role mention: @Role 1");
} }
} }

@ -36,9 +36,11 @@ public class HtmlReplySpecs
// Assert // Assert
message.Text().Should().Contain("reply to deleted"); message.Text().Should().Contain("reply to deleted");
message.QuerySelector(".chatlog__reply-link")?.Text().Should().Contain( message
"Original message was deleted or could not be loaded." .QuerySelector(".chatlog__reply-link")
); ?.Text()
.Should()
.Contain("Original message was deleted or could not be loaded.");
} }
[Fact] [Fact]
@ -54,7 +56,11 @@ public class HtmlReplySpecs
// Assert // Assert
message.Text().Should().Contain("reply to attachment"); message.Text().Should().Contain("reply to attachment");
message.QuerySelector(".chatlog__reply-link")?.Text().Should().Contain("Click to see attachment"); message
.QuerySelector(".chatlog__reply-link")
?.Text()
.Should()
.Contain("Click to see attachment");
} }
[Fact] [Fact]
@ -84,8 +90,11 @@ public class HtmlReplySpecs
); );
// Assert // Assert
message.Text().Should().Contain("This is a test message from an announcement channel on another server"); message
.Text()
.Should()
.Contain("This is a test message from an announcement channel on another server");
message.Text().Should().Contain("SERVER"); message.Text().Should().Contain("SERVER");
message.QuerySelector(".chatlog__reply-link").Should().BeNull(); message.QuerySelector(".chatlog__reply-link").Should().BeNull();
} }
} }

@ -32,7 +32,9 @@ public class HtmlStickerSpecs
); );
// Assert // Assert
var stickerUrl = message.QuerySelector("[title='Yikes'] [data-source]")?.GetAttribute("data-source"); var stickerUrl = message
.QuerySelector("[title='Yikes'] [data-source]")
?.GetAttribute("data-source");
stickerUrl.Should().Be("https://cdn.discordapp.com/stickers/816087132447178774.json"); stickerUrl.Should().Be("https://cdn.discordapp.com/stickers/816087132447178774.json");
} }
} }

@ -24,9 +24,13 @@ public class JsonAttachmentSpecs
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray(); var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
attachments.Should().HaveCount(1); attachments.Should().HaveCount(1);
attachments[0].GetProperty("url").GetString().Should().Be( attachments[0]
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt" .GetProperty("url")
); .GetString()
.Should()
.Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
);
attachments[0].GetProperty("fileName").GetString().Should().Be("Test.txt"); attachments[0].GetProperty("fileName").GetString().Should().Be("Test.txt");
attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(11); attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(11);
} }
@ -46,9 +50,13 @@ public class JsonAttachmentSpecs
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray(); var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
attachments.Should().HaveCount(1); attachments.Should().HaveCount(1);
attachments[0].GetProperty("url").GetString().Should().Be( attachments[0]
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png" .GetProperty("url")
); .GetString()
.Should()
.Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png"
);
attachments[0].GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png"); attachments[0].GetProperty("fileName").GetString().Should().Be("bird-thumbnail.png");
attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(466335); attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(466335);
} }
@ -68,10 +76,18 @@ public class JsonAttachmentSpecs
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray(); var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
attachments.Should().HaveCount(1); attachments.Should().HaveCount(1);
attachments[0].GetProperty("url").GetString().Should().Be( attachments[0]
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4" .GetProperty("url")
); .GetString()
attachments[0].GetProperty("fileName").GetString().Should().Be("file_example_MP4_640_3MG.mp4"); .Should()
.Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
);
attachments[0]
.GetProperty("fileName")
.GetString()
.Should()
.Be("file_example_MP4_640_3MG.mp4");
attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(3114374); attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(3114374);
} }
@ -90,10 +106,14 @@ public class JsonAttachmentSpecs
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray(); var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
attachments.Should().HaveCount(1); attachments.Should().HaveCount(1);
attachments[0].GetProperty("url").GetString().Should().Be( attachments[0]
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3" .GetProperty("url")
); .GetString()
.Should()
.Be(
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
);
attachments[0].GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3"); attachments[0].GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(1087849); attachments[0].GetProperty("fileSizeBytes").GetInt64().Should().Be(1087849);
} }
} }

@ -15,26 +15,32 @@ public class JsonContentSpecs
var messages = await ExportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases); var messages = await ExportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases);
// Assert // Assert
messages.Select(j => j.GetProperty("id").GetString()).Should().Equal( messages
"866674314627121232", .Select(j => j.GetProperty("id").GetString())
"866710679758045195", .Should()
"866732113319428096", .Equal(
"868490009366396958", "866674314627121232",
"868505966528835604", "866710679758045195",
"868505969821364245", "866732113319428096",
"868505973294268457", "868490009366396958",
"885169254029213696" "868505966528835604",
); "868505969821364245",
"868505973294268457",
"885169254029213696"
);
messages.Select(j => j.GetProperty("content").GetString()).Should().Equal( messages
"Hello world", .Select(j => j.GetProperty("content").GetString())
"Goodbye world", .Should()
"Foo bar", .Equal(
"Hurdle Durdle", "Hello world",
"One", "Goodbye world",
"Two", "Foo bar",
"Three", "Hurdle Durdle",
"Yeet" "One",
); "Two",
"Three",
"Yeet"
);
} }
} }

@ -52,4 +52,4 @@ public class JsonEmbedSpecs
embedFields[2].GetProperty("value").GetString().Should().Be("Value 3"); embedFields[2].GetProperty("value").GetString().Should().Be("Value 3");
embedFields[2].GetProperty("isInline").GetBoolean().Should().BeTrue(); embedFields[2].GetProperty("isInline").GetBoolean().Should().BeTrue();
} }
} }

@ -39,7 +39,11 @@ public class JsonMentionSpecs
); );
// Assert // Assert
message.GetProperty("content").GetString().Should().Be("Text channel mention: #mention-tests"); message
.GetProperty("content")
.GetString()
.Should()
.Be("Text channel mention: #mention-tests");
} }
[Fact] [Fact]
@ -52,7 +56,11 @@ public class JsonMentionSpecs
); );
// Assert // Assert
message.GetProperty("content").GetString().Should().Be("Voice channel mention: #general [voice]"); message
.GetProperty("content")
.GetString()
.Should()
.Be("Voice channel mention: #general [voice]");
} }
[Fact] [Fact]
@ -67,4 +75,4 @@ public class JsonMentionSpecs
// Assert // Assert
message.GetProperty("content").GetString().Should().Be("Role mention: @Role 1"); message.GetProperty("content").GetString().Should().Be("Role mention: @Role 1");
} }
} }

@ -19,15 +19,16 @@ public class JsonStickerSpecs
); );
// Assert // Assert
var sticker = message var sticker = message.GetProperty("stickers").EnumerateArray().Single();
.GetProperty("stickers")
.EnumerateArray()
.Single();
sticker.GetProperty("id").GetString().Should().Be("904215665597120572"); sticker.GetProperty("id").GetString().Should().Be("904215665597120572");
sticker.GetProperty("name").GetString().Should().Be("rock"); sticker.GetProperty("name").GetString().Should().Be("rock");
sticker.GetProperty("format").GetString().Should().Be("Apng"); sticker.GetProperty("format").GetString().Should().Be("Apng");
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://cdn.discordapp.com/stickers/904215665597120572.png"); sticker
.GetProperty("sourceUrl")
.GetString()
.Should()
.Be("https://cdn.discordapp.com/stickers/904215665597120572.png");
} }
[Fact] [Fact]
@ -40,14 +41,15 @@ public class JsonStickerSpecs
); );
// Assert // Assert
var sticker = message var sticker = message.GetProperty("stickers").EnumerateArray().Single();
.GetProperty("stickers")
.EnumerateArray()
.Single();
sticker.GetProperty("id").GetString().Should().Be("816087132447178774"); sticker.GetProperty("id").GetString().Should().Be("816087132447178774");
sticker.GetProperty("name").GetString().Should().Be("Yikes"); sticker.GetProperty("name").GetString().Should().Be("Yikes");
sticker.GetProperty("format").GetString().Should().Be("Lottie"); sticker.GetProperty("format").GetString().Should().Be("Lottie");
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://cdn.discordapp.com/stickers/816087132447178774.json"); sticker
.GetProperty("sourceUrl")
.GetString()
.Should()
.Be("https://cdn.discordapp.com/stickers/816087132447178774.json");
} }
} }

@ -31,9 +31,7 @@ public class PartitioningSpecs
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
Directory.EnumerateFiles(dir.Path, "output*") Directory.EnumerateFiles(dir.Path, "output*").Should().HaveCount(3);
.Should()
.HaveCount(3);
} }
[Fact] [Fact]
@ -54,8 +52,6 @@ public class PartitioningSpecs
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
Directory.EnumerateFiles(dir.Path, "output*") Directory.EnumerateFiles(dir.Path, "output*").Should().HaveCount(8);
.Should()
.HaveCount(8);
} }
} }

@ -14,16 +14,18 @@ public class PlainTextContentSpecs
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases); var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases);
// Assert // Assert
document.Should().ContainAll( document
"tyrrrz", .Should()
"Hello world", .ContainAll(
"Goodbye world", "tyrrrz",
"Foo bar", "Hello world",
"Hurdle Durdle", "Goodbye world",
"One", "Foo bar",
"Two", "Hurdle Durdle",
"Three", "One",
"Yeet" "Two",
); "Three",
"Yeet"
);
} }
} }

@ -31,8 +31,7 @@ public class SelfContainedSpecs
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
Html Html.Parse(await File.ReadAllTextAsync(filePath))
.Parse(await File.ReadAllTextAsync(filePath))
.QuerySelectorAll("body [src]") .QuerySelectorAll("body [src]")
.Select(e => e.GetAttribute("src")!) .Select(e => e.GetAttribute("src")!)
.Select(f => Path.GetFullPath(f, dir.Path)) .Select(f => Path.GetFullPath(f, dir.Path))
@ -40,4 +39,4 @@ public class SelfContainedSpecs
.Should() .Should()
.BeTrue(); .BeTrue();
} }
} }

@ -8,4 +8,4 @@ internal static class Html
private static readonly IHtmlParser Parser = new HtmlParser(); private static readonly IHtmlParser Parser = new HtmlParser();
public static IHtmlDocument Parse(string source) => Parser.ParseDocument(source); public static IHtmlDocument Parse(string source) => Parser.ParseDocument(source);
} }

@ -9,8 +9,7 @@ internal partial class TempDir : IDisposable
{ {
public string Path { get; } public string Path { get; }
public TempDir(string path) => public TempDir(string path) => Path = path;
Path = path;
public void Dispose() public void Dispose()
{ {
@ -18,9 +17,7 @@ internal partial class TempDir : IDisposable
{ {
Directory.Delete(Path, true); Directory.Delete(Path, true);
} }
catch (DirectoryNotFoundException) catch (DirectoryNotFoundException) { }
{
}
} }
} }
@ -29,7 +26,8 @@ internal partial class TempDir
public static TempDir Create() public static TempDir Create()
{ {
var dirPath = PathEx.Combine( var dirPath = PathEx.Combine(
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? Directory.GetCurrentDirectory(), PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
?? Directory.GetCurrentDirectory(),
"Temp", "Temp",
Guid.NewGuid().ToString() Guid.NewGuid().ToString()
); );
@ -38,4 +36,4 @@ internal partial class TempDir
return new TempDir(dirPath); return new TempDir(dirPath);
} }
} }

@ -9,8 +9,7 @@ internal partial class TempFile : IDisposable
{ {
public string Path { get; } public string Path { get; }
public TempFile(string path) => public TempFile(string path) => Path = path;
Path = path;
public void Dispose() public void Dispose()
{ {
@ -18,9 +17,7 @@ internal partial class TempFile : IDisposable
{ {
File.Delete(Path); File.Delete(Path);
} }
catch (FileNotFoundException) catch (FileNotFoundException) { }
{
}
} }
} }
@ -29,17 +26,15 @@ internal partial class TempFile
public static TempFile Create() public static TempFile Create()
{ {
var dirPath = PathEx.Combine( var dirPath = PathEx.Combine(
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? Directory.GetCurrentDirectory(), PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
?? Directory.GetCurrentDirectory(),
"Temp" "Temp"
); );
Directory.CreateDirectory(dirPath); Directory.CreateDirectory(dirPath);
var filePath = PathEx.Combine( var filePath = PathEx.Combine(dirPath, Guid.NewGuid() + ".tmp");
dirPath,
Guid.NewGuid() + ".tmp"
);
return new TempFile(filePath); return new TempFile(filePath);
} }
} }

@ -11,4 +11,4 @@ internal static class TimeZoneInfoEx
public static void SetLocal(TimeSpan offset) => public static void SetLocal(TimeSpan offset) =>
SetLocal(TimeZoneInfo.CreateCustomTimeZone("test-tz", offset, "test-tz", "test-tz")); SetLocal(TimeZoneInfo.CreateCustomTimeZone("test-tz", offset, "test-tz", "test-tz"));
} }

@ -38,9 +38,9 @@ public abstract class DiscordCommandBase : ICommand
using (console.WithForegroundColor(ConsoleColor.DarkYellow)) using (console.WithForegroundColor(ConsoleColor.DarkYellow))
{ {
console.Error.WriteLine( console.Error.WriteLine(
"Warning: Option --bot is deprecated and should not be used. " + "Warning: Option --bot is deprecated and should not be used. "
"The type of the provided token is now inferred automatically. " + + "The type of the provided token is now inferred automatically. "
"Please update your workflows as this option may be completely removed in a future version." + "Please update your workflows as this option may be completely removed in a future version."
); );
} }
} }
@ -48,4 +48,4 @@ public abstract class DiscordCommandBase : ICommand
return default; return default;
} }
} }

@ -28,11 +28,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
[CommandOption( [CommandOption(
"output", "output",
'o', 'o',
Description = Description = "Output file or directory path. "
"Output file or directory path. " + + "Directory path must 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. "
"If a directory is specified, file names will be generated automatically. " + + "Supports template tokens, see the documentation for more info."
"Supports template tokens, see the documentation for more info."
)] )]
public string OutputPath public string OutputPath
{ {
@ -42,11 +41,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
init => _outputPath = Path.GetFullPath(value); init => _outputPath = Path.GetFullPath(value);
} }
[CommandOption( [CommandOption("format", 'f', Description = "Export format.")]
"format",
'f',
Description = "Export format."
)]
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark; public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
[CommandOption( [CommandOption(
@ -64,17 +59,15 @@ public abstract class ExportCommandBase : DiscordCommandBase
[CommandOption( [CommandOption(
"partition", "partition",
'p', 'p',
Description = Description = "Split the output into partitions, each limited to the specified "
"Split the output into partitions, each limited to the specified " + + "number of messages (e.g. '100') or file size (e.g. '10mb')."
"number of messages (e.g. '100') or file size (e.g. '10mb')."
)] )]
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null; public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
[CommandOption( [CommandOption(
"filter", "filter",
Description = Description = "Only include messages that satisfy this filter. "
"Only include messages that satisfy this filter. " + + "See the documentation for more info."
"See the documentation for more info."
)] )]
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null; public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
@ -106,9 +99,8 @@ public abstract class ExportCommandBase : DiscordCommandBase
[CommandOption( [CommandOption(
"media-dir", "media-dir",
Description = Description = "Download assets to this directory. "
"Download assets to this directory. " + + "If not specified, the asset directory path will be derived from the output path."
"If not specified, the asset directory path will be derived from the output path."
)] )]
public string? AssetsDirPath public string? AssetsDirPath
{ {
@ -118,10 +110,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null; init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null;
} }
[CommandOption( [CommandOption("dateformat", Description = "Format used when writing dates.")]
"dateformat",
Description = "Format used when writing dates."
)]
public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt"; public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt";
[CommandOption( [CommandOption(
@ -142,17 +131,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425 // https://github.com/Tyrrrz/DiscordChatExporter/issues/425
if (ShouldReuseAssets && !ShouldDownloadAssets) if (ShouldReuseAssets && !ShouldDownloadAssets)
{ {
throw new CommandException( throw new CommandException("Option --reuse-media cannot be used without --media.");
"Option --reuse-media cannot be used without --media."
);
} }
// Assets directory can only be specified if the download assets option is set // Assets directory can only be specified if the download assets option is set
if (!string.IsNullOrWhiteSpace(AssetsDirPath) && !ShouldDownloadAssets) if (!string.IsNullOrWhiteSpace(AssetsDirPath) && !ShouldDownloadAssets)
{ {
throw new CommandException( throw new CommandException("Option --media-dir cannot be used without --media.");
"Option --media-dir cannot be used without --media."
);
} }
// Make sure the user does not try to export multiple channels into one file. // Make sure the user does not try to export multiple channels into one file.
@ -161,17 +146,20 @@ public abstract class ExportCommandBase : DiscordCommandBase
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917 // https://github.com/Tyrrrz/DiscordChatExporter/issues/917
var isValidOutputPath = var isValidOutputPath =
// Anything is valid when exporting a single channel // Anything is valid when exporting a single channel
channels.Count <= 1 || channels.Count <= 1
||
// When using template tokens, assume the user knows what they're doing // When using template tokens, assume the user knows what they're doing
OutputPath.Contains('%') || OutputPath.Contains('%')
||
// Otherwise, require an existing directory or an unambiguous directory path // Otherwise, require an existing directory or an unambiguous directory path
Directory.Exists(OutputPath) || PathEx.IsDirectoryPath(OutputPath); Directory.Exists(OutputPath)
|| PathEx.IsDirectoryPath(OutputPath);
if (!isValidOutputPath) if (!isValidOutputPath)
{ {
throw new CommandException( throw new CommandException(
"Attempted to export multiple channels, but the output path is neither a directory nor a template. " + "Attempted to export multiple channels, but the output path is neither a directory nor a template. "
"If the provided output path is meant to be treated as a directory, make sure it ends with a slash." + "If the provided output path is meant to be treated as a directory, make sure it ends with a slash."
); );
} }
@ -180,56 +168,61 @@ public abstract class ExportCommandBase : DiscordCommandBase
var errorsByChannel = new ConcurrentDictionary<Channel, string>(); var errorsByChannel = new ConcurrentDictionary<Channel, string>();
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)..."); await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
await console.CreateProgressTicker().StartAsync(async progressContext => await console
{ .CreateProgressTicker()
await Parallel.ForEachAsync( .StartAsync(async progressContext =>
channels, {
new ParallelOptions await Parallel.ForEachAsync(
{ channels,
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit), new ParallelOptions
CancellationToken = cancellationToken
},
async (channel, innerCancellationToken) =>
{
try
{ {
await progressContext.StartTaskAsync( MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
$"{channel.Category} / {channel.Name}", CancellationToken = cancellationToken
async progress => },
{ async (channel, innerCancellationToken) =>
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
var request = new ExportRequest(
guild,
channel,
OutputPath,
AssetsDirPath,
ExportFormat,
After,
Before,
PartitionLimit,
MessageFilter,
ShouldFormatMarkdown,
ShouldDownloadAssets,
ShouldReuseAssets,
DateFormat
);
await Exporter.ExportChannelAsync(
request,
progress.ToPercentageBased(),
innerCancellationToken
);
}
);
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{ {
errorsByChannel[channel] = ex.Message; try
{
await progressContext.StartTaskAsync(
$"{channel.Category} / {channel.Name}",
async progress =>
{
var guild = await Discord.GetGuildAsync(
channel.GuildId,
innerCancellationToken
);
var request = new ExportRequest(
guild,
channel,
OutputPath,
AssetsDirPath,
ExportFormat,
After,
Before,
PartitionLimit,
MessageFilter,
ShouldFormatMarkdown,
ShouldDownloadAssets,
ShouldReuseAssets,
DateFormat
);
await Exporter.ExportChannelAsync(
request,
progress.ToPercentageBased(),
innerCancellationToken
);
}
);
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{
errorsByChannel[channel] = ex.Message;
}
} }
} );
); });
});
// Print the result // Print the result
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
@ -285,8 +278,8 @@ public abstract class ExportCommandBase : DiscordCommandBase
if (channel.Kind == ChannelKind.GuildCategory) if (channel.Kind == ChannelKind.GuildCategory)
{ {
var guildChannels = var guildChannels =
channelsByGuild.GetValueOrDefault(channel.GuildId) ?? channelsByGuild.GetValueOrDefault(channel.GuildId)
await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken); ?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
foreach (var guildChannel in guildChannels) foreach (var guildChannel in guildChannels)
{ {
@ -311,18 +304,36 @@ public abstract class ExportCommandBase : DiscordCommandBase
// Support Ukraine callout // Support Ukraine callout
if (!IsUkraineSupportMessageDisabled) if (!IsUkraineSupportMessageDisabled)
{ {
console.Output.WriteLine("┌────────────────────────────────────────────────────────────────────┐"); console.Output.WriteLine(
console.Output.WriteLine("│ Thank you for supporting Ukraine <3 │"); "┌────────────────────────────────────────────────────────────────────┐"
console.Output.WriteLine("│ │"); );
console.Output.WriteLine("│ As Russia wages a genocidal war against my country, │"); console.Output.WriteLine(
console.Output.WriteLine("│ I'm grateful to everyone who continues to │"); "│ Thank you for supporting Ukraine <3 │"
console.Output.WriteLine("│ stand with Ukraine in our fight for freedom. │"); );
console.Output.WriteLine("│ │"); console.Output.WriteLine(
console.Output.WriteLine("│ Learn more: https://tyrrrz.me/ukraine │"); "│ │"
console.Output.WriteLine("└────────────────────────────────────────────────────────────────────┘"); );
console.Output.WriteLine(
"│ As Russia wages a genocidal war against my country, │"
);
console.Output.WriteLine(
"│ I'm grateful to everyone who continues to │"
);
console.Output.WriteLine(
"│ stand with Ukraine in our fight for freedom. │"
);
console.Output.WriteLine(
"│ │"
);
console.Output.WriteLine(
"│ Learn more: https://tyrrrz.me/ukraine │"
);
console.Output.WriteLine(
"└────────────────────────────────────────────────────────────────────┘"
);
console.Output.WriteLine(""); console.Output.WriteLine("");
} }
await base.ExecuteAsync(console); await base.ExecuteAsync(console);
} }
} }

@ -22,4 +22,4 @@ internal class TruthyBooleanBindingConverter : BindingConverter<bool>
return true; return true;
} }
} }

@ -16,41 +16,25 @@ namespace DiscordChatExporter.Cli.Commands;
[Command("exportall", Description = "Exports all accessible channels.")] [Command("exportall", Description = "Exports all accessible channels.")]
public class ExportAllCommand : ExportCommandBase public class ExportAllCommand : ExportCommandBase
{ {
[CommandOption( [CommandOption("include-dm", Description = "Include direct message channels.")]
"include-dm",
Description = "Include direct message channels."
)]
public bool IncludeDirectChannels { get; init; } = true; public bool IncludeDirectChannels { get; init; } = true;
[CommandOption( [CommandOption("include-guilds", Description = "Include guild channels.")]
"include-guilds",
Description = "Include guild channels."
)]
public bool IncludeGuildChannels { get; init; } = true; public bool IncludeGuildChannels { get; init; } = true;
[CommandOption( [CommandOption("include-vc", Description = "Include voice channels.")]
"include-vc",
Description = "Include voice channels."
)]
public bool IncludeVoiceChannels { get; init; } = true; public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption( [CommandOption("include-threads", Description = "Include threads.")]
"include-threads",
Description = "Include threads."
)]
public bool IncludeThreads { get; init; } = false; public bool IncludeThreads { get; init; } = false;
[CommandOption( [CommandOption("include-archived-threads", Description = "Include archived threads.")]
"include-archived-threads",
Description = "Include archived threads."
)]
public bool IncludeArchivedThreads { get; init; } = false; public bool IncludeArchivedThreads { get; init; } = false;
[CommandOption( [CommandOption(
"data-package", "data-package",
Description = Description = "Path to the personal data package (ZIP file) requested from Discord. "
"Path to the personal data package (ZIP file) requested from Discord. " + + "If provided, only channels referenced in the dump will be exported."
"If provided, only channels referenced in the dump will be exported."
)] )]
public string? DataPackageFilePath { get; init; } public string? DataPackageFilePath { get; init; }
@ -77,7 +61,9 @@ public class ExportAllCommand : ExportCommandBase
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken)) await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
{ {
// Regular channels // Regular channels
await foreach (var channel in Discord.GetGuildChannelsAsync(guild.Id, cancellationToken)) await foreach (
var channel in Discord.GetGuildChannelsAsync(guild.Id, cancellationToken)
)
{ {
if (channel.Kind == ChannelKind.GuildCategory) if (channel.Kind == ChannelKind.GuildCategory)
continue; continue;
@ -91,7 +77,13 @@ public class ExportAllCommand : ExportCommandBase
// Threads // Threads
if (IncludeThreads) if (IncludeThreads)
{ {
await foreach (var thread in Discord.GetGuildThreadsAsync(guild.Id, IncludeArchivedThreads, cancellationToken)) await foreach (
var thread in Discord.GetGuildThreadsAsync(
guild.Id,
IncludeArchivedThreads,
cancellationToken
)
)
{ {
channels.Add(thread); channels.Add(thread);
} }
@ -120,7 +112,9 @@ public class ExportAllCommand : ExportCommandBase
if (channelName is null) if (channelName is null)
continue; continue;
await console.Output.WriteLineAsync($"Fetching channel '{channelName}' ({channelId})..."); await console.Output.WriteLineAsync(
$"Fetching channel '{channelName}' ({channelId})..."
);
try try
{ {
@ -129,7 +123,9 @@ public class ExportAllCommand : ExportCommandBase
} }
catch (DiscordChatExporterException) catch (DiscordChatExporterException)
{ {
await console.Error.WriteLineAsync($"Channel '{channelName}' ({channelId}) is inaccessible."); await console.Error.WriteLineAsync(
$"Channel '{channelName}' ({channelId}) is inaccessible."
);
} }
} }
} }
@ -148,4 +144,4 @@ public class ExportAllCommand : ExportCommandBase
await ExportAsync(console, channels); await ExportAsync(console, channels);
} }
} }

@ -14,9 +14,8 @@ public class ExportChannelsCommand : ExportCommandBase
[CommandOption( [CommandOption(
"channel", "channel",
'c', 'c',
Description = Description = "Channel ID(s). "
"Channel ID(s). " + + "If provided with category ID(s), all channels inside those categories will be exported."
"If provided with category ID(s), all channels inside those categories will be exported."
)] )]
public required IReadOnlyList<Snowflake> ChannelIds { get; init; } public required IReadOnlyList<Snowflake> ChannelIds { get; init; }
@ -25,4 +24,4 @@ public class ExportChannelsCommand : ExportCommandBase
await base.ExecuteAsync(console); await base.ExecuteAsync(console);
await ExportAsync(console, ChannelIds); await ExportAsync(console, ChannelIds);
} }
} }

@ -17,8 +17,11 @@ public class ExportDirectMessagesCommand : ExportCommandBase
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Fetching channels..."); await console.Output.WriteLineAsync("Fetching channels...");
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken); var channels = await Discord.GetGuildChannelsAsync(
Guild.DirectMessages.Id,
cancellationToken
);
await ExportAsync(console, channels); await ExportAsync(console, channels);
} }
} }

@ -12,29 +12,16 @@ namespace DiscordChatExporter.Cli.Commands;
[Command("exportguild", Description = "Exports all channels within the specified guild.")] [Command("exportguild", Description = "Exports all channels within the specified guild.")]
public class ExportGuildCommand : ExportCommandBase public class ExportGuildCommand : ExportCommandBase
{ {
[CommandOption( [CommandOption("guild", 'g', Description = "Guild ID.")]
"guild",
'g',
Description = "Guild ID."
)]
public required Snowflake GuildId { get; init; } public required Snowflake GuildId { get; init; }
[CommandOption( [CommandOption("include-vc", Description = "Include voice channels.")]
"include-vc",
Description = "Include voice channels."
)]
public bool IncludeVoiceChannels { get; init; } = true; public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption( [CommandOption("include-threads", Description = "Include threads.")]
"include-threads",
Description = "Include threads."
)]
public bool IncludeThreads { get; init; } = false; public bool IncludeThreads { get; init; } = false;
[CommandOption( [CommandOption("include-archived-threads", Description = "Include archived threads.")]
"include-archived-threads",
Description = "Include archived threads."
)]
public bool IncludeArchivedThreads { get; init; } = false; public bool IncludeArchivedThreads { get; init; } = false;
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
@ -69,7 +56,13 @@ public class ExportGuildCommand : ExportCommandBase
// Threads // Threads
if (IncludeThreads) if (IncludeThreads)
{ {
await foreach (var thread in Discord.GetGuildThreadsAsync(GuildId, IncludeArchivedThreads, cancellationToken)) await foreach (
var thread in Discord.GetGuildThreadsAsync(
GuildId,
IncludeArchivedThreads,
cancellationToken
)
)
{ {
channels.Add(thread); channels.Add(thread);
} }
@ -77,4 +70,4 @@ public class ExportGuildCommand : ExportCommandBase
await ExportAsync(console, channels); await ExportAsync(console, channels);
} }
} }

@ -14,29 +14,16 @@ namespace DiscordChatExporter.Cli.Commands;
[Command("channels", Description = "Get the list of channels in a guild.")] [Command("channels", Description = "Get the list of channels in a guild.")]
public class GetChannelsCommand : DiscordCommandBase public class GetChannelsCommand : DiscordCommandBase
{ {
[CommandOption( [CommandOption("guild", 'g', Description = "Guild ID.")]
"guild",
'g',
Description = "Guild ID."
)]
public required Snowflake GuildId { get; init; } public required Snowflake GuildId { get; init; }
[CommandOption( [CommandOption("include-vc", Description = "Include voice channels.")]
"include-vc",
Description = "Include voice channels."
)]
public bool IncludeVoiceChannels { get; init; } = true; public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption( [CommandOption("include-threads", Description = "Include threads.")]
"include-threads",
Description = "Include threads."
)]
public bool IncludeThreads { get; init; } = false; public bool IncludeThreads { get; init; } = false;
[CommandOption( [CommandOption("include-archived-threads", Description = "Include archived threads.")]
"include-archived-threads",
Description = "Include archived threads."
)]
public bool IncludeArchivedThreads { get; init; } = false; public bool IncludeArchivedThreads { get; init; } = false;
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
@ -66,7 +53,13 @@ public class GetChannelsCommand : DiscordCommandBase
.FirstOrDefault(); .FirstOrDefault();
var threads = IncludeThreads var threads = IncludeThreads
? (await Discord.GetGuildThreadsAsync(GuildId, IncludeArchivedThreads, cancellationToken)) ? (
await Discord.GetGuildThreadsAsync(
GuildId,
IncludeArchivedThreads,
cancellationToken
)
)
.OrderBy(c => c.Name) .OrderBy(c => c.Name)
.ToArray() .ToArray()
: Array.Empty<Channel>(); : Array.Empty<Channel>();
@ -116,8 +109,10 @@ public class GetChannelsCommand : DiscordCommandBase
// Thread status // Thread status
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync(channelThread.IsArchived ? "Archived" : "Active"); await console.Output.WriteLineAsync(
channelThread.IsArchived ? "Archived" : "Active"
);
} }
} }
} }
} }

@ -18,7 +18,9 @@ public class GetDirectChannelsCommand : DiscordCommandBase
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
var channels = (await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken)) var channels = (
await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken)
)
.Where(c => c.Kind != ChannelKind.GuildCategory) .Where(c => c.Kind != ChannelKind.GuildCategory)
.OrderByDescending(c => c.LastMessageId) .OrderByDescending(c => c.LastMessageId)
.ThenBy(c => c.Name) .ThenBy(c => c.Name)
@ -45,4 +47,4 @@ public class GetDirectChannelsCommand : DiscordCommandBase
await console.Output.WriteLineAsync($"{channel.Category} / {channel.Name}"); await console.Output.WriteLineAsync($"{channel.Category} / {channel.Name}");
} }
} }
} }

@ -32,9 +32,7 @@ public class GetGuildsCommand : DiscordCommandBase
foreach (var guild in guilds) foreach (var guild in guilds)
{ {
// Guild ID // Guild ID
await console.Output.WriteAsync( await console.Output.WriteAsync(guild.Id.ToString().PadRight(guildIdMaxLength, ' '));
guild.Id.ToString().PadRight(guildIdMaxLength, ' ')
);
// Separator // Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray)) using (console.WithForegroundColor(ConsoleColor.DarkGray))
@ -45,4 +43,4 @@ public class GetGuildsCommand : DiscordCommandBase
await console.Output.WriteLineAsync(guild.Name); await console.Output.WriteLineAsync(guild.Name);
} }
} }
} }

@ -15,14 +15,18 @@ public class GuideCommand : ICommand
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get user token:"); console.Output.WriteLine("To get user token:");
console.Output.WriteLine(" * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!"); console.Output.WriteLine(
" * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!"
);
console.Output.WriteLine(" 1. Open Discord in your web browser and login"); console.Output.WriteLine(" 1. Open Discord in your web browser and login");
console.Output.WriteLine(" 2. Open any server or direct message channel"); console.Output.WriteLine(" 2. Open any server or direct message channel");
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools"); console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
console.Output.WriteLine(" 4. Navigate to the Network tab"); console.Output.WriteLine(" 4. Navigate to the Network tab");
console.Output.WriteLine(" 5. Press Ctrl+R to reload"); console.Output.WriteLine(" 5. Press Ctrl+R to reload");
console.Output.WriteLine(" 6. Switch between random channels to trigger network requests"); console.Output.WriteLine(" 6. Switch between random channels to trigger network requests");
console.Output.WriteLine(" 7. Search for a request containing \"messages?limit=50\" or similar"); console.Output.WriteLine(
" 7. Search for a request containing \"messages?limit=50\" or similar"
);
console.Output.WriteLine(" 8. Select the Headers tab on the right"); console.Output.WriteLine(" 8. Select the Headers tab on the right");
console.Output.WriteLine(" 9. Scroll down to the Request Headers section"); console.Output.WriteLine(" 9. Scroll down to the Request Headers section");
console.Output.WriteLine(" 10. Copy the value of the \"authorization\" header"); console.Output.WriteLine(" 10. Copy the value of the \"authorization\" header");
@ -36,7 +40,9 @@ public class GuideCommand : ICommand
console.Output.WriteLine(" 2. Open your application's settings"); console.Output.WriteLine(" 2. Open your application's settings");
console.Output.WriteLine(" 3. Navigate to the Bot section on the left"); console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
console.Output.WriteLine(" 4. Under Token click Copy"); console.Output.WriteLine(" 4. Under Token click Copy");
console.Output.WriteLine(" * Your bot needs to have Message Content Intent enabled to read messages"); console.Output.WriteLine(
" * Your bot needs to have Message Content Intent enabled to read messages"
);
console.Output.WriteLine(); console.Output.WriteLine();
// Guild or channel ID // Guild or channel ID
@ -47,15 +53,21 @@ public class GuideCommand : ICommand
console.Output.WriteLine(" 2. Open Settings"); console.Output.WriteLine(" 2. Open Settings");
console.Output.WriteLine(" 3. Go to Advanced section"); console.Output.WriteLine(" 3. Go to Advanced section");
console.Output.WriteLine(" 4. Enable Developer Mode"); console.Output.WriteLine(" 4. Enable Developer Mode");
console.Output.WriteLine(" 5. Right-click on the desired guild or channel and click Copy Server ID or Copy Channel ID"); console.Output.WriteLine(
" 5. Right-click on the desired guild or channel and click Copy Server ID or Copy Channel ID"
);
console.Output.WriteLine(); console.Output.WriteLine();
// Docs link // Docs link
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("If you have questions or issues, please refer to the documentation:"); console.Output.WriteLine(
"If you have questions or issues, please refer to the documentation:"
);
using (console.WithForegroundColor(ConsoleColor.DarkCyan)) using (console.WithForegroundColor(ConsoleColor.DarkCyan))
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"); console.Output.WriteLine(
"https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"
);
return default; return default;
} }
} }

@ -7,6 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="2.3.4" /> <PackageReference Include="CliFx" Version="2.3.4" />
<PackageReference Include="CSharpier.MsBuild" Version="0.25.0" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" /> <PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" /> <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />

@ -1,6 +1,3 @@
using CliFx; using CliFx;
return await new CliApplicationBuilder() return await new CliApplicationBuilder().AddCommandsFromThisAssembly().Build().RunAsync(args);
.AddCommandsFromThisAssembly()
.Build()
.RunAsync(args);

@ -8,34 +8,38 @@ namespace DiscordChatExporter.Cli.Utils.Extensions;
internal static class ConsoleExtensions internal static class ConsoleExtensions
{ {
public static IAnsiConsole CreateAnsiConsole(this IConsole console) => public static IAnsiConsole CreateAnsiConsole(this IConsole console) =>
AnsiConsole.Create(new AnsiConsoleSettings AnsiConsole.Create(
{ new AnsiConsoleSettings
Ansi = AnsiSupport.Detect, {
ColorSystem = ColorSystemSupport.Detect, Ansi = AnsiSupport.Detect,
Out = new AnsiConsoleOutput(console.Output) ColorSystem = ColorSystemSupport.Detect,
}); Out = new AnsiConsoleOutput(console.Output)
}
public static Progress CreateProgressTicker(this IConsole console) => console
.CreateAnsiConsole()
.Progress()
.AutoClear(false)
.AutoRefresh(true)
.HideCompleted(false)
.Columns(
new TaskDescriptionColumn {Alignment = Justify.Left},
new ProgressBarColumn(),
new PercentageColumn()
); );
public static Progress CreateProgressTicker(this IConsole console) =>
console
.CreateAnsiConsole()
.Progress()
.AutoClear(false)
.AutoRefresh(true)
.HideCompleted(false)
.Columns(
new TaskDescriptionColumn { Alignment = Justify.Left },
new ProgressBarColumn(),
new PercentageColumn()
);
public static async ValueTask StartTaskAsync( public static async ValueTask StartTaskAsync(
this ProgressContext progressContext, this ProgressContext progressContext,
string description, string description,
Func<ProgressTask, ValueTask> performOperationAsync) Func<ProgressTask, ValueTask> performOperationAsync
)
{ {
var progressTask = progressContext.AddTask( var progressTask = progressContext.AddTask(
// Don't recognize random square brackets as style tags // Don't recognize random square brackets as style tags
Markup.Escape(description), Markup.Escape(description),
new ProgressTaskSettings {MaxValue = 1} new ProgressTaskSettings { MaxValue = 1 }
); );
try try
@ -48,4 +52,4 @@ internal static class ConsoleExtensions
progressTask.StopTask(); progressTask.StopTask();
} }
} }
} }

@ -15,30 +15,31 @@ public partial record Attachment(
string? Description, string? Description,
int? Width, int? Width,
int? Height, int? Height,
FileSize FileSize) : IHasId FileSize FileSize
) : IHasId
{ {
public string FileExtension => Path.GetExtension(FileName); public string FileExtension => Path.GetExtension(FileName);
public bool IsImage => public bool IsImage =>
string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase) || string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase)
string.Equals(FileExtension, ".jpeg", StringComparison.OrdinalIgnoreCase) || || string.Equals(FileExtension, ".jpeg", StringComparison.OrdinalIgnoreCase)
string.Equals(FileExtension, ".png", StringComparison.OrdinalIgnoreCase) || || string.Equals(FileExtension, ".png", StringComparison.OrdinalIgnoreCase)
string.Equals(FileExtension, ".gif", StringComparison.OrdinalIgnoreCase) || || string.Equals(FileExtension, ".gif", StringComparison.OrdinalIgnoreCase)
string.Equals(FileExtension, ".bmp", StringComparison.OrdinalIgnoreCase) || || string.Equals(FileExtension, ".bmp", StringComparison.OrdinalIgnoreCase)
string.Equals(FileExtension, ".webp", StringComparison.OrdinalIgnoreCase); || string.Equals(FileExtension, ".webp", StringComparison.OrdinalIgnoreCase);
public bool IsVideo => public bool IsVideo =>
string.Equals(FileExtension, ".gifv", StringComparison.OrdinalIgnoreCase) || string.Equals(FileExtension, ".gifv", StringComparison.OrdinalIgnoreCase)
string.Equals(FileExtension, ".mp4", StringComparison.OrdinalIgnoreCase) || || string.Equals(FileExtension, ".mp4", StringComparison.OrdinalIgnoreCase)
string.Equals(FileExtension, ".webm", StringComparison.OrdinalIgnoreCase) || || string.Equals(FileExtension, ".webm", StringComparison.OrdinalIgnoreCase)
string.Equals(FileExtension, ".mov", StringComparison.OrdinalIgnoreCase); || string.Equals(FileExtension, ".mov", StringComparison.OrdinalIgnoreCase);
public bool IsAudio => public bool IsAudio =>
string.Equals(FileExtension, ".mp3", StringComparison.OrdinalIgnoreCase) || string.Equals(FileExtension, ".mp3", StringComparison.OrdinalIgnoreCase)
string.Equals(FileExtension, ".wav", StringComparison.OrdinalIgnoreCase) || || string.Equals(FileExtension, ".wav", StringComparison.OrdinalIgnoreCase)
string.Equals(FileExtension, ".ogg", StringComparison.OrdinalIgnoreCase) || || string.Equals(FileExtension, ".ogg", StringComparison.OrdinalIgnoreCase)
string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase) || || string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase)
string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase); || string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase);
public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal); public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
} }
@ -57,4 +58,4 @@ public partial record Attachment
return new Attachment(id, url, fileName, description, width, height, fileSize); return new Attachment(id, url, fileName, description, width, height, fileSize);
} }
} }

@ -17,24 +17,27 @@ public partial record Channel(
string? IconUrl, string? IconUrl,
string? Topic, string? Topic,
bool IsArchived, bool IsArchived,
Snowflake? LastMessageId) : IHasId Snowflake? LastMessageId
) : IHasId
{ {
// Used for visual backwards-compatibility with old exports, where // Used for visual backwards-compatibility with old exports, where
// channels without a parent (i.e. mostly DM channels) or channels // channels without a parent (i.e. mostly DM channels) or channels
// with an inaccessible parent (i.e. inside private categories) had // with an inaccessible parent (i.e. inside private categories) had
// a fallback category created for them. // a fallback category created for them.
public string Category => Parent?.Name ?? Kind switch public string Category =>
{ Parent?.Name
ChannelKind.GuildCategory => "Category", ?? Kind switch
ChannelKind.GuildTextChat => "Text", {
ChannelKind.DirectTextChat => "Private", ChannelKind.GuildCategory => "Category",
ChannelKind.DirectGroupTextChat => "Group", ChannelKind.GuildTextChat => "Text",
ChannelKind.GuildPrivateThread => "Private Thread", ChannelKind.DirectTextChat => "Private",
ChannelKind.GuildPublicThread => "Public Thread", ChannelKind.DirectGroupTextChat => "Group",
ChannelKind.GuildNews => "News", ChannelKind.GuildPrivateThread => "Private Thread",
ChannelKind.GuildNewsThread => "News Thread", ChannelKind.GuildPublicThread => "Public Thread",
_ => "Default" ChannelKind.GuildNews => "News",
}; ChannelKind.GuildNewsThread => "News Thread",
_ => "Default"
};
// Only needed for WPF data binding. Don't use anywhere else. // Only needed for WPF data binding. Don't use anywhere else.
public bool IsVoice => Kind.IsVoice(); public bool IsVoice => Kind.IsVoice();
@ -48,44 +51,41 @@ public partial record Channel
var kind = (ChannelKind)json.GetProperty("type").GetInt32(); var kind = (ChannelKind)json.GetProperty("type").GetInt32();
var guildId = var guildId =
json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse) ?? json.GetPropertyOrNull("guild_id")
Guild.DirectMessages.Id; ?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse) ?? Guild.DirectMessages.Id;
var name = var name =
// Guild channel // Guild channel
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull()
??
// DM channel // DM channel
json.GetPropertyOrNull("recipients")? json.GetPropertyOrNull("recipients")
.EnumerateArrayOrNull()? ?.EnumerateArrayOrNull()
.Select(User.Parse) ?.Select(User.Parse)
.Select(u => u.DisplayName) .Select(u => u.DisplayName)
.Pipe(s => string.Join(", ", s)) ?? .Pipe(s => string.Join(", ", s))
??
// Fallback // Fallback
id.ToString(); id.ToString();
var position = var position = positionHint ?? json.GetPropertyOrNull("position")?.GetInt32OrNull();
positionHint ??
json.GetPropertyOrNull("position")?.GetInt32OrNull();
// Icons can only be set for group DM channels // Icons can only be set for group DM channels
var iconUrl = json var iconUrl = json.GetPropertyOrNull("icon")
.GetPropertyOrNull("icon")? ?.GetNonWhiteSpaceStringOrNull()
.GetNonWhiteSpaceStringOrNull()? ?.Pipe(h => ImageCdn.GetChannelIconUrl(id, h));
.Pipe(h => ImageCdn.GetChannelIconUrl(id, h));
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull(); var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
var isArchived = json var isArchived =
.GetPropertyOrNull("thread_metadata")? json.GetPropertyOrNull("thread_metadata")
.GetPropertyOrNull("archived")? ?.GetPropertyOrNull("archived")
.GetBooleanOrNull() ?? false; ?.GetBooleanOrNull() ?? false;
var lastMessageId = json var lastMessageId = json.GetPropertyOrNull("last_message_id")
.GetPropertyOrNull("last_message_id")? ?.GetNonWhiteSpaceStringOrNull()
.GetNonWhiteSpaceStringOrNull()? ?.Pipe(Snowflake.Parse);
.Pipe(Snowflake.Parse);
return new Channel( return new Channel(
id, id,
@ -100,4 +100,4 @@ public partial record Channel
lastMessageId lastMessageId
); );
} }
} }

@ -22,12 +22,14 @@ public static class ChannelKindExtensions
public static bool IsDirect(this ChannelKind kind) => public static bool IsDirect(this ChannelKind kind) =>
kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat; kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat;
public static bool IsGuild(this ChannelKind kind) => public static bool IsGuild(this ChannelKind kind) => !kind.IsDirect();
!kind.IsDirect();
public static bool IsVoice(this ChannelKind kind) => public static bool IsVoice(this ChannelKind kind) =>
kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice; kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
public static bool IsThread(this ChannelKind kind) => public static bool IsThread(this ChannelKind kind) =>
kind is ChannelKind.GuildNewsThread or ChannelKind.GuildPublicThread or ChannelKind.GuildPrivateThread; kind
} is ChannelKind.GuildNewsThread
or ChannelKind.GuildPublicThread
or ChannelKind.GuildPrivateThread;
}

@ -41,10 +41,13 @@ public readonly partial record struct FileSize(long TotalBytes)
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public override string ToString() => public override string ToString() =>
string.Create(CultureInfo.InvariantCulture, $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}"); string.Create(
CultureInfo.InvariantCulture,
$"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}"
);
} }
public partial record struct FileSize public partial record struct FileSize
{ {
public static FileSize FromBytes(long bytes) => new(bytes); public static FileSize FromBytes(long bytes) => new(bytes);
} }

@ -3,4 +3,4 @@
public interface IHasId public interface IHasId
{ {
Snowflake Id { get; } Snowflake Id { get; }
} }

@ -19,10 +19,7 @@ public static class ImageCdn
? runes ? runes
: runes.Where(r => r.Value != 0xfe0f); : runes.Where(r => r.Value != 0xfe0f);
var twemojiId = string.Join( var twemojiId = string.Join("-", filteredRunes.Select(r => r.Value.ToString("x")));
"-",
filteredRunes.Select(r => r.Value.ToString("x"))
);
return $"https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/{twemojiId}.svg"; return $"https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/{twemojiId}.svg";
} }
@ -50,11 +47,16 @@ public static class ImageCdn
public static string GetFallbackUserAvatarUrl(int index = 0) => public static string GetFallbackUserAvatarUrl(int index = 0) =>
$"https://cdn.discordapp.com/embed/avatars/{index}.png"; $"https://cdn.discordapp.com/embed/avatars/{index}.png";
public static string GetMemberAvatarUrl(Snowflake guildId, Snowflake userId, string avatarHash, int size = 512) => public static string GetMemberAvatarUrl(
Snowflake guildId,
Snowflake userId,
string avatarHash,
int size = 512
) =>
avatarHash.StartsWith("a_", StringComparison.Ordinal) avatarHash.StartsWith("a_", StringComparison.Ordinal)
? $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{avatarHash}.gif?size={size}" ? $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{avatarHash}.gif?size={size}"
: $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{avatarHash}.png?size={size}"; : $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{avatarHash}.png?size={size}";
public static string GetStickerUrl(Snowflake stickerId, string format = "png") => public static string GetStickerUrl(Snowflake stickerId, string format = "png") =>
$"https://cdn.discordapp.com/stickers/{stickerId}.{format}"; $"https://cdn.discordapp.com/stickers/{stickerId}.{format}";
} }

@ -21,7 +21,8 @@ public partial record Embed(
EmbedImage? Thumbnail, EmbedImage? Thumbnail,
IReadOnlyList<EmbedImage> Images, IReadOnlyList<EmbedImage> Images,
EmbedVideo? Video, EmbedVideo? Video,
EmbedFooter? Footer) EmbedFooter? Footer
)
{ {
// Embeds can only have one image according to the API model, // Embeds can only have one image according to the API model,
// but the client can render multiple images in some cases. // but the client can render multiple images in some cases.
@ -41,24 +42,25 @@ public partial record Embed
var title = json.GetPropertyOrNull("title")?.GetStringOrNull(); var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
var kind = var kind =
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>() ?? json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>()
EmbedKind.Rich; ?? EmbedKind.Rich;
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull(); var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull();
var color = json var color = json.GetPropertyOrNull("color")
.GetPropertyOrNull("color")? ?.GetInt32OrNull()
.GetInt32OrNull()? ?.Pipe(System.Drawing.Color.FromArgb)
.Pipe(System.Drawing.Color.FromArgb)
.ResetAlpha(); .ResetAlpha();
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse); var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
var description = json.GetPropertyOrNull("description")?.GetStringOrNull(); var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
var fields = var fields =
json.GetPropertyOrNull("fields")?.EnumerateArrayOrNull()?.Select(EmbedField.Parse).ToArray() ?? json.GetPropertyOrNull("fields")
Array.Empty<EmbedField>(); ?.EnumerateArrayOrNull()
?.Select(EmbedField.Parse)
.ToArray() ?? Array.Empty<EmbedField>();
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse); var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
@ -70,8 +72,10 @@ public partial record Embed
// with this by merging related embeds at the end of the message parsing process. // with this by merging related embeds at the end of the message parsing process.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/695 // https://github.com/Tyrrrz/DiscordChatExporter/issues/695
var images = var images =
json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).ToSingletonEnumerable().ToArray() ?? json.GetPropertyOrNull("image")
Array.Empty<EmbedImage>(); ?.Pipe(EmbedImage.Parse)
.ToSingletonEnumerable()
.ToArray() ?? Array.Empty<EmbedImage>();
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse); var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
@ -92,4 +96,4 @@ public partial record Embed
footer footer
); );
} }
} }

@ -4,11 +4,7 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data.Embeds; namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure // https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure
public record EmbedAuthor( public record EmbedAuthor(string? Name, string? Url, string? IconUrl, string? IconProxyUrl)
string? Name,
string? Url,
string? IconUrl,
string? IconProxyUrl)
{ {
public static EmbedAuthor Parse(JsonElement json) public static EmbedAuthor Parse(JsonElement json)
{ {
@ -19,4 +15,4 @@ public record EmbedAuthor(
return new EmbedAuthor(name, url, iconUrl, iconProxyUrl); return new EmbedAuthor(name, url, iconUrl, iconProxyUrl);
} }
} }

@ -4,10 +4,7 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data.Embeds; namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure // https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure
public record EmbedField( public record EmbedField(string Name, string Value, bool IsInline)
string Name,
string Value,
bool IsInline)
{ {
public static EmbedField Parse(JsonElement json) public static EmbedField Parse(JsonElement json)
{ {
@ -17,4 +14,4 @@ public record EmbedField(
return new EmbedField(name, value, isInline); return new EmbedField(name, value, isInline);
} }
} }

@ -4,10 +4,7 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data.Embeds; namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure // https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
public record EmbedFooter( public record EmbedFooter(string Text, string? IconUrl, string? IconProxyUrl)
string Text,
string? IconUrl,
string? IconProxyUrl)
{ {
public static EmbedFooter Parse(JsonElement json) public static EmbedFooter Parse(JsonElement json)
{ {
@ -17,4 +14,4 @@ public record EmbedFooter(
return new EmbedFooter(text, iconUrl, iconProxyUrl); return new EmbedFooter(text, iconUrl, iconProxyUrl);
} }
} }

@ -4,11 +4,7 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data.Embeds; namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure // https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure
public record EmbedImage( public record EmbedImage(string? Url, string? ProxyUrl, int? Width, int? Height)
string? Url,
string? ProxyUrl,
int? Width,
int? Height)
{ {
public static EmbedImage Parse(JsonElement json) public static EmbedImage Parse(JsonElement json)
{ {
@ -19,4 +15,4 @@ public record EmbedImage(
return new EmbedImage(url, proxyUrl, width, height); return new EmbedImage(url, proxyUrl, width, height);
} }
} }

@ -8,4 +8,4 @@ public enum EmbedKind
Video, Video,
Gifv, Gifv,
Link Link
} }

@ -4,11 +4,7 @@ using System.Text.Json;
namespace DiscordChatExporter.Core.Discord.Data.Embeds; namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object-embed-video-structure // https://discord.com/developers/docs/resources/channel#embed-object-embed-video-structure
public record EmbedVideo( public record EmbedVideo(string? Url, string? ProxyUrl, int? Width, int? Height)
string? Url,
string? ProxyUrl,
int? Width,
int? Height)
{ {
public static EmbedVideo Parse(JsonElement json) public static EmbedVideo Parse(JsonElement json)
{ {
@ -19,4 +15,4 @@ public record EmbedVideo(
return new EmbedVideo(url, proxyUrl, width, height); return new EmbedVideo(url, proxyUrl, width, height);
} }
} }

@ -12,7 +12,9 @@ public partial record SpotifyTrackEmbedProjection
private static string? TryParseTrackId(string embedUrl) private static string? TryParseTrackId(string embedUrl)
{ {
// https://open.spotify.com/track/1LHZMWefF9502NPfArRfvP?si=3efac6ce9be04f0a // https://open.spotify.com/track/1LHZMWefF9502NPfArRfvP?si=3efac6ce9be04f0a
var trackId = Regex.Match(embedUrl, @"spotify\.com/track/(.*?)(?:\?|&|/|$)").Groups[1].Value; var trackId = Regex.Match(embedUrl, @"spotify\.com/track/(.*?)(?:\?|&|/|$)").Groups[
1
].Value;
if (!string.IsNullOrWhiteSpace(trackId)) if (!string.IsNullOrWhiteSpace(trackId))
return trackId; return trackId;
@ -33,4 +35,4 @@ public partial record SpotifyTrackEmbedProjection
return new SpotifyTrackEmbedProjection(trackId); return new SpotifyTrackEmbedProjection(trackId);
} }
} }

@ -21,4 +21,4 @@ public partial record YouTubeVideoEmbedProjection
return new YouTubeVideoEmbedProjection(videoId); return new YouTubeVideoEmbedProjection(videoId);
} }
} }

@ -14,12 +14,11 @@ public partial record Emoji(
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂) // Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
string Name, string Name,
bool IsAnimated, bool IsAnimated,
string ImageUrl) string ImageUrl
)
{ {
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile) // Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
public string Code => Id is not null public string Code => Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
? Name
: EmojiIndex.TryGetCode(Name) ?? Name;
} }
public partial record Emoji public partial record Emoji
@ -39,19 +38,17 @@ public partial record Emoji
public static Emoji Parse(JsonElement json) public static Emoji Parse(JsonElement json)
{ {
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse); var id = json.GetPropertyOrNull("id")
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse);
// Names may be missing on custom emoji within reactions // Names may be missing on custom emoji within reactions
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji"; var name =
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji";
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false; var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
var imageUrl = GetImageUrl(id, name, isAnimated); var imageUrl = GetImageUrl(id, name, isAnimated);
return new Emoji( return new Emoji(id, name, isAnimated, imageUrl);
id,
name,
isAnimated,
imageUrl
);
} }
} }

File diff suppressed because it is too large Load Diff

@ -9,11 +9,8 @@ namespace DiscordChatExporter.Core.Discord.Data;
public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
{ {
// Direct messages are encapsulated within a special pseudo-guild for consistency // Direct messages are encapsulated within a special pseudo-guild for consistency
public static Guild DirectMessages { get; } = new( public static Guild DirectMessages { get; } =
Snowflake.Zero, new(Snowflake.Zero, "Direct Messages", ImageCdn.GetFallbackUserAvatarUrl());
"Direct Messages",
ImageCdn.GetFallbackUserAvatarUrl()
);
public static Guild Parse(JsonElement json) public static Guild Parse(JsonElement json)
{ {
@ -21,12 +18,10 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
var name = json.GetProperty("name").GetNonNullString(); var name = json.GetProperty("name").GetNonNullString();
var iconUrl = var iconUrl =
json json.GetPropertyOrNull("icon")
.GetPropertyOrNull("icon")? ?.GetNonWhiteSpaceStringOrNull()
.GetNonWhiteSpaceStringOrNull()? ?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? ImageCdn.GetFallbackUserAvatarUrl();
.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ??
ImageCdn.GetFallbackUserAvatarUrl();
return new Guild(id, name, iconUrl); return new Guild(id, name, iconUrl);
} }
} }

@ -15,4 +15,4 @@ public record Interaction(Snowflake Id, string Name, User User)
return new Interaction(id, name, user); return new Interaction(id, name, user);
} }
} }

@ -6,10 +6,7 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data; namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/invite#invite-object // https://discord.com/developers/docs/resources/invite#invite-object
public record Invite( public record Invite(string Code, Guild Guild, Channel? Channel)
string Code,
Guild Guild,
Channel? Channel)
{ {
public static string? TryGetCodeFromUrl(string url) => public static string? TryGetCodeFromUrl(string url) =>
Regex.Match(url, @"^https?://discord\.gg/(\w+)/?$").Groups[1].Value.NullIfWhiteSpace(); Regex.Match(url, @"^https?://discord\.gg/(\w+)/?$").Groups[1].Value.NullIfWhiteSpace();
@ -22,4 +19,4 @@ public record Invite(
return new Invite(code, guild, channel); return new Invite(code, guild, channel);
} }
} }

@ -13,7 +13,8 @@ public partial record Member(
User User, User User,
string? DisplayName, string? DisplayName,
string? AvatarUrl, string? AvatarUrl,
IReadOnlyList<Snowflake> RoleIds) : IHasId IReadOnlyList<Snowflake> RoleIds
) : IHasId
{ {
public Snowflake Id => User.Id; public Snowflake Id => User.Id;
} }
@ -28,25 +29,19 @@ public partial record Member
var user = json.GetProperty("user").Pipe(User.Parse); var user = json.GetProperty("user").Pipe(User.Parse);
var displayName = json.GetPropertyOrNull("nick")?.GetNonWhiteSpaceStringOrNull(); var displayName = json.GetPropertyOrNull("nick")?.GetNonWhiteSpaceStringOrNull();
var roleIds = json var roleIds =
.GetPropertyOrNull("roles")? json.GetPropertyOrNull("roles")
.EnumerateArray() ?.EnumerateArray()
.Select(j => j.GetNonWhiteSpaceString()) .Select(j => j.GetNonWhiteSpaceString())
.Select(Snowflake.Parse) .Select(Snowflake.Parse)
.ToArray() ?? Array.Empty<Snowflake>(); .ToArray() ?? Array.Empty<Snowflake>();
var avatarUrl = guildId is not null var avatarUrl = guildId is not null
? json ? json.GetPropertyOrNull("avatar")
.GetPropertyOrNull("avatar")? ?.GetNonWhiteSpaceStringOrNull()
.GetNonWhiteSpaceStringOrNull()? ?.Pipe(h => ImageCdn.GetMemberAvatarUrl(guildId.Value, user.Id, h))
.Pipe(h => ImageCdn.GetMemberAvatarUrl(guildId.Value, user.Id, h))
: null; : null;
return new Member( return new Member(user, displayName, avatarUrl, roleIds);
user,
displayName,
avatarUrl,
roleIds
);
} }
} }

@ -27,7 +27,8 @@ public partial record Message(
IReadOnlyList<User> MentionedUsers, IReadOnlyList<User> MentionedUsers,
MessageReference? Reference, MessageReference? Reference,
Message? ReferencedMessage, Message? ReferencedMessage,
Interaction? Interaction) : IHasId Interaction? Interaction
) : IHasId
{ {
public bool IsReplyLike => Kind == MessageKind.Reply || Interaction is not null; public bool IsReplyLike => Kind == MessageKind.Reply || Interaction is not null;
@ -70,22 +71,26 @@ public partial record Message
// Find embeds with the same URL that only contain a single image and nothing else // Find embeds with the same URL that only contain a single image and nothing else
var trailingEmbeds = embeds var trailingEmbeds = embeds
.Skip(i + 1) .Skip(i + 1)
.TakeWhile(e => .TakeWhile(
e.Url == embed.Url && e =>
e.Timestamp is null && e.Url == embed.Url
e.Author is null && && e.Timestamp is null
e.Color is null && && e.Author is null
string.IsNullOrWhiteSpace(e.Description) && && e.Color is null
!e.Fields.Any() && && string.IsNullOrWhiteSpace(e.Description)
e.Images.Count == 1 && && !e.Fields.Any()
e.Footer is null && e.Images.Count == 1
&& e.Footer is null
) )
.ToArray(); .ToArray();
if (trailingEmbeds.Any()) if (trailingEmbeds.Any())
{ {
// Concatenate all images into one embed // Concatenate all images into one embed
var images = embed.Images.Concat(trailingEmbeds.SelectMany(e => e.Images)).ToArray(); var images = embed.Images
.Concat(trailingEmbeds.SelectMany(e => e.Images))
.ToArray();
normalizedEmbeds.Add(embed with { Images = images }); normalizedEmbeds.Add(embed with { Images = images });
i += trailingEmbeds.Length; i += trailingEmbeds.Length;
@ -108,42 +113,49 @@ public partial record Message
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var kind = (MessageKind)json.GetProperty("type").GetInt32(); var kind = (MessageKind)json.GetProperty("type").GetInt32();
var flags = (MessageFlags?)json.GetPropertyOrNull("flags")?.GetInt32OrNull() ?? MessageFlags.None; var flags =
(MessageFlags?)json.GetPropertyOrNull("flags")?.GetInt32OrNull() ?? MessageFlags.None;
var author = json.GetProperty("author").Pipe(User.Parse); var author = json.GetProperty("author").Pipe(User.Parse);
var timestamp = json.GetProperty("timestamp").GetDateTimeOffset(); var timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffsetOrNull(); var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffsetOrNull();
var callEndedTimestamp = json var callEndedTimestamp = json.GetPropertyOrNull("call")
.GetPropertyOrNull("call")? ?.GetPropertyOrNull("ended_timestamp")
.GetPropertyOrNull("ended_timestamp")? ?.GetDateTimeOffsetOrNull();
.GetDateTimeOffsetOrNull();
var isPinned = json.GetPropertyOrNull("pinned")?.GetBooleanOrNull() ?? false; var isPinned = json.GetPropertyOrNull("pinned")?.GetBooleanOrNull() ?? false;
var content = json.GetPropertyOrNull("content")?.GetStringOrNull() ?? ""; var content = json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "";
var attachments = var attachments =
json.GetPropertyOrNull("attachments")?.EnumerateArrayOrNull()?.Select(Attachment.Parse).ToArray() ?? json.GetPropertyOrNull("attachments")
Array.Empty<Attachment>(); ?.EnumerateArrayOrNull()
?.Select(Attachment.Parse)
.ToArray() ?? Array.Empty<Attachment>();
var embeds = NormalizeEmbeds( var embeds = NormalizeEmbeds(
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ?? json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
Array.Empty<Embed>() ?? Array.Empty<Embed>()
); );
var stickers = var stickers =
json.GetPropertyOrNull("sticker_items")?.EnumerateArrayOrNull()?.Select(Sticker.Parse).ToArray() ?? json.GetPropertyOrNull("sticker_items")
Array.Empty<Sticker>(); ?.EnumerateArrayOrNull()
?.Select(Sticker.Parse)
.ToArray() ?? Array.Empty<Sticker>();
var reactions = var reactions =
json.GetPropertyOrNull("reactions")?.EnumerateArrayOrNull()?.Select(Reaction.Parse).ToArray() ?? json.GetPropertyOrNull("reactions")
Array.Empty<Reaction>(); ?.EnumerateArrayOrNull()
?.Select(Reaction.Parse)
.ToArray() ?? Array.Empty<Reaction>();
var mentionedUsers = var mentionedUsers =
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray() ?? json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
Array.Empty<User>(); ?? Array.Empty<User>();
var messageReference = json.GetPropertyOrNull("message_reference")?.Pipe(MessageReference.Parse); var messageReference = json.GetPropertyOrNull("message_reference")
?.Pipe(MessageReference.Parse);
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse); var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse); var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
@ -167,4 +179,4 @@ public partial record Message
interaction interaction
); );
} }
} }

@ -15,4 +15,4 @@ public enum MessageFlags
HasThread = 32, HasThread = 32,
Ephemeral = 64, Ephemeral = 64,
Loading = 128 Loading = 128
} }

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

@ -9,21 +9,18 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf
{ {
public static MessageReference Parse(JsonElement json) public static MessageReference Parse(JsonElement json)
{ {
var messageId = json var messageId = json.GetPropertyOrNull("message_id")
.GetPropertyOrNull("message_id")? ?.GetNonWhiteSpaceStringOrNull()
.GetNonWhiteSpaceStringOrNull()? ?.Pipe(Snowflake.Parse);
.Pipe(Snowflake.Parse);
var channelId = json var channelId = json.GetPropertyOrNull("channel_id")
.GetPropertyOrNull("channel_id")? ?.GetNonWhiteSpaceStringOrNull()
.GetNonWhiteSpaceStringOrNull()? ?.Pipe(Snowflake.Parse);
.Pipe(Snowflake.Parse);
var guildId = json var guildId = json.GetPropertyOrNull("guild_id")
.GetPropertyOrNull("guild_id")? ?.GetNonWhiteSpaceStringOrNull()
.GetNonWhiteSpaceStringOrNull()? ?.Pipe(Snowflake.Parse);
.Pipe(Snowflake.Parse);
return new MessageReference(messageId, channelId, guildId); return new MessageReference(messageId, channelId, guildId);
} }
} }

@ -13,4 +13,4 @@ public record Reaction(Emoji Emoji, int Count)
return new Reaction(emoji, count); return new Reaction(emoji, count);
} }
} }

@ -15,13 +15,12 @@ public record Role(Snowflake Id, string Name, int Position, Color? Color) : IHas
var name = json.GetProperty("name").GetNonNullString(); var name = json.GetProperty("name").GetNonNullString();
var position = json.GetProperty("position").GetInt32(); var position = json.GetProperty("position").GetInt32();
var color = json var color = json.GetPropertyOrNull("color")
.GetPropertyOrNull("color")? ?.GetInt32OrNull()
.GetInt32OrNull()? ?.Pipe(System.Drawing.Color.FromArgb)
.Pipe(System.Drawing.Color.FromArgb)
.ResetAlpha() .ResetAlpha()
.NullIf(c => c.ToRgb() <= 0); .NullIf(c => c.ToRgb() <= 0);
return new Role(id, name, position, color); return new Role(id, name, position, color);
} }
} }

@ -15,14 +15,17 @@ public record Sticker(Snowflake Id, string Name, StickerFormat Format, string So
var name = json.GetProperty("name").GetNonNullString(); var name = json.GetProperty("name").GetNonNullString();
var format = (StickerFormat)json.GetProperty("format_type").GetInt32(); var format = (StickerFormat)json.GetProperty("format_type").GetInt32();
var sourceUrl = ImageCdn.GetStickerUrl(id, format switch var sourceUrl = ImageCdn.GetStickerUrl(
{ id,
StickerFormat.Png => "png", format switch
StickerFormat.Apng => "png", {
StickerFormat.Lottie => "json", StickerFormat.Png => "png",
_ => throw new InvalidOperationException($"Unknown sticker format '{format}'.") StickerFormat.Apng => "png",
}); StickerFormat.Lottie => "json",
_ => throw new InvalidOperationException($"Unknown sticker format '{format}'.")
}
);
return new Sticker(id, name, format, sourceUrl); return new Sticker(id, name, format, sourceUrl);
} }
} }

@ -5,4 +5,4 @@ public enum StickerFormat
Png = 1, Png = 1,
Apng = 2, Apng = 2,
Lottie = 3 Lottie = 3
} }

@ -15,18 +15,16 @@ public partial record User(
int? Discriminator, int? Discriminator,
string Name, string Name,
string DisplayName, string DisplayName,
string AvatarUrl) : IHasId string AvatarUrl
) : IHasId
{ {
public string DiscriminatorFormatted => Discriminator is not null public string DiscriminatorFormatted =>
? $"{Discriminator:0000}" Discriminator is not null ? $"{Discriminator:0000}" : "0000";
: "0000";
// This effectively represents the user's true identity. // This effectively represents the user's true identity.
// In the old system, this is formed from the username and discriminator. // In the old system, this is formed from the username and discriminator.
// In the new system, the username is already the user's unique identifier. // In the new system, the username is already the user's unique identifier.
public string FullName => Discriminator is not null public string FullName => Discriminator is not null ? $"{Name}#{DiscriminatorFormatted}" : Name;
? $"{Name}#{DiscriminatorFormatted}"
: Name;
} }
public partial record User public partial record User
@ -36,24 +34,23 @@ public partial record User
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false; var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false;
var discriminator = json var discriminator = json.GetPropertyOrNull("discriminator")
.GetPropertyOrNull("discriminator")? ?.GetNonWhiteSpaceStringOrNull()
.GetNonWhiteSpaceStringOrNull()? ?.Pipe(int.Parse)
.Pipe(int.Parse)
.NullIfDefault(); .NullIfDefault();
var name = json.GetProperty("username").GetNonNullString(); var name = json.GetProperty("username").GetNonNullString();
var displayName = json.GetPropertyOrNull("global_name")?.GetNonWhiteSpaceStringOrNull() ?? name; var displayName =
json.GetPropertyOrNull("global_name")?.GetNonWhiteSpaceStringOrNull() ?? name;
var avatarIndex = discriminator % 5 ?? (int)((id.Value >> 22) % 6); var avatarIndex = discriminator % 5 ?? (int)((id.Value >> 22) % 6);
var avatarUrl = var avatarUrl =
json json.GetPropertyOrNull("avatar")
.GetPropertyOrNull("avatar")? ?.GetNonWhiteSpaceStringOrNull()
.GetNonWhiteSpaceStringOrNull()? ?.Pipe(h => ImageCdn.GetUserAvatarUrl(id, h))
.Pipe(h => ImageCdn.GetUserAvatarUrl(id, h)) ?? ?? ImageCdn.GetFallbackUserAvatarUrl(avatarIndex);
ImageCdn.GetFallbackUserAvatarUrl(avatarIndex);
return new User(id, isBot, discriminator, name, displayName, avatarUrl); return new User(id, isBot, discriminator, name, displayName, avatarUrl);
} }
} }

@ -30,48 +30,46 @@ public class DiscordClient
private async ValueTask<HttpResponseMessage> GetResponseAsync( private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url, string url,
TokenKind tokenKind, TokenKind tokenKind,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken => return await Http.ResponseResiliencePolicy.ExecuteAsync(
{ async innerCancellationToken =>
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
// Don't validate because the token can have special characters
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
request.Headers.TryAddWithoutValidation(
"Authorization",
tokenKind == TokenKind.Bot
? $"Bot {_token}"
: _token
);
var response = await Http.Client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
innerCancellationToken
);
// If this was the last request available before hitting the rate limit,
// wait out the reset time so that future requests can succeed.
// This may add an unnecessary delay in case the user doesn't intend to
// make any more requests, but implementing a smarter solution would
// require properly keeping track of Discord's global/per-route/per-resource
// rate limits and that's just way too much effort.
// https://discord.com/developers/docs/topics/rate-limits
var remainingRequestCount = response
.Headers
.TryGetValue("X-RateLimit-Remaining")?
.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
var resetAfterDelay = response
.Headers
.TryGetValue("X-RateLimit-Reset-After")?
.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
.Pipe(TimeSpan.FromSeconds);
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
{ {
var delay = using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
// Don't validate because the token can have special characters
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
request.Headers.TryAddWithoutValidation(
"Authorization",
tokenKind == TokenKind.Bot ? $"Bot {_token}" : _token
);
var response = await Http.Client.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
innerCancellationToken
);
// If this was the last request available before hitting the rate limit,
// wait out the reset time so that future requests can succeed.
// This may add an unnecessary delay in case the user doesn't intend to
// make any more requests, but implementing a smarter solution would
// require properly keeping track of Discord's global/per-route/per-resource
// rate limits and that's just way too much effort.
// https://discord.com/developers/docs/topics/rate-limits
var remainingRequestCount = response.Headers
.TryGetValue("X-RateLimit-Remaining")
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
var resetAfterDelay = response.Headers
.TryGetValue("X-RateLimit-Reset-After")
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
.Pipe(TimeSpan.FromSeconds);
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
{
var delay =
// Adding a small buffer to the reset time reduces the chance of getting // Adding a small buffer to the reset time reduces the chance of getting
// rate limited again, because it allows for more requests to be released. // rate limited again, because it allows for more requests to be released.
(resetAfterDelay.Value + TimeSpan.FromSeconds(1)) (resetAfterDelay.Value + TimeSpan.FromSeconds(1))
@ -79,14 +77,18 @@ public class DiscordClient
// is not actually enforced by the server. So we cap it at a reasonable value. // is not actually enforced by the server. So we cap it at a reasonable value.
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60)); .Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
await Task.Delay(delay, innerCancellationToken); await Task.Delay(delay, innerCancellationToken);
} }
return response; return response;
}, cancellationToken); },
cancellationToken
);
} }
private async ValueTask<TokenKind> GetTokenKindAsync(CancellationToken cancellationToken = default) private async ValueTask<TokenKind> GetTokenKindAsync(
CancellationToken cancellationToken = default
)
{ {
// Try authenticating as a user // Try authenticating as a user
using var userResponse = await GetResponseAsync( using var userResponse = await GetResponseAsync(
@ -113,7 +115,8 @@ public class DiscordClient
private async ValueTask<HttpResponseMessage> GetResponseAsync( private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url, string url,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
var tokenKind = _resolvedTokenKind ??= await GetTokenKindAsync(cancellationToken); var tokenKind = _resolvedTokenKind ??= await GetTokenKindAsync(cancellationToken);
return await GetResponseAsync(url, tokenKind, cancellationToken); return await GetResponseAsync(url, tokenKind, cancellationToken);
@ -121,7 +124,8 @@ public class DiscordClient
private async ValueTask<JsonElement> GetJsonResponseAsync( private async ValueTask<JsonElement> GetJsonResponseAsync(
string url, string url,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
using var response = await GetResponseAsync(url, cancellationToken); using var response = await GetResponseAsync(url, cancellationToken);
@ -129,26 +133,30 @@ public class DiscordClient
{ {
throw response.StatusCode switch throw response.StatusCode switch
{ {
HttpStatusCode.Unauthorized => throw new DiscordChatExporterException( HttpStatusCode.Unauthorized
"Authentication token is invalid.", => throw new DiscordChatExporterException(
true "Authentication token is invalid.",
), true
),
HttpStatusCode.Forbidden => throw new DiscordChatExporterException(
$"Request to '{url}' failed: forbidden." HttpStatusCode.Forbidden
), => throw new DiscordChatExporterException(
$"Request to '{url}' failed: forbidden."
HttpStatusCode.NotFound => throw new DiscordChatExporterException( ),
$"Request to '{url}' failed: not found."
), HttpStatusCode.NotFound
=> throw new DiscordChatExporterException(
_ => throw new DiscordChatExporterException( $"Request to '{url}' failed: not found."
$""" ),
_
=> throw new DiscordChatExporterException(
$"""
Request to '{url}' failed: {response.StatusCode.ToString().ToSpaceSeparatedWords().ToLowerInvariant()}. Request to '{url}' failed: {response.StatusCode.ToString().ToSpaceSeparatedWords().ToLowerInvariant()}.
Response content: {await response.Content.ReadAsStringAsync(cancellationToken)} Response content: {await response.Content.ReadAsStringAsync(cancellationToken)}
""", """,
true true
) )
}; };
} }
@ -157,7 +165,8 @@ public class DiscordClient
private async ValueTask<JsonElement?> TryGetJsonResponseAsync( private async ValueTask<JsonElement?> TryGetJsonResponseAsync(
string url, string url,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
using var response = await GetResponseAsync(url, cancellationToken); using var response = await GetResponseAsync(url, cancellationToken);
return response.IsSuccessStatusCode return response.IsSuccessStatusCode
@ -167,14 +176,16 @@ public class DiscordClient
public async ValueTask<User?> TryGetUserAsync( public async ValueTask<User?> TryGetUserAsync(
Snowflake userId, Snowflake userId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
var response = await TryGetJsonResponseAsync($"users/{userId}", cancellationToken); var response = await TryGetJsonResponseAsync($"users/{userId}", cancellationToken);
return response?.Pipe(User.Parse); return response?.Pipe(User.Parse);
} }
public async IAsyncEnumerable<Guild> GetUserGuildsAsync( public async IAsyncEnumerable<Guild> GetUserGuildsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default) [EnumeratorCancellation] CancellationToken cancellationToken = default
)
{ {
yield return Guild.DirectMessages; yield return Guild.DirectMessages;
@ -206,7 +217,8 @@ public class DiscordClient
public async ValueTask<Guild> GetGuildAsync( public async ValueTask<Guild> GetGuildAsync(
Snowflake guildId, Snowflake guildId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
if (guildId == Guild.DirectMessages.Id) if (guildId == Guild.DirectMessages.Id)
return Guild.DirectMessages; return Guild.DirectMessages;
@ -217,7 +229,8 @@ public class DiscordClient
public async IAsyncEnumerable<Channel> GetGuildChannelsAsync( public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(
Snowflake guildId, Snowflake guildId,
[EnumeratorCancellation] CancellationToken cancellationToken = default) [EnumeratorCancellation] CancellationToken cancellationToken = default
)
{ {
if (guildId == Guild.DirectMessages.Id) if (guildId == Guild.DirectMessages.Id)
{ {
@ -227,7 +240,10 @@ public class DiscordClient
} }
else else
{ {
var response = await GetJsonResponseAsync($"guilds/{guildId}/channels", cancellationToken); var response = await GetJsonResponseAsync(
$"guilds/{guildId}/channels",
cancellationToken
);
var channelsJson = response var channelsJson = response
.EnumerateArray() .EnumerateArray()
@ -247,9 +263,9 @@ public class DiscordClient
foreach (var channelJson in channelsJson) foreach (var channelJson in channelsJson)
{ {
var parent = channelJson var parent = channelJson
.GetPropertyOrNull("parent_id")? .GetPropertyOrNull("parent_id")
.GetNonWhiteSpaceStringOrNull()? ?.GetNonWhiteSpaceStringOrNull()
.Pipe(Snowflake.Parse) ?.Pipe(Snowflake.Parse)
.Pipe(parentsById.GetValueOrDefault); .Pipe(parentsById.GetValueOrDefault);
yield return Channel.Parse(channelJson, parent, position); yield return Channel.Parse(channelJson, parent, position);
@ -261,7 +277,8 @@ public class DiscordClient
public async IAsyncEnumerable<Channel> GetGuildThreadsAsync( public async IAsyncEnumerable<Channel> GetGuildThreadsAsync(
Snowflake guildId, Snowflake guildId,
bool includeArchived = false, bool includeArchived = false,
[EnumeratorCancellation] CancellationToken cancellationToken = default) [EnumeratorCancellation] CancellationToken cancellationToken = default
)
{ {
if (guildId == Guild.DirectMessages.Id) if (guildId == Guild.DirectMessages.Id)
yield break; yield break;
@ -289,7 +306,9 @@ public class DiscordClient
if (response is null) if (response is null)
break; break;
foreach (var threadJson in response.Value.GetProperty("threads").EnumerateArray()) foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
{ {
yield return Channel.Parse(threadJson, channel); yield return Channel.Parse(threadJson, channel);
currentOffset++; currentOffset++;
@ -319,7 +338,9 @@ public class DiscordClient
if (response is null) if (response is null)
break; break;
foreach (var threadJson in response.Value.GetProperty("threads").EnumerateArray()) foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
{ {
yield return Channel.Parse(threadJson, channel); yield return Channel.Parse(threadJson, channel);
currentOffset++; currentOffset++;
@ -338,13 +359,16 @@ public class DiscordClient
{ {
var parentsById = channels.ToDictionary(c => c.Id); var parentsById = channels.ToDictionary(c => c.Id);
var response = await GetJsonResponseAsync($"guilds/{guildId}/threads/active", cancellationToken); var response = await GetJsonResponseAsync(
$"guilds/{guildId}/threads/active",
cancellationToken
);
foreach (var threadJson in response.GetProperty("threads").EnumerateArray()) foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
{ {
var parent = threadJson var parent = threadJson
.GetPropertyOrNull("parent_id")? .GetPropertyOrNull("parent_id")
.GetNonWhiteSpaceStringOrNull()? ?.GetNonWhiteSpaceStringOrNull()
.Pipe(Snowflake.Parse) ?.Pipe(Snowflake.Parse)
.Pipe(parentsById.GetValueOrDefault); .Pipe(parentsById.GetValueOrDefault);
yield return Channel.Parse(threadJson, parent); yield return Channel.Parse(threadJson, parent);
@ -384,7 +408,8 @@ public class DiscordClient
public async IAsyncEnumerable<Role> GetGuildRolesAsync( public async IAsyncEnumerable<Role> GetGuildRolesAsync(
Snowflake guildId, Snowflake guildId,
[EnumeratorCancellation] CancellationToken cancellationToken = default) [EnumeratorCancellation] CancellationToken cancellationToken = default
)
{ {
if (guildId == Guild.DirectMessages.Id) if (guildId == Guild.DirectMessages.Id)
yield break; yield break;
@ -397,18 +422,23 @@ public class DiscordClient
public async ValueTask<Member?> TryGetGuildMemberAsync( public async ValueTask<Member?> TryGetGuildMemberAsync(
Snowflake guildId, Snowflake guildId,
Snowflake memberId, Snowflake memberId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
if (guildId == Guild.DirectMessages.Id) if (guildId == Guild.DirectMessages.Id)
return null; return null;
var response = await TryGetJsonResponseAsync($"guilds/{guildId}/members/{memberId}", cancellationToken); var response = await TryGetJsonResponseAsync(
$"guilds/{guildId}/members/{memberId}",
cancellationToken
);
return response?.Pipe(j => Member.Parse(j, guildId)); return response?.Pipe(j => Member.Parse(j, guildId));
} }
public async ValueTask<Invite?> TryGetInviteAsync( public async ValueTask<Invite?> TryGetInviteAsync(
string code, string code,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
var response = await TryGetJsonResponseAsync($"invites/{code}", cancellationToken); var response = await TryGetJsonResponseAsync($"invites/{code}", cancellationToken);
return response?.Pipe(Invite.Parse); return response?.Pipe(Invite.Parse);
@ -416,14 +446,15 @@ public class DiscordClient
public async ValueTask<Channel> GetChannelAsync( public async ValueTask<Channel> GetChannelAsync(
Snowflake channelId, Snowflake channelId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken); var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
var parentId = response var parentId = response
.GetPropertyOrNull("parent_id")? .GetPropertyOrNull("parent_id")
.GetNonWhiteSpaceStringOrNull()? ?.GetNonWhiteSpaceStringOrNull()
.Pipe(Snowflake.Parse); ?.Pipe(Snowflake.Parse);
try try
{ {
@ -445,7 +476,8 @@ public class DiscordClient
private async ValueTask<Message?> TryGetLastMessageAsync( private async ValueTask<Message?> TryGetLastMessageAsync(
Snowflake channelId, Snowflake channelId,
Snowflake? before = null, Snowflake? before = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
var url = new UrlBuilder() var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages") .SetPath($"channels/{channelId}/messages")
@ -462,7 +494,8 @@ public class DiscordClient
Snowflake? after = null, Snowflake? after = null,
Snowflake? before = null, Snowflake? before = null,
IProgress<Percentage>? progress = null, IProgress<Percentage>? progress = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default) [EnumeratorCancellation] CancellationToken cancellationToken = default
)
{ {
// Get the last message in the specified range, so we can later calculate the // Get the last message in the specified range, so we can later calculate the
// progress based on the difference between message timestamps. // progress based on the difference between message timestamps.
@ -511,13 +544,15 @@ public class DiscordClient
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration(); var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration(); var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
progress.Report(Percentage.FromFraction( progress.Report(
// Avoid division by zero if all messages have the exact same timestamp Percentage.FromFraction(
// (which happens when there's only one message in the channel) // Avoid division by zero if all messages have the exact same timestamp
totalDuration > TimeSpan.Zero // (which happens when there's only one message in the channel)
? exportedDuration / totalDuration totalDuration > TimeSpan.Zero
: 1 ? exportedDuration / totalDuration
)); : 1
)
);
} }
yield return message; yield return message;
@ -530,7 +565,8 @@ public class DiscordClient
Snowflake channelId, Snowflake channelId,
Snowflake messageId, Snowflake messageId,
Emoji emoji, Emoji emoji,
[EnumeratorCancellation] CancellationToken cancellationToken = default) [EnumeratorCancellation] CancellationToken cancellationToken = default
)
{ {
var reactionName = emoji.Id is not null var reactionName = emoji.Id is not null
// Custom emoji // Custom emoji
@ -542,7 +578,9 @@ public class DiscordClient
while (true) while (true)
{ {
var url = new UrlBuilder() var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages/{messageId}/reactions/{Uri.EscapeDataString(reactionName)}") .SetPath(
$"channels/{channelId}/messages/{messageId}/reactions/{Uri.EscapeDataString(reactionName)}"
)
.SetQueryParameter("limit", "100") .SetQueryParameter("limit", "100")
.SetQueryParameter("after", currentAfter.ToString()) .SetQueryParameter("after", currentAfter.ToString())
.Build(); .Build();
@ -565,4 +603,4 @@ public class DiscordClient
yield break; yield break;
} }
} }
} }

@ -6,9 +6,10 @@ namespace DiscordChatExporter.Core.Discord;
public readonly partial record struct Snowflake(ulong Value) public readonly partial record struct Snowflake(ulong Value)
{ {
public DateTimeOffset ToDate() => DateTimeOffset.FromUnixTimeMilliseconds( public DateTimeOffset ToDate() =>
(long)((Value >> 22) + 1420070400000UL) DateTimeOffset
).ToLocalTime(); .FromUnixTimeMilliseconds((long)((Value >> 22) + 1420070400000UL))
.ToLocalTime();
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); public override string ToString() => Value.ToString(CultureInfo.InvariantCulture);
@ -18,9 +19,8 @@ public partial record struct Snowflake
{ {
public static Snowflake Zero { get; } = new(0); public static Snowflake Zero { get; } = new(0);
public static Snowflake FromDate(DateTimeOffset instant) => new( public static Snowflake FromDate(DateTimeOffset instant) =>
((ulong)instant.ToUnixTimeMilliseconds() - 1420070400000UL) << 22 new(((ulong)instant.ToUnixTimeMilliseconds() - 1420070400000UL) << 22);
);
public static Snowflake? TryParse(string? str, IFormatProvider? formatProvider = null) public static Snowflake? TryParse(string? str, IFormatProvider? formatProvider = null)
{ {
@ -59,4 +59,4 @@ public partial record struct Snowflake : IComparable<Snowflake>, IComparable
public static bool operator >(Snowflake left, Snowflake right) => left.CompareTo(right) > 0; public static bool operator >(Snowflake left, Snowflake right) => left.CompareTo(right) > 0;
public static bool operator <(Snowflake left, Snowflake right) => left.CompareTo(right) < 0; public static bool operator <(Snowflake left, Snowflake right) => left.CompareTo(right) < 0;
} }

@ -4,4 +4,4 @@ public enum TokenKind
{ {
User, User,
Bot Bot
} }

@ -2,6 +2,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncKeyedLock" Version="6.2.1" /> <PackageReference Include="AsyncKeyedLock" Version="6.2.1" />
<PackageReference Include="CSharpier.MsBuild" Version="0.25.0" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Polly" Version="7.2.4" /> <PackageReference Include="Polly" Version="7.2.4" />

@ -11,4 +11,4 @@ public class DiscordChatExporterException : Exception
{ {
IsFatal = isFatal; IsFatal = isFatal;
} }
} }

@ -16,14 +16,13 @@ public class ChannelExporter
public async ValueTask ExportChannelAsync( public async ValueTask ExportChannelAsync(
ExportRequest request, ExportRequest request,
IProgress<Percentage>? progress = null, IProgress<Percentage>? progress = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
// Check if the channel is empty // Check if the channel is empty
if (request.Channel.LastMessageId is null) if (request.Channel.LastMessageId is null)
{ {
throw new DiscordChatExporterException( throw new DiscordChatExporterException("Channel does not contain any messages.");
"Channel does not contain any messages."
);
} }
// Check if the 'after' boundary is valid // Check if the 'after' boundary is valid
@ -40,12 +39,15 @@ public class ChannelExporter
// Export messages // Export messages
await using var messageExporter = new MessageExporter(context); await using var messageExporter = new MessageExporter(context);
await foreach (var message in _discord.GetMessagesAsync( await foreach (
request.Channel.Id, var message in _discord.GetMessagesAsync(
request.After, request.Channel.Id,
request.Before, request.After,
progress, request.Before,
cancellationToken)) progress,
cancellationToken
)
)
{ {
// Resolve members for referenced users // Resolve members for referenced users
foreach (var user in message.GetReferencedUsers()) foreach (var user in message.GetReferencedUsers())
@ -64,4 +66,4 @@ public class ChannelExporter
); );
} }
} }
} }

@ -20,17 +20,20 @@ internal partial class CsvMessageWriter : MessageWriter
private async ValueTask<string> FormatMarkdownAsync( private async ValueTask<string> FormatMarkdownAsync(
string markdown, string markdown,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default
) =>
Context.Request.ShouldFormatMarkdown Context.Request.ShouldFormatMarkdown
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken) ? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
: markdown; : markdown;
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) => public override async ValueTask WritePreambleAsync(
await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions"); CancellationToken cancellationToken = default
) => await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
private async ValueTask WriteAttachmentsAsync( private async ValueTask WriteAttachmentsAsync(
IReadOnlyList<Attachment> attachments, IReadOnlyList<Attachment> attachments,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
var buffer = new StringBuilder(); var buffer = new StringBuilder();
@ -48,7 +51,8 @@ internal partial class CsvMessageWriter : MessageWriter
private async ValueTask WriteReactionsAsync( private async ValueTask WriteReactionsAsync(
IReadOnlyList<Reaction> reactions, IReadOnlyList<Reaction> reactions,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
var buffer = new StringBuilder(); var buffer = new StringBuilder();
@ -70,7 +74,8 @@ internal partial class CsvMessageWriter : MessageWriter
public override async ValueTask WriteMessageAsync( public override async ValueTask WriteMessageAsync(
Message message, Message message,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
await base.WriteMessageAsync(message, cancellationToken); await base.WriteMessageAsync(message, cancellationToken);
@ -89,15 +94,13 @@ internal partial class CsvMessageWriter : MessageWriter
// Message content // Message content
if (message.Kind.IsSystemNotification()) if (message.Kind.IsSystemNotification())
{ {
await _writer.WriteAsync(CsvEncode( await _writer.WriteAsync(CsvEncode(message.GetFallbackContent()));
message.GetFallbackContent()
));
} }
else else
{ {
await _writer.WriteAsync(CsvEncode( await _writer.WriteAsync(
await FormatMarkdownAsync(message.Content, cancellationToken) CsvEncode(await FormatMarkdownAsync(message.Content, cancellationToken))
)); );
} }
await _writer.WriteAsync(','); await _writer.WriteAsync(',');
@ -127,4 +130,4 @@ internal partial class CsvMessageWriter
value = value.Replace("\"", "\"\""); value = value.Replace("\"", "\"\"");
return $"\"{value}\""; return $"\"{value}\"";
} }
} }

@ -15,11 +15,12 @@ namespace DiscordChatExporter.Core.Exporting;
internal partial class ExportAssetDownloader internal partial class ExportAssetDownloader
{ {
private static readonly AsyncKeyedLocker<string> Locker = new(o => private static readonly AsyncKeyedLocker<string> Locker =
{ new(o =>
o.PoolSize = 20; {
o.PoolInitialFill = 1; o.PoolSize = 20;
}); o.PoolInitialFill = 1;
});
private readonly string _workingDirPath; private readonly string _workingDirPath;
private readonly bool _reuse; private readonly bool _reuse;
@ -33,7 +34,10 @@ internal partial class ExportAssetDownloader
_reuse = reuse; _reuse = reuse;
} }
public async ValueTask<string> DownloadAsync(string url, CancellationToken cancellationToken = default) public async ValueTask<string> DownloadAsync(
string url,
CancellationToken cancellationToken = default
)
{ {
var fileName = GetFileNameFromUrl(url); var fileName = GetFileNameFromUrl(url);
var filePath = Path.Combine(_workingDirPath, fileName); var filePath = Path.Combine(_workingDirPath, fileName);
@ -59,11 +63,19 @@ internal partial class ExportAssetDownloader
// Try to set the file date according to the last-modified header // Try to set the file date according to the last-modified header
try try
{ {
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s => var lastModified = response.Content.Headers
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var instant) .TryGetValue("Last-Modified")
? instant ?.Pipe(
: (DateTimeOffset?)null s =>
); DateTimeOffset.TryParse(
s,
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var instant
)
? instant
: (DateTimeOffset?)null
);
if (lastModified is not null) if (lastModified is not null)
{ {
@ -86,11 +98,12 @@ internal partial class ExportAssetDownloader
internal partial class ExportAssetDownloader internal partial class ExportAssetDownloader
{ {
private static string GetUrlHash(string url) => SHA256 private static string GetUrlHash(string url) =>
.HashData(Encoding.UTF8.GetBytes(url)) SHA256
.ToHex() .HashData(Encoding.UTF8.GetBytes(url))
// 5 chars ought to be enough for anybody .ToHex()
.Truncate(5); // 5 chars ought to be enough for anybody
.Truncate(5);
private static string GetFileNameFromUrl(string url) private static string GetFileNameFromUrl(string url)
{ {
@ -115,6 +128,8 @@ internal partial class ExportAssetDownloader
fileExtension = ""; fileExtension = "";
} }
return PathEx.EscapeFileName(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension); return PathEx.EscapeFileName(
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
);
} }
} }

@ -23,8 +23,7 @@ internal class ExportContext
public ExportRequest Request { get; } public ExportRequest Request { get; }
public ExportContext(DiscordClient discord, public ExportContext(DiscordClient discord, ExportRequest request)
ExportRequest request)
{ {
Discord = discord; Discord = discord;
Request = request; Request = request;
@ -35,9 +34,13 @@ internal class ExportContext
); );
} }
public async ValueTask PopulateChannelsAndRolesAsync(CancellationToken cancellationToken = default) public async ValueTask PopulateChannelsAndRolesAsync(
CancellationToken cancellationToken = default
)
{ {
await foreach (var channel in Discord.GetGuildChannelsAsync(Request.Guild.Id, cancellationToken)) await foreach (
var channel in Discord.GetGuildChannelsAsync(Request.Guild.Id, cancellationToken)
)
_channelsById[channel.Id] = channel; _channelsById[channel.Id] = channel;
await foreach (var role in Discord.GetGuildRolesAsync(Request.Guild.Id, cancellationToken)) await foreach (var role in Discord.GetGuildRolesAsync(Request.Guild.Id, cancellationToken))
@ -48,7 +51,8 @@ internal class ExportContext
private async ValueTask PopulateMemberAsync( private async ValueTask PopulateMemberAsync(
Snowflake id, Snowflake id,
User? fallbackUser, User? fallbackUser,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
if (_membersById.ContainsKey(id)) if (_membersById.ContainsKey(id))
return; return;
@ -70,18 +74,23 @@ internal class ExportContext
_membersById[id] = member; _membersById[id] = member;
} }
public async ValueTask PopulateMemberAsync(Snowflake id, CancellationToken cancellationToken = default) => public async ValueTask PopulateMemberAsync(
await PopulateMemberAsync(id, null, cancellationToken); Snowflake id,
CancellationToken cancellationToken = default
) => await PopulateMemberAsync(id, null, cancellationToken);
public async ValueTask PopulateMemberAsync(User user, CancellationToken cancellationToken = default) => public async ValueTask PopulateMemberAsync(
await PopulateMemberAsync(user.Id, user, cancellationToken); User user,
CancellationToken cancellationToken = default
) => await PopulateMemberAsync(user.Id, user, cancellationToken);
public string FormatDate(DateTimeOffset instant) => Request.DateFormat switch public string FormatDate(DateTimeOffset instant) =>
{ Request.DateFormat switch
"unix" => instant.ToUnixTimeSeconds().ToString(), {
"unixms" => instant.ToUnixTimeMilliseconds().ToString(), "unix" => instant.ToUnixTimeSeconds().ToString(),
var format => instant.ToLocalString(format) "unixms" => instant.ToUnixTimeMilliseconds().ToString(),
}; var format => instant.ToLocalString(format)
};
public Member? TryGetMember(Snowflake id) => _membersById.GetValueOrDefault(id); public Member? TryGetMember(Snowflake id) => _membersById.GetValueOrDefault(id);
@ -89,19 +98,20 @@ internal class ExportContext
public Role? TryGetRole(Snowflake id) => _rolesById.GetValueOrDefault(id); public Role? TryGetRole(Snowflake id) => _rolesById.GetValueOrDefault(id);
public IReadOnlyList<Role> GetUserRoles(Snowflake id) => TryGetMember(id)? public IReadOnlyList<Role> GetUserRoles(Snowflake id) =>
.RoleIds TryGetMember(id)?.RoleIds
.Select(TryGetRole) .Select(TryGetRole)
.WhereNotNull() .WhereNotNull()
.OrderByDescending(r => r.Position) .OrderByDescending(r => r.Position)
.ToArray() ?? Array.Empty<Role>(); .ToArray() ?? Array.Empty<Role>();
public Color? TryGetUserColor(Snowflake id) => GetUserRoles(id) public Color? TryGetUserColor(Snowflake id) =>
.Where(r => r.Color is not null) GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
.Select(r => r.Color)
.FirstOrDefault();
public async ValueTask<string> ResolveAssetUrlAsync(string url, CancellationToken cancellationToken = default) public async ValueTask<string> ResolveAssetUrlAsync(
string url,
CancellationToken cancellationToken = default
)
{ {
if (!Request.ShouldDownloadAssets) if (!Request.ShouldDownloadAssets)
return url; return url;
@ -114,8 +124,14 @@ internal class ExportContext
// Prefer relative paths so that the output files can be copied around without breaking references. // Prefer relative paths so that the output files can be copied around without breaking references.
// If the asset directory is outside of the export directory, use an absolute path instead. // If the asset directory is outside of the export directory, use an absolute path instead.
var optimalFilePath = var optimalFilePath =
relativeFilePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || relativeFilePath.StartsWith(
relativeFilePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) ".." + Path.DirectorySeparatorChar,
StringComparison.Ordinal
)
|| relativeFilePath.StartsWith(
".." + Path.AltDirectorySeparatorChar,
StringComparison.Ordinal
)
? filePath ? filePath
: relativeFilePath; : relativeFilePath;
@ -138,4 +154,4 @@ internal class ExportContext
return url; return url;
} }
} }
} }

@ -13,23 +13,25 @@ public enum ExportFormat
public static class ExportFormatExtensions public static class ExportFormatExtensions
{ {
public static string GetFileExtension(this ExportFormat format) => format switch public static string GetFileExtension(this ExportFormat format) =>
{ format switch
ExportFormat.PlainText => "txt", {
ExportFormat.HtmlDark => "html", ExportFormat.PlainText => "txt",
ExportFormat.HtmlLight => "html", ExportFormat.HtmlDark => "html",
ExportFormat.Csv => "csv", ExportFormat.HtmlLight => "html",
ExportFormat.Json => "json", ExportFormat.Csv => "csv",
_ => throw new ArgumentOutOfRangeException(nameof(format)) ExportFormat.Json => "json",
}; _ => throw new ArgumentOutOfRangeException(nameof(format))
};
public static string GetDisplayName(this ExportFormat format) => format switch public static string GetDisplayName(this ExportFormat format) =>
{ format switch
ExportFormat.PlainText => "TXT", {
ExportFormat.HtmlDark => "HTML (Dark)", ExportFormat.PlainText => "TXT",
ExportFormat.HtmlLight => "HTML (Light)", ExportFormat.HtmlDark => "HTML (Dark)",
ExportFormat.Csv => "CSV", ExportFormat.HtmlLight => "HTML (Light)",
ExportFormat.Json => "JSON", ExportFormat.Csv => "CSV",
_ => throw new ArgumentOutOfRangeException(nameof(format)) ExportFormat.Json => "JSON",
}; _ => throw new ArgumentOutOfRangeException(nameof(format))
} };
}

@ -54,7 +54,8 @@ public partial class ExportRequest
bool shouldFormatMarkdown, bool shouldFormatMarkdown,
bool shouldDownloadAssets, bool shouldDownloadAssets,
bool shouldReuseAssets, bool shouldReuseAssets,
string dateFormat) string dateFormat
)
{ {
Guild = guild; Guild = guild;
Channel = channel; Channel = channel;
@ -68,25 +69,12 @@ public partial class ExportRequest
ShouldReuseAssets = shouldReuseAssets; ShouldReuseAssets = shouldReuseAssets;
DateFormat = dateFormat; DateFormat = dateFormat;
OutputFilePath = GetOutputBaseFilePath( OutputFilePath = GetOutputBaseFilePath(Guild, Channel, outputPath, Format, After, Before);
Guild,
Channel,
outputPath,
Format,
After,
Before
);
OutputDirPath = Path.GetDirectoryName(OutputFilePath)!; OutputDirPath = Path.GetDirectoryName(OutputFilePath)!;
AssetsDirPath = !string.IsNullOrWhiteSpace(assetsDirPath) AssetsDirPath = !string.IsNullOrWhiteSpace(assetsDirPath)
? FormatPath( ? FormatPath(assetsDirPath, Guild, Channel, After, Before)
assetsDirPath,
Guild,
Channel,
After,
Before
)
: $"{OutputFilePath}_Files{Path.DirectorySeparatorChar}"; : $"{OutputFilePath}_Files{Path.DirectorySeparatorChar}";
} }
} }
@ -98,7 +86,8 @@ public partial class ExportRequest
Channel channel, Channel channel,
ExportFormat format, ExportFormat format,
Snowflake? after = null, Snowflake? after = null,
Snowflake? before = null) Snowflake? before = null
)
{ {
var buffer = new StringBuilder(); var buffer = new StringBuilder();
@ -113,7 +102,9 @@ public partial class ExportRequest
// Both 'after' and 'before' are set // Both 'after' and 'before' are set
if (after is not null && before is not null) if (after is not null && before is not null)
{ {
buffer.Append($"{after.Value.ToDate():yyyy-MM-dd} to {before.Value.ToDate():yyyy-MM-dd}"); buffer.Append(
$"{after.Value.ToDate():yyyy-MM-dd} to {before.Value.ToDate():yyyy-MM-dd}"
);
} }
// Only 'after' is set // Only 'after' is set
else if (after is not null) else if (after is not null)
@ -140,27 +131,41 @@ public partial class ExportRequest
Guild guild, Guild guild,
Channel channel, Channel channel,
Snowflake? after, Snowflake? after,
Snowflake? before) Snowflake? before
)
{ {
return Regex.Replace( return Regex.Replace(
path, path,
"%.", "%.",
m => PathEx.EscapeFileName(m.Value switch m =>
{ PathEx.EscapeFileName(
"%g" => guild.Id.ToString(), m.Value switch
"%G" => guild.Name, {
"%t" => channel.Parent?.Id.ToString() ?? "", "%g" => guild.Id.ToString(),
"%T" => channel.Parent?.Name ?? "", "%G" => guild.Name,
"%c" => channel.Id.ToString(), "%t" => channel.Parent?.Id.ToString() ?? "",
"%C" => channel.Name, "%T" => channel.Parent?.Name ?? "",
"%p" => channel.Position?.ToString(CultureInfo.InvariantCulture) ?? "0", "%c" => channel.Id.ToString(),
"%P" => channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture) ?? "0", "%C" => channel.Name,
"%a" => after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "", "%p" => channel.Position?.ToString(CultureInfo.InvariantCulture) ?? "0",
"%b" => before?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "", "%P"
"%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), => channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture)
"%%" => "%", ?? "0",
_ => m.Value "%a"
}) => after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
?? "",
"%b"
=> before?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
?? "",
"%d"
=> DateTimeOffset.Now.ToString(
"yyyy-MM-dd",
CultureInfo.InvariantCulture
),
"%%" => "%",
_ => m.Value
}
)
); );
} }
@ -170,12 +175,16 @@ public partial class ExportRequest
string outputPath, string outputPath,
ExportFormat format, ExportFormat format,
Snowflake? after = null, Snowflake? after = null,
Snowflake? before = null) Snowflake? before = null
)
{ {
var actualOutputPath = FormatPath(outputPath, guild, channel, after, before); var actualOutputPath = FormatPath(outputPath, guild, channel, after, before);
// Output is a directory // Output is a directory
if (Directory.Exists(actualOutputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(actualOutputPath))) if (
Directory.Exists(actualOutputPath)
|| string.IsNullOrWhiteSpace(Path.GetExtension(actualOutputPath))
)
{ {
var fileName = GetDefaultOutputFileName(guild, channel, format, after, before); var fileName = GetDefaultOutputFileName(guild, channel, format, after, before);
return Path.Combine(actualOutputPath, fileName); return Path.Combine(actualOutputPath, fileName);
@ -184,4 +193,4 @@ public partial class ExportRequest
// Output is a file // Output is a file
return actualOutputPath; return actualOutputPath;
} }
} }

@ -4,4 +4,4 @@ internal enum BinaryExpressionKind
{ {
Or, Or,
And And
} }

@ -9,17 +9,22 @@ internal class BinaryExpressionMessageFilter : MessageFilter
private readonly MessageFilter _second; private readonly MessageFilter _second;
private readonly BinaryExpressionKind _kind; private readonly BinaryExpressionKind _kind;
public BinaryExpressionMessageFilter(MessageFilter first, MessageFilter second, BinaryExpressionKind kind) public BinaryExpressionMessageFilter(
MessageFilter first,
MessageFilter second,
BinaryExpressionKind kind
)
{ {
_first = first; _first = first;
_second = second; _second = second;
_kind = kind; _kind = kind;
} }
public override bool IsMatch(Message message) => _kind switch public override bool IsMatch(Message message) =>
{ _kind switch
BinaryExpressionKind.Or => _first.IsMatch(message) || _second.IsMatch(message), {
BinaryExpressionKind.And => _first.IsMatch(message) && _second.IsMatch(message), BinaryExpressionKind.Or => _first.IsMatch(message) || _second.IsMatch(message),
_ => throw new InvalidOperationException($"Unknown binary expression kind '{_kind}'.") BinaryExpressionKind.And => _first.IsMatch(message) && _second.IsMatch(message),
}; _ => throw new InvalidOperationException($"Unknown binary expression kind '{_kind}'.")
} };
}

@ -17,25 +17,21 @@ internal class ContainsMessageFilter : MessageFilter
// parentheses are not considered word characters. // parentheses are not considered word characters.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/909 // https://github.com/Tyrrrz/DiscordChatExporter/issues/909
private bool IsMatch(string? content) => private bool IsMatch(string? content) =>
!string.IsNullOrWhiteSpace(content) && !string.IsNullOrWhiteSpace(content)
Regex.IsMatch( && Regex.IsMatch(
content, content,
@"(?:\b|\s|^)" + @"(?:\b|\s|^)" + Regex.Escape(_text) + @"(?:\b|\s|$)",
Regex.Escape(_text) +
@"(?:\b|\s|$)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
); );
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
IsMatch(message.Content) || IsMatch(message.Content)
message.Embeds.Any(e => || message.Embeds.Any(
IsMatch(e.Title) || e =>
IsMatch(e.Author?.Name) || IsMatch(e.Title)
IsMatch(e.Description) || || IsMatch(e.Author?.Name)
IsMatch(e.Footer?.Text) || || IsMatch(e.Description)
e.Fields.Any(f => || IsMatch(e.Footer?.Text)
IsMatch(f.Name) || || e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
IsMatch(f.Value)
)
); );
} }

@ -10,8 +10,8 @@ internal class FromMessageFilter : MessageFilter
public FromMessageFilter(string value) => _value = value; public FromMessageFilter(string value) => _value = value;
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
string.Equals(_value, message.Author.Name, StringComparison.OrdinalIgnoreCase) || string.Equals(_value, message.Author.Name, StringComparison.OrdinalIgnoreCase)
string.Equals(_value, message.Author.DisplayName, StringComparison.OrdinalIgnoreCase) || || string.Equals(_value, message.Author.DisplayName, StringComparison.OrdinalIgnoreCase)
string.Equals(_value, message.Author.FullName, StringComparison.OrdinalIgnoreCase) || || string.Equals(_value, message.Author.FullName, StringComparison.OrdinalIgnoreCase)
string.Equals(_value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase); || string.Equals(_value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase);
} }

@ -11,15 +11,20 @@ internal class HasMessageFilter : MessageFilter
public HasMessageFilter(MessageContentMatchKind kind) => _kind = kind; public HasMessageFilter(MessageContentMatchKind kind) => _kind = kind;
public override bool IsMatch(Message message) => _kind switch public override bool IsMatch(Message message) =>
{ _kind switch
MessageContentMatchKind.Link => Regex.IsMatch(message.Content, "https?://\\S*[^\\.,:;\"\'\\s]"), {
MessageContentMatchKind.Embed => message.Embeds.Any(), MessageContentMatchKind.Link
MessageContentMatchKind.File => message.Attachments.Any(), => Regex.IsMatch(message.Content, "https?://\\S*[^\\.,:;\"\'\\s]"),
MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo), MessageContentMatchKind.Embed => message.Embeds.Any(),
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage), MessageContentMatchKind.File => message.Attachments.Any(),
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio), MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo),
MessageContentMatchKind.Pin => message.IsPinned, MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
_ => throw new InvalidOperationException($"Unknown message content match kind '{_kind}'.") MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
}; MessageContentMatchKind.Pin => message.IsPinned,
} _
=> throw new InvalidOperationException(
$"Unknown message content match kind '{_kind}'."
)
};
}

@ -10,10 +10,12 @@ internal class MentionsMessageFilter : MessageFilter
public MentionsMessageFilter(string value) => _value = value; public MentionsMessageFilter(string value) => _value = value;
public override bool IsMatch(Message message) => message.MentionedUsers.Any(user => public override bool IsMatch(Message message) =>
string.Equals(_value, user.Name, StringComparison.OrdinalIgnoreCase) || message.MentionedUsers.Any(
string.Equals(_value, user.DisplayName, StringComparison.OrdinalIgnoreCase) || user =>
string.Equals(_value, user.FullName, StringComparison.OrdinalIgnoreCase) || string.Equals(_value, user.Name, StringComparison.OrdinalIgnoreCase)
string.Equals(_value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase) || string.Equals(_value, user.DisplayName, StringComparison.OrdinalIgnoreCase)
); || string.Equals(_value, user.FullName, StringComparison.OrdinalIgnoreCase)
} || string.Equals(_value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
);
}

@ -9,4 +9,4 @@ internal enum MessageContentMatchKind
Image, Image,
Sound, Sound,
Pin Pin
} }

@ -14,4 +14,4 @@ public partial class MessageFilter
public static MessageFilter Null { get; } = new NullMessageFilter(); public static MessageFilter Null { get; } = new NullMessageFilter();
public static MessageFilter Parse(string value) => FilterGrammar.Filter.Parse(value); public static MessageFilter Parse(string value) => FilterGrammar.Filter.Parse(value);
} }

@ -9,4 +9,4 @@ internal class NegatedMessageFilter : MessageFilter
public NegatedMessageFilter(MessageFilter filter) => _filter = filter; public NegatedMessageFilter(MessageFilter filter) => _filter = filter;
public override bool IsMatch(Message message) => !_filter.IsMatch(message); public override bool IsMatch(Message message) => !_filter.IsMatch(message);
} }

@ -5,4 +5,4 @@ namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class NullMessageFilter : MessageFilter internal class NullMessageFilter : MessageFilter
{ {
public override bool IsMatch(Message message) => true; public override bool IsMatch(Message message) => true;
} }

@ -6,8 +6,9 @@ namespace DiscordChatExporter.Core.Exporting.Filtering.Parsing;
internal static class FilterGrammar internal static class FilterGrammar
{ {
private static readonly TextParser<char> EscapedCharacter = private static readonly TextParser<char> EscapedCharacter = Character
Character.EqualTo('\\').IgnoreThen(Character.AnyChar); .EqualTo('\\')
.IgnoreThen(Character.AnyChar);
private static readonly TextParser<string> QuotedString = private static readonly TextParser<string> QuotedString =
from open in Character.In('"', '\'') from open in Character.In('"', '\'')
@ -15,70 +16,77 @@ internal static class FilterGrammar
from close in Character.EqualTo(open) from close in Character.EqualTo(open)
select value; select value;
private static readonly TextParser<string> UnquotedString = private static readonly TextParser<string> UnquotedString = Parse
Parse.OneOf( .OneOf(
EscapedCharacter, EscapedCharacter,
// Avoid whitespace as it's treated as an implicit 'and' operator. // Avoid whitespace as it's treated as an implicit 'and' operator.
// Also avoid all special tokens used by other parsers. // Also avoid all special tokens used by other parsers.
Character.ExceptIn(' ', '(', ')', '"', '\'', '-', '~', '|', '&') Character.ExceptIn(' ', '(', ')', '"', '\'', '-', '~', '|', '&')
).AtLeastOnce().Text(); )
.AtLeastOnce()
.Text();
private static readonly TextParser<string> String = private static readonly TextParser<string> String = Parse
Parse.OneOf(QuotedString, UnquotedString).Named("text string"); .OneOf(QuotedString, UnquotedString)
.Named("text string");
private static readonly TextParser<MessageFilter> ContainsFilter = private static readonly TextParser<MessageFilter> ContainsFilter = String.Select(
String.Select(v => (MessageFilter)new ContainsMessageFilter(v)); v => (MessageFilter)new ContainsMessageFilter(v)
);
private static readonly TextParser<MessageFilter> FromFilter = private static readonly TextParser<MessageFilter> FromFilter = Span.EqualToIgnoreCase("from:")
Span .Try()
.EqualToIgnoreCase("from:") .IgnoreThen(String)
.Try() .Select(v => (MessageFilter)new FromMessageFilter(v))
.IgnoreThen(String) .Named("from:<value>");
.Select(v => (MessageFilter)new FromMessageFilter(v))
.Named("from:<value>");
private static readonly TextParser<MessageFilter> MentionsFilter = private static readonly TextParser<MessageFilter> MentionsFilter = Span.EqualToIgnoreCase(
Span "mentions:"
.EqualToIgnoreCase("mentions:") )
.Try() .Try()
.IgnoreThen(String) .IgnoreThen(String)
.Select(v => (MessageFilter)new MentionsMessageFilter(v)) .Select(v => (MessageFilter)new MentionsMessageFilter(v))
.Named("mentions:<value>"); .Named("mentions:<value>");
private static readonly TextParser<MessageFilter> ReactionFilter = private static readonly TextParser<MessageFilter> ReactionFilter = Span.EqualToIgnoreCase(
Span "reaction:"
.EqualToIgnoreCase("reaction:") )
.Try() .Try()
.IgnoreThen(String) .IgnoreThen(String)
.Select(v => (MessageFilter)new ReactionMessageFilter(v)) .Select(v => (MessageFilter)new ReactionMessageFilter(v))
.Named("reaction:<value>"); .Named("reaction:<value>");
private static readonly TextParser<MessageFilter> HasFilter = private static readonly TextParser<MessageFilter> HasFilter = Span.EqualToIgnoreCase("has:")
Span .Try()
.EqualToIgnoreCase("has:") .IgnoreThen(
.Try() Parse.OneOf(
.IgnoreThen(Parse.OneOf( Span.EqualToIgnoreCase("link")
Span.EqualToIgnoreCase("link").IgnoreThen(Parse.Return(MessageContentMatchKind.Link)), .IgnoreThen(Parse.Return(MessageContentMatchKind.Link)),
Span.EqualToIgnoreCase("embed").IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)), Span.EqualToIgnoreCase("embed")
Span.EqualToIgnoreCase("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)), .IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)),
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)), Span.EqualToIgnoreCase("file")
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)), .IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)), Span.EqualToIgnoreCase("video")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
Span.EqualToIgnoreCase("image")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
Span.EqualToIgnoreCase("sound")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)),
Span.EqualToIgnoreCase("pin").IgnoreThen(Parse.Return(MessageContentMatchKind.Pin)) Span.EqualToIgnoreCase("pin").IgnoreThen(Parse.Return(MessageContentMatchKind.Pin))
)) )
.Select(k => (MessageFilter)new HasMessageFilter(k)) )
.Named("has:<value>"); .Select(k => (MessageFilter)new HasMessageFilter(k))
.Named("has:<value>");
// Make sure that property-based filters like 'has:link' don't prevent text like 'hello' from being parsed. // Make sure that property-based filters like 'has:link' don't prevent text like 'hello' from being parsed.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/909#issuecomment-1227575455 // https://github.com/Tyrrrz/DiscordChatExporter/issues/909#issuecomment-1227575455
private static readonly TextParser<MessageFilter> PrimitiveFilter = private static readonly TextParser<MessageFilter> PrimitiveFilter = Parse.OneOf(
Parse.OneOf( FromFilter,
FromFilter, MentionsFilter,
MentionsFilter, ReactionFilter,
ReactionFilter, HasFilter,
HasFilter, ContainsFilter
ContainsFilter );
);
private static readonly TextParser<MessageFilter> GroupedFilter = private static readonly TextParser<MessageFilter> GroupedFilter =
from open in Character.EqualTo('(') from open in Character.EqualTo('(')
@ -86,36 +94,30 @@ internal static class FilterGrammar
from close in Character.EqualTo(')') from close in Character.EqualTo(')')
select content; select content;
private static readonly TextParser<MessageFilter> NegatedFilter = private static readonly TextParser<MessageFilter> NegatedFilter = Character
Character // Dash is annoying to use from CLI due to conflicts with options, so we provide tilde as an alias
// Dash is annoying to use from CLI due to conflicts with options, so we provide tilde as an alias .In('-', '~')
.In('-', '~') .IgnoreThen(Parse.OneOf(GroupedFilter, PrimitiveFilter))
.IgnoreThen(Parse.OneOf(GroupedFilter, PrimitiveFilter)) .Select(f => (MessageFilter)new NegatedMessageFilter(f));
.Select(f => (MessageFilter)new NegatedMessageFilter(f));
private static readonly TextParser<MessageFilter> ChainedFilter = private static readonly TextParser<MessageFilter> ChainedFilter = Parse.Chain(
Parse.Chain( // Operator
// Operator Parse.OneOf(
Parse.OneOf( // Explicit operator
// Explicit operator Character.In('|', '&').Token().Try(),
Character.In('|', '&').Token().Try(), // Implicit operator (resolves to 'and')
// Implicit operator (resolves to 'and') Character.EqualTo(' ').AtLeastOnce().IgnoreThen(Parse.Return(' '))
Character.EqualTo(' ').AtLeastOnce().IgnoreThen(Parse.Return(' ')) ),
), // Operand
// Operand Parse.OneOf(NegatedFilter, GroupedFilter, PrimitiveFilter),
Parse.OneOf( // Reducer
NegatedFilter, (op, left, right) =>
GroupedFilter, op switch
PrimitiveFilter
),
// Reducer
(op, left, right) => op switch
{ {
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or), '|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And) _ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And)
} }
); );
public static readonly TextParser<MessageFilter> Filter = public static readonly TextParser<MessageFilter> Filter = ChainedFilter.Token().AtEnd();
ChainedFilter.Token().AtEnd(); }
}

@ -10,9 +10,11 @@ internal class ReactionMessageFilter : MessageFilter
public ReactionMessageFilter(string value) => _value = value; public ReactionMessageFilter(string value) => _value = value;
public override bool IsMatch(Message message) => message.Reactions.Any(r => public override bool IsMatch(Message message) =>
string.Equals(_value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase) || message.Reactions.Any(
string.Equals(_value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase) || r =>
string.Equals(_value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase) string.Equals(_value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase)
); || string.Equals(_value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
} || string.Equals(_value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
);
}

@ -27,7 +27,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override ValueTask VisitTextAsync( protected override ValueTask VisitTextAsync(
TextNode text, TextNode text,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_buffer.Append(HtmlEncode(text.Text)); _buffer.Append(HtmlEncode(text.Text));
return default; return default;
@ -35,53 +36,63 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override async ValueTask VisitFormattingAsync( protected override async ValueTask VisitFormattingAsync(
FormattingNode formatting, FormattingNode formatting,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
var (openingTag, closingTag) = formatting.Kind switch var (openingTag, closingTag) = formatting.Kind switch
{ {
FormattingKind.Bold => ( FormattingKind.Bold
// lang=html => (
"<strong>", // lang=html
// lang=html "<strong>",
"</strong>" // lang=html
), "</strong>"
),
FormattingKind.Italic => (
// lang=html FormattingKind.Italic
"<em>", => (
// lang=html // lang=html
"</em>" "<em>",
), // lang=html
"</em>"
FormattingKind.Underline => ( ),
// lang=html
"<u>", FormattingKind.Underline
// lang=html => (
"</u>" // lang=html
), "<u>",
// lang=html
FormattingKind.Strikethrough => ( "</u>"
// lang=html ),
"<s>",
// lang=html FormattingKind.Strikethrough
"</s>" => (
), // lang=html
"<s>",
FormattingKind.Spoiler => ( // lang=html
// lang=html "</s>"
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""", ),
// lang=html
"""</span>""" FormattingKind.Spoiler
), => (
// lang=html
FormattingKind.Quote => ( """<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
// lang=html // lang=html
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""", """</span>"""
// lang=html ),
"""</div></div>"""
), FormattingKind.Quote
=> (
_ => throw new InvalidOperationException($"Unknown formatting kind '{formatting.Kind}'.") // lang=html
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
// lang=html
"""</div></div>"""
),
_
=> throw new InvalidOperationException(
$"Unknown formatting kind '{formatting.Kind}'."
)
}; };
_buffer.Append(openingTag); _buffer.Append(openingTag);
@ -91,7 +102,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override async ValueTask VisitHeadingAsync( protected override async ValueTask VisitHeadingAsync(
HeadingNode heading, HeadingNode heading,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_buffer.Append( _buffer.Append(
// lang=html // lang=html
@ -108,7 +120,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override async ValueTask VisitListAsync( protected override async ValueTask VisitListAsync(
ListNode list, ListNode list,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_buffer.Append( _buffer.Append(
// lang=html // lang=html
@ -125,7 +138,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override async ValueTask VisitListItemAsync( protected override async ValueTask VisitListItemAsync(
ListItemNode listItem, ListItemNode listItem,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_buffer.Append( _buffer.Append(
// lang=html // lang=html
@ -142,7 +156,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override ValueTask VisitInlineCodeBlockAsync( protected override ValueTask VisitInlineCodeBlockAsync(
InlineCodeBlockNode inlineCodeBlock, InlineCodeBlockNode inlineCodeBlock,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_buffer.Append( _buffer.Append(
// lang=html // lang=html
@ -156,7 +171,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override ValueTask VisitMultiLineCodeBlockAsync( protected override ValueTask VisitMultiLineCodeBlockAsync(
MultiLineCodeBlockNode multiLineCodeBlock, MultiLineCodeBlockNode multiLineCodeBlock,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
var highlightClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language) var highlightClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
? $"language-{multiLineCodeBlock.Language}" ? $"language-{multiLineCodeBlock.Language}"
@ -174,13 +190,13 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override async ValueTask VisitLinkAsync( protected override async ValueTask VisitLinkAsync(
LinkNode link, LinkNode link,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
// Try to extract the message ID if the link points to a Discord message // Try to extract the message ID if the link points to a Discord message
var linkedMessageId = Regex.Match( var linkedMessageId = Regex
link.Url, .Match(link.Url, @"^https?://(?:discord|discordapp)\.com/channels/.*?/(\d+)/?$")
@"^https?://(?:discord|discordapp)\.com/channels/.*?/(\d+)/?$" .Groups[1].Value;
).Groups[1].Value;
_buffer.Append( _buffer.Append(
!string.IsNullOrWhiteSpace(linkedMessageId) !string.IsNullOrWhiteSpace(linkedMessageId)
@ -200,7 +216,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override async ValueTask VisitEmojiAsync( protected override async ValueTask VisitEmojiAsync(
EmojiNode emoji, EmojiNode emoji,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated); var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = _isJumbo ? "chatlog__emoji--large" : ""; var jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
@ -218,8 +235,10 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
); );
} }
protected override async ValueTask VisitMentionAsync(MentionNode mention, protected override async ValueTask VisitMentionAsync(
CancellationToken cancellationToken = default) MentionNode mention,
CancellationToken cancellationToken = default
)
{ {
if (mention.Kind == MentionKind.Everyone) if (mention.Kind == MentionKind.Everyone)
{ {
@ -294,7 +313,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
protected override ValueTask VisitTimestampAsync( protected override ValueTask VisitTimestampAsync(
TimestampNode timestamp, TimestampNode timestamp,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
var formatted = timestamp.Instant is not null var formatted = timestamp.Instant is not null
? !string.IsNullOrWhiteSpace(timestamp.Format) ? !string.IsNullOrWhiteSpace(timestamp.Format)
@ -323,17 +343,25 @@ internal partial class HtmlMarkdownVisitor
ExportContext context, ExportContext context,
string markdown, string markdown,
bool isJumboAllowed = true, bool isJumboAllowed = true,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
var nodes = MarkdownParser.Parse(markdown); var nodes = MarkdownParser.Parse(markdown);
var isJumbo = var isJumbo =
isJumboAllowed && isJumboAllowed
nodes.All(n => n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)); && nodes.All(
n =>
n is EmojiNode
|| n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)
);
var buffer = new StringBuilder(); var buffer = new StringBuilder();
await new HtmlMarkdownVisitor(context, buffer, isJumbo).VisitAsync(nodes, cancellationToken); await new HtmlMarkdownVisitor(context, buffer, isJumbo).VisitAsync(
nodes,
cancellationToken
);
return buffer.ToString(); return buffer.ToString();
} }
} }

@ -15,8 +15,7 @@ internal static class HtmlMessageExtensions
var embed = message.Embeds[0]; var embed = message.Embeds[0];
return return string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase)
string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase) && && embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
} }
} }

@ -58,7 +58,13 @@ internal class HtmlMessageWriter : MessageWriter
// If the author changed their name after the last message, their new messages // If the author changed their name after the last message, their new messages
// cannot join the existing group. // cannot join the existing group.
if (!string.Equals(message.Author.FullName, lastMessage.Author.FullName, StringComparison.Ordinal)) if (
!string.Equals(
message.Author.FullName,
lastMessage.Author.FullName,
StringComparison.Ordinal
)
)
return false; return false;
} }
@ -69,7 +75,8 @@ internal class HtmlMessageWriter : MessageWriter
private string Minify(string html) => _minifier.Minify(html, false).MinifiedContent; private string Minify(string html) => _minifier.Minify(html, false).MinifiedContent;
public override async ValueTask WritePreambleAsync( public override async ValueTask WritePreambleAsync(
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
await _writer.WriteLineAsync( await _writer.WriteLineAsync(
Minify( Minify(
@ -84,7 +91,8 @@ internal class HtmlMessageWriter : MessageWriter
private async ValueTask WriteMessageGroupAsync( private async ValueTask WriteMessageGroupAsync(
IReadOnlyList<Message> messages, IReadOnlyList<Message> messages,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
await _writer.WriteLineAsync( await _writer.WriteLineAsync(
Minify( Minify(
@ -99,7 +107,8 @@ internal class HtmlMessageWriter : MessageWriter
public override async ValueTask WriteMessageAsync( public override async ValueTask WriteMessageAsync(
Message message, Message message,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
await base.WriteMessageAsync(message, cancellationToken); await base.WriteMessageAsync(message, cancellationToken);
@ -118,7 +127,9 @@ internal class HtmlMessageWriter : MessageWriter
} }
} }
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default) public override async ValueTask WritePostambleAsync(
CancellationToken cancellationToken = default
)
{ {
// Flush current message group // Flush current message group
if (_messageGroup.Any()) if (_messageGroup.Any())
@ -140,4 +151,4 @@ internal class HtmlMessageWriter : MessageWriter
await _writer.DisposeAsync(); await _writer.DisposeAsync();
await base.DisposeAsync(); await base.DisposeAsync();
} }
} }

@ -18,34 +18,39 @@ internal class JsonMessageWriter : MessageWriter
public JsonMessageWriter(Stream stream, ExportContext context) public JsonMessageWriter(Stream stream, ExportContext context)
: base(stream, context) : base(stream, context)
{ {
_writer = new Utf8JsonWriter(stream, new JsonWriterOptions _writer = new Utf8JsonWriter(
{ stream,
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450 new JsonWriterOptions
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, {
Indented = true, // https://github.com/Tyrrrz/DiscordChatExporter/issues/450
// Validation errors may mask actual failures Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413 Indented = true,
SkipValidation = true // Validation errors may mask actual failures
}); // https://github.com/Tyrrrz/DiscordChatExporter/issues/413
SkipValidation = true
}
);
} }
private async ValueTask<string> FormatMarkdownAsync( private async ValueTask<string> FormatMarkdownAsync(
string markdown, string markdown,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default
) =>
Context.Request.ShouldFormatMarkdown Context.Request.ShouldFormatMarkdown
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken) ? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
: markdown; : markdown;
private async ValueTask WriteUserAsync( private async ValueTask WriteUserAsync(User user, CancellationToken cancellationToken = default)
User user,
CancellationToken cancellationToken = default)
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("id", user.Id.ToString()); _writer.WriteString("id", user.Id.ToString());
_writer.WriteString("name", user.Name); _writer.WriteString("name", user.Name);
_writer.WriteString("discriminator", user.DiscriminatorFormatted); _writer.WriteString("discriminator", user.DiscriminatorFormatted);
_writer.WriteString("nickname", Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName); _writer.WriteString(
"nickname",
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
);
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex()); _writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
_writer.WriteBoolean("isBot", user.IsBot); _writer.WriteBoolean("isBot", user.IsBot);
@ -66,7 +71,8 @@ internal class JsonMessageWriter : MessageWriter
private async ValueTask WriteRolesAsync( private async ValueTask WriteRolesAsync(
IReadOnlyList<Role> roles, IReadOnlyList<Role> roles,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_writer.WriteStartArray(); _writer.WriteStartArray();
@ -88,7 +94,8 @@ internal class JsonMessageWriter : MessageWriter
private async ValueTask WriteEmbedAuthorAsync( private async ValueTask WriteEmbedAuthorAsync(
EmbedAuthor embedAuthor, EmbedAuthor embedAuthor,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
@ -99,7 +106,10 @@ internal class JsonMessageWriter : MessageWriter
{ {
_writer.WriteString( _writer.WriteString(
"iconUrl", "iconUrl",
await Context.ResolveAssetUrlAsync(embedAuthor.IconProxyUrl ?? embedAuthor.IconUrl, cancellationToken) await Context.ResolveAssetUrlAsync(
embedAuthor.IconProxyUrl ?? embedAuthor.IconUrl,
cancellationToken
)
); );
} }
@ -109,7 +119,8 @@ internal class JsonMessageWriter : MessageWriter
private async ValueTask WriteEmbedImageAsync( private async ValueTask WriteEmbedImageAsync(
EmbedImage embedImage, EmbedImage embedImage,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
@ -117,7 +128,10 @@ internal class JsonMessageWriter : MessageWriter
{ {
_writer.WriteString( _writer.WriteString(
"url", "url",
await Context.ResolveAssetUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken) await Context.ResolveAssetUrlAsync(
embedImage.ProxyUrl ?? embedImage.Url,
cancellationToken
)
); );
} }
@ -130,7 +144,8 @@ internal class JsonMessageWriter : MessageWriter
private async ValueTask WriteEmbedFooterAsync( private async ValueTask WriteEmbedFooterAsync(
EmbedFooter embedFooter, EmbedFooter embedFooter,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
@ -140,7 +155,10 @@ internal class JsonMessageWriter : MessageWriter
{ {
_writer.WriteString( _writer.WriteString(
"iconUrl", "iconUrl",
await Context.ResolveAssetUrlAsync(embedFooter.IconProxyUrl ?? embedFooter.IconUrl, cancellationToken) await Context.ResolveAssetUrlAsync(
embedFooter.IconProxyUrl ?? embedFooter.IconUrl,
cancellationToken
)
); );
} }
@ -150,12 +168,16 @@ internal class JsonMessageWriter : MessageWriter
private async ValueTask WriteEmbedFieldAsync( private async ValueTask WriteEmbedFieldAsync(
EmbedField embedField, EmbedField embedField,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("name", await FormatMarkdownAsync(embedField.Name, cancellationToken)); _writer.WriteString("name", await FormatMarkdownAsync(embedField.Name, cancellationToken));
_writer.WriteString("value", await FormatMarkdownAsync(embedField.Value, cancellationToken)); _writer.WriteString(
"value",
await FormatMarkdownAsync(embedField.Value, cancellationToken)
);
_writer.WriteBoolean("isInline", embedField.IsInline); _writer.WriteBoolean("isInline", embedField.IsInline);
_writer.WriteEndObject(); _writer.WriteEndObject();
@ -164,14 +186,21 @@ internal class JsonMessageWriter : MessageWriter
private async ValueTask WriteEmbedAsync( private async ValueTask WriteEmbedAsync(
Embed embed, Embed embed,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("title", await FormatMarkdownAsync(embed.Title ?? "", cancellationToken)); _writer.WriteString(
"title",
await FormatMarkdownAsync(embed.Title ?? "", cancellationToken)
);
_writer.WriteString("url", embed.Url); _writer.WriteString("url", embed.Url);
_writer.WriteString("timestamp", embed.Timestamp); _writer.WriteString("timestamp", embed.Timestamp);
_writer.WriteString("description", await FormatMarkdownAsync(embed.Description ?? "", cancellationToken)); _writer.WriteString(
"description",
await FormatMarkdownAsync(embed.Description ?? "", cancellationToken)
);
if (embed.Color is not null) if (embed.Color is not null)
_writer.WriteString("color", embed.Color.Value.ToHex()); _writer.WriteString("color", embed.Color.Value.ToHex());
@ -220,7 +249,9 @@ internal class JsonMessageWriter : MessageWriter
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
} }
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) public override async ValueTask WritePreambleAsync(
CancellationToken cancellationToken = default
)
{ {
// Root object (start) // Root object (start)
_writer.WriteStartObject(); _writer.WriteStartObject();
@ -250,7 +281,10 @@ internal class JsonMessageWriter : MessageWriter
{ {
_writer.WriteString( _writer.WriteString(
"iconUrl", "iconUrl",
await Context.ResolveAssetUrlAsync(Context.Request.Channel.IconUrl, cancellationToken) await Context.ResolveAssetUrlAsync(
Context.Request.Channel.IconUrl,
cancellationToken
)
); );
} }
@ -272,7 +306,8 @@ internal class JsonMessageWriter : MessageWriter
public override async ValueTask WriteMessageAsync( public override async ValueTask WriteMessageAsync(
Message message, Message message,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
await base.WriteMessageAsync(message, cancellationToken); await base.WriteMessageAsync(message, cancellationToken);
@ -293,7 +328,10 @@ internal class JsonMessageWriter : MessageWriter
} }
else else
{ {
_writer.WriteString("content", await FormatMarkdownAsync(message.Content, cancellationToken)); _writer.WriteString(
"content",
await FormatMarkdownAsync(message.Content, cancellationToken)
);
} }
// Author // Author
@ -308,7 +346,10 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("id", attachment.Id.ToString()); _writer.WriteString("id", attachment.Id.ToString());
_writer.WriteString("url", await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)); _writer.WriteString(
"url",
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
);
_writer.WriteString("fileName", attachment.FileName); _writer.WriteString("fileName", attachment.FileName);
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes); _writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
@ -335,7 +376,10 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("id", sticker.Id.ToString()); _writer.WriteString("id", sticker.Id.ToString());
_writer.WriteString("name", sticker.Name); _writer.WriteString("name", sticker.Name);
_writer.WriteString("format", sticker.Format.ToString()); _writer.WriteString("format", sticker.Format.ToString());
_writer.WriteString("sourceUrl", await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)); _writer.WriteString(
"sourceUrl",
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
);
_writer.WriteEndObject(); _writer.WriteEndObject();
} }
@ -355,17 +399,23 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("name", reaction.Emoji.Name); _writer.WriteString("name", reaction.Emoji.Name);
_writer.WriteString("code", reaction.Emoji.Code); _writer.WriteString("code", reaction.Emoji.Code);
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated); _writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
_writer.WriteString("imageUrl", await Context.ResolveAssetUrlAsync(reaction.Emoji.ImageUrl, cancellationToken)); _writer.WriteString(
"imageUrl",
await Context.ResolveAssetUrlAsync(reaction.Emoji.ImageUrl, cancellationToken)
);
_writer.WriteEndObject(); _writer.WriteEndObject();
_writer.WriteNumber("count", reaction.Count); _writer.WriteNumber("count", reaction.Count);
_writer.WriteStartArray("users"); _writer.WriteStartArray("users");
await foreach (var user in Context.Discord.GetMessageReactionsAsync( await foreach (
Context.Request.Channel.Id, var user in Context.Discord.GetMessageReactionsAsync(
message.Id, Context.Request.Channel.Id,
reaction.Emoji, message.Id,
cancellationToken)) reaction.Emoji,
cancellationToken
)
)
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
@ -374,7 +424,10 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("id", user.Id.ToString()); _writer.WriteString("id", user.Id.ToString());
_writer.WriteString("name", user.Name); _writer.WriteString("name", user.Name);
_writer.WriteString("discriminator", user.DiscriminatorFormatted); _writer.WriteString("discriminator", user.DiscriminatorFormatted);
_writer.WriteString("nickname", Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName); _writer.WriteString(
"nickname",
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
);
_writer.WriteBoolean("isBot", user.IsBot); _writer.WriteBoolean("isBot", user.IsBot);
_writer.WriteString( _writer.WriteString(
@ -431,7 +484,9 @@ internal class JsonMessageWriter : MessageWriter
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
} }
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default) public override async ValueTask WritePostambleAsync(
CancellationToken cancellationToken = default
)
{ {
// Message array (end) // Message array (end)
_writer.WriteEndArray(); _writer.WriteEndArray();
@ -448,4 +503,4 @@ internal class JsonMessageWriter : MessageWriter
await _writer.DisposeAsync(); await _writer.DisposeAsync();
await base.DisposeAsync(); await base.DisposeAsync();
} }
} }

@ -37,11 +37,18 @@ internal partial class MessageExporter : IAsyncDisposable
} }
} }
private async ValueTask<MessageWriter> GetWriterAsync(CancellationToken cancellationToken = default) private async ValueTask<MessageWriter> GetWriterAsync(
CancellationToken cancellationToken = default
)
{ {
// Ensure that the partition limit has not been reached // Ensure that the partition limit has not been reached
if (_writer is not null && if (
_context.Request.PartitionLimit.IsReached(_writer.MessagesWritten, _writer.BytesWritten)) _writer is not null
&& _context.Request.PartitionLimit.IsReached(
_writer.MessagesWritten,
_writer.BytesWritten
)
)
{ {
await ResetWriterAsync(cancellationToken); await ResetWriterAsync(cancellationToken);
_partitionIndex++; _partitionIndex++;
@ -60,7 +67,10 @@ internal partial class MessageExporter : IAsyncDisposable
return _writer = writer; return _writer = writer;
} }
public async ValueTask ExportMessageAsync(Message message, CancellationToken cancellationToken = default) public async ValueTask ExportMessageAsync(
Message message,
CancellationToken cancellationToken = default
)
{ {
var writer = await GetWriterAsync(cancellationToken); var writer = await GetWriterAsync(cancellationToken);
await writer.WriteMessageAsync(message, cancellationToken); await writer.WriteMessageAsync(message, cancellationToken);
@ -84,22 +94,26 @@ internal partial class MessageExporter
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}"; var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
var dirPath = Path.GetDirectoryName(baseFilePath); var dirPath = Path.GetDirectoryName(baseFilePath);
return !string.IsNullOrWhiteSpace(dirPath) return !string.IsNullOrWhiteSpace(dirPath) ? Path.Combine(dirPath, fileName) : fileName;
? Path.Combine(dirPath, fileName)
: fileName;
} }
private static MessageWriter CreateMessageWriter( private static MessageWriter CreateMessageWriter(
string filePath, string filePath,
ExportFormat format, ExportFormat format,
ExportContext context) => ExportContext context
) =>
format switch format switch
{ {
ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context), ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context),
ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context), ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context),
ExportFormat.HtmlDark => new HtmlMessageWriter(File.Create(filePath), context, "Dark"), ExportFormat.HtmlDark => new HtmlMessageWriter(File.Create(filePath), context, "Dark"),
ExportFormat.HtmlLight => new HtmlMessageWriter(File.Create(filePath), context, "Light"), ExportFormat.HtmlLight
=> new HtmlMessageWriter(File.Create(filePath), context, "Light"),
ExportFormat.Json => new JsonMessageWriter(File.Create(filePath), context), ExportFormat.Json => new JsonMessageWriter(File.Create(filePath), context),
_ => throw new ArgumentOutOfRangeException(nameof(format), $"Unknown export format '{format}'.") _
=> throw new ArgumentOutOfRangeException(
nameof(format),
$"Unknown export format '{format}'."
)
}; };
} }

@ -22,15 +22,20 @@ internal abstract class MessageWriter : IAsyncDisposable
Context = context; Context = context;
} }
public virtual ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) => default; public virtual ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) =>
default;
public virtual ValueTask WriteMessageAsync(Message message, CancellationToken cancellationToken = default) public virtual ValueTask WriteMessageAsync(
Message message,
CancellationToken cancellationToken = default
)
{ {
MessagesWritten++; MessagesWritten++;
return default; return default;
} }
public virtual ValueTask WritePostambleAsync(CancellationToken cancellationToken = default) => default; public virtual ValueTask WritePostambleAsync(CancellationToken cancellationToken = default) =>
default;
public virtual async ValueTask DisposeAsync() => await Stream.DisposeAsync(); public virtual async ValueTask DisposeAsync() => await Stream.DisposeAsync();
} }

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save