mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2025-05-29 05:55:21 -04:00
Use CSharpier
This commit is contained in:
parent
c410e745b1
commit
20f58963a6
174 changed files with 11084 additions and 10670 deletions
|
@ -8,11 +8,11 @@
|
|||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="*.secret" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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="GitHubActionsTestLogger" Version="2.3.2" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
|
|
|
@ -19,14 +19,16 @@ namespace DiscordChatExporter.Cli.Tests.Infra;
|
|||
|
||||
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;
|
||||
});
|
||||
|
||||
private static readonly string DirPath = Path.Combine(
|
||||
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? Directory.GetCurrentDirectory(),
|
||||
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
?? Directory.GetCurrentDirectory(),
|
||||
"ExportCache"
|
||||
);
|
||||
|
||||
|
@ -36,9 +38,7 @@ public static class ExportWrapper
|
|||
{
|
||||
Directory.Delete(DirPath, true);
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (DirectoryNotFoundException) { }
|
||||
|
||||
Directory.CreateDirectory(DirPath);
|
||||
}
|
||||
|
@ -66,13 +66,11 @@ public static class ExportWrapper
|
|||
return await File.ReadAllTextAsync(filePath);
|
||||
}
|
||||
|
||||
public static async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId) => Html.Parse(
|
||||
await ExportAsync(channelId, ExportFormat.HtmlDark)
|
||||
);
|
||||
public static async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId) =>
|
||||
Html.Parse(await ExportAsync(channelId, ExportFormat.HtmlDark));
|
||||
|
||||
public static async ValueTask<JsonElement> ExportAsJsonAsync(Snowflake channelId) => Json.Parse(
|
||||
await ExportAsync(channelId, ExportFormat.Json)
|
||||
);
|
||||
public static async ValueTask<JsonElement> ExportAsJsonAsync(Snowflake channelId) =>
|
||||
Json.Parse(await ExportAsync(channelId, ExportFormat.Json));
|
||||
|
||||
public static async ValueTask<string> ExportAsPlainTextAsync(Snowflake channelId) =>
|
||||
await ExportAsync(channelId, ExportFormat.PlainText);
|
||||
|
@ -80,20 +78,21 @@ public static class ExportWrapper
|
|||
public static async ValueTask<string> ExportAsCsvAsync(Snowflake channelId) =>
|
||||
await ExportAsync(channelId, ExportFormat.Csv);
|
||||
|
||||
public static async ValueTask<IReadOnlyList<IElement>> GetMessagesAsHtmlAsync(Snowflake channelId) =>
|
||||
(await ExportAsHtmlAsync(channelId))
|
||||
.QuerySelectorAll("[data-message-id]")
|
||||
.ToArray();
|
||||
public static async ValueTask<IReadOnlyList<IElement>> GetMessagesAsHtmlAsync(
|
||||
Snowflake channelId
|
||||
) => (await ExportAsHtmlAsync(channelId)).QuerySelectorAll("[data-message-id]").ToArray();
|
||||
|
||||
public static async ValueTask<IReadOnlyList<JsonElement>> GetMessagesAsJsonAsync(Snowflake channelId) =>
|
||||
(await ExportAsJsonAsync(channelId))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.ToArray();
|
||||
public static async ValueTask<IReadOnlyList<JsonElement>> GetMessagesAsJsonAsync(
|
||||
Snowflake channelId
|
||||
) => (await ExportAsJsonAsync(channelId)).GetProperty("messages").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(
|
||||
e =>
|
||||
string.Equals(
|
||||
e.GetAttribute("data-message-id"),
|
||||
messageId.ToString(),
|
||||
|
@ -111,9 +110,13 @@ public static class ExportWrapper
|
|||
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(
|
||||
j =>
|
||||
string.Equals(
|
||||
j.GetProperty("id").GetString(),
|
||||
messageId.ToString(),
|
||||
|
|
|
@ -12,6 +12,6 @@ internal static class Secrets
|
|||
.Build();
|
||||
|
||||
public static string DiscordToken =>
|
||||
Configuration["DISCORD_TOKEN"] ??
|
||||
throw new InvalidOperationException("Discord token not provided for tests.");
|
||||
Configuration["DISCORD_TOKEN"]
|
||||
?? throw new InvalidOperationException("Discord token not provided for tests.");
|
||||
}
|
|
@ -14,7 +14,9 @@ public class CsvContentSpecs
|
|||
var document = await ExportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases);
|
||||
|
||||
// Assert
|
||||
document.Should().ContainAll(
|
||||
document
|
||||
.Should()
|
||||
.ContainAll(
|
||||
"tyrrrz",
|
||||
"Hello world",
|
||||
"Goodbye world",
|
||||
|
|
|
@ -34,8 +34,7 @@ public class DateRangeSpecs
|
|||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
var timestamps = Json
|
||||
.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||
|
@ -43,21 +42,28 @@ public class DateRangeSpecs
|
|||
|
||||
timestamps.All(t => t > after).Should().BeTrue();
|
||||
|
||||
timestamps.Should().BeEquivalentTo(new[]
|
||||
timestamps
|
||||
.Should()
|
||||
.BeEquivalentTo(
|
||||
new[]
|
||||
{
|
||||
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero)
|
||||
}, o =>
|
||||
},
|
||||
o =>
|
||||
{
|
||||
return o
|
||||
.Using<DateTimeOffset>(ctx =>
|
||||
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||
return o.Using<DateTimeOffset>(
|
||||
ctx =>
|
||||
ctx.Subject
|
||||
.Should()
|
||||
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||
)
|
||||
.WhenTypeIs<DateTimeOffset>();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
@ -78,8 +84,7 @@ public class DateRangeSpecs
|
|||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
var timestamps = Json
|
||||
.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||
|
@ -87,19 +92,26 @@ public class DateRangeSpecs
|
|||
|
||||
timestamps.All(t => t < before).Should().BeTrue();
|
||||
|
||||
timestamps.Should().BeEquivalentTo(new[]
|
||||
timestamps
|
||||
.Should()
|
||||
.BeEquivalentTo(
|
||||
new[]
|
||||
{
|
||||
new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero)
|
||||
}, o =>
|
||||
},
|
||||
o =>
|
||||
{
|
||||
return o
|
||||
.Using<DateTimeOffset>(ctx =>
|
||||
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||
return o.Using<DateTimeOffset>(
|
||||
ctx =>
|
||||
ctx.Subject
|
||||
.Should()
|
||||
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||
)
|
||||
.WhenTypeIs<DateTimeOffset>();
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
@ -122,8 +134,7 @@ public class DateRangeSpecs
|
|||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
var timestamps = Json
|
||||
.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||
|
@ -131,19 +142,26 @@ public class DateRangeSpecs
|
|||
|
||||
timestamps.All(t => t < before && t > after).Should().BeTrue();
|
||||
|
||||
timestamps.Should().BeEquivalentTo(new[]
|
||||
timestamps
|
||||
.Should()
|
||||
.BeEquivalentTo(
|
||||
new[]
|
||||
{
|
||||
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero)
|
||||
}, o =>
|
||||
},
|
||||
o =>
|
||||
{
|
||||
return o
|
||||
.Using<DateTimeOffset>(ctx =>
|
||||
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||
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());
|
||||
|
||||
// Assert
|
||||
Json
|
||||
.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("content").GetString())
|
||||
|
@ -58,8 +57,7 @@ public class FilterSpecs
|
|||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
Json
|
||||
.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("author").GetProperty("name").GetString())
|
||||
|
@ -84,8 +82,7 @@ public class FilterSpecs
|
|||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
Json
|
||||
.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("content").GetString())
|
||||
|
@ -110,8 +107,7 @@ public class FilterSpecs
|
|||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
Json
|
||||
.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("content").GetString())
|
||||
|
@ -136,8 +132,7 @@ public class FilterSpecs
|
|||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
Json
|
||||
.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("content").GetString())
|
||||
|
|
|
@ -20,11 +20,7 @@ public class HtmlAttachmentSpecs
|
|||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().ContainAll(
|
||||
"Generic file attachment",
|
||||
"Test.txt",
|
||||
"11 bytes"
|
||||
);
|
||||
message.Text().Should().ContainAll("Generic file attachment", "Test.txt", "11 bytes");
|
||||
|
||||
message
|
||||
.QuerySelectorAll("a")
|
||||
|
@ -71,7 +67,9 @@ public class HtmlAttachmentSpecs
|
|||
message.Text().Should().Contain("Video attachment");
|
||||
|
||||
var videoUrl = message.QuerySelector("video source")?.GetAttribute("src");
|
||||
videoUrl.Should().Be(
|
||||
videoUrl
|
||||
.Should()
|
||||
.Be(
|
||||
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
||||
);
|
||||
}
|
||||
|
@ -91,7 +89,9 @@ public class HtmlAttachmentSpecs
|
|||
message.Text().Should().Contain("Audio attachment");
|
||||
|
||||
var audioUrl = message.QuerySelector("audio source")?.GetAttribute("src");
|
||||
audioUrl.Should().Be(
|
||||
audioUrl
|
||||
.Should()
|
||||
.Be(
|
||||
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
||||
);
|
||||
}
|
||||
|
|
|
@ -16,7 +16,10 @@ public class HtmlContentSpecs
|
|||
var messages = await ExportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases);
|
||||
|
||||
// Assert
|
||||
messages.Select(e => e.GetAttribute("data-message-id")).Should().Equal(
|
||||
messages
|
||||
.Select(e => e.GetAttribute("data-message-id"))
|
||||
.Should()
|
||||
.Equal(
|
||||
"866674314627121232",
|
||||
"866710679758045195",
|
||||
"866732113319428096",
|
||||
|
@ -27,7 +30,10 @@ public class HtmlContentSpecs
|
|||
"885169254029213696"
|
||||
);
|
||||
|
||||
messages.SelectMany(e => e.Text()).Should().ContainInOrder(
|
||||
messages
|
||||
.SelectMany(e => e.Text())
|
||||
.Should()
|
||||
.ContainInOrder(
|
||||
"Hello world",
|
||||
"Goodbye world",
|
||||
"Foo bar",
|
||||
|
|
|
@ -21,13 +21,19 @@ public class HtmlEmbedSpecs
|
|||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().ContainAll(
|
||||
message
|
||||
.Text()
|
||||
.Should()
|
||||
.ContainAll(
|
||||
"Embed author",
|
||||
"Embed title",
|
||||
"Embed description",
|
||||
"Field 1", "Value 1",
|
||||
"Field 2", "Value 2",
|
||||
"Field 3", "Value 3",
|
||||
"Field 1",
|
||||
"Value 1",
|
||||
"Field 2",
|
||||
"Value 2",
|
||||
"Field 3",
|
||||
"Value 3",
|
||||
"Embed footer"
|
||||
);
|
||||
}
|
||||
|
@ -83,7 +89,12 @@ public class HtmlEmbedSpecs
|
|||
.QuerySelectorAll("source")
|
||||
.Select(e => e.GetAttribute("src"))
|
||||
.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()
|
||||
.ContainSingle();
|
||||
}
|
||||
|
|
|
@ -32,8 +32,7 @@ public class HtmlGroupingSpecs
|
|||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
var messageGroups = Html
|
||||
.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
var messageGroups = Html.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.QuerySelectorAll(".chatlog__message-group");
|
||||
|
||||
messageGroups.Should().HaveCount(2);
|
||||
|
@ -59,12 +58,6 @@ public class HtmlGroupingSpecs
|
|||
.QuerySelectorAll(".chatlog__content")
|
||||
.Select(e => e.Text())
|
||||
.Should()
|
||||
.ContainInOrder(
|
||||
"Eleventh",
|
||||
"Twelveth",
|
||||
"Thirteenth",
|
||||
"Fourteenth",
|
||||
"Fifteenth"
|
||||
);
|
||||
.ContainInOrder("Eleventh", "Twelveth", "Thirteenth", "Fourteenth", "Fifteenth");
|
||||
}
|
||||
}
|
|
@ -170,7 +170,10 @@ public class HtmlMarkdownSpecs
|
|||
);
|
||||
|
||||
// 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");
|
||||
}
|
||||
finally
|
||||
|
|
|
@ -36,9 +36,11 @@ public class HtmlReplySpecs
|
|||
|
||||
// Assert
|
||||
message.Text().Should().Contain("reply to deleted");
|
||||
message.QuerySelector(".chatlog__reply-link")?.Text().Should().Contain(
|
||||
"Original message was deleted or could not be loaded."
|
||||
);
|
||||
message
|
||||
.QuerySelector(".chatlog__reply-link")
|
||||
?.Text()
|
||||
.Should()
|
||||
.Contain("Original message was deleted or could not be loaded.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
@ -54,7 +56,11 @@ public class HtmlReplySpecs
|
|||
|
||||
// Assert
|
||||
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]
|
||||
|
@ -84,7 +90,10 @@ public class HtmlReplySpecs
|
|||
);
|
||||
|
||||
// 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.QuerySelector(".chatlog__reply-link").Should().BeNull();
|
||||
}
|
||||
|
|
|
@ -32,7 +32,9 @@ public class HtmlStickerSpecs
|
|||
);
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
|
@ -24,7 +24,11 @@ public class JsonAttachmentSpecs
|
|||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||
attachments.Should().HaveCount(1);
|
||||
|
||||
attachments[0].GetProperty("url").GetString().Should().Be(
|
||||
attachments[0]
|
||||
.GetProperty("url")
|
||||
.GetString()
|
||||
.Should()
|
||||
.Be(
|
||||
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt"
|
||||
);
|
||||
attachments[0].GetProperty("fileName").GetString().Should().Be("Test.txt");
|
||||
|
@ -46,7 +50,11 @@ public class JsonAttachmentSpecs
|
|||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||
attachments.Should().HaveCount(1);
|
||||
|
||||
attachments[0].GetProperty("url").GetString().Should().Be(
|
||||
attachments[0]
|
||||
.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");
|
||||
|
@ -68,10 +76,18 @@ public class JsonAttachmentSpecs
|
|||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||
attachments.Should().HaveCount(1);
|
||||
|
||||
attachments[0].GetProperty("url").GetString().Should().Be(
|
||||
attachments[0]
|
||||
.GetProperty("url")
|
||||
.GetString()
|
||||
.Should()
|
||||
.Be(
|
||||
"https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4"
|
||||
);
|
||||
attachments[0].GetProperty("fileName").GetString().Should().Be("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);
|
||||
}
|
||||
|
||||
|
@ -90,7 +106,11 @@ public class JsonAttachmentSpecs
|
|||
var attachments = message.GetProperty("attachments").EnumerateArray().ToArray();
|
||||
attachments.Should().HaveCount(1);
|
||||
|
||||
attachments[0].GetProperty("url").GetString().Should().Be(
|
||||
attachments[0]
|
||||
.GetProperty("url")
|
||||
.GetString()
|
||||
.Should()
|
||||
.Be(
|
||||
"https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3"
|
||||
);
|
||||
attachments[0].GetProperty("fileName").GetString().Should().Be("file_example_MP3_1MG.mp3");
|
||||
|
|
|
@ -15,7 +15,10 @@ public class JsonContentSpecs
|
|||
var messages = await ExportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases);
|
||||
|
||||
// Assert
|
||||
messages.Select(j => j.GetProperty("id").GetString()).Should().Equal(
|
||||
messages
|
||||
.Select(j => j.GetProperty("id").GetString())
|
||||
.Should()
|
||||
.Equal(
|
||||
"866674314627121232",
|
||||
"866710679758045195",
|
||||
"866732113319428096",
|
||||
|
@ -26,7 +29,10 @@ public class JsonContentSpecs
|
|||
"885169254029213696"
|
||||
);
|
||||
|
||||
messages.Select(j => j.GetProperty("content").GetString()).Should().Equal(
|
||||
messages
|
||||
.Select(j => j.GetProperty("content").GetString())
|
||||
.Should()
|
||||
.Equal(
|
||||
"Hello world",
|
||||
"Goodbye world",
|
||||
"Foo bar",
|
||||
|
|
|
@ -39,7 +39,11 @@ public class JsonMentionSpecs
|
|||
);
|
||||
|
||||
// Assert
|
||||
message.GetProperty("content").GetString().Should().Be("Text channel mention: #mention-tests");
|
||||
message
|
||||
.GetProperty("content")
|
||||
.GetString()
|
||||
.Should()
|
||||
.Be("Text channel mention: #mention-tests");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
@ -52,7 +56,11 @@ public class JsonMentionSpecs
|
|||
);
|
||||
|
||||
// Assert
|
||||
message.GetProperty("content").GetString().Should().Be("Voice channel mention: #general [voice]");
|
||||
message
|
||||
.GetProperty("content")
|
||||
.GetString()
|
||||
.Should()
|
||||
.Be("Voice channel mention: #general [voice]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
@ -19,15 +19,16 @@ public class JsonStickerSpecs
|
|||
);
|
||||
|
||||
// Assert
|
||||
var sticker = message
|
||||
.GetProperty("stickers")
|
||||
.EnumerateArray()
|
||||
.Single();
|
||||
var sticker = message.GetProperty("stickers").EnumerateArray().Single();
|
||||
|
||||
sticker.GetProperty("id").GetString().Should().Be("904215665597120572");
|
||||
sticker.GetProperty("name").GetString().Should().Be("rock");
|
||||
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]
|
||||
|
@ -40,14 +41,15 @@ public class JsonStickerSpecs
|
|||
);
|
||||
|
||||
// Assert
|
||||
var sticker = message
|
||||
.GetProperty("stickers")
|
||||
.EnumerateArray()
|
||||
.Single();
|
||||
var sticker = message.GetProperty("stickers").EnumerateArray().Single();
|
||||
|
||||
sticker.GetProperty("id").GetString().Should().Be("816087132447178774");
|
||||
sticker.GetProperty("name").GetString().Should().Be("Yikes");
|
||||
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());
|
||||
|
||||
// Assert
|
||||
Directory.EnumerateFiles(dir.Path, "output*")
|
||||
.Should()
|
||||
.HaveCount(3);
|
||||
Directory.EnumerateFiles(dir.Path, "output*").Should().HaveCount(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
@ -54,8 +52,6 @@ public class PartitioningSpecs
|
|||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
Directory.EnumerateFiles(dir.Path, "output*")
|
||||
.Should()
|
||||
.HaveCount(8);
|
||||
Directory.EnumerateFiles(dir.Path, "output*").Should().HaveCount(8);
|
||||
}
|
||||
}
|
|
@ -14,7 +14,9 @@ public class PlainTextContentSpecs
|
|||
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases);
|
||||
|
||||
// Assert
|
||||
document.Should().ContainAll(
|
||||
document
|
||||
.Should()
|
||||
.ContainAll(
|
||||
"tyrrrz",
|
||||
"Hello world",
|
||||
"Goodbye world",
|
||||
|
|
|
@ -31,8 +31,7 @@ public class SelfContainedSpecs
|
|||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
Html
|
||||
.Parse(await File.ReadAllTextAsync(filePath))
|
||||
Html.Parse(await File.ReadAllTextAsync(filePath))
|
||||
.QuerySelectorAll("body [src]")
|
||||
.Select(e => e.GetAttribute("src")!)
|
||||
.Select(f => Path.GetFullPath(f, dir.Path))
|
||||
|
|
|
@ -9,8 +9,7 @@ internal partial class TempDir : IDisposable
|
|||
{
|
||||
public string Path { get; }
|
||||
|
||||
public TempDir(string path) =>
|
||||
Path = path;
|
||||
public TempDir(string path) => Path = path;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
@ -18,9 +17,7 @@ internal partial class TempDir : IDisposable
|
|||
{
|
||||
Directory.Delete(Path, true);
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (DirectoryNotFoundException) { }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -29,7 +26,8 @@ internal partial class TempDir
|
|||
public static TempDir Create()
|
||||
{
|
||||
var dirPath = PathEx.Combine(
|
||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? Directory.GetCurrentDirectory(),
|
||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
?? Directory.GetCurrentDirectory(),
|
||||
"Temp",
|
||||
Guid.NewGuid().ToString()
|
||||
);
|
||||
|
|
|
@ -9,8 +9,7 @@ internal partial class TempFile : IDisposable
|
|||
{
|
||||
public string Path { get; }
|
||||
|
||||
public TempFile(string path) =>
|
||||
Path = path;
|
||||
public TempFile(string path) => Path = path;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
@ -18,9 +17,7 @@ internal partial class TempFile : IDisposable
|
|||
{
|
||||
File.Delete(Path);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (FileNotFoundException) { }
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -29,16 +26,14 @@ internal partial class TempFile
|
|||
public static TempFile Create()
|
||||
{
|
||||
var dirPath = PathEx.Combine(
|
||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? Directory.GetCurrentDirectory(),
|
||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
?? Directory.GetCurrentDirectory(),
|
||||
"Temp"
|
||||
);
|
||||
|
||||
Directory.CreateDirectory(dirPath);
|
||||
|
||||
var filePath = PathEx.Combine(
|
||||
dirPath,
|
||||
Guid.NewGuid() + ".tmp"
|
||||
);
|
||||
var filePath = PathEx.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
||||
|
||||
return new TempFile(filePath);
|
||||
}
|
||||
|
|
|
@ -38,9 +38,9 @@ public abstract class DiscordCommandBase : ICommand
|
|||
using (console.WithForegroundColor(ConsoleColor.DarkYellow))
|
||||
{
|
||||
console.Error.WriteLine(
|
||||
"Warning: Option --bot is deprecated and should not be used. " +
|
||||
"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."
|
||||
"Warning: Option --bot is deprecated and should not be used. "
|
||||
+ "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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,11 +28,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||
[CommandOption(
|
||||
"output",
|
||||
'o',
|
||||
Description =
|
||||
"Output file or directory path. " +
|
||||
"Directory path must end with a slash to avoid ambiguity. " +
|
||||
"If a directory is specified, file names will be generated automatically. " +
|
||||
"Supports template tokens, see the documentation for more info."
|
||||
Description = "Output file or directory path. "
|
||||
+ "Directory path must end with a slash to avoid ambiguity. "
|
||||
+ "If a directory is specified, file names will be generated automatically. "
|
||||
+ "Supports template tokens, see the documentation for more info."
|
||||
)]
|
||||
public string OutputPath
|
||||
{
|
||||
|
@ -42,11 +41,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||
init => _outputPath = Path.GetFullPath(value);
|
||||
}
|
||||
|
||||
[CommandOption(
|
||||
"format",
|
||||
'f',
|
||||
Description = "Export format."
|
||||
)]
|
||||
[CommandOption("format", 'f', Description = "Export format.")]
|
||||
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
||||
|
||||
[CommandOption(
|
||||
|
@ -64,17 +59,15 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||
[CommandOption(
|
||||
"partition",
|
||||
'p',
|
||||
Description =
|
||||
"Split the output into partitions, each limited to the specified " +
|
||||
"number of messages (e.g. '100') or file size (e.g. '10mb')."
|
||||
Description = "Split the output into partitions, each limited to the specified "
|
||||
+ "number of messages (e.g. '100') or file size (e.g. '10mb')."
|
||||
)]
|
||||
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
||||
|
||||
[CommandOption(
|
||||
"filter",
|
||||
Description =
|
||||
"Only include messages that satisfy this filter. " +
|
||||
"See the documentation for more info."
|
||||
Description = "Only include messages that satisfy this filter. "
|
||||
+ "See the documentation for more info."
|
||||
)]
|
||||
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
|
||||
|
||||
|
@ -106,9 +99,8 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||
|
||||
[CommandOption(
|
||||
"media-dir",
|
||||
Description =
|
||||
"Download assets to this directory. " +
|
||||
"If not specified, the asset directory path will be derived from the output path."
|
||||
Description = "Download assets to this directory. "
|
||||
+ "If not specified, the asset directory path will be derived from the output path."
|
||||
)]
|
||||
public string? AssetsDirPath
|
||||
{
|
||||
|
@ -118,10 +110,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||
init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null;
|
||||
}
|
||||
|
||||
[CommandOption(
|
||||
"dateformat",
|
||||
Description = "Format used when writing dates."
|
||||
)]
|
||||
[CommandOption("dateformat", Description = "Format used when writing dates.")]
|
||||
public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt";
|
||||
|
||||
[CommandOption(
|
||||
|
@ -142,17 +131,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
|
||||
if (ShouldReuseAssets && !ShouldDownloadAssets)
|
||||
{
|
||||
throw new CommandException(
|
||||
"Option --reuse-media cannot be used without --media."
|
||||
);
|
||||
throw new CommandException("Option --reuse-media cannot be used without --media.");
|
||||
}
|
||||
|
||||
// Assets directory can only be specified if the download assets option is set
|
||||
if (!string.IsNullOrWhiteSpace(AssetsDirPath) && !ShouldDownloadAssets)
|
||||
{
|
||||
throw new CommandException(
|
||||
"Option --media-dir cannot be used without --media."
|
||||
);
|
||||
throw new CommandException("Option --media-dir cannot be used without --media.");
|
||||
}
|
||||
|
||||
// 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
|
||||
var isValidOutputPath =
|
||||
// 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
|
||||
OutputPath.Contains('%') ||
|
||||
OutputPath.Contains('%')
|
||||
||
|
||||
// Otherwise, require an existing directory or an unambiguous directory path
|
||||
Directory.Exists(OutputPath) || PathEx.IsDirectoryPath(OutputPath);
|
||||
Directory.Exists(OutputPath)
|
||||
|| PathEx.IsDirectoryPath(OutputPath);
|
||||
|
||||
if (!isValidOutputPath)
|
||||
{
|
||||
throw new CommandException(
|
||||
"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."
|
||||
"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."
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -180,7 +168,9 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
|
||||
|
||||
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
|
||||
await console.CreateProgressTicker().StartAsync(async progressContext =>
|
||||
await console
|
||||
.CreateProgressTicker()
|
||||
.StartAsync(async progressContext =>
|
||||
{
|
||||
await Parallel.ForEachAsync(
|
||||
channels,
|
||||
|
@ -197,7 +187,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||
$"{channel.Category} / {channel.Name}",
|
||||
async progress =>
|
||||
{
|
||||
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
|
||||
var guild = await Discord.GetGuildAsync(
|
||||
channel.GuildId,
|
||||
innerCancellationToken
|
||||
);
|
||||
|
||||
var request = new ExportRequest(
|
||||
guild,
|
||||
|
@ -285,8 +278,8 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||
if (channel.Kind == ChannelKind.GuildCategory)
|
||||
{
|
||||
var guildChannels =
|
||||
channelsByGuild.GetValueOrDefault(channel.GuildId) ??
|
||||
await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
||||
channelsByGuild.GetValueOrDefault(channel.GuildId)
|
||||
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
||||
|
||||
foreach (var guildChannel in guildChannels)
|
||||
{
|
||||
|
@ -311,15 +304,33 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||
// Support Ukraine callout
|
||||
if (!IsUkraineSupportMessageDisabled)
|
||||
{
|
||||
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("│ 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(
|
||||
"│ Thank you for supporting Ukraine <3 │"
|
||||
);
|
||||
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("");
|
||||
}
|
||||
|
||||
|
|
|
@ -16,41 +16,25 @@ namespace DiscordChatExporter.Cli.Commands;
|
|||
[Command("exportall", Description = "Exports all accessible channels.")]
|
||||
public class ExportAllCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption(
|
||||
"include-dm",
|
||||
Description = "Include direct message channels."
|
||||
)]
|
||||
[CommandOption("include-dm", Description = "Include direct message channels.")]
|
||||
public bool IncludeDirectChannels { get; init; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"include-guilds",
|
||||
Description = "Include guild channels."
|
||||
)]
|
||||
[CommandOption("include-guilds", Description = "Include guild channels.")]
|
||||
public bool IncludeGuildChannels { get; init; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"include-vc",
|
||||
Description = "Include voice channels."
|
||||
)]
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
public bool IncludeVoiceChannels { get; init; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"include-threads",
|
||||
Description = "Include threads."
|
||||
)]
|
||||
[CommandOption("include-threads", Description = "Include threads.")]
|
||||
public bool IncludeThreads { get; init; } = false;
|
||||
|
||||
[CommandOption(
|
||||
"include-archived-threads",
|
||||
Description = "Include archived threads."
|
||||
)]
|
||||
[CommandOption("include-archived-threads", Description = "Include archived threads.")]
|
||||
public bool IncludeArchivedThreads { get; init; } = false;
|
||||
|
||||
[CommandOption(
|
||||
"data-package",
|
||||
Description =
|
||||
"Path to the personal data package (ZIP file) requested from Discord. " +
|
||||
"If provided, only channels referenced in the dump will be exported."
|
||||
Description = "Path to the personal data package (ZIP file) requested from Discord. "
|
||||
+ "If provided, only channels referenced in the dump will be exported."
|
||||
)]
|
||||
public string? DataPackageFilePath { get; init; }
|
||||
|
||||
|
@ -77,7 +61,9 @@ public class ExportAllCommand : ExportCommandBase
|
|||
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
|
||||
{
|
||||
// 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)
|
||||
continue;
|
||||
|
@ -91,7 +77,13 @@ public class ExportAllCommand : ExportCommandBase
|
|||
// Threads
|
||||
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);
|
||||
}
|
||||
|
@ -120,7 +112,9 @@ public class ExportAllCommand : ExportCommandBase
|
|||
if (channelName is null)
|
||||
continue;
|
||||
|
||||
await console.Output.WriteLineAsync($"Fetching channel '{channelName}' ({channelId})...");
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Fetching channel '{channelName}' ({channelId})..."
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
|
@ -129,7 +123,9 @@ public class ExportAllCommand : ExportCommandBase
|
|||
}
|
||||
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(
|
||||
"channel",
|
||||
'c',
|
||||
Description =
|
||||
"Channel ID(s). " +
|
||||
"If provided with category ID(s), all channels inside those categories will be exported."
|
||||
Description = "Channel ID(s). "
|
||||
+ "If provided with category ID(s), all channels inside those categories will be exported."
|
||||
)]
|
||||
public required IReadOnlyList<Snowflake> ChannelIds { get; init; }
|
||||
|
||||
|
|
|
@ -17,7 +17,10 @@ public class ExportDirectMessagesCommand : ExportCommandBase
|
|||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -12,29 +12,16 @@ namespace DiscordChatExporter.Cli.Commands;
|
|||
[Command("exportguild", Description = "Exports all channels within the specified guild.")]
|
||||
public class ExportGuildCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption(
|
||||
"guild",
|
||||
'g',
|
||||
Description = "Guild ID."
|
||||
)]
|
||||
[CommandOption("guild", 'g', Description = "Guild ID.")]
|
||||
public required Snowflake GuildId { get; init; }
|
||||
|
||||
[CommandOption(
|
||||
"include-vc",
|
||||
Description = "Include voice channels."
|
||||
)]
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
public bool IncludeVoiceChannels { get; init; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"include-threads",
|
||||
Description = "Include threads."
|
||||
)]
|
||||
[CommandOption("include-threads", Description = "Include threads.")]
|
||||
public bool IncludeThreads { get; init; } = false;
|
||||
|
||||
[CommandOption(
|
||||
"include-archived-threads",
|
||||
Description = "Include archived threads."
|
||||
)]
|
||||
[CommandOption("include-archived-threads", Description = "Include archived threads.")]
|
||||
public bool IncludeArchivedThreads { get; init; } = false;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
|
@ -69,7 +56,13 @@ public class ExportGuildCommand : ExportCommandBase
|
|||
// Threads
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -14,29 +14,16 @@ namespace DiscordChatExporter.Cli.Commands;
|
|||
[Command("channels", Description = "Get the list of channels in a guild.")]
|
||||
public class GetChannelsCommand : DiscordCommandBase
|
||||
{
|
||||
[CommandOption(
|
||||
"guild",
|
||||
'g',
|
||||
Description = "Guild ID."
|
||||
)]
|
||||
[CommandOption("guild", 'g', Description = "Guild ID.")]
|
||||
public required Snowflake GuildId { get; init; }
|
||||
|
||||
[CommandOption(
|
||||
"include-vc",
|
||||
Description = "Include voice channels."
|
||||
)]
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
public bool IncludeVoiceChannels { get; init; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"include-threads",
|
||||
Description = "Include threads."
|
||||
)]
|
||||
[CommandOption("include-threads", Description = "Include threads.")]
|
||||
public bool IncludeThreads { get; init; } = false;
|
||||
|
||||
[CommandOption(
|
||||
"include-archived-threads",
|
||||
Description = "Include archived threads."
|
||||
)]
|
||||
[CommandOption("include-archived-threads", Description = "Include archived threads.")]
|
||||
public bool IncludeArchivedThreads { get; init; } = false;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
|
@ -66,7 +53,13 @@ public class GetChannelsCommand : DiscordCommandBase
|
|||
.FirstOrDefault();
|
||||
|
||||
var threads = IncludeThreads
|
||||
? (await Discord.GetGuildThreadsAsync(GuildId, IncludeArchivedThreads, cancellationToken))
|
||||
? (
|
||||
await Discord.GetGuildThreadsAsync(
|
||||
GuildId,
|
||||
IncludeArchivedThreads,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToArray()
|
||||
: Array.Empty<Channel>();
|
||||
|
@ -116,7 +109,9 @@ public class GetChannelsCommand : DiscordCommandBase
|
|||
|
||||
// Thread status
|
||||
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 channels = (await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken))
|
||||
var channels = (
|
||||
await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken)
|
||||
)
|
||||
.Where(c => c.Kind != ChannelKind.GuildCategory)
|
||||
.OrderByDescending(c => c.LastMessageId)
|
||||
.ThenBy(c => c.Name)
|
||||
|
|
|
@ -32,9 +32,7 @@ public class GetGuildsCommand : DiscordCommandBase
|
|||
foreach (var guild in guilds)
|
||||
{
|
||||
// Guild ID
|
||||
await console.Output.WriteAsync(
|
||||
guild.Id.ToString().PadRight(guildIdMaxLength, ' ')
|
||||
);
|
||||
await console.Output.WriteAsync(guild.Id.ToString().PadRight(guildIdMaxLength, ' '));
|
||||
|
||||
// Separator
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||
|
|
|
@ -15,14 +15,18 @@ public class GuideCommand : ICommand
|
|||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
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(" 2. Open any server or direct message channel");
|
||||
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
||||
console.Output.WriteLine(" 4. Navigate to the Network tab");
|
||||
console.Output.WriteLine(" 5. Press Ctrl+R to reload");
|
||||
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(" 9. Scroll down to the Request Headers section");
|
||||
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(" 3. Navigate to the Bot section on the left");
|
||||
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();
|
||||
|
||||
// Guild or channel ID
|
||||
|
@ -47,14 +53,20 @@ public class GuideCommand : ICommand
|
|||
console.Output.WriteLine(" 2. Open Settings");
|
||||
console.Output.WriteLine(" 3. Go to Advanced section");
|
||||
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();
|
||||
|
||||
// Docs link
|
||||
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))
|
||||
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs");
|
||||
console.Output.WriteLine(
|
||||
"https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"
|
||||
);
|
||||
|
||||
return default;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<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="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
using CliFx;
|
||||
|
||||
return await new CliApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
return await new CliApplicationBuilder().AddCommandsFromThisAssembly().Build().RunAsync(args);
|
||||
|
|
|
@ -8,14 +8,17 @@ namespace DiscordChatExporter.Cli.Utils.Extensions;
|
|||
internal static class ConsoleExtensions
|
||||
{
|
||||
public static IAnsiConsole CreateAnsiConsole(this IConsole console) =>
|
||||
AnsiConsole.Create(new AnsiConsoleSettings
|
||||
AnsiConsole.Create(
|
||||
new AnsiConsoleSettings
|
||||
{
|
||||
Ansi = AnsiSupport.Detect,
|
||||
ColorSystem = ColorSystemSupport.Detect,
|
||||
Out = new AnsiConsoleOutput(console.Output)
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
public static Progress CreateProgressTicker(this IConsole console) => console
|
||||
public static Progress CreateProgressTicker(this IConsole console) =>
|
||||
console
|
||||
.CreateAnsiConsole()
|
||||
.Progress()
|
||||
.AutoClear(false)
|
||||
|
@ -30,7 +33,8 @@ internal static class ConsoleExtensions
|
|||
public static async ValueTask StartTaskAsync(
|
||||
this ProgressContext progressContext,
|
||||
string description,
|
||||
Func<ProgressTask, ValueTask> performOperationAsync)
|
||||
Func<ProgressTask, ValueTask> performOperationAsync
|
||||
)
|
||||
{
|
||||
var progressTask = progressContext.AddTask(
|
||||
// Don't recognize random square brackets as style tags
|
||||
|
|
|
@ -15,30 +15,31 @@ public partial record Attachment(
|
|||
string? Description,
|
||||
int? Width,
|
||||
int? Height,
|
||||
FileSize FileSize) : IHasId
|
||||
FileSize FileSize
|
||||
) : IHasId
|
||||
{
|
||||
public string FileExtension => Path.GetExtension(FileName);
|
||||
|
||||
public bool IsImage =>
|
||||
string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(FileExtension, ".jpeg", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(FileExtension, ".png", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(FileExtension, ".gif", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(FileExtension, ".bmp", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(FileExtension, ".webp", StringComparison.OrdinalIgnoreCase);
|
||||
string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(FileExtension, ".jpeg", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(FileExtension, ".png", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(FileExtension, ".gif", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(FileExtension, ".bmp", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(FileExtension, ".webp", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsVideo =>
|
||||
string.Equals(FileExtension, ".gifv", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(FileExtension, ".mp4", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(FileExtension, ".webm", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(FileExtension, ".mov", StringComparison.OrdinalIgnoreCase);
|
||||
string.Equals(FileExtension, ".gifv", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(FileExtension, ".mp4", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(FileExtension, ".webm", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(FileExtension, ".mov", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsAudio =>
|
||||
string.Equals(FileExtension, ".mp3", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(FileExtension, ".wav", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(FileExtension, ".ogg", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase);
|
||||
string.Equals(FileExtension, ".mp3", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(FileExtension, ".wav", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(FileExtension, ".ogg", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
||||
}
|
||||
|
|
|
@ -17,13 +17,16 @@ public partial record Channel(
|
|||
string? IconUrl,
|
||||
string? Topic,
|
||||
bool IsArchived,
|
||||
Snowflake? LastMessageId) : IHasId
|
||||
Snowflake? LastMessageId
|
||||
) : IHasId
|
||||
{
|
||||
// Used for visual backwards-compatibility with old exports, where
|
||||
// channels without a parent (i.e. mostly DM channels) or channels
|
||||
// with an inaccessible parent (i.e. inside private categories) had
|
||||
// a fallback category created for them.
|
||||
public string Category => Parent?.Name ?? Kind switch
|
||||
public string Category =>
|
||||
Parent?.Name
|
||||
?? Kind switch
|
||||
{
|
||||
ChannelKind.GuildCategory => "Category",
|
||||
ChannelKind.GuildTextChat => "Text",
|
||||
|
@ -48,44 +51,41 @@ public partial record Channel
|
|||
var kind = (ChannelKind)json.GetProperty("type").GetInt32();
|
||||
|
||||
var guildId =
|
||||
json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse) ??
|
||||
Guild.DirectMessages.Id;
|
||||
json.GetPropertyOrNull("guild_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse) ?? Guild.DirectMessages.Id;
|
||||
|
||||
var name =
|
||||
// Guild channel
|
||||
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ??
|
||||
|
||||
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull()
|
||||
??
|
||||
// DM channel
|
||||
json.GetPropertyOrNull("recipients")?
|
||||
.EnumerateArrayOrNull()?
|
||||
.Select(User.Parse)
|
||||
json.GetPropertyOrNull("recipients")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(User.Parse)
|
||||
.Select(u => u.DisplayName)
|
||||
.Pipe(s => string.Join(", ", s)) ??
|
||||
|
||||
.Pipe(s => string.Join(", ", s))
|
||||
??
|
||||
// Fallback
|
||||
id.ToString();
|
||||
|
||||
var position =
|
||||
positionHint ??
|
||||
json.GetPropertyOrNull("position")?.GetInt32OrNull();
|
||||
var position = positionHint ?? json.GetPropertyOrNull("position")?.GetInt32OrNull();
|
||||
|
||||
// Icons can only be set for group DM channels
|
||||
var iconUrl = json
|
||||
.GetPropertyOrNull("icon")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(h => ImageCdn.GetChannelIconUrl(id, h));
|
||||
var iconUrl = json.GetPropertyOrNull("icon")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(h => ImageCdn.GetChannelIconUrl(id, h));
|
||||
|
||||
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
|
||||
|
||||
var isArchived = json
|
||||
.GetPropertyOrNull("thread_metadata")?
|
||||
.GetPropertyOrNull("archived")?
|
||||
.GetBooleanOrNull() ?? false;
|
||||
var isArchived =
|
||||
json.GetPropertyOrNull("thread_metadata")
|
||||
?.GetPropertyOrNull("archived")
|
||||
?.GetBooleanOrNull() ?? false;
|
||||
|
||||
var lastMessageId = json
|
||||
.GetPropertyOrNull("last_message_id")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(Snowflake.Parse);
|
||||
var lastMessageId = json.GetPropertyOrNull("last_message_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
|
||||
return new Channel(
|
||||
id,
|
||||
|
|
|
@ -22,12 +22,14 @@ public static class ChannelKindExtensions
|
|||
public static bool IsDirect(this ChannelKind kind) =>
|
||||
kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat;
|
||||
|
||||
public static bool IsGuild(this ChannelKind kind) =>
|
||||
!kind.IsDirect();
|
||||
public static bool IsGuild(this ChannelKind kind) => !kind.IsDirect();
|
||||
|
||||
public static bool IsVoice(this ChannelKind kind) =>
|
||||
kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
|
||||
|
||||
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]
|
||||
public override string ToString() =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}");
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}"
|
||||
);
|
||||
}
|
||||
|
||||
public partial record struct FileSize
|
||||
|
|
|
@ -19,10 +19,7 @@ public static class ImageCdn
|
|||
? runes
|
||||
: runes.Where(r => r.Value != 0xfe0f);
|
||||
|
||||
var twemojiId = string.Join(
|
||||
"-",
|
||||
filteredRunes.Select(r => r.Value.ToString("x"))
|
||||
);
|
||||
var twemojiId = string.Join("-", filteredRunes.Select(r => r.Value.ToString("x")));
|
||||
|
||||
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) =>
|
||||
$"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)
|
||||
? $"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}";
|
||||
|
|
|
@ -21,7 +21,8 @@ public partial record Embed(
|
|||
EmbedImage? Thumbnail,
|
||||
IReadOnlyList<EmbedImage> Images,
|
||||
EmbedVideo? Video,
|
||||
EmbedFooter? Footer)
|
||||
EmbedFooter? Footer
|
||||
)
|
||||
{
|
||||
// Embeds can only have one image according to the API model,
|
||||
// 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 kind =
|
||||
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>() ??
|
||||
EmbedKind.Rich;
|
||||
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>()
|
||||
?? EmbedKind.Rich;
|
||||
|
||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull();
|
||||
|
||||
var color = json
|
||||
.GetPropertyOrNull("color")?
|
||||
.GetInt32OrNull()?
|
||||
.Pipe(System.Drawing.Color.FromArgb)
|
||||
var color = json.GetPropertyOrNull("color")
|
||||
?.GetInt32OrNull()
|
||||
?.Pipe(System.Drawing.Color.FromArgb)
|
||||
.ResetAlpha();
|
||||
|
||||
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
|
||||
var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
|
||||
|
||||
var fields =
|
||||
json.GetPropertyOrNull("fields")?.EnumerateArrayOrNull()?.Select(EmbedField.Parse).ToArray() ??
|
||||
Array.Empty<EmbedField>();
|
||||
json.GetPropertyOrNull("fields")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(EmbedField.Parse)
|
||||
.ToArray() ?? Array.Empty<EmbedField>();
|
||||
|
||||
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.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/695
|
||||
var images =
|
||||
json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).ToSingletonEnumerable().ToArray() ??
|
||||
Array.Empty<EmbedImage>();
|
||||
json.GetPropertyOrNull("image")
|
||||
?.Pipe(EmbedImage.Parse)
|
||||
.ToSingletonEnumerable()
|
||||
.ToArray() ?? Array.Empty<EmbedImage>();
|
||||
|
||||
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
||||
|
||||
|
|
|
@ -4,11 +4,7 @@ using JsonExtensions.Reading;
|
|||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure
|
||||
public record EmbedAuthor(
|
||||
string? Name,
|
||||
string? Url,
|
||||
string? IconUrl,
|
||||
string? IconProxyUrl)
|
||||
public record EmbedAuthor(string? Name, string? Url, string? IconUrl, string? IconProxyUrl)
|
||||
{
|
||||
public static EmbedAuthor Parse(JsonElement json)
|
||||
{
|
||||
|
|
|
@ -4,10 +4,7 @@ using JsonExtensions.Reading;
|
|||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure
|
||||
public record EmbedField(
|
||||
string Name,
|
||||
string Value,
|
||||
bool IsInline)
|
||||
public record EmbedField(string Name, string Value, bool IsInline)
|
||||
{
|
||||
public static EmbedField Parse(JsonElement json)
|
||||
{
|
||||
|
|
|
@ -4,10 +4,7 @@ using JsonExtensions.Reading;
|
|||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
|
||||
public record EmbedFooter(
|
||||
string Text,
|
||||
string? IconUrl,
|
||||
string? IconProxyUrl)
|
||||
public record EmbedFooter(string Text, string? IconUrl, string? IconProxyUrl)
|
||||
{
|
||||
public static EmbedFooter Parse(JsonElement json)
|
||||
{
|
||||
|
|
|
@ -4,11 +4,7 @@ using JsonExtensions.Reading;
|
|||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure
|
||||
public record EmbedImage(
|
||||
string? Url,
|
||||
string? ProxyUrl,
|
||||
int? Width,
|
||||
int? Height)
|
||||
public record EmbedImage(string? Url, string? ProxyUrl, int? Width, int? Height)
|
||||
{
|
||||
public static EmbedImage Parse(JsonElement json)
|
||||
{
|
||||
|
|
|
@ -4,11 +4,7 @@ using System.Text.Json;
|
|||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#embed-object-embed-video-structure
|
||||
public record EmbedVideo(
|
||||
string? Url,
|
||||
string? ProxyUrl,
|
||||
int? Width,
|
||||
int? Height)
|
||||
public record EmbedVideo(string? Url, string? ProxyUrl, int? Width, int? Height)
|
||||
{
|
||||
public static EmbedVideo Parse(JsonElement json)
|
||||
{
|
||||
|
|
|
@ -12,7 +12,9 @@ public partial record SpotifyTrackEmbedProjection
|
|||
private static string? TryParseTrackId(string embedUrl)
|
||||
{
|
||||
// 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))
|
||||
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. 🙂)
|
||||
string Name,
|
||||
bool IsAnimated,
|
||||
string ImageUrl)
|
||||
string ImageUrl
|
||||
)
|
||||
{
|
||||
// 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
|
||||
? Name
|
||||
: EmojiIndex.TryGetCode(Name) ?? Name;
|
||||
public string Code => Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
|
||||
}
|
||||
|
||||
public partial record Emoji
|
||||
|
@ -39,19 +38,17 @@ public partial record Emoji
|
|||
|
||||
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
|
||||
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji";
|
||||
var name =
|
||||
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji";
|
||||
|
||||
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
|
||||
var imageUrl = GetImageUrl(id, name, isAnimated);
|
||||
|
||||
return new Emoji(
|
||||
id,
|
||||
name,
|
||||
isAnimated,
|
||||
imageUrl
|
||||
);
|
||||
return new Emoji(id, name, isAnimated, imageUrl);
|
||||
}
|
||||
}
|
|
@ -8,7 +8,8 @@ namespace DiscordChatExporter.Core.Utils;
|
|||
[ExcludeFromCodeCoverage]
|
||||
internal static class EmojiIndex
|
||||
{
|
||||
private static Dictionary<string, string> _toCodes = new(5000, StringComparer.Ordinal)
|
||||
private static Dictionary<string, string> _toCodes =
|
||||
new(5000, StringComparer.Ordinal)
|
||||
{
|
||||
["😀"] = "grinning",
|
||||
["😃"] = "smiley",
|
||||
|
@ -3550,7 +3551,8 @@ internal static class EmojiIndex
|
|||
["🇺🇳"] = "united_nations"
|
||||
};
|
||||
|
||||
private static Dictionary<string, string> _fromCodes = new(5000, StringComparer.Ordinal)
|
||||
private static Dictionary<string, string> _fromCodes =
|
||||
new(5000, StringComparer.Ordinal)
|
||||
{
|
||||
["grinning"] = "😀",
|
||||
["smiley"] = "😃",
|
||||
|
@ -6256,15 +6258,18 @@ internal static class EmojiIndex
|
|||
["woman_and_man_holding_hands_tone2"] = "👫🏼",
|
||||
["woman_and_man_holding_hands_medium_light_skin_tone"] = "👫🏼",
|
||||
["woman_and_man_holding_hands_tone2_tone3"] = "👩🏼🤝👨🏽",
|
||||
["woman_and_man_holding_hands_medium_light_skin_tone_medium_skin_tone"] = "👩🏼🤝👨🏽",
|
||||
["woman_and_man_holding_hands_medium_light_skin_tone_medium_skin_tone"] =
|
||||
"👩🏼🤝👨🏽",
|
||||
["woman_and_man_holding_hands_tone2_tone4"] = "👩🏼🤝👨🏾",
|
||||
["woman_and_man_holding_hands_medium_light_skin_tone_medium_dark_skin_tone"] = "👩🏼🤝👨🏾",
|
||||
["woman_and_man_holding_hands_medium_light_skin_tone_medium_dark_skin_tone"] =
|
||||
"👩🏼🤝👨🏾",
|
||||
["woman_and_man_holding_hands_tone2_tone5"] = "👩🏼🤝👨🏿",
|
||||
["woman_and_man_holding_hands_medium_light_skin_tone_dark_skin_tone"] = "👩🏼🤝👨🏿",
|
||||
["woman_and_man_holding_hands_tone3_tone1"] = "👩🏽🤝👨🏻",
|
||||
["woman_and_man_holding_hands_medium_skin_tone_light_skin_tone"] = "👩🏽🤝👨🏻",
|
||||
["woman_and_man_holding_hands_tone3_tone2"] = "👩🏽🤝👨🏼",
|
||||
["woman_and_man_holding_hands_medium_skin_tone_medium_light_skin_tone"] = "👩🏽🤝👨🏼",
|
||||
["woman_and_man_holding_hands_medium_skin_tone_medium_light_skin_tone"] =
|
||||
"👩🏽🤝👨🏼",
|
||||
["woman_and_man_holding_hands_tone3"] = "👫🏽",
|
||||
["woman_and_man_holding_hands_medium_skin_tone"] = "👫🏽",
|
||||
["woman_and_man_holding_hands_tone3_tone4"] = "👩🏽🤝👨🏾",
|
||||
|
@ -6274,7 +6279,8 @@ internal static class EmojiIndex
|
|||
["woman_and_man_holding_hands_tone4_tone1"] = "👩🏾🤝👨🏻",
|
||||
["woman_and_man_holding_hands_medium_dark_skin_tone_light_skin_tone"] = "👩🏾🤝👨🏻",
|
||||
["woman_and_man_holding_hands_tone4_tone2"] = "👩🏾🤝👨🏼",
|
||||
["woman_and_man_holding_hands_medium_dark_skin_tone_medium_light_skin_tone"] = "👩🏾🤝👨🏼",
|
||||
["woman_and_man_holding_hands_medium_dark_skin_tone_medium_light_skin_tone"] =
|
||||
"👩🏾🤝👨🏼",
|
||||
["woman_and_man_holding_hands_tone4_tone3"] = "👩🏾🤝👨🏽",
|
||||
["woman_and_man_holding_hands_medium_dark_skin_tone_medium_skin_tone"] = "👩🏾🤝👨🏽",
|
||||
["woman_and_man_holding_hands_tone4"] = "👫🏾",
|
||||
|
@ -6397,51 +6403,65 @@ internal static class EmojiIndex
|
|||
["couple_with_heart_tone1"] = "💑🏻",
|
||||
["couple_with_heart_light_skin_tone"] = "💑🏻",
|
||||
["couple_with_heart_person_person_tone1_tone2"] = "🧑🏻❤️🧑🏼",
|
||||
["couple_with_heart_person_person_light_skin_tone_medium_light_skin_tone"] = "🧑🏻❤️🧑🏼",
|
||||
["couple_with_heart_person_person_light_skin_tone_medium_light_skin_tone"] =
|
||||
"🧑🏻❤️🧑🏼",
|
||||
["couple_with_heart_person_person_tone1_tone3"] = "🧑🏻❤️🧑🏽",
|
||||
["couple_with_heart_person_person_light_skin_tone_medium_skin_tone"] = "🧑🏻❤️🧑🏽",
|
||||
["couple_with_heart_person_person_tone1_tone4"] = "🧑🏻❤️🧑🏾",
|
||||
["couple_with_heart_person_person_light_skin_tone_medium_dark_skin_tone"] = "🧑🏻❤️🧑🏾",
|
||||
["couple_with_heart_person_person_light_skin_tone_medium_dark_skin_tone"] =
|
||||
"🧑🏻❤️🧑🏾",
|
||||
["couple_with_heart_person_person_tone1_tone5"] = "🧑🏻❤️🧑🏿",
|
||||
["couple_with_heart_person_person_light_skin_tone_dark_skin_tone"] = "🧑🏻❤️🧑🏿",
|
||||
["couple_with_heart_person_person_tone2_tone1"] = "🧑🏼❤️🧑🏻",
|
||||
["couple_with_heart_person_person_medium_light_skin_tone_light_skin_tone"] = "🧑🏼❤️🧑🏻",
|
||||
["couple_with_heart_person_person_medium_light_skin_tone_light_skin_tone"] =
|
||||
"🧑🏼❤️🧑🏻",
|
||||
["couple_with_heart_tone2"] = "💑🏼",
|
||||
["couple_with_heart_medium_light_skin_tone"] = "💑🏼",
|
||||
["couple_with_heart_person_person_tone2_tone3"] = "🧑🏼❤️🧑🏽",
|
||||
["couple_with_heart_person_person_medium_light_skin_tone_medium_skin_tone"] = "🧑🏼❤️🧑🏽",
|
||||
["couple_with_heart_person_person_medium_light_skin_tone_medium_skin_tone"] =
|
||||
"🧑🏼❤️🧑🏽",
|
||||
["couple_with_heart_person_person_tone2_tone4"] = "🧑🏼❤️🧑🏾",
|
||||
["couple_with_heart_person_person_medium_light_skin_tone_medium_dark_skin_tone"] = "🧑🏼❤️🧑🏾",
|
||||
["couple_with_heart_person_person_medium_light_skin_tone_medium_dark_skin_tone"] =
|
||||
"🧑🏼❤️🧑🏾",
|
||||
["couple_with_heart_person_person_tone2_tone5"] = "🧑🏼❤️🧑🏿",
|
||||
["couple_with_heart_person_person_medium_light_skin_tone_dark_skin_tone"] = "🧑🏼❤️🧑🏿",
|
||||
["couple_with_heart_person_person_medium_light_skin_tone_dark_skin_tone"] =
|
||||
"🧑🏼❤️🧑🏿",
|
||||
["couple_with_heart_person_person_tone3_tone1"] = "🧑🏽❤️🧑🏻",
|
||||
["couple_with_heart_person_person_medium_skin_tone_light_skin_tone"] = "🧑🏽❤️🧑🏻",
|
||||
["couple_with_heart_person_person_tone3_tone2"] = "🧑🏽❤️🧑🏼",
|
||||
["couple_with_heart_person_person_medium_skin_tone_medium_light_skin_tone"] = "🧑🏽❤️🧑🏼",
|
||||
["couple_with_heart_person_person_medium_skin_tone_medium_light_skin_tone"] =
|
||||
"🧑🏽❤️🧑🏼",
|
||||
["couple_with_heart_tone3"] = "💑🏽",
|
||||
["couple_with_heart_medium_skin_tone"] = "💑🏽",
|
||||
["couple_with_heart_person_person_tone3_tone4"] = "🧑🏽❤️🧑🏾",
|
||||
["couple_with_heart_person_person_medium_skin_tone_medium_dark_skin_tone"] = "🧑🏽❤️🧑🏾",
|
||||
["couple_with_heart_person_person_medium_skin_tone_medium_dark_skin_tone"] =
|
||||
"🧑🏽❤️🧑🏾",
|
||||
["couple_with_heart_person_person_tone3_tone5"] = "🧑🏽❤️🧑🏿",
|
||||
["couple_with_heart_person_person_medium_skin_tone_dark_skin_tone"] = "🧑🏽❤️🧑🏿",
|
||||
["couple_with_heart_person_person_tone4_tone1"] = "🧑🏾❤️🧑🏻",
|
||||
["couple_with_heart_person_person_medium_dark_skin_tone_light_skin_tone"] = "🧑🏾❤️🧑🏻",
|
||||
["couple_with_heart_person_person_medium_dark_skin_tone_light_skin_tone"] =
|
||||
"🧑🏾❤️🧑🏻",
|
||||
["couple_with_heart_person_person_tone4_tone2"] = "🧑🏾❤️🧑🏼",
|
||||
["couple_with_heart_person_person_medium_dark_skin_tone_medium_light_skin_tone"] = "🧑🏾❤️🧑🏼",
|
||||
["couple_with_heart_person_person_medium_dark_skin_tone_medium_light_skin_tone"] =
|
||||
"🧑🏾❤️🧑🏼",
|
||||
["couple_with_heart_person_person_tone4_tone3"] = "🧑🏾❤️🧑🏽",
|
||||
["couple_with_heart_person_person_medium_dark_skin_tone_medium_skin_tone"] = "🧑🏾❤️🧑🏽",
|
||||
["couple_with_heart_person_person_medium_dark_skin_tone_medium_skin_tone"] =
|
||||
"🧑🏾❤️🧑🏽",
|
||||
["couple_with_heart_tone4"] = "💑🏾",
|
||||
["couple_with_heart_medium_dark_skin_tone"] = "💑🏾",
|
||||
["couple_with_heart_person_person_tone4_tone5"] = "🧑🏾❤️🧑🏿",
|
||||
["couple_with_heart_person_person_medium_dark_skin_tone_dark_skin_tone"] = "🧑🏾❤️🧑🏿",
|
||||
["couple_with_heart_person_person_medium_dark_skin_tone_dark_skin_tone"] =
|
||||
"🧑🏾❤️🧑🏿",
|
||||
["couple_with_heart_person_person_tone5_tone1"] = "🧑🏿❤️🧑🏻",
|
||||
["couple_with_heart_person_person_dark_skin_tone_light_skin_tone"] = "🧑🏿❤️🧑🏻",
|
||||
["couple_with_heart_person_person_tone5_tone2"] = "🧑🏿❤️🧑🏼",
|
||||
["couple_with_heart_person_person_dark_skin_tone_medium_light_skin_tone"] = "🧑🏿❤️🧑🏼",
|
||||
["couple_with_heart_person_person_dark_skin_tone_medium_light_skin_tone"] =
|
||||
"🧑🏿❤️🧑🏼",
|
||||
["couple_with_heart_person_person_tone5_tone3"] = "🧑🏿❤️🧑🏽",
|
||||
["couple_with_heart_person_person_dark_skin_tone_medium_skin_tone"] = "🧑🏿❤️🧑🏽",
|
||||
["couple_with_heart_person_person_tone5_tone4"] = "🧑🏿❤️🧑🏾",
|
||||
["couple_with_heart_person_person_dark_skin_tone_medium_dark_skin_tone"] = "🧑🏿❤️🧑🏾",
|
||||
["couple_with_heart_person_person_dark_skin_tone_medium_dark_skin_tone"] =
|
||||
"🧑🏿❤️🧑🏾",
|
||||
["couple_with_heart_tone5"] = "💑🏿",
|
||||
["couple_with_heart_dark_skin_tone"] = "💑🏿",
|
||||
["couple_with_heart_woman_man"] = "👩❤️👨",
|
||||
|
@ -6460,15 +6480,18 @@ internal static class EmojiIndex
|
|||
["couple_with_heart_woman_man_tone2"] = "👩🏼❤️👨🏼",
|
||||
["couple_with_heart_woman_man_medium_light_skin_tone"] = "👩🏼❤️👨🏼",
|
||||
["couple_with_heart_woman_man_tone2_tone3"] = "👩🏼❤️👨🏽",
|
||||
["couple_with_heart_woman_man_medium_light_skin_tone_medium_skin_tone"] = "👩🏼❤️👨🏽",
|
||||
["couple_with_heart_woman_man_medium_light_skin_tone_medium_skin_tone"] =
|
||||
"👩🏼❤️👨🏽",
|
||||
["couple_with_heart_woman_man_tone2_tone4"] = "👩🏼❤️👨🏾",
|
||||
["couple_with_heart_woman_man_medium_light_skin_tone_medium_dark_skin_tone"] = "👩🏼❤️👨🏾",
|
||||
["couple_with_heart_woman_man_medium_light_skin_tone_medium_dark_skin_tone"] =
|
||||
"👩🏼❤️👨🏾",
|
||||
["couple_with_heart_woman_man_tone2_tone5"] = "👩🏼❤️👨🏿",
|
||||
["couple_with_heart_woman_man_medium_light_skin_tone_dark_skin_tone"] = "👩🏼❤️👨🏿",
|
||||
["couple_with_heart_woman_man_tone3_tone1"] = "👩🏽❤️👨🏻",
|
||||
["couple_with_heart_woman_man_medium_skin_tone_light_skin_tone"] = "👩🏽❤️👨🏻",
|
||||
["couple_with_heart_woman_man_tone3_tone2"] = "👩🏽❤️👨🏼",
|
||||
["couple_with_heart_woman_man_medium_skin_tone_medium_light_skin_tone"] = "👩🏽❤️👨🏼",
|
||||
["couple_with_heart_woman_man_medium_skin_tone_medium_light_skin_tone"] =
|
||||
"👩🏽❤️👨🏼",
|
||||
["couple_with_heart_woman_man_tone3"] = "👩🏽❤️👨🏽",
|
||||
["couple_with_heart_woman_man_medium_skin_tone"] = "👩🏽❤️👨🏽",
|
||||
["couple_with_heart_woman_man_tone3_tone4"] = "👩🏽❤️👨🏾",
|
||||
|
@ -6478,7 +6501,8 @@ internal static class EmojiIndex
|
|||
["couple_with_heart_woman_man_tone4_tone1"] = "👩🏾❤️👨🏻",
|
||||
["couple_with_heart_woman_man_medium_dark_skin_tone_light_skin_tone"] = "👩🏾❤️👨🏻",
|
||||
["couple_with_heart_woman_man_tone4_tone2"] = "👩🏾❤️👨🏼",
|
||||
["couple_with_heart_woman_man_medium_dark_skin_tone_medium_light_skin_tone"] = "👩🏾❤️👨🏼",
|
||||
["couple_with_heart_woman_man_medium_dark_skin_tone_medium_light_skin_tone"] =
|
||||
"👩🏾❤️👨🏼",
|
||||
["couple_with_heart_woman_man_tone4_tone3"] = "👩🏾❤️👨🏽",
|
||||
["couple_with_heart_woman_man_medium_dark_skin_tone_medium_skin_tone"] = "👩🏾❤️👨🏽",
|
||||
["couple_with_heart_woman_man_tone4"] = "👩🏾❤️👨🏾",
|
||||
|
@ -6500,39 +6524,50 @@ internal static class EmojiIndex
|
|||
["couple_with_heart_woman_woman_tone1"] = "👩🏻❤️👩🏻",
|
||||
["couple_with_heart_woman_woman_light_skin_tone"] = "👩🏻❤️👩🏻",
|
||||
["couple_with_heart_woman_woman_tone1_tone2"] = "👩🏻❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_light_skin_tone_medium_light_skin_tone"] = "👩🏻❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_light_skin_tone_medium_light_skin_tone"] =
|
||||
"👩🏻❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_tone1_tone3"] = "👩🏻❤️👩🏽",
|
||||
["couple_with_heart_woman_woman_light_skin_tone_medium_skin_tone"] = "👩🏻❤️👩🏽",
|
||||
["couple_with_heart_woman_woman_tone1_tone4"] = "👩🏻❤️👩🏾",
|
||||
["couple_with_heart_woman_woman_light_skin_tone_medium_dark_skin_tone"] = "👩🏻❤️👩🏾",
|
||||
["couple_with_heart_woman_woman_light_skin_tone_medium_dark_skin_tone"] =
|
||||
"👩🏻❤️👩🏾",
|
||||
["couple_with_heart_woman_woman_tone1_tone5"] = "👩🏻❤️👩🏿",
|
||||
["couple_with_heart_woman_woman_light_skin_tone_dark_skin_tone"] = "👩🏻❤️👩🏿",
|
||||
["couple_with_heart_woman_woman_tone2_tone1"] = "👩🏼❤️👩🏻",
|
||||
["couple_with_heart_woman_woman_medium_light_skin_tone_light_skin_tone"] = "👩🏼❤️👩🏻",
|
||||
["couple_with_heart_woman_woman_medium_light_skin_tone_light_skin_tone"] =
|
||||
"👩🏼❤️👩🏻",
|
||||
["couple_with_heart_woman_woman_tone2"] = "👩🏼❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_medium_light_skin_tone"] = "👩🏼❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_tone2_tone3"] = "👩🏼❤️👩🏽",
|
||||
["couple_with_heart_woman_woman_medium_light_skin_tone_medium_skin_tone"] = "👩🏼❤️👩🏽",
|
||||
["couple_with_heart_woman_woman_medium_light_skin_tone_medium_skin_tone"] =
|
||||
"👩🏼❤️👩🏽",
|
||||
["couple_with_heart_woman_woman_tone2_tone4"] = "👩🏼❤️👩🏾",
|
||||
["couple_with_heart_woman_woman_medium_light_skin_tone_medium_dark_skin_tone"] = "👩🏼❤️👩🏾",
|
||||
["couple_with_heart_woman_woman_medium_light_skin_tone_medium_dark_skin_tone"] =
|
||||
"👩🏼❤️👩🏾",
|
||||
["couple_with_heart_woman_woman_tone2_tone5"] = "👩🏼❤️👩🏿",
|
||||
["couple_with_heart_woman_woman_medium_light_skin_tone_dark_skin_tone"] = "👩🏼❤️👩🏿",
|
||||
["couple_with_heart_woman_woman_medium_light_skin_tone_dark_skin_tone"] =
|
||||
"👩🏼❤️👩🏿",
|
||||
["couple_with_heart_woman_woman_tone3_tone1"] = "👩🏽❤️👩🏻",
|
||||
["couple_with_heart_woman_woman_medium_skin_tone_light_skin_tone"] = "👩🏽❤️👩🏻",
|
||||
["couple_with_heart_woman_woman_tone3_tone2"] = "👩🏽❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_medium_skin_tone_medium_light_skin_tone"] = "👩🏽❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_medium_skin_tone_medium_light_skin_tone"] =
|
||||
"👩🏽❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_tone3"] = "👩🏽❤️👩🏽",
|
||||
["couple_with_heart_woman_woman_medium_skin_tone"] = "👩🏽❤️👩🏽",
|
||||
["couple_with_heart_woman_woman_tone3_tone4"] = "👩🏽❤️👩🏾",
|
||||
["couple_with_heart_woman_woman_medium_skin_tone_medium_dark_skin_tone"] = "👩🏽❤️👩🏾",
|
||||
["couple_with_heart_woman_woman_medium_skin_tone_medium_dark_skin_tone"] =
|
||||
"👩🏽❤️👩🏾",
|
||||
["couple_with_heart_woman_woman_tone3_tone5"] = "👩🏽❤️👩🏿",
|
||||
["couple_with_heart_woman_woman_medium_skin_tone_dark_skin_tone"] = "👩🏽❤️👩🏿",
|
||||
["couple_with_heart_woman_woman_tone4_tone1"] = "👩🏾❤️👩🏻",
|
||||
["couple_with_heart_woman_woman_medium_dark_skin_tone_light_skin_tone"] = "👩🏾❤️👩🏻",
|
||||
["couple_with_heart_woman_woman_medium_dark_skin_tone_light_skin_tone"] =
|
||||
"👩🏾❤️👩🏻",
|
||||
["couple_with_heart_woman_woman_tone4_tone2"] = "👩🏾❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_medium_dark_skin_tone_medium_light_skin_tone"] = "👩🏾❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_medium_dark_skin_tone_medium_light_skin_tone"] =
|
||||
"👩🏾❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_tone4_tone3"] = "👩🏾❤️👩🏽",
|
||||
["couple_with_heart_woman_woman_medium_dark_skin_tone_medium_skin_tone"] = "👩🏾❤️👩🏽",
|
||||
["couple_with_heart_woman_woman_medium_dark_skin_tone_medium_skin_tone"] =
|
||||
"👩🏾❤️👩🏽",
|
||||
["couple_with_heart_woman_woman_tone4"] = "👩🏾❤️👩🏾",
|
||||
["couple_with_heart_woman_woman_medium_dark_skin_tone"] = "👩🏾❤️👩🏾",
|
||||
["couple_with_heart_woman_woman_tone4_tone5"] = "👩🏾❤️👩🏿",
|
||||
|
@ -6540,7 +6575,8 @@ internal static class EmojiIndex
|
|||
["couple_with_heart_woman_woman_tone5_tone1"] = "👩🏿❤️👩🏻",
|
||||
["couple_with_heart_woman_woman_dark_skin_tone_light_skin_tone"] = "👩🏿❤️👩🏻",
|
||||
["couple_with_heart_woman_woman_tone5_tone2"] = "👩🏿❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_dark_skin_tone_medium_light_skin_tone"] = "👩🏿❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_dark_skin_tone_medium_light_skin_tone"] =
|
||||
"👩🏿❤️👩🏼",
|
||||
["couple_with_heart_woman_woman_tone5_tone3"] = "👩🏿❤️👩🏽",
|
||||
["couple_with_heart_woman_woman_dark_skin_tone_medium_skin_tone"] = "👩🏿❤️👩🏽",
|
||||
["couple_with_heart_woman_woman_tone5_tone4"] = "👩🏿❤️👩🏾",
|
||||
|
@ -6566,7 +6602,8 @@ internal static class EmojiIndex
|
|||
["couple_with_heart_man_man_tone2_tone3"] = "👨🏼❤️👨🏽",
|
||||
["couple_with_heart_man_man_medium_light_skin_tone_medium_skin_tone"] = "👨🏼❤️👨🏽",
|
||||
["couple_with_heart_man_man_tone2_tone4"] = "👨🏼❤️👨🏾",
|
||||
["couple_with_heart_man_man_medium_light_skin_tone_medium_dark_skin_tone"] = "👨🏼❤️👨🏾",
|
||||
["couple_with_heart_man_man_medium_light_skin_tone_medium_dark_skin_tone"] =
|
||||
"👨🏼❤️👨🏾",
|
||||
["couple_with_heart_man_man_tone2_tone5"] = "👨🏼❤️👨🏿",
|
||||
["couple_with_heart_man_man_medium_light_skin_tone_dark_skin_tone"] = "👨🏼❤️👨🏿",
|
||||
["couple_with_heart_man_man_tone3_tone1"] = "👨🏽❤️👨🏻",
|
||||
|
@ -6582,7 +6619,8 @@ internal static class EmojiIndex
|
|||
["couple_with_heart_man_man_tone4_tone1"] = "👨🏾❤️👨🏻",
|
||||
["couple_with_heart_man_man_medium_dark_skin_tone_light_skin_tone"] = "👨🏾❤️👨🏻",
|
||||
["couple_with_heart_man_man_tone4_tone2"] = "👨🏾❤️👨🏼",
|
||||
["couple_with_heart_man_man_medium_dark_skin_tone_medium_light_skin_tone"] = "👨🏾❤️👨🏼",
|
||||
["couple_with_heart_man_man_medium_dark_skin_tone_medium_light_skin_tone"] =
|
||||
"👨🏾❤️👨🏼",
|
||||
["couple_with_heart_man_man_tone4_tone3"] = "👨🏾❤️👨🏽",
|
||||
["couple_with_heart_man_man_medium_dark_skin_tone_medium_skin_tone"] = "👨🏾❤️👨🏽",
|
||||
["couple_with_heart_man_man_tone4"] = "👨🏾❤️👨🏾",
|
||||
|
|
|
@ -9,11 +9,8 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
|||
public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
|
||||
{
|
||||
// Direct messages are encapsulated within a special pseudo-guild for consistency
|
||||
public static Guild DirectMessages { get; } = new(
|
||||
Snowflake.Zero,
|
||||
"Direct Messages",
|
||||
ImageCdn.GetFallbackUserAvatarUrl()
|
||||
);
|
||||
public static Guild DirectMessages { get; } =
|
||||
new(Snowflake.Zero, "Direct Messages", ImageCdn.GetFallbackUserAvatarUrl());
|
||||
|
||||
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 iconUrl =
|
||||
json
|
||||
.GetPropertyOrNull("icon")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ??
|
||||
ImageCdn.GetFallbackUserAvatarUrl();
|
||||
json.GetPropertyOrNull("icon")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? ImageCdn.GetFallbackUserAvatarUrl();
|
||||
|
||||
return new Guild(id, name, iconUrl);
|
||||
}
|
||||
|
|
|
@ -6,10 +6,7 @@ using JsonExtensions.Reading;
|
|||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
// https://discord.com/developers/docs/resources/invite#invite-object
|
||||
public record Invite(
|
||||
string Code,
|
||||
Guild Guild,
|
||||
Channel? Channel)
|
||||
public record Invite(string Code, Guild Guild, Channel? Channel)
|
||||
{
|
||||
public static string? TryGetCodeFromUrl(string url) =>
|
||||
Regex.Match(url, @"^https?://discord\.gg/(\w+)/?$").Groups[1].Value.NullIfWhiteSpace();
|
||||
|
|
|
@ -13,7 +13,8 @@ public partial record Member(
|
|||
User User,
|
||||
string? DisplayName,
|
||||
string? AvatarUrl,
|
||||
IReadOnlyList<Snowflake> RoleIds) : IHasId
|
||||
IReadOnlyList<Snowflake> RoleIds
|
||||
) : IHasId
|
||||
{
|
||||
public Snowflake Id => User.Id;
|
||||
}
|
||||
|
@ -28,25 +29,19 @@ public partial record Member
|
|||
var user = json.GetProperty("user").Pipe(User.Parse);
|
||||
var displayName = json.GetPropertyOrNull("nick")?.GetNonWhiteSpaceStringOrNull();
|
||||
|
||||
var roleIds = json
|
||||
.GetPropertyOrNull("roles")?
|
||||
.EnumerateArray()
|
||||
var roleIds =
|
||||
json.GetPropertyOrNull("roles")
|
||||
?.EnumerateArray()
|
||||
.Select(j => j.GetNonWhiteSpaceString())
|
||||
.Select(Snowflake.Parse)
|
||||
.ToArray() ?? Array.Empty<Snowflake>();
|
||||
|
||||
var avatarUrl = guildId is not null
|
||||
? json
|
||||
.GetPropertyOrNull("avatar")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(h => ImageCdn.GetMemberAvatarUrl(guildId.Value, user.Id, h))
|
||||
? json.GetPropertyOrNull("avatar")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(h => ImageCdn.GetMemberAvatarUrl(guildId.Value, user.Id, h))
|
||||
: null;
|
||||
|
||||
return new Member(
|
||||
user,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
roleIds
|
||||
);
|
||||
return new Member(user, displayName, avatarUrl, roleIds);
|
||||
}
|
||||
}
|
|
@ -27,7 +27,8 @@ public partial record Message(
|
|||
IReadOnlyList<User> MentionedUsers,
|
||||
MessageReference? Reference,
|
||||
Message? ReferencedMessage,
|
||||
Interaction? Interaction) : IHasId
|
||||
Interaction? Interaction
|
||||
) : IHasId
|
||||
{
|
||||
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
|
||||
var trailingEmbeds = embeds
|
||||
.Skip(i + 1)
|
||||
.TakeWhile(e =>
|
||||
e.Url == embed.Url &&
|
||||
e.Timestamp is null &&
|
||||
e.Author is null &&
|
||||
e.Color is null &&
|
||||
string.IsNullOrWhiteSpace(e.Description) &&
|
||||
!e.Fields.Any() &&
|
||||
e.Images.Count == 1 &&
|
||||
e.Footer is null
|
||||
.TakeWhile(
|
||||
e =>
|
||||
e.Url == embed.Url
|
||||
&& e.Timestamp is null
|
||||
&& e.Author is null
|
||||
&& e.Color is null
|
||||
&& string.IsNullOrWhiteSpace(e.Description)
|
||||
&& !e.Fields.Any()
|
||||
&& e.Images.Count == 1
|
||||
&& e.Footer is null
|
||||
)
|
||||
.ToArray();
|
||||
|
||||
if (trailingEmbeds.Any())
|
||||
{
|
||||
// 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 });
|
||||
|
||||
i += trailingEmbeds.Length;
|
||||
|
@ -108,42 +113,49 @@ public partial record Message
|
|||
{
|
||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||
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 timestamp = json.GetProperty("timestamp").GetDateTimeOffset();
|
||||
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffsetOrNull();
|
||||
|
||||
var callEndedTimestamp = json
|
||||
.GetPropertyOrNull("call")?
|
||||
.GetPropertyOrNull("ended_timestamp")?
|
||||
.GetDateTimeOffsetOrNull();
|
||||
var callEndedTimestamp = json.GetPropertyOrNull("call")
|
||||
?.GetPropertyOrNull("ended_timestamp")
|
||||
?.GetDateTimeOffsetOrNull();
|
||||
|
||||
var isPinned = json.GetPropertyOrNull("pinned")?.GetBooleanOrNull() ?? false;
|
||||
var content = json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "";
|
||||
|
||||
var attachments =
|
||||
json.GetPropertyOrNull("attachments")?.EnumerateArrayOrNull()?.Select(Attachment.Parse).ToArray() ??
|
||||
Array.Empty<Attachment>();
|
||||
json.GetPropertyOrNull("attachments")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Attachment.Parse)
|
||||
.ToArray() ?? Array.Empty<Attachment>();
|
||||
|
||||
var embeds = NormalizeEmbeds(
|
||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ??
|
||||
Array.Empty<Embed>()
|
||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||
?? Array.Empty<Embed>()
|
||||
);
|
||||
|
||||
var stickers =
|
||||
json.GetPropertyOrNull("sticker_items")?.EnumerateArrayOrNull()?.Select(Sticker.Parse).ToArray() ??
|
||||
Array.Empty<Sticker>();
|
||||
json.GetPropertyOrNull("sticker_items")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Sticker.Parse)
|
||||
.ToArray() ?? Array.Empty<Sticker>();
|
||||
|
||||
var reactions =
|
||||
json.GetPropertyOrNull("reactions")?.EnumerateArrayOrNull()?.Select(Reaction.Parse).ToArray() ??
|
||||
Array.Empty<Reaction>();
|
||||
json.GetPropertyOrNull("reactions")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Reaction.Parse)
|
||||
.ToArray() ?? Array.Empty<Reaction>();
|
||||
|
||||
var mentionedUsers =
|
||||
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray() ??
|
||||
Array.Empty<User>();
|
||||
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
||||
?? 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 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)
|
||||
{
|
||||
var messageId = json
|
||||
.GetPropertyOrNull("message_id")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(Snowflake.Parse);
|
||||
var messageId = json.GetPropertyOrNull("message_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
|
||||
var channelId = json
|
||||
.GetPropertyOrNull("channel_id")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(Snowflake.Parse);
|
||||
var channelId = json.GetPropertyOrNull("channel_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
|
||||
var guildId = json
|
||||
.GetPropertyOrNull("guild_id")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(Snowflake.Parse);
|
||||
var guildId = json.GetPropertyOrNull("guild_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
|
||||
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 position = json.GetProperty("position").GetInt32();
|
||||
|
||||
var color = json
|
||||
.GetPropertyOrNull("color")?
|
||||
.GetInt32OrNull()?
|
||||
.Pipe(System.Drawing.Color.FromArgb)
|
||||
var color = json.GetPropertyOrNull("color")
|
||||
?.GetInt32OrNull()
|
||||
?.Pipe(System.Drawing.Color.FromArgb)
|
||||
.ResetAlpha()
|
||||
.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 format = (StickerFormat)json.GetProperty("format_type").GetInt32();
|
||||
|
||||
var sourceUrl = ImageCdn.GetStickerUrl(id, format switch
|
||||
var sourceUrl = ImageCdn.GetStickerUrl(
|
||||
id,
|
||||
format switch
|
||||
{
|
||||
StickerFormat.Png => "png",
|
||||
StickerFormat.Apng => "png",
|
||||
StickerFormat.Lottie => "json",
|
||||
_ => throw new InvalidOperationException($"Unknown sticker format '{format}'.")
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return new Sticker(id, name, format, sourceUrl);
|
||||
}
|
||||
|
|
|
@ -15,18 +15,16 @@ public partial record User(
|
|||
int? Discriminator,
|
||||
string Name,
|
||||
string DisplayName,
|
||||
string AvatarUrl) : IHasId
|
||||
string AvatarUrl
|
||||
) : IHasId
|
||||
{
|
||||
public string DiscriminatorFormatted => Discriminator is not null
|
||||
? $"{Discriminator:0000}"
|
||||
: "0000";
|
||||
public string DiscriminatorFormatted =>
|
||||
Discriminator is not null ? $"{Discriminator:0000}" : "0000";
|
||||
|
||||
// This effectively represents the user's true identity.
|
||||
// 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.
|
||||
public string FullName => Discriminator is not null
|
||||
? $"{Name}#{DiscriminatorFormatted}"
|
||||
: Name;
|
||||
public string FullName => Discriminator is not null ? $"{Name}#{DiscriminatorFormatted}" : Name;
|
||||
}
|
||||
|
||||
public partial record User
|
||||
|
@ -36,23 +34,22 @@ public partial record User
|
|||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||
var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false;
|
||||
|
||||
var discriminator = json
|
||||
.GetPropertyOrNull("discriminator")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(int.Parse)
|
||||
var discriminator = json.GetPropertyOrNull("discriminator")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(int.Parse)
|
||||
.NullIfDefault();
|
||||
|
||||
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 avatarUrl =
|
||||
json
|
||||
.GetPropertyOrNull("avatar")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(h => ImageCdn.GetUserAvatarUrl(id, h)) ??
|
||||
ImageCdn.GetFallbackUserAvatarUrl(avatarIndex);
|
||||
json.GetPropertyOrNull("avatar")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(h => ImageCdn.GetUserAvatarUrl(id, h))
|
||||
?? ImageCdn.GetFallbackUserAvatarUrl(avatarIndex);
|
||||
|
||||
return new User(id, isBot, discriminator, name, displayName, avatarUrl);
|
||||
}
|
||||
|
|
|
@ -30,9 +30,11 @@ public class DiscordClient
|
|||
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
||||
string url,
|
||||
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));
|
||||
|
||||
|
@ -40,9 +42,7 @@ public class DiscordClient
|
|||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
"Authorization",
|
||||
tokenKind == TokenKind.Bot
|
||||
? $"Bot {_token}"
|
||||
: _token
|
||||
tokenKind == TokenKind.Bot ? $"Bot {_token}" : _token
|
||||
);
|
||||
|
||||
var response = await Http.Client.SendAsync(
|
||||
|
@ -58,15 +58,13 @@ public class DiscordClient
|
|||
// 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 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))
|
||||
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)
|
||||
|
@ -83,10 +81,14 @@ public class DiscordClient
|
|||
}
|
||||
|
||||
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
|
||||
using var userResponse = await GetResponseAsync(
|
||||
|
@ -113,7 +115,8 @@ public class DiscordClient
|
|||
|
||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
||||
string url,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var tokenKind = _resolvedTokenKind ??= await GetTokenKindAsync(cancellationToken);
|
||||
return await GetResponseAsync(url, tokenKind, cancellationToken);
|
||||
|
@ -121,7 +124,8 @@ public class DiscordClient
|
|||
|
||||
private async ValueTask<JsonElement> GetJsonResponseAsync(
|
||||
string url,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
using var response = await GetResponseAsync(url, cancellationToken);
|
||||
|
||||
|
@ -129,20 +133,24 @@ public class DiscordClient
|
|||
{
|
||||
throw response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => throw new DiscordChatExporterException(
|
||||
HttpStatusCode.Unauthorized
|
||||
=> throw new DiscordChatExporterException(
|
||||
"Authentication token is invalid.",
|
||||
true
|
||||
),
|
||||
|
||||
HttpStatusCode.Forbidden => throw new DiscordChatExporterException(
|
||||
HttpStatusCode.Forbidden
|
||||
=> throw new DiscordChatExporterException(
|
||||
$"Request to '{url}' failed: forbidden."
|
||||
),
|
||||
|
||||
HttpStatusCode.NotFound => throw new DiscordChatExporterException(
|
||||
HttpStatusCode.NotFound
|
||||
=> throw new DiscordChatExporterException(
|
||||
$"Request to '{url}' failed: not found."
|
||||
),
|
||||
|
||||
_ => throw new DiscordChatExporterException(
|
||||
_
|
||||
=> throw new DiscordChatExporterException(
|
||||
$"""
|
||||
Request to '{url}' failed: {response.StatusCode.ToString().ToSpaceSeparatedWords().ToLowerInvariant()}.
|
||||
Response content: {await response.Content.ReadAsStringAsync(cancellationToken)}
|
||||
|
@ -157,7 +165,8 @@ public class DiscordClient
|
|||
|
||||
private async ValueTask<JsonElement?> TryGetJsonResponseAsync(
|
||||
string url,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
using var response = await GetResponseAsync(url, cancellationToken);
|
||||
return response.IsSuccessStatusCode
|
||||
|
@ -167,14 +176,16 @@ public class DiscordClient
|
|||
|
||||
public async ValueTask<User?> TryGetUserAsync(
|
||||
Snowflake userId,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var response = await TryGetJsonResponseAsync($"users/{userId}", cancellationToken);
|
||||
return response?.Pipe(User.Parse);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Guild> GetUserGuildsAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
yield return Guild.DirectMessages;
|
||||
|
||||
|
@ -206,7 +217,8 @@ public class DiscordClient
|
|||
|
||||
public async ValueTask<Guild> GetGuildAsync(
|
||||
Snowflake guildId,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
return Guild.DirectMessages;
|
||||
|
@ -217,7 +229,8 @@ public class DiscordClient
|
|||
|
||||
public async IAsyncEnumerable<Channel> GetGuildChannelsAsync(
|
||||
Snowflake guildId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
{
|
||||
|
@ -227,7 +240,10 @@ public class DiscordClient
|
|||
}
|
||||
else
|
||||
{
|
||||
var response = await GetJsonResponseAsync($"guilds/{guildId}/channels", cancellationToken);
|
||||
var response = await GetJsonResponseAsync(
|
||||
$"guilds/{guildId}/channels",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var channelsJson = response
|
||||
.EnumerateArray()
|
||||
|
@ -247,9 +263,9 @@ public class DiscordClient
|
|||
foreach (var channelJson in channelsJson)
|
||||
{
|
||||
var parent = channelJson
|
||||
.GetPropertyOrNull("parent_id")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(Snowflake.Parse)
|
||||
.GetPropertyOrNull("parent_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse)
|
||||
.Pipe(parentsById.GetValueOrDefault);
|
||||
|
||||
yield return Channel.Parse(channelJson, parent, position);
|
||||
|
@ -261,7 +277,8 @@ public class DiscordClient
|
|||
public async IAsyncEnumerable<Channel> GetGuildThreadsAsync(
|
||||
Snowflake guildId,
|
||||
bool includeArchived = false,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
yield break;
|
||||
|
@ -289,7 +306,9 @@ public class DiscordClient
|
|||
if (response is null)
|
||||
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);
|
||||
currentOffset++;
|
||||
|
@ -319,7 +338,9 @@ public class DiscordClient
|
|||
if (response is null)
|
||||
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);
|
||||
currentOffset++;
|
||||
|
@ -338,13 +359,16 @@ public class DiscordClient
|
|||
{
|
||||
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())
|
||||
{
|
||||
var parent = threadJson
|
||||
.GetPropertyOrNull("parent_id")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(Snowflake.Parse)
|
||||
.GetPropertyOrNull("parent_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse)
|
||||
.Pipe(parentsById.GetValueOrDefault);
|
||||
|
||||
yield return Channel.Parse(threadJson, parent);
|
||||
|
@ -384,7 +408,8 @@ public class DiscordClient
|
|||
|
||||
public async IAsyncEnumerable<Role> GetGuildRolesAsync(
|
||||
Snowflake guildId,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
yield break;
|
||||
|
@ -397,18 +422,23 @@ public class DiscordClient
|
|||
public async ValueTask<Member?> TryGetGuildMemberAsync(
|
||||
Snowflake guildId,
|
||||
Snowflake memberId,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
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));
|
||||
}
|
||||
|
||||
public async ValueTask<Invite?> TryGetInviteAsync(
|
||||
string code,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var response = await TryGetJsonResponseAsync($"invites/{code}", cancellationToken);
|
||||
return response?.Pipe(Invite.Parse);
|
||||
|
@ -416,14 +446,15 @@ public class DiscordClient
|
|||
|
||||
public async ValueTask<Channel> GetChannelAsync(
|
||||
Snowflake channelId,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
|
||||
|
||||
var parentId = response
|
||||
.GetPropertyOrNull("parent_id")?
|
||||
.GetNonWhiteSpaceStringOrNull()?
|
||||
.Pipe(Snowflake.Parse);
|
||||
.GetPropertyOrNull("parent_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
|
||||
try
|
||||
{
|
||||
|
@ -445,7 +476,8 @@ public class DiscordClient
|
|||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? before = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
|
@ -462,7 +494,8 @@ public class DiscordClient
|
|||
Snowflake? after = null,
|
||||
Snowflake? before = 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
|
||||
// progress based on the difference between message timestamps.
|
||||
|
@ -511,13 +544,15 @@ public class DiscordClient
|
|||
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
|
||||
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
||||
|
||||
progress.Report(Percentage.FromFraction(
|
||||
progress.Report(
|
||||
Percentage.FromFraction(
|
||||
// Avoid division by zero if all messages have the exact same timestamp
|
||||
// (which happens when there's only one message in the channel)
|
||||
totalDuration > TimeSpan.Zero
|
||||
? exportedDuration / totalDuration
|
||||
: 1
|
||||
));
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
yield return message;
|
||||
|
@ -530,7 +565,8 @@ public class DiscordClient
|
|||
Snowflake channelId,
|
||||
Snowflake messageId,
|
||||
Emoji emoji,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var reactionName = emoji.Id is not null
|
||||
// Custom emoji
|
||||
|
@ -542,7 +578,9 @@ public class DiscordClient
|
|||
while (true)
|
||||
{
|
||||
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("after", currentAfter.ToString())
|
||||
.Build();
|
||||
|
|
|
@ -6,9 +6,10 @@ namespace DiscordChatExporter.Core.Discord;
|
|||
|
||||
public readonly partial record struct Snowflake(ulong Value)
|
||||
{
|
||||
public DateTimeOffset ToDate() => DateTimeOffset.FromUnixTimeMilliseconds(
|
||||
(long)((Value >> 22) + 1420070400000UL)
|
||||
).ToLocalTime();
|
||||
public DateTimeOffset ToDate() =>
|
||||
DateTimeOffset
|
||||
.FromUnixTimeMilliseconds((long)((Value >> 22) + 1420070400000UL))
|
||||
.ToLocalTime();
|
||||
|
||||
[ExcludeFromCodeCoverage]
|
||||
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 FromDate(DateTimeOffset instant) => new(
|
||||
((ulong)instant.ToUnixTimeMilliseconds() - 1420070400000UL) << 22
|
||||
);
|
||||
public static Snowflake FromDate(DateTimeOffset instant) =>
|
||||
new(((ulong)instant.ToUnixTimeMilliseconds() - 1420070400000UL) << 22);
|
||||
|
||||
public static Snowflake? TryParse(string? str, IFormatProvider? formatProvider = null)
|
||||
{
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<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="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Polly" Version="7.2.4" />
|
||||
|
|
|
@ -16,14 +16,13 @@ public class ChannelExporter
|
|||
public async ValueTask ExportChannelAsync(
|
||||
ExportRequest request,
|
||||
IProgress<Percentage>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
// Check if the channel is empty
|
||||
if (request.Channel.LastMessageId is null)
|
||||
{
|
||||
throw new DiscordChatExporterException(
|
||||
"Channel does not contain any messages."
|
||||
);
|
||||
throw new DiscordChatExporterException("Channel does not contain any messages.");
|
||||
}
|
||||
|
||||
// Check if the 'after' boundary is valid
|
||||
|
@ -40,12 +39,15 @@ public class ChannelExporter
|
|||
|
||||
// Export messages
|
||||
await using var messageExporter = new MessageExporter(context);
|
||||
await foreach (var message in _discord.GetMessagesAsync(
|
||||
await foreach (
|
||||
var message in _discord.GetMessagesAsync(
|
||||
request.Channel.Id,
|
||||
request.After,
|
||||
request.Before,
|
||||
progress,
|
||||
cancellationToken))
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
// Resolve members for referenced users
|
||||
foreach (var user in message.GetReferencedUsers())
|
||||
|
|
|
@ -20,17 +20,20 @@ internal partial class CsvMessageWriter : MessageWriter
|
|||
|
||||
private async ValueTask<string> FormatMarkdownAsync(
|
||||
string markdown,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CancellationToken cancellationToken = default
|
||||
) =>
|
||||
Context.Request.ShouldFormatMarkdown
|
||||
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
|
||||
: markdown;
|
||||
|
||||
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) =>
|
||||
await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
|
||||
public override async ValueTask WritePreambleAsync(
|
||||
CancellationToken cancellationToken = default
|
||||
) => await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
|
||||
|
||||
private async ValueTask WriteAttachmentsAsync(
|
||||
IReadOnlyList<Attachment> attachments,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
|
@ -48,7 +51,8 @@ internal partial class CsvMessageWriter : MessageWriter
|
|||
|
||||
private async ValueTask WriteReactionsAsync(
|
||||
IReadOnlyList<Reaction> reactions,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
|
@ -70,7 +74,8 @@ internal partial class CsvMessageWriter : MessageWriter
|
|||
|
||||
public override async ValueTask WriteMessageAsync(
|
||||
Message message,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
await base.WriteMessageAsync(message, cancellationToken);
|
||||
|
||||
|
@ -89,15 +94,13 @@ internal partial class CsvMessageWriter : MessageWriter
|
|||
// Message content
|
||||
if (message.Kind.IsSystemNotification())
|
||||
{
|
||||
await _writer.WriteAsync(CsvEncode(
|
||||
message.GetFallbackContent()
|
||||
));
|
||||
await _writer.WriteAsync(CsvEncode(message.GetFallbackContent()));
|
||||
}
|
||||
else
|
||||
{
|
||||
await _writer.WriteAsync(CsvEncode(
|
||||
await FormatMarkdownAsync(message.Content, cancellationToken)
|
||||
));
|
||||
await _writer.WriteAsync(
|
||||
CsvEncode(await FormatMarkdownAsync(message.Content, cancellationToken))
|
||||
);
|
||||
}
|
||||
|
||||
await _writer.WriteAsync(',');
|
||||
|
|
|
@ -15,7 +15,8 @@ namespace DiscordChatExporter.Core.Exporting;
|
|||
|
||||
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;
|
||||
|
@ -33,7 +34,10 @@ internal partial class ExportAssetDownloader
|
|||
_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 filePath = Path.Combine(_workingDirPath, fileName);
|
||||
|
@ -59,8 +63,16 @@ internal partial class ExportAssetDownloader
|
|||
// Try to set the file date according to the last-modified header
|
||||
try
|
||||
{
|
||||
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
|
||||
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var instant)
|
||||
var lastModified = response.Content.Headers
|
||||
.TryGetValue("Last-Modified")
|
||||
?.Pipe(
|
||||
s =>
|
||||
DateTimeOffset.TryParse(
|
||||
s,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var instant
|
||||
)
|
||||
? instant
|
||||
: (DateTimeOffset?)null
|
||||
);
|
||||
|
@ -86,7 +98,8 @@ internal partial class ExportAssetDownloader
|
|||
|
||||
internal partial class ExportAssetDownloader
|
||||
{
|
||||
private static string GetUrlHash(string url) => SHA256
|
||||
private static string GetUrlHash(string url) =>
|
||||
SHA256
|
||||
.HashData(Encoding.UTF8.GetBytes(url))
|
||||
.ToHex()
|
||||
// 5 chars ought to be enough for anybody
|
||||
|
@ -115,6 +128,8 @@ internal partial class ExportAssetDownloader
|
|||
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 ExportContext(DiscordClient discord,
|
||||
ExportRequest request)
|
||||
public ExportContext(DiscordClient discord, ExportRequest request)
|
||||
{
|
||||
Discord = discord;
|
||||
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;
|
||||
|
||||
await foreach (var role in Discord.GetGuildRolesAsync(Request.Guild.Id, cancellationToken))
|
||||
|
@ -48,7 +51,8 @@ internal class ExportContext
|
|||
private async ValueTask PopulateMemberAsync(
|
||||
Snowflake id,
|
||||
User? fallbackUser,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (_membersById.ContainsKey(id))
|
||||
return;
|
||||
|
@ -70,13 +74,18 @@ internal class ExportContext
|
|||
_membersById[id] = member;
|
||||
}
|
||||
|
||||
public async ValueTask PopulateMemberAsync(Snowflake id, CancellationToken cancellationToken = default) =>
|
||||
await PopulateMemberAsync(id, null, cancellationToken);
|
||||
public async ValueTask PopulateMemberAsync(
|
||||
Snowflake id,
|
||||
CancellationToken cancellationToken = default
|
||||
) => await PopulateMemberAsync(id, null, cancellationToken);
|
||||
|
||||
public async ValueTask PopulateMemberAsync(User user, CancellationToken cancellationToken = default) =>
|
||||
await PopulateMemberAsync(user.Id, user, cancellationToken);
|
||||
public async ValueTask PopulateMemberAsync(
|
||||
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(),
|
||||
|
@ -89,19 +98,20 @@ internal class ExportContext
|
|||
|
||||
public Role? TryGetRole(Snowflake id) => _rolesById.GetValueOrDefault(id);
|
||||
|
||||
public IReadOnlyList<Role> GetUserRoles(Snowflake id) => TryGetMember(id)?
|
||||
.RoleIds
|
||||
public IReadOnlyList<Role> GetUserRoles(Snowflake id) =>
|
||||
TryGetMember(id)?.RoleIds
|
||||
.Select(TryGetRole)
|
||||
.WhereNotNull()
|
||||
.OrderByDescending(r => r.Position)
|
||||
.ToArray() ?? Array.Empty<Role>();
|
||||
|
||||
public Color? TryGetUserColor(Snowflake id) => GetUserRoles(id)
|
||||
.Where(r => r.Color is not null)
|
||||
.Select(r => r.Color)
|
||||
.FirstOrDefault();
|
||||
public Color? TryGetUserColor(Snowflake id) =>
|
||||
GetUserRoles(id).Where(r => r.Color is not null).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)
|
||||
return url;
|
||||
|
@ -114,8 +124,14 @@ internal class ExportContext
|
|||
// 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.
|
||||
var optimalFilePath =
|
||||
relativeFilePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
|
||||
relativeFilePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)
|
||||
relativeFilePath.StartsWith(
|
||||
".." + Path.DirectorySeparatorChar,
|
||||
StringComparison.Ordinal
|
||||
)
|
||||
|| relativeFilePath.StartsWith(
|
||||
".." + Path.AltDirectorySeparatorChar,
|
||||
StringComparison.Ordinal
|
||||
)
|
||||
? filePath
|
||||
: relativeFilePath;
|
||||
|
||||
|
|
|
@ -13,7 +13,8 @@ public enum ExportFormat
|
|||
|
||||
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",
|
||||
|
@ -23,7 +24,8 @@ public static class ExportFormatExtensions
|
|||
_ => 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)",
|
||||
|
|
|
@ -54,7 +54,8 @@ public partial class ExportRequest
|
|||
bool shouldFormatMarkdown,
|
||||
bool shouldDownloadAssets,
|
||||
bool shouldReuseAssets,
|
||||
string dateFormat)
|
||||
string dateFormat
|
||||
)
|
||||
{
|
||||
Guild = guild;
|
||||
Channel = channel;
|
||||
|
@ -68,25 +69,12 @@ public partial class ExportRequest
|
|||
ShouldReuseAssets = shouldReuseAssets;
|
||||
DateFormat = dateFormat;
|
||||
|
||||
OutputFilePath = GetOutputBaseFilePath(
|
||||
Guild,
|
||||
Channel,
|
||||
outputPath,
|
||||
Format,
|
||||
After,
|
||||
Before
|
||||
);
|
||||
OutputFilePath = GetOutputBaseFilePath(Guild, Channel, outputPath, Format, After, Before);
|
||||
|
||||
OutputDirPath = Path.GetDirectoryName(OutputFilePath)!;
|
||||
|
||||
AssetsDirPath = !string.IsNullOrWhiteSpace(assetsDirPath)
|
||||
? FormatPath(
|
||||
assetsDirPath,
|
||||
Guild,
|
||||
Channel,
|
||||
After,
|
||||
Before
|
||||
)
|
||||
? FormatPath(assetsDirPath, Guild, Channel, After, Before)
|
||||
: $"{OutputFilePath}_Files{Path.DirectorySeparatorChar}";
|
||||
}
|
||||
}
|
||||
|
@ -98,7 +86,8 @@ public partial class ExportRequest
|
|||
Channel channel,
|
||||
ExportFormat format,
|
||||
Snowflake? after = null,
|
||||
Snowflake? before = null)
|
||||
Snowflake? before = null
|
||||
)
|
||||
{
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
|
@ -113,7 +102,9 @@ public partial class ExportRequest
|
|||
// Both 'after' and 'before' are set
|
||||
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
|
||||
else if (after is not null)
|
||||
|
@ -140,12 +131,15 @@ public partial class ExportRequest
|
|||
Guild guild,
|
||||
Channel channel,
|
||||
Snowflake? after,
|
||||
Snowflake? before)
|
||||
Snowflake? before
|
||||
)
|
||||
{
|
||||
return Regex.Replace(
|
||||
path,
|
||||
"%.",
|
||||
m => PathEx.EscapeFileName(m.Value switch
|
||||
m =>
|
||||
PathEx.EscapeFileName(
|
||||
m.Value switch
|
||||
{
|
||||
"%g" => guild.Id.ToString(),
|
||||
"%G" => guild.Name,
|
||||
|
@ -154,13 +148,24 @@ public partial class ExportRequest
|
|||
"%c" => channel.Id.ToString(),
|
||||
"%C" => channel.Name,
|
||||
"%p" => channel.Position?.ToString(CultureInfo.InvariantCulture) ?? "0",
|
||||
"%P" => channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture) ?? "0",
|
||||
"%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),
|
||||
"%P"
|
||||
=> channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture)
|
||||
?? "0",
|
||||
"%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,
|
||||
ExportFormat format,
|
||||
Snowflake? after = null,
|
||||
Snowflake? before = null)
|
||||
Snowflake? before = null
|
||||
)
|
||||
{
|
||||
var actualOutputPath = FormatPath(outputPath, guild, channel, after, before);
|
||||
|
||||
// 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);
|
||||
return Path.Combine(actualOutputPath, fileName);
|
||||
|
|
|
@ -9,14 +9,19 @@ internal class BinaryExpressionMessageFilter : MessageFilter
|
|||
private readonly MessageFilter _second;
|
||||
private readonly BinaryExpressionKind _kind;
|
||||
|
||||
public BinaryExpressionMessageFilter(MessageFilter first, MessageFilter second, BinaryExpressionKind kind)
|
||||
public BinaryExpressionMessageFilter(
|
||||
MessageFilter first,
|
||||
MessageFilter second,
|
||||
BinaryExpressionKind kind
|
||||
)
|
||||
{
|
||||
_first = first;
|
||||
_second = second;
|
||||
_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),
|
||||
|
|
|
@ -17,25 +17,21 @@ internal class ContainsMessageFilter : MessageFilter
|
|||
// parentheses are not considered word characters.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/909
|
||||
private bool IsMatch(string? content) =>
|
||||
!string.IsNullOrWhiteSpace(content) &&
|
||||
Regex.IsMatch(
|
||||
!string.IsNullOrWhiteSpace(content)
|
||||
&& Regex.IsMatch(
|
||||
content,
|
||||
@"(?:\b|\s|^)" +
|
||||
Regex.Escape(_text) +
|
||||
@"(?:\b|\s|$)",
|
||||
@"(?:\b|\s|^)" + Regex.Escape(_text) + @"(?:\b|\s|$)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
|
||||
);
|
||||
|
||||
public override bool IsMatch(Message message) =>
|
||||
IsMatch(message.Content) ||
|
||||
message.Embeds.Any(e =>
|
||||
IsMatch(e.Title) ||
|
||||
IsMatch(e.Author?.Name) ||
|
||||
IsMatch(e.Description) ||
|
||||
IsMatch(e.Footer?.Text) ||
|
||||
e.Fields.Any(f =>
|
||||
IsMatch(f.Name) ||
|
||||
IsMatch(f.Value)
|
||||
)
|
||||
IsMatch(message.Content)
|
||||
|| message.Embeds.Any(
|
||||
e =>
|
||||
IsMatch(e.Title)
|
||||
|| IsMatch(e.Author?.Name)
|
||||
|| IsMatch(e.Description)
|
||||
|| IsMatch(e.Footer?.Text)
|
||||
|| e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
|
||||
);
|
||||
}
|
|
@ -10,8 +10,8 @@ internal class FromMessageFilter : MessageFilter
|
|||
public FromMessageFilter(string value) => _value = value;
|
||||
|
||||
public override bool IsMatch(Message message) =>
|
||||
string.Equals(_value, message.Author.Name, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(_value, message.Author.DisplayName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(_value, message.Author.FullName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(_value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
string.Equals(_value, message.Author.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(_value, message.Author.DisplayName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(_value, message.Author.FullName, 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 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.Link
|
||||
=> Regex.IsMatch(message.Content, "https?://\\S*[^\\.,:;\"\'\\s]"),
|
||||
MessageContentMatchKind.Embed => message.Embeds.Any(),
|
||||
MessageContentMatchKind.File => message.Attachments.Any(),
|
||||
MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo),
|
||||
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
|
||||
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
|
||||
MessageContentMatchKind.Pin => message.IsPinned,
|
||||
_ => throw new InvalidOperationException($"Unknown message content match kind '{_kind}'.")
|
||||
_
|
||||
=> throw new InvalidOperationException(
|
||||
$"Unknown message content match kind '{_kind}'."
|
||||
)
|
||||
};
|
||||
}
|
|
@ -10,10 +10,12 @@ internal class MentionsMessageFilter : MessageFilter
|
|||
|
||||
public MentionsMessageFilter(string value) => _value = value;
|
||||
|
||||
public override bool IsMatch(Message message) => message.MentionedUsers.Any(user =>
|
||||
string.Equals(_value, user.Name, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(_value, user.DisplayName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(_value, user.FullName, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(_value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
|
||||
public override bool IsMatch(Message message) =>
|
||||
message.MentionedUsers.Any(
|
||||
user =>
|
||||
string.Equals(_value, user.Name, 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
|
||||
{
|
||||
private static readonly TextParser<char> EscapedCharacter =
|
||||
Character.EqualTo('\\').IgnoreThen(Character.AnyChar);
|
||||
private static readonly TextParser<char> EscapedCharacter = Character
|
||||
.EqualTo('\\')
|
||||
.IgnoreThen(Character.AnyChar);
|
||||
|
||||
private static readonly TextParser<string> QuotedString =
|
||||
from open in Character.In('"', '\'')
|
||||
|
@ -15,64 +16,71 @@ internal static class FilterGrammar
|
|||
from close in Character.EqualTo(open)
|
||||
select value;
|
||||
|
||||
private static readonly TextParser<string> UnquotedString =
|
||||
Parse.OneOf(
|
||||
private static readonly TextParser<string> UnquotedString = Parse
|
||||
.OneOf(
|
||||
EscapedCharacter,
|
||||
// Avoid whitespace as it's treated as an implicit 'and' operator.
|
||||
// Also avoid all special tokens used by other parsers.
|
||||
Character.ExceptIn(' ', '(', ')', '"', '\'', '-', '~', '|', '&')
|
||||
).AtLeastOnce().Text();
|
||||
)
|
||||
.AtLeastOnce()
|
||||
.Text();
|
||||
|
||||
private static readonly TextParser<string> String =
|
||||
Parse.OneOf(QuotedString, UnquotedString).Named("text string");
|
||||
private static readonly TextParser<string> String = Parse
|
||||
.OneOf(QuotedString, UnquotedString)
|
||||
.Named("text string");
|
||||
|
||||
private static readonly TextParser<MessageFilter> ContainsFilter =
|
||||
String.Select(v => (MessageFilter)new ContainsMessageFilter(v));
|
||||
private static readonly TextParser<MessageFilter> ContainsFilter = String.Select(
|
||||
v => (MessageFilter)new ContainsMessageFilter(v)
|
||||
);
|
||||
|
||||
private static readonly TextParser<MessageFilter> FromFilter =
|
||||
Span
|
||||
.EqualToIgnoreCase("from:")
|
||||
private static readonly TextParser<MessageFilter> FromFilter = Span.EqualToIgnoreCase("from:")
|
||||
.Try()
|
||||
.IgnoreThen(String)
|
||||
.Select(v => (MessageFilter)new FromMessageFilter(v))
|
||||
.Named("from:<value>");
|
||||
|
||||
private static readonly TextParser<MessageFilter> MentionsFilter =
|
||||
Span
|
||||
.EqualToIgnoreCase("mentions:")
|
||||
private static readonly TextParser<MessageFilter> MentionsFilter = Span.EqualToIgnoreCase(
|
||||
"mentions:"
|
||||
)
|
||||
.Try()
|
||||
.IgnoreThen(String)
|
||||
.Select(v => (MessageFilter)new MentionsMessageFilter(v))
|
||||
.Named("mentions:<value>");
|
||||
|
||||
private static readonly TextParser<MessageFilter> ReactionFilter =
|
||||
Span
|
||||
.EqualToIgnoreCase("reaction:")
|
||||
private static readonly TextParser<MessageFilter> ReactionFilter = Span.EqualToIgnoreCase(
|
||||
"reaction:"
|
||||
)
|
||||
.Try()
|
||||
.IgnoreThen(String)
|
||||
.Select(v => (MessageFilter)new ReactionMessageFilter(v))
|
||||
.Named("reaction:<value>");
|
||||
|
||||
private static readonly TextParser<MessageFilter> HasFilter =
|
||||
Span
|
||||
.EqualToIgnoreCase("has:")
|
||||
private static readonly TextParser<MessageFilter> HasFilter = Span.EqualToIgnoreCase("has:")
|
||||
.Try()
|
||||
.IgnoreThen(Parse.OneOf(
|
||||
Span.EqualToIgnoreCase("link").IgnoreThen(Parse.Return(MessageContentMatchKind.Link)),
|
||||
Span.EqualToIgnoreCase("embed").IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)),
|
||||
Span.EqualToIgnoreCase("file").IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
|
||||
Span.EqualToIgnoreCase("video").IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
|
||||
Span.EqualToIgnoreCase("image").IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
|
||||
Span.EqualToIgnoreCase("sound").IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)),
|
||||
.IgnoreThen(
|
||||
Parse.OneOf(
|
||||
Span.EqualToIgnoreCase("link")
|
||||
.IgnoreThen(Parse.Return(MessageContentMatchKind.Link)),
|
||||
Span.EqualToIgnoreCase("embed")
|
||||
.IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)),
|
||||
Span.EqualToIgnoreCase("file")
|
||||
.IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
|
||||
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))
|
||||
))
|
||||
)
|
||||
)
|
||||
.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.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/909#issuecomment-1227575455
|
||||
private static readonly TextParser<MessageFilter> PrimitiveFilter =
|
||||
Parse.OneOf(
|
||||
private static readonly TextParser<MessageFilter> PrimitiveFilter = Parse.OneOf(
|
||||
FromFilter,
|
||||
MentionsFilter,
|
||||
ReactionFilter,
|
||||
|
@ -86,15 +94,13 @@ internal static class FilterGrammar
|
|||
from close in Character.EqualTo(')')
|
||||
select content;
|
||||
|
||||
private static readonly TextParser<MessageFilter> NegatedFilter =
|
||||
Character
|
||||
private static readonly TextParser<MessageFilter> NegatedFilter = Character
|
||||
// Dash is annoying to use from CLI due to conflicts with options, so we provide tilde as an alias
|
||||
.In('-', '~')
|
||||
.IgnoreThen(Parse.OneOf(GroupedFilter, PrimitiveFilter))
|
||||
.Select(f => (MessageFilter)new NegatedMessageFilter(f));
|
||||
|
||||
private static readonly TextParser<MessageFilter> ChainedFilter =
|
||||
Parse.Chain(
|
||||
private static readonly TextParser<MessageFilter> ChainedFilter = Parse.Chain(
|
||||
// Operator
|
||||
Parse.OneOf(
|
||||
// Explicit operator
|
||||
|
@ -103,19 +109,15 @@ internal static class FilterGrammar
|
|||
Character.EqualTo(' ').AtLeastOnce().IgnoreThen(Parse.Return(' '))
|
||||
),
|
||||
// Operand
|
||||
Parse.OneOf(
|
||||
NegatedFilter,
|
||||
GroupedFilter,
|
||||
PrimitiveFilter
|
||||
),
|
||||
Parse.OneOf(NegatedFilter, GroupedFilter, PrimitiveFilter),
|
||||
// Reducer
|
||||
(op, left, right) => op switch
|
||||
(op, left, right) =>
|
||||
op switch
|
||||
{
|
||||
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
|
||||
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And)
|
||||
}
|
||||
);
|
||||
|
||||
public static readonly TextParser<MessageFilter> Filter =
|
||||
ChainedFilter.Token().AtEnd();
|
||||
public static readonly TextParser<MessageFilter> Filter = ChainedFilter.Token().AtEnd();
|
||||
}
|
|
@ -10,9 +10,11 @@ internal class ReactionMessageFilter : MessageFilter
|
|||
|
||||
public ReactionMessageFilter(string value) => _value = value;
|
||||
|
||||
public override bool IsMatch(Message message) => message.Reactions.Any(r =>
|
||||
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)
|
||||
public override bool IsMatch(Message message) =>
|
||||
message.Reactions.Any(
|
||||
r =>
|
||||
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(
|
||||
TextNode text,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(HtmlEncode(text.Text));
|
||||
return default;
|
||||
|
@ -35,53 +36,63 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||
|
||||
protected override async ValueTask VisitFormattingAsync(
|
||||
FormattingNode formatting,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var (openingTag, closingTag) = formatting.Kind switch
|
||||
{
|
||||
FormattingKind.Bold => (
|
||||
FormattingKind.Bold
|
||||
=> (
|
||||
// lang=html
|
||||
"<strong>",
|
||||
// lang=html
|
||||
"</strong>"
|
||||
),
|
||||
|
||||
FormattingKind.Italic => (
|
||||
FormattingKind.Italic
|
||||
=> (
|
||||
// lang=html
|
||||
"<em>",
|
||||
// lang=html
|
||||
"</em>"
|
||||
),
|
||||
|
||||
FormattingKind.Underline => (
|
||||
FormattingKind.Underline
|
||||
=> (
|
||||
// lang=html
|
||||
"<u>",
|
||||
// lang=html
|
||||
"</u>"
|
||||
),
|
||||
|
||||
FormattingKind.Strikethrough => (
|
||||
FormattingKind.Strikethrough
|
||||
=> (
|
||||
// lang=html
|
||||
"<s>",
|
||||
// lang=html
|
||||
"</s>"
|
||||
),
|
||||
|
||||
FormattingKind.Spoiler => (
|
||||
FormattingKind.Spoiler
|
||||
=> (
|
||||
// lang=html
|
||||
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
|
||||
// lang=html
|
||||
"""</span>"""
|
||||
),
|
||||
|
||||
FormattingKind.Quote => (
|
||||
FormattingKind.Quote
|
||||
=> (
|
||||
// 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}'.")
|
||||
_
|
||||
=> throw new InvalidOperationException(
|
||||
$"Unknown formatting kind '{formatting.Kind}'."
|
||||
)
|
||||
};
|
||||
|
||||
_buffer.Append(openingTag);
|
||||
|
@ -91,7 +102,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||
|
||||
protected override async ValueTask VisitHeadingAsync(
|
||||
HeadingNode heading,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(
|
||||
// lang=html
|
||||
|
@ -108,7 +120,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||
|
||||
protected override async ValueTask VisitListAsync(
|
||||
ListNode list,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(
|
||||
// lang=html
|
||||
|
@ -125,7 +138,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||
|
||||
protected override async ValueTask VisitListItemAsync(
|
||||
ListItemNode listItem,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(
|
||||
// lang=html
|
||||
|
@ -142,7 +156,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||
|
||||
protected override ValueTask VisitInlineCodeBlockAsync(
|
||||
InlineCodeBlockNode inlineCodeBlock,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(
|
||||
// lang=html
|
||||
|
@ -156,7 +171,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||
|
||||
protected override ValueTask VisitMultiLineCodeBlockAsync(
|
||||
MultiLineCodeBlockNode multiLineCodeBlock,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var highlightClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
|
||||
? $"language-{multiLineCodeBlock.Language}"
|
||||
|
@ -174,13 +190,13 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||
|
||||
protected override async ValueTask VisitLinkAsync(
|
||||
LinkNode link,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
// Try to extract the message ID if the link points to a Discord message
|
||||
var linkedMessageId = Regex.Match(
|
||||
link.Url,
|
||||
@"^https?://(?:discord|discordapp)\.com/channels/.*?/(\d+)/?$"
|
||||
).Groups[1].Value;
|
||||
var linkedMessageId = Regex
|
||||
.Match(link.Url, @"^https?://(?:discord|discordapp)\.com/channels/.*?/(\d+)/?$")
|
||||
.Groups[1].Value;
|
||||
|
||||
_buffer.Append(
|
||||
!string.IsNullOrWhiteSpace(linkedMessageId)
|
||||
|
@ -200,7 +216,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||
|
||||
protected override async ValueTask VisitEmojiAsync(
|
||||
EmojiNode emoji,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
||||
var jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
|
||||
|
@ -218,8 +235,10 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||
);
|
||||
}
|
||||
|
||||
protected override async ValueTask VisitMentionAsync(MentionNode mention,
|
||||
CancellationToken cancellationToken = default)
|
||||
protected override async ValueTask VisitMentionAsync(
|
||||
MentionNode mention,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (mention.Kind == MentionKind.Everyone)
|
||||
{
|
||||
|
@ -294,7 +313,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||
|
||||
protected override ValueTask VisitTimestampAsync(
|
||||
TimestampNode timestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var formatted = timestamp.Instant is not null
|
||||
? !string.IsNullOrWhiteSpace(timestamp.Format)
|
||||
|
@ -323,16 +343,24 @@ internal partial class HtmlMarkdownVisitor
|
|||
ExportContext context,
|
||||
string markdown,
|
||||
bool isJumboAllowed = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var nodes = MarkdownParser.Parse(markdown);
|
||||
|
||||
var isJumbo =
|
||||
isJumboAllowed &&
|
||||
nodes.All(n => n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text));
|
||||
isJumboAllowed
|
||||
&& nodes.All(
|
||||
n =>
|
||||
n is EmojiNode
|
||||
|| n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)
|
||||
);
|
||||
|
||||
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();
|
||||
}
|
||||
|
|
|
@ -15,8 +15,7 @@ internal static class HtmlMessageExtensions
|
|||
|
||||
var embed = message.Embeds[0];
|
||||
|
||||
return
|
||||
string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase) &&
|
||||
embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
||||
return string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase)
|
||||
&& 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
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
@ -69,7 +75,8 @@ internal class HtmlMessageWriter : MessageWriter
|
|||
private string Minify(string html) => _minifier.Minify(html, false).MinifiedContent;
|
||||
|
||||
public override async ValueTask WritePreambleAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
await _writer.WriteLineAsync(
|
||||
Minify(
|
||||
|
@ -84,7 +91,8 @@ internal class HtmlMessageWriter : MessageWriter
|
|||
|
||||
private async ValueTask WriteMessageGroupAsync(
|
||||
IReadOnlyList<Message> messages,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
await _writer.WriteLineAsync(
|
||||
Minify(
|
||||
|
@ -99,7 +107,8 @@ internal class HtmlMessageWriter : MessageWriter
|
|||
|
||||
public override async ValueTask WriteMessageAsync(
|
||||
Message message,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
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
|
||||
if (_messageGroup.Any())
|
||||
|
|
|
@ -18,7 +18,9 @@ internal class JsonMessageWriter : MessageWriter
|
|||
public JsonMessageWriter(Stream stream, ExportContext context)
|
||||
: base(stream, context)
|
||||
{
|
||||
_writer = new Utf8JsonWriter(stream, new JsonWriterOptions
|
||||
_writer = new Utf8JsonWriter(
|
||||
stream,
|
||||
new JsonWriterOptions
|
||||
{
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
|
@ -26,26 +28,29 @@ internal class JsonMessageWriter : MessageWriter
|
|||
// Validation errors may mask actual failures
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
||||
SkipValidation = true
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async ValueTask<string> FormatMarkdownAsync(
|
||||
string markdown,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CancellationToken cancellationToken = default
|
||||
) =>
|
||||
Context.Request.ShouldFormatMarkdown
|
||||
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
|
||||
: markdown;
|
||||
|
||||
private async ValueTask WriteUserAsync(
|
||||
User user,
|
||||
CancellationToken cancellationToken = default)
|
||||
private async ValueTask WriteUserAsync(User user, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", user.Id.ToString());
|
||||
_writer.WriteString("name", user.Name);
|
||||
_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.WriteBoolean("isBot", user.IsBot);
|
||||
|
||||
|
@ -66,7 +71,8 @@ internal class JsonMessageWriter : MessageWriter
|
|||
|
||||
private async ValueTask WriteRolesAsync(
|
||||
IReadOnlyList<Role> roles,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_writer.WriteStartArray();
|
||||
|
||||
|
@ -88,7 +94,8 @@ internal class JsonMessageWriter : MessageWriter
|
|||
|
||||
private async ValueTask WriteEmbedAuthorAsync(
|
||||
EmbedAuthor embedAuthor,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
|
@ -99,7 +106,10 @@ internal class JsonMessageWriter : MessageWriter
|
|||
{
|
||||
_writer.WriteString(
|
||||
"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(
|
||||
EmbedImage embedImage,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
|
@ -117,7 +128,10 @@ internal class JsonMessageWriter : MessageWriter
|
|||
{
|
||||
_writer.WriteString(
|
||||
"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(
|
||||
EmbedFooter embedFooter,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
|
@ -140,7 +155,10 @@ internal class JsonMessageWriter : MessageWriter
|
|||
{
|
||||
_writer.WriteString(
|
||||
"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(
|
||||
EmbedField embedField,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_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.WriteEndObject();
|
||||
|
@ -164,14 +186,21 @@ internal class JsonMessageWriter : MessageWriter
|
|||
|
||||
private async ValueTask WriteEmbedAsync(
|
||||
Embed embed,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_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("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)
|
||||
_writer.WriteString("color", embed.Color.Value.ToHex());
|
||||
|
@ -220,7 +249,9 @@ internal class JsonMessageWriter : MessageWriter
|
|||
await _writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
|
||||
public override async ValueTask WritePreambleAsync(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
// Root object (start)
|
||||
_writer.WriteStartObject();
|
||||
|
@ -250,7 +281,10 @@ internal class JsonMessageWriter : MessageWriter
|
|||
{
|
||||
_writer.WriteString(
|
||||
"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(
|
||||
Message message,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
await base.WriteMessageAsync(message, cancellationToken);
|
||||
|
||||
|
@ -293,7 +328,10 @@ internal class JsonMessageWriter : MessageWriter
|
|||
}
|
||||
else
|
||||
{
|
||||
_writer.WriteString("content", await FormatMarkdownAsync(message.Content, cancellationToken));
|
||||
_writer.WriteString(
|
||||
"content",
|
||||
await FormatMarkdownAsync(message.Content, cancellationToken)
|
||||
);
|
||||
}
|
||||
|
||||
// Author
|
||||
|
@ -308,7 +346,10 @@ internal class JsonMessageWriter : MessageWriter
|
|||
_writer.WriteStartObject();
|
||||
|
||||
_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.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||
|
||||
|
@ -335,7 +376,10 @@ internal class JsonMessageWriter : MessageWriter
|
|||
_writer.WriteString("id", sticker.Id.ToString());
|
||||
_writer.WriteString("name", sticker.Name);
|
||||
_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();
|
||||
}
|
||||
|
@ -355,17 +399,23 @@ internal class JsonMessageWriter : MessageWriter
|
|||
_writer.WriteString("name", reaction.Emoji.Name);
|
||||
_writer.WriteString("code", reaction.Emoji.Code);
|
||||
_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.WriteNumber("count", reaction.Count);
|
||||
|
||||
_writer.WriteStartArray("users");
|
||||
await foreach (var user in Context.Discord.GetMessageReactionsAsync(
|
||||
await foreach (
|
||||
var user in Context.Discord.GetMessageReactionsAsync(
|
||||
Context.Request.Channel.Id,
|
||||
message.Id,
|
||||
reaction.Emoji,
|
||||
cancellationToken))
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
|
@ -374,7 +424,10 @@ internal class JsonMessageWriter : MessageWriter
|
|||
_writer.WriteString("id", user.Id.ToString());
|
||||
_writer.WriteString("name", user.Name);
|
||||
_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.WriteString(
|
||||
|
@ -431,7 +484,9 @@ internal class JsonMessageWriter : MessageWriter
|
|||
await _writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
|
||||
public override async ValueTask WritePostambleAsync(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
// Message array (end)
|
||||
_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
|
||||
if (_writer is not null &&
|
||||
_context.Request.PartitionLimit.IsReached(_writer.MessagesWritten, _writer.BytesWritten))
|
||||
if (
|
||||
_writer is not null
|
||||
&& _context.Request.PartitionLimit.IsReached(
|
||||
_writer.MessagesWritten,
|
||||
_writer.BytesWritten
|
||||
)
|
||||
)
|
||||
{
|
||||
await ResetWriterAsync(cancellationToken);
|
||||
_partitionIndex++;
|
||||
|
@ -60,7 +67,10 @@ internal partial class MessageExporter : IAsyncDisposable
|
|||
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);
|
||||
await writer.WriteMessageAsync(message, cancellationToken);
|
||||
|
@ -84,22 +94,26 @@ internal partial class MessageExporter
|
|||
var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}";
|
||||
var dirPath = Path.GetDirectoryName(baseFilePath);
|
||||
|
||||
return !string.IsNullOrWhiteSpace(dirPath)
|
||||
? Path.Combine(dirPath, fileName)
|
||||
: fileName;
|
||||
return !string.IsNullOrWhiteSpace(dirPath) ? Path.Combine(dirPath, fileName) : fileName;
|
||||
}
|
||||
|
||||
private static MessageWriter CreateMessageWriter(
|
||||
string filePath,
|
||||
ExportFormat format,
|
||||
ExportContext context) =>
|
||||
ExportContext context
|
||||
) =>
|
||||
format switch
|
||||
{
|
||||
ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context),
|
||||
ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context),
|
||||
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),
|
||||
_ => 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;
|
||||
}
|
||||
|
||||
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++;
|
||||
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();
|
||||
}
|
|
@ -18,11 +18,14 @@ public partial class PartitionLimit
|
|||
var match = Regex.Match(value, @"^\s*(\d+[\.,]?\d*)\s*(\w)?b\s*$", RegexOptions.IgnoreCase);
|
||||
|
||||
// Number part
|
||||
if (!double.TryParse(
|
||||
if (
|
||||
!double.TryParse(
|
||||
match.Groups[1].Value,
|
||||
NumberStyles.Float,
|
||||
formatProvider,
|
||||
out var number))
|
||||
out var number
|
||||
)
|
||||
)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
@ -58,5 +61,6 @@ public partial class PartitionLimit
|
|||
}
|
||||
|
||||
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(
|
||||
TextNode text,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(text.Text);
|
||||
return default;
|
||||
|
@ -29,19 +30,18 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
|||
|
||||
protected override ValueTask VisitEmojiAsync(
|
||||
EmojiNode emoji,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(
|
||||
emoji.IsCustomEmoji
|
||||
? $":{emoji.Name}:"
|
||||
: emoji.Name
|
||||
);
|
||||
_buffer.Append(emoji.IsCustomEmoji ? $":{emoji.Name}:" : emoji.Name);
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
protected override async ValueTask VisitMentionAsync(MentionNode mention,
|
||||
CancellationToken cancellationToken = default)
|
||||
protected override async ValueTask VisitMentionAsync(
|
||||
MentionNode mention,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (mention.Kind == MentionKind.Everyone)
|
||||
{
|
||||
|
@ -86,7 +86,8 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
|||
|
||||
protected override ValueTask VisitTimestampAsync(
|
||||
TimestampNode timestamp,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(
|
||||
timestamp.Instant is not null
|
||||
|
@ -105,7 +106,8 @@ internal partial class PlainTextMarkdownVisitor
|
|||
public static async ValueTask<string> FormatAsync(
|
||||
ExportContext context,
|
||||
string markdown,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var nodes = MarkdownParser.ParseMinimal(markdown);
|
||||
|
||||
|
|
|
@ -7,20 +7,23 @@ namespace DiscordChatExporter.Core.Exporting;
|
|||
|
||||
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()
|
||||
MessageKind.RecipientAdd
|
||||
=> message.MentionedUsers.Any()
|
||||
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
||||
: "Added a recipient.",
|
||||
|
||||
MessageKind.RecipientRemove => message.MentionedUsers.Any()
|
||||
MessageKind.RecipientRemove
|
||||
=> message.MentionedUsers.Any()
|
||||
? message.Author.Id == message.MentionedUsers.First().Id
|
||||
? "Left the group."
|
||||
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
|
||||
: "Removed a recipient.",
|
||||
|
||||
MessageKind.Call =>
|
||||
$"Started a call that lasted {
|
||||
MessageKind.Call
|
||||
=> $"Started a call that lasted {
|
||||
message
|
||||
.CallEndedTimestamp?
|
||||
.Pipe(t => t - message.Timestamp)
|
||||
|
@ -28,8 +31,8 @@ internal static class PlainTextMessageExtensions
|
|||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||
} minutes.",
|
||||
|
||||
MessageKind.ChannelNameChange =>
|
||||
!string.IsNullOrWhiteSpace(message.Content)
|
||||
MessageKind.ChannelNameChange
|
||||
=> !string.IsNullOrWhiteSpace(message.Content)
|
||||
? $"Changed the channel name: {message.Content}"
|
||||
: "Changed the channel name.",
|
||||
|
||||
|
|
|
@ -20,7 +20,8 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||
|
||||
private async ValueTask<string> FormatMarkdownAsync(
|
||||
string markdown,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CancellationToken cancellationToken = default
|
||||
) =>
|
||||
Context.Request.ShouldFormatMarkdown
|
||||
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
|
||||
: markdown;
|
||||
|
@ -40,7 +41,8 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||
|
||||
private async ValueTask WriteAttachmentsAsync(
|
||||
IReadOnlyList<Attachment> attachments,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (!attachments.Any())
|
||||
return;
|
||||
|
@ -61,7 +63,8 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||
|
||||
private async ValueTask WriteEmbedsAsync(
|
||||
IReadOnlyList<Embed> embeds,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
foreach (var embed in embeds)
|
||||
{
|
||||
|
@ -144,7 +147,8 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||
|
||||
private async ValueTask WriteStickersAsync(
|
||||
IReadOnlyList<Sticker> stickers,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (!stickers.Any())
|
||||
return;
|
||||
|
@ -165,7 +169,8 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||
|
||||
private async ValueTask WriteReactionsAsync(
|
||||
IReadOnlyList<Reaction> reactions,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (!reactions.Any())
|
||||
return;
|
||||
|
@ -189,11 +194,15 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||
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($"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))
|
||||
{
|
||||
|
@ -202,12 +211,16 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||
|
||||
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)
|
||||
{
|
||||
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));
|
||||
|
@ -216,7 +229,8 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||
|
||||
public override async ValueTask WriteMessageAsync(
|
||||
Message message,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
await base.WriteMessageAsync(message, cancellationToken);
|
||||
|
||||
|
@ -246,7 +260,9 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||
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($"Exported {MessagesWritten:N0} message(s)");
|
||||
|
|
|
@ -8,17 +8,14 @@ internal record EmojiNode(
|
|||
Snowflake? Id,
|
||||
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
|
||||
string Name,
|
||||
bool IsAnimated) : MarkdownNode
|
||||
bool IsAnimated
|
||||
) : MarkdownNode
|
||||
{
|
||||
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)
|
||||
public string Code => IsCustomEmoji
|
||||
? Name
|
||||
: EmojiIndex.TryGetCode(Name) ?? Name;
|
||||
public string Code => IsCustomEmoji ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
|
||||
|
||||
public EmojiNode(string name)
|
||||
: this(null, name, false)
|
||||
{
|
||||
}
|
||||
: this(null, name, false) { }
|
||||
}
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace DiscordChatExporter.Core.Markdown;
|
||||
|
||||
internal record FormattingNode(
|
||||
FormattingKind Kind,
|
||||
IReadOnlyList<MarkdownNode> Children
|
||||
) : MarkdownNode, IContainerNode;
|
||||
internal record FormattingNode(FormattingKind Kind, IReadOnlyList<MarkdownNode> Children)
|
||||
: MarkdownNode,
|
||||
IContainerNode;
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace DiscordChatExporter.Core.Markdown;
|
||||
|
||||
internal record HeadingNode(
|
||||
int Level,
|
||||
IReadOnlyList<MarkdownNode> Children
|
||||
) : MarkdownNode, IContainerNode;
|
||||
internal record HeadingNode(int Level, IReadOnlyList<MarkdownNode> Children)
|
||||
: MarkdownNode,
|
||||
IContainerNode;
|
||||
|
|
|
@ -3,12 +3,10 @@
|
|||
namespace DiscordChatExporter.Core.Markdown;
|
||||
|
||||
// Named links can contain child nodes (e.g. [**bold URL**](https://test.com))
|
||||
internal record LinkNode(
|
||||
string Url,
|
||||
IReadOnlyList<MarkdownNode> Children) : MarkdownNode, IContainerNode
|
||||
internal record LinkNode(string Url, IReadOnlyList<MarkdownNode> Children)
|
||||
: MarkdownNode,
|
||||
IContainerNode
|
||||
{
|
||||
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)
|
||||
: this((IReadOnlyList<IMatcher<T>>) matchers)
|
||||
{
|
||||
}
|
||||
: this((IReadOnlyList<IMatcher<T>>)matchers) { }
|
||||
|
||||
public ParsedMatch<T>? TryMatch(StringSegment segment)
|
||||
{
|
||||
|
@ -31,7 +29,9 @@ internal class AggregateMatcher<T> : IMatcher<T>
|
|||
continue;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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>(
|
||||
this IMatcher<T> matcher,
|
||||
StringSegment segment,
|
||||
Func<StringSegment, T> transformFallback)
|
||||
Func<StringSegment, T> transformFallback
|
||||
)
|
||||
{
|
||||
// Loop through segments divided by individual matches
|
||||
var currentIndex = segment.StartIndex;
|
||||
|
@ -21,10 +22,7 @@ internal static class MatcherExtensions
|
|||
{
|
||||
// Find a match within this segment
|
||||
var match = matcher.TryMatch(
|
||||
segment.Relocate(
|
||||
currentIndex,
|
||||
segment.EndIndex - currentIndex
|
||||
)
|
||||
segment.Relocate(currentIndex, segment.EndIndex - currentIndex)
|
||||
);
|
||||
|
||||
if (match is null)
|
||||
|
@ -38,7 +36,10 @@ internal static class MatcherExtensions
|
|||
match.Segment.StartIndex - currentIndex
|
||||
);
|
||||
|
||||
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment));
|
||||
yield return new ParsedMatch<T>(
|
||||
fallbackSegment,
|
||||
transformFallback(fallbackSegment)
|
||||
);
|
||||
}
|
||||
|
||||
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 (currentIndex < segment.EndIndex)
|
||||
{
|
||||
var fallbackSegment = segment.Relocate(
|
||||
currentIndex,
|
||||
segment.EndIndex - currentIndex
|
||||
);
|
||||
var fallbackSegment = segment.Relocate(currentIndex, segment.EndIndex - currentIndex);
|
||||
|
||||
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment));
|
||||
}
|
||||
|
|
|
@ -16,40 +16,52 @@ namespace DiscordChatExporter.Core.Markdown.Parsing;
|
|||
internal static partial class MarkdownParser
|
||||
{
|
||||
private const RegexOptions DefaultRegexOptions =
|
||||
RegexOptions.Compiled |
|
||||
RegexOptions.IgnorePatternWhitespace |
|
||||
RegexOptions.CultureInvariant |
|
||||
RegexOptions.Multiline;
|
||||
RegexOptions.Compiled
|
||||
| RegexOptions.IgnorePatternWhitespace
|
||||
| RegexOptions.CultureInvariant
|
||||
| RegexOptions.Multiline;
|
||||
|
||||
/* Formatting */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// There must be exactly two closing asterisks.
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// There must be exactly one closing asterisk.
|
||||
// Opening asterisk must not be followed by whitespace.
|
||||
// Closing asterisk must not be preceded by whitespace.
|
||||
new Regex(@"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// There must be exactly three closing asterisks.
|
||||
new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher))
|
||||
(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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Closing underscore must not be followed by a word character.
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// There must be exactly two closing underscores.
|
||||
new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1])))
|
||||
|
@ -59,45 +71,54 @@ internal static partial class MarkdownParser
|
|||
new RegexMatcher<MarkdownNode>(
|
||||
// There must be exactly three closing underscores.
|
||||
new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) => new FormattingNode(
|
||||
(s, m) =>
|
||||
new FormattingNode(
|
||||
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 RegexMatcher<MarkdownNode>(
|
||||
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) => new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1])))
|
||||
(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 RegexMatcher<MarkdownNode>(
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Include the linebreak in the content so that the lines are preserved in quotes.
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Include the linebreaks in the content, so that the lines are preserved in quotes.
|
||||
// Empty content is allowed within quotes.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1115
|
||||
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions),
|
||||
(s, m) => new FormattingNode(
|
||||
(s, m) =>
|
||||
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 RegexMatcher<MarkdownNode>(
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Consume the linebreak so that it's not attached to following nodes.
|
||||
new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions),
|
||||
(s, m) => new HeadingNode(m.Groups[1].Length, Parse(s.Relocate(m.Groups[2])))
|
||||
|
@ -108,51 +129,57 @@ internal static partial class MarkdownParser
|
|||
// 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.
|
||||
new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions),
|
||||
(s, m) => new ListNode(
|
||||
(s, m) =>
|
||||
new ListNode(
|
||||
m.Groups[2].Captures.Select(c => new ListItemNode(Parse(s.Relocate(c)))).ToArray()
|
||||
)
|
||||
);
|
||||
|
||||
/* Code blocks */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// One or two backticks are allowed, but they must match on both sides.
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Language identifier is one word immediately after opening backticks, followed immediately by a linebreak.
|
||||
// Blank lines at the beginning and at the end of content are trimmed.
|
||||
new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(_, m) => new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
|
||||
(_, m) =>
|
||||
new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
|
||||
);
|
||||
|
||||
/* Mentions */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher = new StringMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher =
|
||||
new StringMatcher<MarkdownNode>(
|
||||
"@everyone",
|
||||
_ => new MentionNode(null, MentionKind.Everyone)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher = new StringMatcher<MarkdownNode>(
|
||||
"@here",
|
||||
_ => new MentionNode(null, MentionKind.Here)
|
||||
);
|
||||
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher =
|
||||
new StringMatcher<MarkdownNode>("@here", _ => new MentionNode(null, MentionKind.Here));
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Capture <@123456> or <@!123456>
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Capture <#123456>
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Capture <@&123456>
|
||||
new Regex(@"<@&(\d+)>", DefaultRegexOptions),
|
||||
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role)
|
||||
|
@ -160,64 +187,74 @@ internal static partial class MarkdownParser
|
|||
|
||||
/* Emoji */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
new Regex(
|
||||
@"(" +
|
||||
@"("
|
||||
+
|
||||
// Country flag emoji (two regional indicator surrogate pairs)
|
||||
@"(?:\uD83C[\uDDE6-\uDDFF]){2}|" +
|
||||
@"(?:\uD83C[\uDDE6-\uDDFF]){2}|"
|
||||
+
|
||||
// Digit emoji (digit followed by enclosing mark)
|
||||
@"\d\p{Me}|" +
|
||||
@"\d\p{Me}|"
|
||||
+
|
||||
// Surrogate pair
|
||||
@"\p{Cs}{2}|" +
|
||||
@"\p{Cs}{2}|"
|
||||
+
|
||||
// Miscellaneous characters
|
||||
@"[" +
|
||||
@"\u2600-\u2604" +
|
||||
@"\u260E\u2611" +
|
||||
@"\u2614-\u2615" +
|
||||
@"\u2618\u261D\u2620" +
|
||||
@"\u2622-\u2623" +
|
||||
@"\u2626\u262A" +
|
||||
@"\u262E-\u262F" +
|
||||
@"\u2638-\u263A" +
|
||||
@"\u2640\u2642" +
|
||||
@"\u2648-\u2653" +
|
||||
@"\u265F-\u2660" +
|
||||
@"\u2663" +
|
||||
@"\u2665-\u2666" +
|
||||
@"\u2668\u267B" +
|
||||
@"\u267E-\u267F" +
|
||||
@"\u2692-\u2697" +
|
||||
@"\u2699" +
|
||||
@"\u269B-\u269C" +
|
||||
@"\u26A0-\u26A1" +
|
||||
@"\u26A7" +
|
||||
@"\u26AA-\u26AB" +
|
||||
@"\u26B0-\u26B1" +
|
||||
@"\u26BD-\u26BE" +
|
||||
@"\u26C4-\u26C5" +
|
||||
@"\u26C8" +
|
||||
@"\u26CE-\u26CF" +
|
||||
@"\u26D1" +
|
||||
@"\u26D3-\u26D4" +
|
||||
@"\u26E9-\u26EA" +
|
||||
@"\u26F0-\u26F5" +
|
||||
@"\u26F7-\u26FA" +
|
||||
@"\u26FD" +
|
||||
@"]" +
|
||||
@")", DefaultRegexOptions),
|
||||
@"["
|
||||
+ @"\u2600-\u2604"
|
||||
+ @"\u260E\u2611"
|
||||
+ @"\u2614-\u2615"
|
||||
+ @"\u2618\u261D\u2620"
|
||||
+ @"\u2622-\u2623"
|
||||
+ @"\u2626\u262A"
|
||||
+ @"\u262E-\u262F"
|
||||
+ @"\u2638-\u263A"
|
||||
+ @"\u2640\u2642"
|
||||
+ @"\u2648-\u2653"
|
||||
+ @"\u265F-\u2660"
|
||||
+ @"\u2663"
|
||||
+ @"\u2665-\u2666"
|
||||
+ @"\u2668\u267B"
|
||||
+ @"\u267E-\u267F"
|
||||
+ @"\u2692-\u2697"
|
||||
+ @"\u2699"
|
||||
+ @"\u269B-\u269C"
|
||||
+ @"\u26A0-\u26A1"
|
||||
+ @"\u26A7"
|
||||
+ @"\u26AA-\u26AB"
|
||||
+ @"\u26B0-\u26B1"
|
||||
+ @"\u26BD-\u26BE"
|
||||
+ @"\u26C4-\u26C5"
|
||||
+ @"\u26C8"
|
||||
+ @"\u26CE-\u26CF"
|
||||
+ @"\u26D1"
|
||||
+ @"\u26D3-\u26D4"
|
||||
+ @"\u26E9-\u26EA"
|
||||
+ @"\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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Capture :thinking:
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Capture <:lul:123456> or <a:lul:123456>
|
||||
new Regex(@"<(a)?:(.+?):(\d+?)>", DefaultRegexOptions),
|
||||
(_, m) => new EmojiNode(
|
||||
(_, m) =>
|
||||
new EmojiNode(
|
||||
Snowflake.TryParse(m.Groups[3].Value),
|
||||
m.Groups[2].Value,
|
||||
!string.IsNullOrWhiteSpace(m.Groups[1].Value)
|
||||
|
@ -226,20 +263,23 @@ internal static partial class MarkdownParser
|
|||
|
||||
/* Links */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Any non-whitespace character after http:// or https://
|
||||
// until the last punctuation character or whitespace.
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Same as auto link but also surrounded by angular brackets
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Capture [title](link)
|
||||
new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions),
|
||||
(s, m) => new LinkNode(m.Groups[2].Value, Parse(s.Relocate(m.Groups[1])))
|
||||
|
@ -247,21 +287,24 @@ internal static partial class MarkdownParser
|
|||
|
||||
/* Text */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher = new StringMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher =
|
||||
new StringMatcher<MarkdownNode>(
|
||||
// Capture the shrug kaomoji.
|
||||
// This escapes it from matching for formatting.
|
||||
@"¯\_(ツ)_/¯",
|
||||
s => new TextNode(s.ToString())
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Capture some specific emoji that don't get rendered.
|
||||
// This escapes them from matching for emoji.
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Capture any "symbol/other" character or surrogate pair preceded by a backslash.
|
||||
// This escapes them from matching for emoji.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/230
|
||||
|
@ -269,7 +312,8 @@ internal static partial class MarkdownParser
|
|||
(_, m) => new TextNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
|
||||
// This escapes them from matching for formatting or other tokens.
|
||||
new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions),
|
||||
|
@ -278,15 +322,22 @@ internal static partial class MarkdownParser
|
|||
|
||||
/* Misc */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> TimestampNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownNode> TimestampNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Capture <t:12345678> or <t:12345678:R>
|
||||
new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions),
|
||||
(_, m) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var instant = DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(
|
||||
long.Parse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture)
|
||||
var instant =
|
||||
DateTimeOffset.UnixEpoch
|
||||
+ TimeSpan.FromSeconds(
|
||||
long.Parse(
|
||||
m.Groups[1].Value,
|
||||
NumberStyles.Integer,
|
||||
CultureInfo.InvariantCulture
|
||||
)
|
||||
);
|
||||
|
||||
var format = m.Groups[2].Value switch
|
||||
|
@ -305,7 +356,8 @@ internal static partial class MarkdownParser
|
|||
}
|
||||
// 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)
|
||||
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;
|
||||
|
@ -320,7 +372,6 @@ internal static partial class MarkdownParser
|
|||
IgnoredEmojiTextNodeMatcher,
|
||||
EscapedSymbolTextNodeMatcher,
|
||||
EscapedCharacterTextNodeMatcher,
|
||||
|
||||
// Formatting
|
||||
ItalicBoldFormattingNodeMatcher,
|
||||
ItalicUnderlineFormattingNodeMatcher,
|
||||
|
@ -335,53 +386,46 @@ internal static partial class MarkdownParser
|
|||
SingleLineQuoteNodeMatcher,
|
||||
HeadingNodeMatcher,
|
||||
ListNodeMatcher,
|
||||
|
||||
// Code blocks
|
||||
MultiLineCodeBlockNodeMatcher,
|
||||
InlineCodeBlockNodeMatcher,
|
||||
|
||||
// Mentions
|
||||
EveryoneMentionNodeMatcher,
|
||||
HereMentionNodeMatcher,
|
||||
UserMentionNodeMatcher,
|
||||
ChannelMentionNodeMatcher,
|
||||
RoleMentionNodeMatcher,
|
||||
|
||||
// Links
|
||||
MaskedLinkNodeMatcher,
|
||||
AutoLinkNodeMatcher,
|
||||
HiddenLinkNodeMatcher,
|
||||
|
||||
// Emoji
|
||||
StandardEmojiNodeMatcher,
|
||||
CustomEmojiNodeMatcher,
|
||||
CodedStandardEmojiNodeMatcher,
|
||||
|
||||
// Misc
|
||||
TimestampNodeMatcher
|
||||
);
|
||||
|
||||
// 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 =
|
||||
new AggregateMatcher<MarkdownNode>(
|
||||
// Mentions
|
||||
EveryoneMentionNodeMatcher,
|
||||
HereMentionNodeMatcher,
|
||||
UserMentionNodeMatcher,
|
||||
ChannelMentionNodeMatcher,
|
||||
RoleMentionNodeMatcher,
|
||||
|
||||
// Emoji
|
||||
CustomEmojiNodeMatcher,
|
||||
|
||||
// Misc
|
||||
TimestampNodeMatcher
|
||||
);
|
||||
|
||||
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment, IMatcher<MarkdownNode> matcher) =>
|
||||
matcher
|
||||
.MatchAll(segment, s => new TextNode(s.ToString()))
|
||||
.Select(r => r.Value)
|
||||
.ToArray();
|
||||
private static IReadOnlyList<MarkdownNode> Parse(
|
||||
StringSegment segment,
|
||||
IMatcher<MarkdownNode> matcher
|
||||
) => matcher.MatchAll(segment, s => new TextNode(s.ToString())).Select(r => r.Value).ToArray();
|
||||
}
|
||||
|
||||
internal static partial class MarkdownParser
|
||||
|
|
|
@ -9,56 +9,63 @@ internal abstract class MarkdownVisitor
|
|||
{
|
||||
protected virtual ValueTask VisitTextAsync(
|
||||
TextNode text,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
CancellationToken cancellationToken = default
|
||||
) => default;
|
||||
|
||||
protected virtual async ValueTask VisitFormattingAsync(
|
||||
FormattingNode formatting,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await VisitAsync(formatting.Children, cancellationToken);
|
||||
CancellationToken cancellationToken = default
|
||||
) => await VisitAsync(formatting.Children, cancellationToken);
|
||||
|
||||
protected virtual async ValueTask VisitHeadingAsync(
|
||||
HeadingNode heading,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await VisitAsync(heading.Children, cancellationToken);
|
||||
CancellationToken cancellationToken = default
|
||||
) => await VisitAsync(heading.Children, cancellationToken);
|
||||
|
||||
protected virtual async ValueTask VisitListAsync(
|
||||
ListNode list,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await VisitAsync(list.Items, cancellationToken);
|
||||
CancellationToken cancellationToken = default
|
||||
) => await VisitAsync(list.Items, cancellationToken);
|
||||
|
||||
protected virtual async ValueTask VisitListItemAsync(
|
||||
ListItemNode listItem,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await VisitAsync(listItem.Children, cancellationToken);
|
||||
CancellationToken cancellationToken = default
|
||||
) => await VisitAsync(listItem.Children, cancellationToken);
|
||||
|
||||
protected virtual ValueTask VisitInlineCodeBlockAsync(
|
||||
InlineCodeBlockNode inlineCodeBlock,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
CancellationToken cancellationToken = default
|
||||
) => default;
|
||||
|
||||
protected virtual ValueTask VisitMultiLineCodeBlockAsync(
|
||||
MultiLineCodeBlockNode multiLineCodeBlock,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
CancellationToken cancellationToken = default
|
||||
) => default;
|
||||
|
||||
protected virtual async ValueTask VisitLinkAsync(
|
||||
LinkNode link,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
await VisitAsync(link.Children, cancellationToken);
|
||||
CancellationToken cancellationToken = default
|
||||
) => await VisitAsync(link.Children, cancellationToken);
|
||||
|
||||
protected virtual ValueTask VisitEmojiAsync(
|
||||
EmojiNode emoji,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
CancellationToken cancellationToken = default
|
||||
) => default;
|
||||
|
||||
protected virtual ValueTask VisitMentionAsync(
|
||||
MentionNode mention,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
CancellationToken cancellationToken = default
|
||||
) => default;
|
||||
|
||||
protected virtual ValueTask VisitTimestampAsync(
|
||||
TimestampNode timestamp,
|
||||
CancellationToken cancellationToken = default) => default;
|
||||
CancellationToken cancellationToken = default
|
||||
) => default;
|
||||
|
||||
public async ValueTask VisitAsync(
|
||||
MarkdownNode node,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (node is TextNode text)
|
||||
{
|
||||
|
@ -131,7 +138,8 @@ internal abstract class MarkdownVisitor
|
|||
|
||||
public async ValueTask VisitAsync(
|
||||
IEnumerable<MarkdownNode> nodes,
|
||||
CancellationToken cancellationToken = default)
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
foreach (var node in nodes)
|
||||
await VisitAsync(node, cancellationToken);
|
||||
|
|
|
@ -31,8 +31,6 @@ internal class RegexMatcher<T> : IMatcher<T>
|
|||
var segmentMatch = segment.Relocate(match);
|
||||
var value = _transform(segmentMatch, match);
|
||||
|
||||
return value is not null
|
||||
? new ParsedMatch<T>(segmentMatch, value)
|
||||
: null;
|
||||
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
|
||||
}
|
||||
}
|
|
@ -8,7 +8,11 @@ internal class StringMatcher<T> : IMatcher<T>
|
|||
private readonly StringComparison _comparison;
|
||||
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;
|
||||
_comparison = comparison;
|
||||
|
@ -16,21 +20,22 @@ internal class StringMatcher<T> : IMatcher<T>
|
|||
}
|
||||
|
||||
public StringMatcher(string needle, Func<StringSegment, T> transform)
|
||||
: this(needle, StringComparison.Ordinal, transform)
|
||||
{
|
||||
}
|
||||
: this(needle, StringComparison.Ordinal, transform) { }
|
||||
|
||||
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)
|
||||
return null;
|
||||
|
||||
var segmentMatch = segment.Relocate(index, _needle.Length);
|
||||
var value = _transform(segmentMatch);
|
||||
|
||||
return value is not null
|
||||
? new ParsedMatch<T>(segmentMatch, value)
|
||||
: null;
|
||||
return value is not 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 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);
|
||||
|
||||
|
|
|
@ -7,7 +7,8 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||
public static class AsyncCollectionExtensions
|
||||
{
|
||||
private static async ValueTask<IReadOnlyList<T>> CollectAsync<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable)
|
||||
this IAsyncEnumerable<T> asyncEnumerable
|
||||
)
|
||||
{
|
||||
var list = new List<T>();
|
||||
|
||||
|
@ -18,6 +19,6 @@ public static class AsyncCollectionExtensions
|
|||
}
|
||||
|
||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable) =>
|
||||
asyncEnumerable.CollectAsync().GetAwaiter();
|
||||
this IAsyncEnumerable<T> asyncEnumerable
|
||||
) => asyncEnumerable.CollectAsync().GetAwaiter();
|
||||
}
|
|
@ -11,9 +11,7 @@ public static class BinaryExtensions
|
|||
|
||||
foreach (var b in data)
|
||||
{
|
||||
buffer.Append(
|
||||
b.ToString(isUpperCase ? "X2" : "x2", CultureInfo.InvariantCulture)
|
||||
);
|
||||
buffer.Append(b.ToString(isUpperCase ? "X2" : "x2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
|
|
|
@ -16,7 +16,8 @@ public static class CollectionExtensions
|
|||
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)
|
||||
{
|
||||
|
|
|
@ -5,13 +5,12 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||
|
||||
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 =>
|
||||
!predicate(value)
|
||||
? value
|
||||
: null;
|
||||
public static T? NullIf<T>(this T value, Func<T, bool> predicate)
|
||||
where T : struct => !predicate(value) ? value : null;
|
||||
|
||||
public static T? NullIfDefault<T>(this T value) where T : struct =>
|
||||
value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
|
||||
public static T? NullIfDefault<T>(this T value)
|
||||
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 string? TryGetValue(this HttpHeaders headers, string name) =>
|
||||
headers.TryGetValues(name, out var values)
|
||||
? string.Concat(values)
|
||||
: null;
|
||||
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
|
||||
}
|
|
@ -7,14 +7,10 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||
public static class StringExtensions
|
||||
{
|
||||
public static string? NullIfWhiteSpace(this string str) =>
|
||||
!string.IsNullOrWhiteSpace(str)
|
||||
? str
|
||||
: null;
|
||||
!string.IsNullOrWhiteSpace(str) ? str : null;
|
||||
|
||||
public static string Truncate(this string str, int charCount) =>
|
||||
str.Length > charCount
|
||||
? str[..charCount]
|
||||
: str;
|
||||
str.Length > charCount ? str[..charCount] : str;
|
||||
|
||||
public static string ToSpaceSeparatedWords(this string str)
|
||||
{
|
||||
|
@ -41,13 +37,9 @@ public static class StringExtensions
|
|||
}
|
||||
}
|
||||
|
||||
public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true) where T : struct, Enum =>
|
||||
Enum.TryParse<T>(str, ignoreCase, out var result)
|
||||
? result
|
||||
: null;
|
||||
public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true)
|
||||
where T : struct, Enum => Enum.TryParse<T>(str, ignoreCase, out var result) ? result : null;
|
||||
|
||||
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
|
||||
builder.Length > 0
|
||||
? builder.Append(value)
|
||||
: builder;
|
||||
builder.Length > 0 ? builder.Append(value) : builder;
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue