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" />

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

@ -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,8 +132,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())

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

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

@ -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,7 +90,10 @@ 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,9 +106,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/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"
);
} }
} }

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

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

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

@ -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,16 +26,14 @@ 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);
} }

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

@ -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,15 +304,33 @@ 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("");
} }

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

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

@ -17,7 +17,10 @@ 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);
} }

@ -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,7 +109,9 @@ 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)

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

@ -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,14 +53,20 @@ 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

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

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

@ -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,7 +41,10 @@ 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

@ -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,7 +47,12 @@ 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}";

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

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

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

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

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

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

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

@ -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,11 +18,9 @@ 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);
} }

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

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

@ -9,20 +9,17 @@ 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);
} }

@ -15,10 +15,9 @@ 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);

@ -15,13 +15,16 @@ 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);
} }

@ -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,23 +34,22 @@ 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();

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

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

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

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

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

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

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

@ -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,16 +343,24 @@ 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())

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

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

@ -18,11 +18,14 @@ public partial class PartitionLimit
var match = Regex.Match(value, @"^\s*(\d+[\.,]?\d*)\s*(\w)?b\s*$", RegexOptions.IgnoreCase); var match = Regex.Match(value, @"^\s*(\d+[\.,]?\d*)\s*(\w)?b\s*$", RegexOptions.IgnoreCase);
// Number part // Number part
if (!double.TryParse( if (
!double.TryParse(
match.Groups[1].Value, match.Groups[1].Value,
NumberStyles.Float, NumberStyles.Float,
formatProvider, formatProvider,
out var number)) out var number
)
)
{ {
return null; return null;
} }
@ -42,7 +45,7 @@ public partial class PartitionLimit
return null; return null;
} }
return (long) (number * magnitude); return (long)(number * magnitude);
} }
public static PartitionLimit? TryParse(string value, IFormatProvider? formatProvider = null) public static PartitionLimit? TryParse(string value, IFormatProvider? formatProvider = null)
@ -58,5 +61,6 @@ public partial class PartitionLimit
} }
public static PartitionLimit Parse(string value, IFormatProvider? formatProvider = null) => public static PartitionLimit Parse(string value, IFormatProvider? formatProvider = null) =>
TryParse(value, formatProvider) ?? throw new FormatException($"Invalid partition limit '{value}'."); TryParse(value, formatProvider)
?? throw new FormatException($"Invalid partition limit '{value}'.");
} }

@ -21,7 +21,8 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
protected override ValueTask VisitTextAsync( protected override ValueTask VisitTextAsync(
TextNode text, TextNode text,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_buffer.Append(text.Text); _buffer.Append(text.Text);
return default; return default;
@ -29,19 +30,18 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
protected override ValueTask VisitEmojiAsync( protected override ValueTask VisitEmojiAsync(
EmojiNode emoji, EmojiNode emoji,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_buffer.Append( _buffer.Append(emoji.IsCustomEmoji ? $":{emoji.Name}:" : emoji.Name);
emoji.IsCustomEmoji
? $":{emoji.Name}:"
: emoji.Name
);
return default; return default;
} }
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)
{ {
@ -86,7 +86,8 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
protected override ValueTask VisitTimestampAsync( protected override ValueTask VisitTimestampAsync(
TimestampNode timestamp, TimestampNode timestamp,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
_buffer.Append( _buffer.Append(
timestamp.Instant is not null timestamp.Instant is not null
@ -105,7 +106,8 @@ internal partial class PlainTextMarkdownVisitor
public static async ValueTask<string> FormatAsync( public static async ValueTask<string> FormatAsync(
ExportContext context, ExportContext context,
string markdown, string markdown,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
var nodes = MarkdownParser.ParseMinimal(markdown); var nodes = MarkdownParser.ParseMinimal(markdown);

@ -7,20 +7,23 @@ namespace DiscordChatExporter.Core.Exporting;
internal static class PlainTextMessageExtensions internal static class PlainTextMessageExtensions
{ {
public static string GetFallbackContent(this Message message) => message.Kind switch public static string GetFallbackContent(this Message message) =>
{ message.Kind switch
MessageKind.RecipientAdd => message.MentionedUsers.Any() {
? $"Added {message.MentionedUsers.First().DisplayName} to the group." MessageKind.RecipientAdd
: "Added a recipient.", => message.MentionedUsers.Any()
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
: "Added a recipient.",
MessageKind.RecipientRemove => message.MentionedUsers.Any() MessageKind.RecipientRemove
? message.Author.Id == message.MentionedUsers.First().Id => message.MentionedUsers.Any()
? "Left the group." ? message.Author.Id == message.MentionedUsers.First().Id
: $"Removed {message.MentionedUsers.First().DisplayName} from the group." ? "Left the group."
: "Removed a recipient.", : $"Removed {message.MentionedUsers.First().DisplayName} from the group."
: "Removed a recipient.",
MessageKind.Call => MessageKind.Call
$"Started a call that lasted { => $"Started a call that lasted {
message message
.CallEndedTimestamp? .CallEndedTimestamp?
.Pipe(t => t - message.Timestamp) .Pipe(t => t - message.Timestamp)
@ -28,16 +31,16 @@ internal static class PlainTextMessageExtensions
.ToString("n0", CultureInfo.InvariantCulture) ?? "0" .ToString("n0", CultureInfo.InvariantCulture) ?? "0"
} minutes.", } minutes.",
MessageKind.ChannelNameChange => MessageKind.ChannelNameChange
!string.IsNullOrWhiteSpace(message.Content) => !string.IsNullOrWhiteSpace(message.Content)
? $"Changed the channel name: {message.Content}" ? $"Changed the channel name: {message.Content}"
: "Changed the channel name.", : "Changed the channel name.",
MessageKind.ChannelIconChange => "Changed the channel icon.", MessageKind.ChannelIconChange => "Changed the channel icon.",
MessageKind.ChannelPinnedMessage => "Pinned a message.", MessageKind.ChannelPinnedMessage => "Pinned a message.",
MessageKind.ThreadCreated => "Started a thread.", MessageKind.ThreadCreated => "Started a thread.",
MessageKind.GuildMemberJoin => "Joined the server.", MessageKind.GuildMemberJoin => "Joined the server.",
_ => message.Content _ => message.Content
}; };
} }

@ -20,7 +20,8 @@ internal class PlainTextMessageWriter : 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;
@ -40,7 +41,8 @@ internal class PlainTextMessageWriter : MessageWriter
private async ValueTask WriteAttachmentsAsync( private async ValueTask WriteAttachmentsAsync(
IReadOnlyList<Attachment> attachments, IReadOnlyList<Attachment> attachments,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
if (!attachments.Any()) if (!attachments.Any())
return; return;
@ -61,7 +63,8 @@ internal class PlainTextMessageWriter : MessageWriter
private async ValueTask WriteEmbedsAsync( private async ValueTask WriteEmbedsAsync(
IReadOnlyList<Embed> embeds, IReadOnlyList<Embed> embeds,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
foreach (var embed in embeds) foreach (var embed in embeds)
{ {
@ -144,7 +147,8 @@ internal class PlainTextMessageWriter : MessageWriter
private async ValueTask WriteStickersAsync( private async ValueTask WriteStickersAsync(
IReadOnlyList<Sticker> stickers, IReadOnlyList<Sticker> stickers,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
if (!stickers.Any()) if (!stickers.Any())
return; return;
@ -165,7 +169,8 @@ internal class PlainTextMessageWriter : MessageWriter
private async ValueTask WriteReactionsAsync( private async ValueTask WriteReactionsAsync(
IReadOnlyList<Reaction> reactions, IReadOnlyList<Reaction> reactions,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
if (!reactions.Any()) if (!reactions.Any())
return; return;
@ -189,11 +194,15 @@ internal class PlainTextMessageWriter : MessageWriter
await _writer.WriteLineAsync(); await _writer.WriteLineAsync();
} }
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) public override async ValueTask WritePreambleAsync(
CancellationToken cancellationToken = default
)
{ {
await _writer.WriteLineAsync(new string('=', 62)); await _writer.WriteLineAsync(new string('=', 62));
await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}"); await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}");
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category} / {Context.Request.Channel.Name}"); await _writer.WriteLineAsync(
$"Channel: {Context.Request.Channel.Category} / {Context.Request.Channel.Name}"
);
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic)) if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
{ {
@ -202,12 +211,16 @@ internal class PlainTextMessageWriter : MessageWriter
if (Context.Request.After is not null) if (Context.Request.After is not null)
{ {
await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value.ToDate())}"); await _writer.WriteLineAsync(
$"After: {Context.FormatDate(Context.Request.After.Value.ToDate())}"
);
} }
if (Context.Request.Before is not null) if (Context.Request.Before is not null)
{ {
await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value.ToDate())}"); await _writer.WriteLineAsync(
$"Before: {Context.FormatDate(Context.Request.Before.Value.ToDate())}"
);
} }
await _writer.WriteLineAsync(new string('=', 62)); await _writer.WriteLineAsync(new string('=', 62));
@ -216,7 +229,8 @@ internal class PlainTextMessageWriter : 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);
@ -246,7 +260,9 @@ internal class PlainTextMessageWriter : MessageWriter
await _writer.WriteLineAsync(); await _writer.WriteLineAsync();
} }
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default) public override async ValueTask WritePostambleAsync(
CancellationToken cancellationToken = default
)
{ {
await _writer.WriteLineAsync(new string('=', 62)); await _writer.WriteLineAsync(new string('=', 62));
await _writer.WriteLineAsync($"Exported {MessagesWritten:N0} message(s)"); await _writer.WriteLineAsync($"Exported {MessagesWritten:N0} message(s)");

@ -8,17 +8,14 @@ internal record EmojiNode(
Snowflake? Id, Snowflake? Id,
// 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) : MarkdownNode bool IsAnimated
) : MarkdownNode
{ {
public bool IsCustomEmoji => Id is not null; public bool IsCustomEmoji => Id is not null;
// 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 => IsCustomEmoji public string Code => IsCustomEmoji ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
? Name
: EmojiIndex.TryGetCode(Name) ?? Name;
public EmojiNode(string name) public EmojiNode(string name)
: this(null, name, false) : this(null, name, false) { }
{
}
} }

@ -2,7 +2,6 @@
namespace DiscordChatExporter.Core.Markdown; namespace DiscordChatExporter.Core.Markdown;
internal record FormattingNode( internal record FormattingNode(FormattingKind Kind, IReadOnlyList<MarkdownNode> Children)
FormattingKind Kind, : MarkdownNode,
IReadOnlyList<MarkdownNode> Children IContainerNode;
) : MarkdownNode, IContainerNode;

@ -2,7 +2,6 @@
namespace DiscordChatExporter.Core.Markdown; namespace DiscordChatExporter.Core.Markdown;
internal record HeadingNode( internal record HeadingNode(int Level, IReadOnlyList<MarkdownNode> Children)
int Level, : MarkdownNode,
IReadOnlyList<MarkdownNode> Children IContainerNode;
) : MarkdownNode, IContainerNode;

@ -3,12 +3,10 @@
namespace DiscordChatExporter.Core.Markdown; namespace DiscordChatExporter.Core.Markdown;
// Named links can contain child nodes (e.g. [**bold URL**](https://test.com)) // Named links can contain child nodes (e.g. [**bold URL**](https://test.com))
internal record LinkNode( internal record LinkNode(string Url, IReadOnlyList<MarkdownNode> Children)
string Url, : MarkdownNode,
IReadOnlyList<MarkdownNode> Children) : MarkdownNode, IContainerNode IContainerNode
{ {
public LinkNode(string url) public LinkNode(string url)
: this(url, new[] { new TextNode(url) }) : this(url, new[] { new TextNode(url) }) { }
{
}
} }

@ -12,9 +12,7 @@ internal class AggregateMatcher<T> : IMatcher<T>
} }
public AggregateMatcher(params IMatcher<T>[] matchers) public AggregateMatcher(params IMatcher<T>[] matchers)
: this((IReadOnlyList<IMatcher<T>>) matchers) : this((IReadOnlyList<IMatcher<T>>)matchers) { }
{
}
public ParsedMatch<T>? TryMatch(StringSegment segment) public ParsedMatch<T>? TryMatch(StringSegment segment)
{ {
@ -31,7 +29,9 @@ internal class AggregateMatcher<T> : IMatcher<T>
continue; continue;
// If this match is earlier than previous earliest - replace // If this match is earlier than previous earliest - replace
if (earliestMatch is null || match.Segment.StartIndex < earliestMatch.Segment.StartIndex) if (
earliestMatch is null || match.Segment.StartIndex < earliestMatch.Segment.StartIndex
)
earliestMatch = match; earliestMatch = match;
// If the earliest match starts at the very beginning - break, // If the earliest match starts at the very beginning - break,

@ -13,7 +13,8 @@ internal static class MatcherExtensions
public static IEnumerable<ParsedMatch<T>> MatchAll<T>( public static IEnumerable<ParsedMatch<T>> MatchAll<T>(
this IMatcher<T> matcher, this IMatcher<T> matcher,
StringSegment segment, StringSegment segment,
Func<StringSegment, T> transformFallback) Func<StringSegment, T> transformFallback
)
{ {
// Loop through segments divided by individual matches // Loop through segments divided by individual matches
var currentIndex = segment.StartIndex; var currentIndex = segment.StartIndex;
@ -21,10 +22,7 @@ internal static class MatcherExtensions
{ {
// Find a match within this segment // Find a match within this segment
var match = matcher.TryMatch( var match = matcher.TryMatch(
segment.Relocate( segment.Relocate(currentIndex, segment.EndIndex - currentIndex)
currentIndex,
segment.EndIndex - currentIndex
)
); );
if (match is null) if (match is null)
@ -38,7 +36,10 @@ internal static class MatcherExtensions
match.Segment.StartIndex - currentIndex match.Segment.StartIndex - currentIndex
); );
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment)); yield return new ParsedMatch<T>(
fallbackSegment,
transformFallback(fallbackSegment)
);
} }
yield return match; yield return match;
@ -50,10 +51,7 @@ internal static class MatcherExtensions
// If EOL hasn't been reached - transform and yield remaining part as fallback // If EOL hasn't been reached - transform and yield remaining part as fallback
if (currentIndex < segment.EndIndex) if (currentIndex < segment.EndIndex)
{ {
var fallbackSegment = segment.Relocate( var fallbackSegment = segment.Relocate(currentIndex, segment.EndIndex - currentIndex);
currentIndex,
segment.EndIndex - currentIndex
);
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment)); yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment));
} }

@ -16,302 +16,354 @@ namespace DiscordChatExporter.Core.Markdown.Parsing;
internal static partial class MarkdownParser internal static partial class MarkdownParser
{ {
private const RegexOptions DefaultRegexOptions = private const RegexOptions DefaultRegexOptions =
RegexOptions.Compiled | RegexOptions.Compiled
RegexOptions.IgnorePatternWhitespace | | RegexOptions.IgnorePatternWhitespace
RegexOptions.CultureInvariant | | RegexOptions.CultureInvariant
RegexOptions.Multiline; | RegexOptions.Multiline;
/* Formatting */ /* Formatting */
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher =
// There must be exactly two closing asterisks. new RegexMatcher<MarkdownNode>(
new Regex(@"\*\*(.+?)\*\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline), // There must be exactly two closing asterisks.
(s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1]))) new Regex(@"\*\*(.+?)\*\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
); (s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher =
// There must be exactly one closing asterisk. new RegexMatcher<MarkdownNode>(
// Opening asterisk must not be followed by whitespace. // There must be exactly one closing asterisk.
// Closing asterisk must not be preceded by whitespace. // Opening asterisk must not be followed by whitespace.
new Regex(@"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline), // Closing asterisk must not be preceded by whitespace.
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]))) new Regex(
); @"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)",
DefaultRegexOptions | RegexOptions.Singleline
),
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> ItalicBoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> ItalicBoldFormattingNodeMatcher =
// There must be exactly three closing asterisks. new RegexMatcher<MarkdownNode>(
new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline), // There must be exactly three closing asterisks.
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher)) new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
); (s, m) =>
new FormattingNode(
FormattingKind.Italic,
Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher)
)
);
private static readonly IMatcher<MarkdownNode> ItalicAltFormattingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> ItalicAltFormattingNodeMatcher =
// Closing underscore must not be followed by a word character. new RegexMatcher<MarkdownNode>(
new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline), // Closing underscore must not be followed by a word character.
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]))) new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
); (s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> UnderlineFormattingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> UnderlineFormattingNodeMatcher =
// There must be exactly two closing underscores. new RegexMatcher<MarkdownNode>(
new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline), // There must be exactly two closing underscores.
(s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1]))) new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
); (s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher = private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownNode>(
// There must be exactly three closing underscores. // There must be exactly three closing underscores.
new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode( (s, m) =>
FormattingKind.Italic, new FormattingNode(
Parse(s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher) FormattingKind.Italic,
) Parse(s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher)
)
); );
private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher =
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline), new RegexMatcher<MarkdownNode>(
(s, m) => new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1]))) new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
); (s, m) =>
new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> SpoilerFormattingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> SpoilerFormattingNodeMatcher =
new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline), new RegexMatcher<MarkdownNode>(
(s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(m.Groups[1]))) new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline),
); (s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher =
// Include the linebreak in the content so that the lines are preserved in quotes. new RegexMatcher<MarkdownNode>(
new Regex(@"^>\s(.+\n?)", DefaultRegexOptions), // Include the linebreak in the content so that the lines are preserved in quotes.
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1]))) new Regex(@"^>\s(.+\n?)", DefaultRegexOptions),
); (s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
// Include the linebreaks in the content, so that the lines are preserved in quotes. new RegexMatcher<MarkdownNode>(
// Empty content is allowed within quotes. // Include the linebreaks in the content, so that the lines are preserved in quotes.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1115 // Empty content is allowed within quotes.
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions), // https://github.com/Tyrrrz/DiscordChatExporter/issues/1115
(s, m) => new FormattingNode( new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions),
FormattingKind.Quote, (s, m) =>
m.Groups[1].Captures.SelectMany(c => Parse(s.Relocate(c))).ToArray() new FormattingNode(
) FormattingKind.Quote,
); m.Groups[1].Captures.SelectMany(c => Parse(s.Relocate(c))).ToArray()
)
);
private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher =
new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline), new RegexMatcher<MarkdownNode>(
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1]))) new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
); (s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> HeadingNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> HeadingNodeMatcher =
// Consume the linebreak so that it's not attached to following nodes. new RegexMatcher<MarkdownNode>(
new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions), // Consume the linebreak so that it's not attached to following nodes.
(s, m) => new HeadingNode(m.Groups[1].Length, Parse(s.Relocate(m.Groups[2]))) new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions),
); (s, m) => new HeadingNode(m.Groups[1].Length, Parse(s.Relocate(m.Groups[2])))
);
private static readonly IMatcher<MarkdownNode> ListNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> ListNodeMatcher = new RegexMatcher<MarkdownNode>(
// Can be preceded by whitespace, which specifies the list's nesting level. // Can be preceded by whitespace, which specifies the list's nesting level.
// Following lines that start with (level+1) whitespace are considered part of the list item. // Following lines that start with (level+1) whitespace are considered part of the list item.
// Consume the linebreak so that it's not attached to following nodes. // Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions), new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions),
(s, m) => new ListNode( (s, m) =>
m.Groups[2].Captures.Select(c => new ListItemNode(Parse(s.Relocate(c)))).ToArray() new ListNode(
) m.Groups[2].Captures.Select(c => new ListItemNode(Parse(s.Relocate(c)))).ToArray()
)
); );
/* Code blocks */ /* Code blocks */
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher =
// One or two backticks are allowed, but they must match on both sides. new RegexMatcher<MarkdownNode>(
new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline), // One or two backticks are allowed, but they must match on both sides.
(_, m) => new InlineCodeBlockNode(m.Groups[2].Value) new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline),
); (_, m) => new InlineCodeBlockNode(m.Groups[2].Value)
);
private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher =
// Language identifier is one word immediately after opening backticks, followed immediately by a linebreak. new RegexMatcher<MarkdownNode>(
// Blank lines at the beginning and at the end of content are trimmed. // Language identifier is one word immediately after opening backticks, followed immediately by a linebreak.
new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline), // Blank lines at the beginning and at the end of content are trimmed.
(_, m) => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n')) new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
); (_, m) =>
new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
);
/* Mentions */ /* Mentions */
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher = new StringMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher =
"@everyone", new StringMatcher<MarkdownNode>(
_ => new MentionNode(null, MentionKind.Everyone) "@everyone",
); _ => new MentionNode(null, MentionKind.Everyone)
);
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher = new StringMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher =
"@here", new StringMatcher<MarkdownNode>("@here", _ => new MentionNode(null, MentionKind.Here));
_ => new MentionNode(null, MentionKind.Here)
);
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher =
// Capture <@123456> or <@!123456> new RegexMatcher<MarkdownNode>(
new Regex(@"<@!?(\d+)>", DefaultRegexOptions), // Capture <@123456> or <@!123456>
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User) new Regex(@"<@!?(\d+)>", DefaultRegexOptions),
); (_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User)
);
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher =
// Capture <#123456> new RegexMatcher<MarkdownNode>(
new Regex(@"<\#!?(\d+)>", DefaultRegexOptions), // Capture <#123456>
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel) new Regex(@"<\#!?(\d+)>", DefaultRegexOptions),
); (_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel)
);
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher =
// Capture <@&123456> new RegexMatcher<MarkdownNode>(
new Regex(@"<@&(\d+)>", DefaultRegexOptions), // Capture <@&123456>
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role) new Regex(@"<@&(\d+)>", DefaultRegexOptions),
); (_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role)
);
/* Emoji */ /* Emoji */
private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher =
new Regex( new RegexMatcher<MarkdownNode>(
@"(" + new Regex(
// Country flag emoji (two regional indicator surrogate pairs) @"("
@"(?:\uD83C[\uDDE6-\uDDFF]){2}|" + +
// Digit emoji (digit followed by enclosing mark) // Country flag emoji (two regional indicator surrogate pairs)
@"\d\p{Me}|" + @"(?:\uD83C[\uDDE6-\uDDFF]){2}|"
// Surrogate pair +
@"\p{Cs}{2}|" + // Digit emoji (digit followed by enclosing mark)
// Miscellaneous characters @"\d\p{Me}|"
@"[" + +
@"\u2600-\u2604" + // Surrogate pair
@"\u260E\u2611" + @"\p{Cs}{2}|"
@"\u2614-\u2615" + +
@"\u2618\u261D\u2620" + // Miscellaneous characters
@"\u2622-\u2623" + @"["
@"\u2626\u262A" + + @"\u2600-\u2604"
@"\u262E-\u262F" + + @"\u260E\u2611"
@"\u2638-\u263A" + + @"\u2614-\u2615"
@"\u2640\u2642" + + @"\u2618\u261D\u2620"
@"\u2648-\u2653" + + @"\u2622-\u2623"
@"\u265F-\u2660" + + @"\u2626\u262A"
@"\u2663" + + @"\u262E-\u262F"
@"\u2665-\u2666" + + @"\u2638-\u263A"
@"\u2668\u267B" + + @"\u2640\u2642"
@"\u267E-\u267F" + + @"\u2648-\u2653"
@"\u2692-\u2697" + + @"\u265F-\u2660"
@"\u2699" + + @"\u2663"
@"\u269B-\u269C" + + @"\u2665-\u2666"
@"\u26A0-\u26A1" + + @"\u2668\u267B"
@"\u26A7" + + @"\u267E-\u267F"
@"\u26AA-\u26AB" + + @"\u2692-\u2697"
@"\u26B0-\u26B1" + + @"\u2699"
@"\u26BD-\u26BE" + + @"\u269B-\u269C"
@"\u26C4-\u26C5" + + @"\u26A0-\u26A1"
@"\u26C8" + + @"\u26A7"
@"\u26CE-\u26CF" + + @"\u26AA-\u26AB"
@"\u26D1" + + @"\u26B0-\u26B1"
@"\u26D3-\u26D4" + + @"\u26BD-\u26BE"
@"\u26E9-\u26EA" + + @"\u26C4-\u26C5"
@"\u26F0-\u26F5" + + @"\u26C8"
@"\u26F7-\u26FA" + + @"\u26CE-\u26CF"
@"\u26FD" + + @"\u26D1"
@"]" + + @"\u26D3-\u26D4"
@")", DefaultRegexOptions), + @"\u26E9-\u26EA"
(_, m) => new EmojiNode(m.Groups[1].Value) + @"\u26F0-\u26F5"
); + @"\u26F7-\u26FA"
+ @"\u26FD"
+ @"]"
+ @")",
DefaultRegexOptions
),
(_, m) => new EmojiNode(m.Groups[1].Value)
);
private static readonly IMatcher<MarkdownNode> CodedStandardEmojiNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> CodedStandardEmojiNodeMatcher =
// Capture :thinking: new RegexMatcher<MarkdownNode>(
new Regex(@":([\w_]+):", DefaultRegexOptions), // Capture :thinking:
(_, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n)) new Regex(@":([\w_]+):", DefaultRegexOptions),
); (_, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n))
);
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher =
// Capture <:lul:123456> or <a:lul:123456> new RegexMatcher<MarkdownNode>(
new Regex(@"<(a)?:(.+?):(\d+?)>", DefaultRegexOptions), // Capture <:lul:123456> or <a:lul:123456>
(_, m) => new EmojiNode( new Regex(@"<(a)?:(.+?):(\d+?)>", DefaultRegexOptions),
Snowflake.TryParse(m.Groups[3].Value), (_, m) =>
m.Groups[2].Value, new EmojiNode(
!string.IsNullOrWhiteSpace(m.Groups[1].Value) Snowflake.TryParse(m.Groups[3].Value),
) m.Groups[2].Value,
); !string.IsNullOrWhiteSpace(m.Groups[1].Value)
)
);
/* Links */ /* Links */
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher =
// Any non-whitespace character after http:// or https:// new RegexMatcher<MarkdownNode>(
// until the last punctuation character or whitespace. // Any non-whitespace character after http:// or https://
new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions), // until the last punctuation character or whitespace.
(_, m) => new LinkNode(m.Groups[1].Value) new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions),
); (_, m) => new LinkNode(m.Groups[1].Value)
);
private static readonly IMatcher<MarkdownNode> HiddenLinkNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> HiddenLinkNodeMatcher =
// Same as auto link but also surrounded by angular brackets new RegexMatcher<MarkdownNode>(
new Regex(@"<(https?://\S*[^\.,:;""'\s])>", DefaultRegexOptions), // Same as auto link but also surrounded by angular brackets
(_, m) => new LinkNode(m.Groups[1].Value) new Regex(@"<(https?://\S*[^\.,:;""'\s])>", DefaultRegexOptions),
); (_, m) => new LinkNode(m.Groups[1].Value)
);
private static readonly IMatcher<MarkdownNode> MaskedLinkNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> MaskedLinkNodeMatcher =
// Capture [title](link) new RegexMatcher<MarkdownNode>(
new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions), // Capture [title](link)
(s, m) => new LinkNode(m.Groups[2].Value, Parse(s.Relocate(m.Groups[1]))) new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions),
); (s, m) => new LinkNode(m.Groups[2].Value, Parse(s.Relocate(m.Groups[1])))
);
/* Text */ /* Text */
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher = new StringMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher =
// Capture the shrug kaomoji. new StringMatcher<MarkdownNode>(
// This escapes it from matching for formatting. // Capture the shrug kaomoji.
@"¯\_(ツ)_/¯", // This escapes it from matching for formatting.
s => new TextNode(s.ToString()) @"¯\_(ツ)_/¯",
); s => new TextNode(s.ToString())
);
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher =
// Capture some specific emoji that don't get rendered. new RegexMatcher<MarkdownNode>(
// This escapes them from matching for emoji. // Capture some specific emoji that don't get rendered.
new Regex(@"([\u26A7\u2640\u2642\u2695\u267E\u00A9\u00AE\u2122])", DefaultRegexOptions), // This escapes them from matching for emoji.
(_, m) => new TextNode(m.Groups[1].Value) new Regex(@"([\u26A7\u2640\u2642\u2695\u267E\u00A9\u00AE\u2122])", DefaultRegexOptions),
); (_, m) => new TextNode(m.Groups[1].Value)
);
private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher =
// Capture any "symbol/other" character or surrogate pair preceded by a backslash. new RegexMatcher<MarkdownNode>(
// This escapes them from matching for emoji. // Capture any "symbol/other" character or surrogate pair preceded by a backslash.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/230 // This escapes them from matching for emoji.
new Regex(@"\\(\p{So}|\p{Cs}{2})", DefaultRegexOptions), // https://github.com/Tyrrrz/DiscordChatExporter/issues/230
(_, m) => new TextNode(m.Groups[1].Value) new Regex(@"\\(\p{So}|\p{Cs}{2})", DefaultRegexOptions),
); (_, m) => new TextNode(m.Groups[1].Value)
);
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher =
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash. new RegexMatcher<MarkdownNode>(
// This escapes them from matching for formatting or other tokens. // Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions), // This escapes them from matching for formatting or other tokens.
(_, m) => new TextNode(m.Groups[1].Value) new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions),
); (_, m) => new TextNode(m.Groups[1].Value)
);
/* Misc */ /* Misc */
private static readonly IMatcher<MarkdownNode> TimestampNodeMatcher = new RegexMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> TimestampNodeMatcher =
// Capture <t:12345678> or <t:12345678:R> new RegexMatcher<MarkdownNode>(
new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions), // Capture <t:12345678> or <t:12345678:R>
(_, m) => new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions),
{ (_, m) =>
try
{ {
var instant = DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds( try
long.Parse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture)
);
var format = m.Groups[2].Value switch
{ {
"t" => "h:mm tt", var instant =
"T" => "h:mm:ss tt", DateTimeOffset.UnixEpoch
"d" => "MM/dd/yyyy", + TimeSpan.FromSeconds(
"D" => "MMMM dd, yyyy", long.Parse(
"f" => "MMMM dd, yyyy h:mm tt", m.Groups[1].Value,
"F" => "dddd, MMMM dd, yyyy h:mm tt", NumberStyles.Integer,
// Relative format is ignored because it doesn't make much sense in a static export CultureInfo.InvariantCulture
_ => null )
}; );
return new TimestampNode(instant, format); var format = m.Groups[2].Value switch
} {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/681 "t" => "h:mm tt",
// https://github.com/Tyrrrz/DiscordChatExporter/issues/766 "T" => "h:mm:ss tt",
catch (Exception ex) when (ex is FormatException or ArgumentOutOfRangeException or OverflowException) "d" => "MM/dd/yyyy",
{ "D" => "MMMM dd, yyyy",
// For invalid timestamps, Discord renders "Invalid Date" instead of ignoring the markdown "f" => "MMMM dd, yyyy h:mm tt",
return TimestampNode.Invalid; "F" => "dddd, MMMM dd, yyyy h:mm tt",
// Relative format is ignored because it doesn't make much sense in a static export
_ => null
};
return new TimestampNode(instant, format);
}
// https://github.com/Tyrrrz/DiscordChatExporter/issues/681
// https://github.com/Tyrrrz/DiscordChatExporter/issues/766
catch (Exception ex)
when (ex is FormatException or ArgumentOutOfRangeException or OverflowException)
{
// For invalid timestamps, Discord renders "Invalid Date" instead of ignoring the markdown
return TimestampNode.Invalid;
}
} }
} );
);
// Matchers that have similar patterns are ordered from most specific to least specific // Matchers that have similar patterns are ordered from most specific to least specific
private static readonly IMatcher<MarkdownNode> NodeMatcher = new AggregateMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> NodeMatcher = new AggregateMatcher<MarkdownNode>(
@ -320,7 +372,6 @@ internal static partial class MarkdownParser
IgnoredEmojiTextNodeMatcher, IgnoredEmojiTextNodeMatcher,
EscapedSymbolTextNodeMatcher, EscapedSymbolTextNodeMatcher,
EscapedCharacterTextNodeMatcher, EscapedCharacterTextNodeMatcher,
// Formatting // Formatting
ItalicBoldFormattingNodeMatcher, ItalicBoldFormattingNodeMatcher,
ItalicUnderlineFormattingNodeMatcher, ItalicUnderlineFormattingNodeMatcher,
@ -335,53 +386,46 @@ internal static partial class MarkdownParser
SingleLineQuoteNodeMatcher, SingleLineQuoteNodeMatcher,
HeadingNodeMatcher, HeadingNodeMatcher,
ListNodeMatcher, ListNodeMatcher,
// Code blocks // Code blocks
MultiLineCodeBlockNodeMatcher, MultiLineCodeBlockNodeMatcher,
InlineCodeBlockNodeMatcher, InlineCodeBlockNodeMatcher,
// Mentions // Mentions
EveryoneMentionNodeMatcher, EveryoneMentionNodeMatcher,
HereMentionNodeMatcher, HereMentionNodeMatcher,
UserMentionNodeMatcher, UserMentionNodeMatcher,
ChannelMentionNodeMatcher, ChannelMentionNodeMatcher,
RoleMentionNodeMatcher, RoleMentionNodeMatcher,
// Links // Links
MaskedLinkNodeMatcher, MaskedLinkNodeMatcher,
AutoLinkNodeMatcher, AutoLinkNodeMatcher,
HiddenLinkNodeMatcher, HiddenLinkNodeMatcher,
// Emoji // Emoji
StandardEmojiNodeMatcher, StandardEmojiNodeMatcher,
CustomEmojiNodeMatcher, CustomEmojiNodeMatcher,
CodedStandardEmojiNodeMatcher, CodedStandardEmojiNodeMatcher,
// Misc // Misc
TimestampNodeMatcher TimestampNodeMatcher
); );
// Minimal set of matchers for non-multimedia formats (e.g. plain text) // Minimal set of matchers for non-multimedia formats (e.g. plain text)
private static readonly IMatcher<MarkdownNode> MinimalNodeMatcher = new AggregateMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownNode> MinimalNodeMatcher =
// Mentions new AggregateMatcher<MarkdownNode>(
EveryoneMentionNodeMatcher, // Mentions
HereMentionNodeMatcher, EveryoneMentionNodeMatcher,
UserMentionNodeMatcher, HereMentionNodeMatcher,
ChannelMentionNodeMatcher, UserMentionNodeMatcher,
RoleMentionNodeMatcher, ChannelMentionNodeMatcher,
RoleMentionNodeMatcher,
// Emoji // Emoji
CustomEmojiNodeMatcher, CustomEmojiNodeMatcher,
// Misc
// Misc TimestampNodeMatcher
TimestampNodeMatcher );
);
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment, IMatcher<MarkdownNode> matcher) => private static IReadOnlyList<MarkdownNode> Parse(
matcher StringSegment segment,
.MatchAll(segment, s => new TextNode(s.ToString())) IMatcher<MarkdownNode> matcher
.Select(r => r.Value) ) => matcher.MatchAll(segment, s => new TextNode(s.ToString())).Select(r => r.Value).ToArray();
.ToArray();
} }
internal static partial class MarkdownParser internal static partial class MarkdownParser

@ -9,56 +9,63 @@ internal abstract class MarkdownVisitor
{ {
protected virtual ValueTask VisitTextAsync( protected virtual ValueTask VisitTextAsync(
TextNode text, TextNode text,
CancellationToken cancellationToken = default) => default; CancellationToken cancellationToken = default
) => default;
protected virtual async ValueTask VisitFormattingAsync( protected virtual async ValueTask VisitFormattingAsync(
FormattingNode formatting, FormattingNode formatting,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default
await VisitAsync(formatting.Children, cancellationToken); ) => await VisitAsync(formatting.Children, cancellationToken);
protected virtual async ValueTask VisitHeadingAsync( protected virtual async ValueTask VisitHeadingAsync(
HeadingNode heading, HeadingNode heading,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default
await VisitAsync(heading.Children, cancellationToken); ) => await VisitAsync(heading.Children, cancellationToken);
protected virtual async ValueTask VisitListAsync( protected virtual async ValueTask VisitListAsync(
ListNode list, ListNode list,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default
await VisitAsync(list.Items, cancellationToken); ) => await VisitAsync(list.Items, cancellationToken);
protected virtual async ValueTask VisitListItemAsync( protected virtual async ValueTask VisitListItemAsync(
ListItemNode listItem, ListItemNode listItem,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default
await VisitAsync(listItem.Children, cancellationToken); ) => await VisitAsync(listItem.Children, cancellationToken);
protected virtual ValueTask VisitInlineCodeBlockAsync( protected virtual ValueTask VisitInlineCodeBlockAsync(
InlineCodeBlockNode inlineCodeBlock, InlineCodeBlockNode inlineCodeBlock,
CancellationToken cancellationToken = default) => default; CancellationToken cancellationToken = default
) => default;
protected virtual ValueTask VisitMultiLineCodeBlockAsync( protected virtual ValueTask VisitMultiLineCodeBlockAsync(
MultiLineCodeBlockNode multiLineCodeBlock, MultiLineCodeBlockNode multiLineCodeBlock,
CancellationToken cancellationToken = default) => default; CancellationToken cancellationToken = default
) => default;
protected virtual async ValueTask VisitLinkAsync( protected virtual async ValueTask VisitLinkAsync(
LinkNode link, LinkNode link,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default
await VisitAsync(link.Children, cancellationToken); ) => await VisitAsync(link.Children, cancellationToken);
protected virtual ValueTask VisitEmojiAsync( protected virtual ValueTask VisitEmojiAsync(
EmojiNode emoji, EmojiNode emoji,
CancellationToken cancellationToken = default) => default; CancellationToken cancellationToken = default
) => default;
protected virtual ValueTask VisitMentionAsync( protected virtual ValueTask VisitMentionAsync(
MentionNode mention, MentionNode mention,
CancellationToken cancellationToken = default) => default; CancellationToken cancellationToken = default
) => default;
protected virtual ValueTask VisitTimestampAsync( protected virtual ValueTask VisitTimestampAsync(
TimestampNode timestamp, TimestampNode timestamp,
CancellationToken cancellationToken = default) => default; CancellationToken cancellationToken = default
) => default;
public async ValueTask VisitAsync( public async ValueTask VisitAsync(
MarkdownNode node, MarkdownNode node,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
if (node is TextNode text) if (node is TextNode text)
{ {
@ -131,7 +138,8 @@ internal abstract class MarkdownVisitor
public async ValueTask VisitAsync( public async ValueTask VisitAsync(
IEnumerable<MarkdownNode> nodes, IEnumerable<MarkdownNode> nodes,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default
)
{ {
foreach (var node in nodes) foreach (var node in nodes)
await VisitAsync(node, cancellationToken); await VisitAsync(node, cancellationToken);

@ -31,8 +31,6 @@ internal class RegexMatcher<T> : IMatcher<T>
var segmentMatch = segment.Relocate(match); var segmentMatch = segment.Relocate(match);
var value = _transform(segmentMatch, match); var value = _transform(segmentMatch, match);
return value is not null return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
? new ParsedMatch<T>(segmentMatch, value)
: null;
} }
} }

@ -8,7 +8,11 @@ internal class StringMatcher<T> : IMatcher<T>
private readonly StringComparison _comparison; private readonly StringComparison _comparison;
private readonly Func<StringSegment, T?> _transform; private readonly Func<StringSegment, T?> _transform;
public StringMatcher(string needle, StringComparison comparison, Func<StringSegment, T?> transform) public StringMatcher(
string needle,
StringComparison comparison,
Func<StringSegment, T?> transform
)
{ {
_needle = needle; _needle = needle;
_comparison = comparison; _comparison = comparison;
@ -16,21 +20,22 @@ internal class StringMatcher<T> : IMatcher<T>
} }
public StringMatcher(string needle, Func<StringSegment, T> transform) public StringMatcher(string needle, Func<StringSegment, T> transform)
: this(needle, StringComparison.Ordinal, transform) : this(needle, StringComparison.Ordinal, transform) { }
{
}
public ParsedMatch<T>? TryMatch(StringSegment segment) public ParsedMatch<T>? TryMatch(StringSegment segment)
{ {
var index = segment.Source.IndexOf(_needle, segment.StartIndex, segment.Length, _comparison); var index = segment.Source.IndexOf(
_needle,
segment.StartIndex,
segment.Length,
_comparison
);
if (index < 0) if (index < 0)
return null; return null;
var segmentMatch = segment.Relocate(index, _needle.Length); var segmentMatch = segment.Relocate(index, _needle.Length);
var value = _transform(segmentMatch); var value = _transform(segmentMatch);
return value is not null return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
? new ParsedMatch<T>(segmentMatch, value)
: null;
} }
} }

@ -7,11 +7,10 @@ internal readonly record struct StringSegment(string Source, int StartIndex, int
public int EndIndex => StartIndex + Length; public int EndIndex => StartIndex + Length;
public StringSegment(string target) public StringSegment(string target)
: this(target, 0, target.Length) : this(target, 0, target.Length) { }
{
}
public StringSegment Relocate(int newStartIndex, int newLength) => new(Source, newStartIndex, newLength); public StringSegment Relocate(int newStartIndex, int newLength) =>
new(Source, newStartIndex, newLength);
public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length); public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length);

@ -7,7 +7,8 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class AsyncCollectionExtensions public static class AsyncCollectionExtensions
{ {
private static async ValueTask<IReadOnlyList<T>> CollectAsync<T>( private static async ValueTask<IReadOnlyList<T>> CollectAsync<T>(
this IAsyncEnumerable<T> asyncEnumerable) this IAsyncEnumerable<T> asyncEnumerable
)
{ {
var list = new List<T>(); var list = new List<T>();
@ -18,6 +19,6 @@ public static class AsyncCollectionExtensions
} }
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>( public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
this IAsyncEnumerable<T> asyncEnumerable) => this IAsyncEnumerable<T> asyncEnumerable
asyncEnumerable.CollectAsync().GetAwaiter(); ) => asyncEnumerable.CollectAsync().GetAwaiter();
} }

@ -11,9 +11,7 @@ public static class BinaryExtensions
foreach (var b in data) foreach (var b in data)
{ {
buffer.Append( buffer.Append(b.ToString(isUpperCase ? "X2" : "x2", CultureInfo.InvariantCulture));
b.ToString(isUpperCase ? "X2" : "x2", CultureInfo.InvariantCulture)
);
} }
return buffer.ToString(); return buffer.ToString();

@ -16,7 +16,8 @@ public static class CollectionExtensions
yield return (o, i++); yield return (o, i++);
} }
public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source) where T : class public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source)
where T : class
{ {
foreach (var o in source) foreach (var o in source)
{ {

@ -24,7 +24,7 @@ public static class ExceptionExtensions
public static IReadOnlyList<Exception> GetSelfAndChildren(this Exception exception) public static IReadOnlyList<Exception> GetSelfAndChildren(this Exception exception)
{ {
var children = new List<Exception> {exception}; var children = new List<Exception> { exception };
PopulateChildren(exception, children); PopulateChildren(exception, children);
return children; return children;
} }

@ -5,13 +5,12 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class GenericExtensions public static class GenericExtensions
{ {
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) => transform(input); public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) =>
transform(input);
public static T? NullIf<T>(this T value, Func<T, bool> predicate) where T : struct => public static T? NullIf<T>(this T value, Func<T, bool> predicate)
!predicate(value) where T : struct => !predicate(value) ? value : null;
? value
: null;
public static T? NullIfDefault<T>(this T value) where T : struct => public static T? NullIfDefault<T>(this T value)
value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default)); where T : struct => value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
} }

@ -5,7 +5,5 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class HttpExtensions public static class HttpExtensions
{ {
public static string? TryGetValue(this HttpHeaders headers, string name) => public static string? TryGetValue(this HttpHeaders headers, string name) =>
headers.TryGetValues(name, out var values) headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
? string.Concat(values)
: null;
} }

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

Loading…
Cancel
Save