Compare commits

...

26 Commits

Author SHA1 Message Date
Tyrrrz 9b8985a920 Fix tests 2024-01-18 20:39:54 +02:00
Tyrrrz 014712b747 Reorder tests 2024-01-18 20:25:30 +02:00
Oleksii Holub d8e43d89be Add support for has:invite filter that matches messages with guild invites (#1188) 2024-01-18 20:24:25 +02:00
Tyrrrz 4e69ff317b Fix nullability mismatch in WPF converters 2024-01-15 23:59:16 +02:00
Oleksii Holub 057beaacd6 Use null as the default locale, which resolves to the current system default locale (#1185) 2024-01-15 23:58:28 +02:00
Tyrrrz 982ba6a76c Fix tests 2024-01-11 23:28:11 +02:00
Tyrrrz dccd9a2f08 Cleanup 2024-01-11 22:43:13 +02:00
Tyrrrz 182f24846b Use new array syntax 2024-01-11 22:21:53 +02:00
Oleksii Holub 777f92a3f1 Fix docker user permission issues by creating the user manually (#1183) 2024-01-09 21:28:19 +02:00
Tyrrrz d4be307fb1 Specify the user in dockerfile via username rather than ID 2024-01-08 17:13:48 +02:00
Tyrrrz 2bbb585931 Update license 2024-01-01 03:09:52 +02:00
Tyrrrz 73080ecfa6 Specify comparison type in Replace(...) 2023-12-31 06:38:53 +02:00
Tyrrrz 0745d49f44 Use native method for enumerating runes 2023-12-31 06:24:23 +02:00
Tyrrrz c16e14e9b6 Update readme 2023-12-30 17:57:18 +02:00
Tyrrrz 8e36002ae7 Slightly better naming 2023-12-29 00:17:11 +02:00
Oleksii Holub a2c5d2e2dd Warn about redirected streams in the CLI only when running inside Docker (#1176) 2023-12-29 00:14:43 +02:00
Tyrrrz 4e3deb409c Update NuGet packages 2023-12-29 00:08:16 +02:00
Oleksii Holub 512f181be2 Refer to "guilds" as "servers" in docs and UI elements (#1175) 2023-12-29 00:03:28 +02:00
Tyrrrz e04eb890e6 Improve wording 2023-12-28 23:56:19 +02:00
Tyrrrz 1ad42ee48b Fix typo 2023-12-27 19:25:06 +02:00
Tyrrrz b8a67617bc Set a high default assembly version to avoid auto-update prompts 2023-12-27 16:20:05 +02:00
Tyrrrz 062427d143 Make test assertions more relaxed in terms of user-made content URLs 2023-12-26 02:40:50 +02:00
Tyrrrz 5f343a46d1 Standardize exception messages 2023-12-25 18:59:47 +02:00
Tyrrrz b3a1cbf635 Update NuGet packages 2023-12-10 22:38:34 +02:00
Tyrrrz 619fe9ccf7 Refactor using c# 12 features 2023-12-10 22:38:34 +02:00
LostXOR 174b92cbb0 Fix GUI link in docs going to CLI page (#1167) 2023-11-30 15:31:31 +02:00
77 changed files with 579 additions and 657 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
- [Get .NET Core Runtime](Dotnet.md) (Required for CLI; Installed automatically for GUI; Not required in Docker) - [Get .NET Core Runtime](Dotnet.md) (Required for CLI; Installed automatically for GUI; Not required in Docker)
- [Windows](Getting-started.md#gui-or-cli) | [macOS](MacOS.md) | [Linux](Linux.md) | [Docker](Docker.md) - [Windows](Getting-started.md#gui-or-cli) | [macOS](MacOS.md) | [Linux](Linux.md) | [Docker](Docker.md)
- Getting started: - Getting started:
- [Using the GUI](Using-the-CLI.md) - [Using the GUI](Using-the-GUI.md)
- [Using the CLI](Using-the-CLI.md) - [Using the CLI](Using-the-CLI.md)
- [File formats](Getting-started.md#file-formats) - [File formats](Getting-started.md#file-formats)
+19 -19
View File
@@ -38,16 +38,16 @@ dotnet DiscordChatExporter.Cli.dll
## CLI commands ## CLI commands
| Command | Description | | Command | Description |
|-------------------------|-----------------------------------------------------| |-------------------------|------------------------------------------------------|
| export | Exports a channel | | export | Exports a channel |
| exportdm | Exports all direct message channels | | exportdm | Exports all direct message channels |
| exportguild | Exports all channels within the specified server | | exportguild | Exports all channels within the specified server |
| exportall | Exports all accessible channels | | exportall | Exports all accessible channels |
| channels | Outputs the list of channels in the given server | | channels | Outputs the list of channels in the given server |
| dm | Outputs the list of direct message channels | | dm | Outputs the list of direct message channels |
| guilds | Outputs the list of accessible servers | | guilds | Outputs the list of accessible servers |
| guide | Explains how to obtain token, guild, and channel ID | | guide | Explains how to obtain token, server, and channel ID |
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `dotnet DiscordChatExporter.Cli.dll guide`. To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `dotnet DiscordChatExporter.Cli.dll guide`.
@@ -110,7 +110,7 @@ dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o "C:\Discord
#### Generating the filename and output directory dynamically #### Generating the filename and output directory dynamically
You can use template tokens to generate the output file path based on the guild and channel metadata. You can use template tokens to generate the output file path based on the server and channel metadata.
```console ```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html" dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html"
@@ -122,8 +122,8 @@ path: `C:\Discord Exports\My server\Text channels\my-channel.html`
Here is the full list of supported template tokens: Here is the full list of supported template tokens:
- `%g` - guild ID - `%g` - server ID
- `%G` - guild name - `%G` - server name
- `%t` - category ID - `%t` - category ID
- `%T` - category name - `%T` - category name
- `%c` - channel ID - `%c` - channel ID
@@ -217,9 +217,9 @@ Don't forget to quote (") the date if it has spaces!
More info about .NET date More info about .NET date
formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings). formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
### Export channels from a specific guild (server) ### Export channels from a specific server
To export all channels in a specific guild, use the `exportguild` command and provide the guild ID through the `-g|--guild` option: To export all channels in a specific server, use the `exportguild` command and provide the server ID through the `-g|--guild` option:
```console ```console
dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814 dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814
@@ -261,9 +261,9 @@ To exclude DMs, add the `--include-dm false` option.
dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn" --include-dm false dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn" --include-dm false
``` ```
### List channels in a guild (server) ### List channels in a server
To list the channels available in a specific guild, use the `channels` command and provide the guild ID through the `-g|--guild` option: To list the channels available in a specific server, use the `channels` command and provide the server ID through the `-g|--guild` option:
```console ```console
dotnet DiscordChatExporter.Cli.dll channels -t "mfa.Ifrn" -g 21814 dotnet DiscordChatExporter.Cli.dll channels -t "mfa.Ifrn" -g 21814
@@ -277,9 +277,9 @@ To list all DM channels accessible to the current account, use the `dm` command:
dotnet DiscordChatExporter.Cli.dll dm -t "mfa.Ifrn" dotnet DiscordChatExporter.Cli.dll dm -t "mfa.Ifrn"
``` ```
### List guilds (servers) ### List servers
To list all guilds accessible by the current account, use the `guilds` command: To list all servers accessible by the current account, use the `guilds` command:
```console ```console
dotnet DiscordChatExporter.Cli.dll guilds -t "mfa.Ifrn" > C:\path\to\output.txt dotnet DiscordChatExporter.Cli.dll guilds -t "mfa.Ifrn" > C:\path\to\output.txt
+1 -1
View File
@@ -94,7 +94,7 @@ body:
required: true required: true
- label: I have provided a descriptive title for this issue - label: I have provided a descriptive title for this issue
required: true required: true
- label: I have made sure that that this bug is reproducible on the latest version of the application - label: I have made sure that this bug is reproducible on the latest version of the application
required: true required: true
- label: I have provided all the information needed to reproduce this bug as efficiently as possible - label: I have provided all the information needed to reproduce this bug as efficiently as possible
required: true required: true
+1
View File
@@ -2,6 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>999.9.9-dev</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright> <Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
@@ -13,7 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.0.7" /> <PackageReference Include="AngleSharp" Version="1.0.7" />
<PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" /> <PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.2" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="6.12.0" /> <PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
@@ -22,8 +22,8 @@
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="ReflectionMagic" Version="5.0.0" /> <PackageReference Include="ReflectionMagic" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.6.1" /> <PackageReference Include="xunit" Version="2.6.4" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" PrivateAssets="all" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.5.6" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -57,7 +57,7 @@ public static class ExportWrapper
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { channelId }, ChannelIds = [channelId],
ExportFormat = format, ExportFormat = format,
OutputPath = filePath, OutputPath = filePath,
Locale = "en-US", Locale = "en-US",
@@ -27,7 +27,7 @@ public class DateRangeSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
After = Snowflake.FromDate(after) After = Snowflake.FromDate(after)
@@ -45,24 +45,20 @@ public class DateRangeSpecs
timestamps timestamps
.Should() .Should()
.BeEquivalentTo( .BeEquivalentTo(
new[] [
{
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero), 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, 38, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 39, 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, 07, 24, 14, 52, 40, TimeSpan.Zero),
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero) new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero)
}, ],
o => o =>
{ o.Using<DateTimeOffset>(
return o.Using<DateTimeOffset>(
ctx => ctx =>
ctx.Subject ctx.Subject.Should()
.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) .BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
) )
.WhenTypeIs<DateTimeOffset>(); .WhenTypeIs<DateTimeOffset>()
}
); );
} }
@@ -77,7 +73,7 @@ public class DateRangeSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
Before = Snowflake.FromDate(before) Before = Snowflake.FromDate(before)
@@ -95,22 +91,18 @@ public class DateRangeSpecs
timestamps timestamps
.Should() .Should()
.BeEquivalentTo( .BeEquivalentTo(
new[] [
{
new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero), 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, 15, 58, 48, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero) new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero)
}, ],
o => o =>
{ o.Using<DateTimeOffset>(
return o.Using<DateTimeOffset>(
ctx => ctx =>
ctx.Subject ctx.Subject.Should()
.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) .BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
) )
.WhenTypeIs<DateTimeOffset>(); .WhenTypeIs<DateTimeOffset>()
}
); );
} }
@@ -126,7 +118,7 @@ public class DateRangeSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
Before = Snowflake.FromDate(before), Before = Snowflake.FromDate(before),
@@ -145,23 +137,19 @@ public class DateRangeSpecs
timestamps timestamps
.Should() .Should()
.BeEquivalentTo( .BeEquivalentTo(
new[] [
{
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero), 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, 38, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 39, 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, 07, 24, 14, 52, 40, TimeSpan.Zero)
}, ],
o => o =>
{ o.Using<DateTimeOffset>(
return o.Using<DateTimeOffset>(
ctx => ctx =>
ctx.Subject ctx.Subject.Should()
.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1)) .BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
) )
.WhenTypeIs<DateTimeOffset>(); .WhenTypeIs<DateTimeOffset>()
}
); );
} }
} }
@@ -1,4 +1,5 @@
using System.IO; using System;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Infrastructure; using CliFx.Infrastructure;
@@ -25,7 +26,7 @@ public class FilterSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("some text") MessageFilter = MessageFilter.Parse("some text")
@@ -37,7 +38,7 @@ public class FilterSpecs
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("content").GetString()) .Select(j => j.GetProperty("content").GetString())
.Should() .Should()
.ContainSingle("Some random text"); .AllSatisfy(c => c.Contains("Some random text", StringComparison.Ordinal));
} }
[Fact] [Fact]
@@ -50,7 +51,7 @@ public class FilterSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("from:Tyrrrz") MessageFilter = MessageFilter.Parse("from:Tyrrrz")
@@ -66,7 +67,7 @@ public class FilterSpecs
} }
[Fact] [Fact]
public async Task I_can_filter_the_export_to_only_include_messages_that_contain_the_specified_content() public async Task I_can_filter_the_export_to_only_include_messages_that_contain_images()
{ {
// Arrange // Arrange
using var file = TempFile.Create(); using var file = TempFile.Create();
@@ -75,7 +76,7 @@ public class FilterSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:image") MessageFilter = MessageFilter.Parse("has:image")
@@ -87,7 +88,7 @@ public class FilterSpecs
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("content").GetString()) .Select(j => j.GetProperty("content").GetString())
.Should() .Should()
.ContainSingle("This has image"); .AllSatisfy(c => c.Contains("This has image", StringComparison.Ordinal));
} }
[Fact] [Fact]
@@ -100,7 +101,7 @@ public class FilterSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:pin") MessageFilter = MessageFilter.Parse("has:pin")
@@ -112,7 +113,32 @@ public class FilterSpecs
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("content").GetString()) .Select(j => j.GetProperty("content").GetString())
.Should() .Should()
.ContainSingle("This is pinned"); .AllSatisfy(c => c.Contains("This is pinned", StringComparison.Ordinal));
}
[Fact]
public async Task I_can_filter_the_export_to_only_include_messages_that_contain_guild_invites()
{
// Arrange
using var file = TempFile.Create();
// Act
await new ExportChannelsCommand
{
Token = Secrets.DiscordToken,
ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:invite")
}.ExecuteAsync(new FakeConsole());
// Assert
Json.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages")
.EnumerateArray()
.Select(j => j.GetProperty("content").GetString())
.Should()
.AllSatisfy(c => c.Contains("This has invite", StringComparison.Ordinal));
} }
[Fact] [Fact]
@@ -125,7 +151,7 @@ public class FilterSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz") MessageFilter = MessageFilter.Parse("mentions:Tyrrrz")
@@ -137,6 +163,6 @@ public class FilterSpecs
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("content").GetString()) .Select(j => j.GetProperty("content").GetString())
.Should() .Should()
.ContainSingle("This has mention"); .AllSatisfy(c => c.Contains("This has mention", StringComparison.Ordinal));
} }
} }
@@ -27,13 +27,7 @@ public class HtmlAttachmentSpecs
.QuerySelectorAll("a") .QuerySelectorAll("a")
.Select(e => e.GetAttribute("href")) .Select(e => e.GetAttribute("href"))
.Should() .Should()
.Contain( .Contain(u => u.Contains("Test.txt", StringComparison.Ordinal));
u =>
u.StartsWith(
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt",
StringComparison.Ordinal
)
);
} }
[Fact] [Fact]
@@ -52,13 +46,7 @@ public class HtmlAttachmentSpecs
.QuerySelectorAll("img") .QuerySelectorAll("img")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.Should() .Should()
.Contain( .Contain(u => u.Contains("bird-thumbnail.png", StringComparison.Ordinal));
u =>
u.StartsWith(
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png",
StringComparison.Ordinal
)
);
} }
[Fact] [Fact]
@@ -1,4 +1,5 @@
using System.Linq; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp.Dom; using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
@@ -54,7 +55,7 @@ public class HtmlEmbedSpecs
.QuerySelectorAll("img") .QuerySelectorAll("img")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.WhereNotNull() .WhereNotNull()
.Where(s => s.EndsWith("f8w05ja8s4e61.png")) .Where(s => s.Contains("f8w05ja8s4e61.png", StringComparison.Ordinal))
.Should() .Should()
.ContainSingle(); .ContainSingle();
} }
@@ -91,8 +92,9 @@ public class HtmlEmbedSpecs
.WhereNotNull() .WhereNotNull()
.Where( .Where(
s => s =>
s.EndsWith( s.Contains(
"i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4" "i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4",
StringComparison.Ordinal
) )
) )
.Should() .Should()
@@ -113,7 +115,7 @@ public class HtmlEmbedSpecs
.QuerySelectorAll("source") .QuerySelectorAll("source")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.WhereNotNull() .WhereNotNull()
.Where(s => s.EndsWith("tooncasm-test-copy.mp4")) .Where(s => s.Contains("tooncasm-test-copy.mp4", StringComparison.Ordinal))
.Should() .Should()
.ContainSingle(); .ContainSingle();
} }
@@ -176,15 +178,49 @@ public class HtmlEmbedSpecs
); );
// Assert // Assert
message var imageUrls = message
.QuerySelectorAll("img") .QuerySelectorAll("img")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.ToArray();
imageUrls
.Should() .Should()
.ContainInOrder( .Contain(
"https://images-ext-1.discordapp.net/external/-n--xW3EHH_3jlrheVkMXHCM7T86b5Ty4-MzXCT4m1Q/https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png", u =>
"https://images-ext-2.discordapp.net/external/z5nEmGeEldV-kswydGLhqUsFHbb5AWHtdvc9XT6N5rE/https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png", u.EndsWith(
"https://images-ext-2.discordapp.net/external/gnip03SawMB6uZLagN5sRDpA_1Ap1CcEhMbJfK1z6WQ/https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png", "https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
"https://images-ext-2.discordapp.net/external/jl1v6cCbLaGmiwmKU-ZkXnF4cFsJ39f9A3-oEdqPdZs/https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png" StringComparison.Ordinal
)
);
imageUrls
.Should()
.Contain(
u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
StringComparison.Ordinal
)
);
imageUrls
.Should()
.Contain(
u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png",
StringComparison.Ordinal
)
);
imageUrls
.Should()
.Contain(
u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png",
StringComparison.Ordinal
)
); );
message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle(); message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle();
@@ -202,6 +238,6 @@ public class HtmlEmbedSpecs
); );
// Assert // Assert
message.Text().Should().Contain("DiscordChatExporter TestServer"); message.Text().Should().Contain("DiscordChatExporter Test Server");
} }
} }
@@ -26,7 +26,7 @@ public class HtmlGroupingSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.GroupingTestCases }, ChannelIds = [ChannelIds.GroupingTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = file.Path OutputPath = file.Path
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
@@ -24,7 +24,7 @@ public class PartitioningSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
PartitionLimit = PartitionLimit.Parse("3") PartitionLimit = PartitionLimit.Parse("3")
@@ -45,7 +45,7 @@ public class PartitioningSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
PartitionLimit = PartitionLimit.Parse("1kb") PartitionLimit = PartitionLimit.Parse("1kb")
@@ -24,7 +24,7 @@ public class SelfContainedSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.SelfContainedTestCases }, ChannelIds = [ChannelIds.SelfContainedTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
ShouldDownloadAssets = true ShouldDownloadAssets = true
@@ -5,11 +5,9 @@ using PathEx = System.IO.Path;
namespace DiscordChatExporter.Cli.Tests.Utils; namespace DiscordChatExporter.Cli.Tests.Utils;
internal partial class TempDir : IDisposable internal partial class TempDir(string path) : IDisposable
{ {
public string Path { get; } public string Path { get; } = path;
public TempDir(string path) => Path = path;
public void Dispose() public void Dispose()
{ {
@@ -5,11 +5,9 @@ using PathEx = System.IO.Path;
namespace DiscordChatExporter.Cli.Tests.Utils; namespace DiscordChatExporter.Cli.Tests.Utils;
internal partial class TempFile : IDisposable internal partial class TempFile(string path) : IDisposable
{ {
public string Path { get; } public string Path { get; } = path;
public TempFile(string path) => Path = path;
public void Dispose() public void Dispose()
{ {
+3 -3
View File
@@ -46,10 +46,10 @@ ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
ENV LC_ALL=en_US.UTF-8 ENV LC_ALL=en_US.UTF-8
ENV LANG=en_US.UTF-8 ENV LANG=en_US.UTF-8
# Use a non-root user to ensure that the files shared with the host are accessible by the host user. # Use a non-root user to ensure that the files shared with the host are accessible by the host user
# We can use the default user provided by the base image for this purpose.
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851 # https://github.com/Tyrrrz/DiscordChatExporter/issues/851
USER $APP_UID RUN adduser --disabled-password --no-create-home dce
USER dce
# This directory is exposed to the user for mounting purposes, so it's important that it always # This directory is exposed to the user for mounting purposes, so it's important that it always
# stays the same for backwards compatibility. # stays the same for backwards compatibility.
@@ -4,6 +4,7 @@ using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Cli.Commands.Base; namespace DiscordChatExporter.Cli.Commands.Base;
@@ -37,26 +38,22 @@ public abstract class DiscordCommandBase : ICommand
{ {
using (console.WithForegroundColor(ConsoleColor.DarkYellow)) using (console.WithForegroundColor(ConsoleColor.DarkYellow))
{ {
console console.Error.WriteLine(
.Error "Warning: The --bot option is deprecated and should not be used. "
.WriteLine( + "The token type is now inferred automatically. "
"Warning: Option --bot is deprecated and should not be used. " + "Please update your workflows as this option may be completely removed in a future version."
+ "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."
);
} }
} }
#pragma warning restore CS0618 #pragma warning restore CS0618
// Note about interactivity // Note about interactivity for Docker
if (console.IsOutputRedirected) if (console.IsOutputRedirected && Docker.IsRunningInContainer)
{ {
console console.Error.WriteLine(
.Output "Note: Output streams are redirected, rich console interactions are disabled. "
.WriteLine( + "If you are running this command in Docker, consider allocating a pseudo-terminal for better user experience (docker run -it ...)."
"Note: Output streams are redirected, rich console interactions are disabled. " );
+ "If you are running this command in Docker, consider allocating a pseudo-terminal for better user experience (docker run -it ...)."
);
} }
return default; return default;
@@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -30,8 +29,8 @@ public abstract class ExportCommandBase : DiscordCommandBase
"output", "output",
'o', 'o',
Description = "Output file or directory path. " 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 based on the channel names and export parameters. "
+ "If a directory is specified, file names will be generated automatically. " + "Directory paths must end with a slash to avoid ambiguity. "
+ "Supports template tokens, see the documentation for more info." + "Supports template tokens, see the documentation for more info."
)] )]
public string OutputPath public string OutputPath
@@ -118,8 +117,12 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt"; public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt";
[CommandOption("locale", Description = "Locale to use when formatting dates and numbers.")] [CommandOption(
public string Locale { get; init; } = CultureInfo.CurrentCulture.Name; "locale",
Description = "Locale to use when formatting dates and numbers. "
+ "If not specified, the default system locale will be used."
)]
public string? Locale { get; init; }
[CommandOption("utc", Description = "Normalize all timestamps to UTC+0.")] [CommandOption("utc", Description = "Normalize all timestamps to UTC+0.")]
public bool IsUtcNormalizationEnabled { get; init; } = false; public bool IsUtcNormalizationEnabled { get; init; } = false;
@@ -244,11 +247,9 @@ public abstract class ExportCommandBase : DiscordCommandBase
// Print the result // Print the result
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
{ {
await console await console.Output.WriteLineAsync(
.Output $"Successfully exported {channels.Count - errorsByChannel.Count} channel(s)."
.WriteLineAsync( );
$"Successfully exported {channels.Count - errorsByChannel.Count} channel(s)."
);
} }
// Print errors // Print errors
@@ -258,11 +259,9 @@ public abstract class ExportCommandBase : DiscordCommandBase
using (console.WithForegroundColor(ConsoleColor.Red)) using (console.WithForegroundColor(ConsoleColor.Red))
{ {
await console await console.Error.WriteLineAsync(
.Error $"Failed to export {errorsByChannel.Count} the following channel(s):"
.WriteLineAsync( );
$"Failed to export {errorsByChannel.Count} the following channel(s):"
);
} }
foreach (var (channel, error) in errorsByChannel) foreach (var (channel, error) in errorsByChannel)
@@ -324,51 +323,33 @@ public abstract class ExportCommandBase : DiscordCommandBase
// Support Ukraine callout // Support Ukraine callout
if (!IsUkraineSupportMessageDisabled) if (!IsUkraineSupportMessageDisabled)
{ {
console console.Output.WriteLine(
.Output "┌────────────────────────────────────────────────────────────────────┐"
.WriteLine( );
"┌────────────────────────────────────────────────────────────────────┐" console.Output.WriteLine(
); "│ Thank you for supporting Ukraine <3 │"
console );
.Output console.Output.WriteLine(
.WriteLine( "│ │"
"│ Thank you for supporting Ukraine <3 │" );
); console.Output.WriteLine(
console "│ As Russia wages a genocidal war against my country, │"
.Output );
.WriteLine( console.Output.WriteLine(
"│ │" "│ I'm grateful to everyone who continues to │"
); );
console console.Output.WriteLine(
.Output "│ stand with Ukraine in our fight for freedom. │"
.WriteLine( );
"│ As Russia wages a genocidal war against my country, │" console.Output.WriteLine(
); "│ │"
console );
.Output console.Output.WriteLine(
.WriteLine( "│ Learn more: https://tyrrrz.me/ukraine │"
"│ I'm grateful to everyone who continues to │" );
); console.Output.WriteLine(
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("");
} }
@@ -21,7 +21,7 @@ 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; public bool IncludeDirectChannels { get; init; } = true;
[CommandOption("include-guilds", Description = "Include guild channels.")] [CommandOption("include-guilds", Description = "Include server channels.")]
public bool IncludeGuildChannels { get; init; } = true; public bool IncludeGuildChannels { get; init; } = true;
[CommandOption("include-vc", Description = "Include voice channels.")] [CommandOption("include-vc", Description = "Include voice channels.")]
@@ -54,9 +54,9 @@ public class ExportAllCommand : ExportCommandBase
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken)) await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
{ {
// Regular channels // Regular channels
await console await console.Output.WriteLineAsync(
.Output $"Fetching channels for server '{guild.Name}'..."
.WriteLineAsync($"Fetching channels for guild '{guild.Name}'..."); );
var fetchedChannelsCount = 0; var fetchedChannelsCount = 0;
await console await console
@@ -94,9 +94,9 @@ public class ExportAllCommand : ExportCommandBase
// Threads // Threads
if (ThreadInclusionMode != ThreadInclusionMode.None) if (ThreadInclusionMode != ThreadInclusionMode.None)
{ {
await console await console.Output.WriteLineAsync(
.Output $"Fetching threads for server '{guild.Name}'..."
.WriteLineAsync($"Fetching threads for guild '{guild.Name}'..."); );
var fetchedThreadsCount = 0; var fetchedThreadsCount = 0;
await console await console
@@ -126,9 +126,9 @@ public class ExportAllCommand : ExportCommandBase
} }
); );
await console await console.Output.WriteLineAsync(
.Output $"Fetched {fetchedThreadsCount} thread(s)."
.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s)."); );
} }
} }
} }
@@ -180,9 +180,9 @@ public class ExportAllCommand : ExportCommandBase
using (console.WithForegroundColor(ConsoleColor.Red)) using (console.WithForegroundColor(ConsoleColor.Red))
{ {
await console await console.Error.WriteLineAsync(
.Error "Failed to access the following channel(s):"
.WriteLineAsync("Failed to access the following channel(s):"); );
} }
foreach (var dumpChannel in inaccessibleChannels) foreach (var dumpChannel in inaccessibleChannels)
@@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
@@ -13,10 +12,10 @@ using Spectre.Console;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("exportguild", Description = "Exports all channels within the specified guild.")] [Command("exportguild", Description = "Exports all channels within the specified server.")]
public class ExportGuildCommand : ExportCommandBase public class ExportGuildCommand : ExportCommandBase
{ {
[CommandOption("guild", 'g', Description = "Guild ID.")] [CommandOption("guild", 'g', Description = "Server ID.")]
public required Snowflake GuildId { get; init; } public required Snowflake GuildId { get; init; }
[CommandOption("include-vc", Description = "Include voice channels.")] [CommandOption("include-vc", Description = "Include voice channels.")]
@@ -12,10 +12,10 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("channels", Description = "Get the list of channels in a guild.")] [Command("channels", Description = "Get the list of channels in a server.")]
public class GetChannelsCommand : DiscordCommandBase public class GetChannelsCommand : DiscordCommandBase
{ {
[CommandOption("guild", 'g', Description = "Guild ID.")] [CommandOption("guild", 'g', Description = "Server ID.")]
public required Snowflake GuildId { get; init; } public required Snowflake GuildId { get; init; }
[CommandOption("include-vc", Description = "Include voice channels.")] [CommandOption("include-vc", Description = "Include voice channels.")]
@@ -64,9 +64,9 @@ public class GetChannelsCommand : DiscordCommandBase
foreach (var channel in channels) foreach (var channel in channels)
{ {
// Channel ID // Channel ID
await console await console.Output.WriteAsync(
.Output channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
.WriteAsync(channel.Id.ToString().PadRight(channelIdMaxLength, ' ')); );
// Separator // Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray)) using (console.WithForegroundColor(ConsoleColor.DarkGray))
@@ -88,11 +88,9 @@ public class GetChannelsCommand : DiscordCommandBase
await console.Output.WriteAsync(" * "); await console.Output.WriteAsync(" * ");
// Thread ID // Thread ID
await console await console.Output.WriteAsync(
.Output channelThread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
.WriteAsync( );
channelThread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
);
// Separator // Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray)) using (console.WithForegroundColor(ConsoleColor.DarkGray))
@@ -108,9 +106,9 @@ public class GetChannelsCommand : DiscordCommandBase
// Thread status // Thread status
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
await console await console.Output.WriteLineAsync(
.Output channelThread.IsArchived ? "Archived" : "Active"
.WriteLineAsync(channelThread.IsArchived ? "Archived" : "Active"); );
} }
} }
} }
@@ -33,9 +33,9 @@ public class GetDirectChannelsCommand : DiscordCommandBase
foreach (var channel in channels) foreach (var channel in channels)
{ {
// Channel ID // Channel ID
await console await console.Output.WriteAsync(
.Output channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
.WriteAsync(channel.Id.ToString().PadRight(channelIdMaxLength, ' ')); );
// Separator // Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray)) using (console.WithForegroundColor(ConsoleColor.DarkGray))
@@ -9,7 +9,7 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("guilds", Description = "Gets the list of accessible guilds.")] [Command("guilds", Description = "Gets the list of accessible servers.")]
public class GetGuildsCommand : DiscordCommandBase public class GetGuildsCommand : DiscordCommandBase
{ {
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
@@ -6,20 +6,18 @@ using CliFx.Infrastructure;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("guide", Description = "Explains how to obtain the token, guild or channel ID.")] [Command("guide", Description = "Explains how to obtain the token, server or channel ID.")]
public class GuideCommand : ICommand public class GuideCommand : ICommand
{ {
public ValueTask ExecuteAsync(IConsole console) public ValueTask ExecuteAsync(IConsole console)
{ {
// User token // User token
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get user token:"); console.Output.WriteLine("To get the token for your personal account:");
console console.Output.WriteLine(
.Output " * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!"
.WriteLine( );
" * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!"
);
console.Output.WriteLine(" 1. Open Discord in your web browser and login"); console.Output.WriteLine(" 1. Open Discord in your web browser and login");
console.Output.WriteLine(" 2. Open any server or direct message channel"); console.Output.WriteLine(" 2. Open any server or direct message channel");
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools"); console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
@@ -34,43 +32,44 @@ public class GuideCommand : ICommand
// Bot token // Bot token
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get bot token:"); console.Output.WriteLine("To get the token for your bot:");
console.Output.WriteLine(" 1. Go to Discord developer portal"); console.Output.WriteLine(" 1. Go to Discord developer portal");
console.Output.WriteLine(" 2. Open your application's settings"); console.Output.WriteLine(" 2. Open your application's settings");
console.Output.WriteLine(" 3. Navigate to the Bot section on the left"); console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
console.Output.WriteLine(" 4. Under Token click Copy"); console.Output.WriteLine(" 4. Under Token click Copy");
console console.Output.WriteLine(
.Output " * Your bot needs to have the Message Content Intent enabled to read messages"
.WriteLine( );
" * Your bot needs to have Message Content Intent enabled to read messages"
);
console.Output.WriteLine(); console.Output.WriteLine();
// Guild or channel ID // Guild or channel ID
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get guild ID or channel ID:"); console.Output.WriteLine("To get the ID of a server or a channel:");
console.Output.WriteLine(" 1. Open Discord"); console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Open Settings"); console.Output.WriteLine(" 2. Open Settings");
console.Output.WriteLine(" 3. Go to Advanced section"); console.Output.WriteLine(" 3. Go to Advanced section");
console.Output.WriteLine(" 4. Enable Developer Mode"); console.Output.WriteLine(" 4. Enable Developer Mode");
console console.Output.WriteLine(
.Output " 5. Right-click on the desired server or channel and click Copy Server ID or Copy Channel ID"
.WriteLine( );
" 5. Right-click on the desired guild or channel and click Copy Server ID or Copy Channel ID"
);
console.Output.WriteLine(); console.Output.WriteLine();
// Docs link // Docs link
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console {
.Output console.Output.WriteLine(
.WriteLine("If you have questions or issues, please refer to the documentation:"); "If you have questions or issues, please refer to the documentation:"
);
}
using (console.WithForegroundColor(ConsoleColor.DarkCyan)) using (console.WithForegroundColor(ConsoleColor.DarkCyan))
console {
.Output console.Output.WriteLine(
.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"); "https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"
);
}
return default; return default;
} }
@@ -7,11 +7,11 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="2.3.5" /> <PackageReference Include="CliFx" Version="2.3.5" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.2" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" /> <PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" /> <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.2" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="Spectre.Console" Version="0.47.0" /> <PackageReference Include="Spectre.Console" Version="0.48.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,7 +1,6 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Discord.Data.Common; namespace DiscordChatExporter.Core.Discord.Data.Common;
@@ -11,7 +10,7 @@ public static class ImageCdn
// Standard emoji are rendered through Twemoji // Standard emoji are rendered through Twemoji
public static string GetStandardEmojiUrl(string emojiName) public static string GetStandardEmojiUrl(string emojiName)
{ {
var runes = emojiName.GetRunes().ToArray(); var runes = emojiName.EnumerateRunes().ToArray();
// Variant selector rune is skipped in Twemoji IDs, // Variant selector rune is skipped in Twemoji IDs,
// except when the emoji also contains a zero-width joiner. // except when the emoji also contains a zero-width joiner.
@@ -100,8 +100,7 @@ public partial record Message
{ {
// Concatenate all images into one embed // Concatenate all images into one embed
var images = embed var images = embed
.Images .Images.Concat(trailingEmbeds.SelectMany(e => e.Images))
.Concat(trailingEmbeds.SelectMany(e => e.Images))
.ToArray(); .ToArray();
normalizedEmbeds.Add(embed with { Images = images }); normalizedEmbeds.Add(embed with { Images = images });
@@ -18,15 +18,11 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord; namespace DiscordChatExporter.Core.Discord;
public class DiscordClient public class DiscordClient(string token)
{ {
private readonly string _token;
private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute); private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute);
private TokenKind? _resolvedTokenKind; private TokenKind? _resolvedTokenKind;
public DiscordClient(string token) => _token = token;
private async ValueTask<HttpResponseMessage> GetResponseAsync( private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url, string url,
TokenKind tokenKind, TokenKind tokenKind,
@@ -40,12 +36,10 @@ public class DiscordClient
// Don't validate because the token can have special characters // Don't validate because the token can have special characters
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828 // https://github.com/Tyrrrz/DiscordChatExporter/issues/828
request request.Headers.TryAddWithoutValidation(
.Headers "Authorization",
.TryAddWithoutValidation( tokenKind == TokenKind.Bot ? $"Bot {token}" : token
"Authorization", );
tokenKind == TokenKind.Bot ? $"Bot {_token}" : _token
);
var response = await Http.Client.SendAsync( var response = await Http.Client.SendAsync(
request, request,
@@ -61,13 +55,11 @@ public class DiscordClient
// rate limits and that's just way too much effort. // rate limits and that's just way too much effort.
// https://discord.com/developers/docs/topics/rate-limits // https://discord.com/developers/docs/topics/rate-limits
var remainingRequestCount = response var remainingRequestCount = response
.Headers .Headers.TryGetValue("X-RateLimit-Remaining")
.TryGetValue("X-RateLimit-Remaining")
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture)); ?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
var resetAfterDelay = response var resetAfterDelay = response
.Headers .Headers.TryGetValue("X-RateLimit-Reset-After")
.TryGetValue("X-RateLimit-Reset-After")
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture)) ?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
.Pipe(TimeSpan.FromSeconds); .Pipe(TimeSpan.FromSeconds);
@@ -8,11 +8,9 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Dump; namespace DiscordChatExporter.Core.Discord.Dump;
public partial class DataDump public partial class DataDump(IReadOnlyList<DataDumpChannel> channels)
{ {
public IReadOnlyList<DataDumpChannel> Channels { get; } public IReadOnlyList<DataDumpChannel> Channels { get; } = channels;
public DataDump(IReadOnlyList<DataDumpChannel> channels) => Channels = channels;
} }
public partial class DataDump public partial class DataDump
@@ -48,7 +46,7 @@ public partial class DataDump
if (entry is null) if (entry is null)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"Could not find the channel index inside the data package." "Failed to locate the channel index inside the data package."
); );
} }
@@ -1,15 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncKeyedLock" Version="6.2.2" /> <PackageReference Include="AsyncKeyedLock" Version="6.2.4" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.2" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Polly" Version="8.2.0" /> <PackageReference Include="Polly" Version="8.2.0" />
<PackageReference Include="RazorBlade" Version="0.4.4" /> <PackageReference Include="RazorBlade" Version="0.5.0" />
<PackageReference Include="Superpower" Version="3.0.0" /> <PackageReference Include="Superpower" Version="3.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.14.0" /> <PackageReference Include="WebMarkupMin.Core" Version="2.14.0" />
<PackageReference Include="YoutubeExplode" Version="6.3.7" /> <PackageReference Include="YoutubeExplode" Version="6.3.10" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -2,17 +2,11 @@
namespace DiscordChatExporter.Core.Exceptions; namespace DiscordChatExporter.Core.Exceptions;
public class DiscordChatExporterException : Exception public class DiscordChatExporterException(
string message,
bool isFatal = false,
Exception? innerException = null
) : Exception(message, innerException)
{ {
public bool IsFatal { get; } public bool IsFatal { get; } = isFatal;
public DiscordChatExporterException(
string message,
bool isFatal = false,
Exception? innerException = null
)
: base(message, innerException)
{
IsFatal = isFatal;
}
} }
@@ -10,8 +10,6 @@ namespace DiscordChatExporter.Core.Exporting;
public class ChannelExporter(DiscordClient discord) public class ChannelExporter(DiscordClient discord)
{ {
private readonly DiscordClient _discord = discord;
public async ValueTask ExportChannelAsync( public async ValueTask ExportChannelAsync(
ExportRequest request, ExportRequest request,
IProgress<Percentage>? progress = null, IProgress<Percentage>? progress = null,
@@ -63,13 +61,13 @@ public class ChannelExporter(DiscordClient discord)
} }
// Build context // Build context
var context = new ExportContext(_discord, request); var context = new ExportContext(discord, request);
await context.PopulateChannelsAndRolesAsync(cancellationToken); await context.PopulateChannelsAndRolesAsync(cancellationToken);
// Export messages // Export messages
await using var messageExporter = new MessageExporter(context); await using var messageExporter = new MessageExporter(context);
await foreach ( await foreach (
var message in _discord.GetMessagesAsync( var message in discord.GetMessagesAsync(
request.Channel.Id, request.Channel.Id,
request.After, request.After,
request.Before, request.Before,
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
@@ -122,7 +123,7 @@ internal partial class CsvMessageWriter
{ {
private static string CsvEncode(string value) private static string CsvEncode(string value)
{ {
value = value.Replace("\"", "\"\""); value = value.Replace("\"", "\"\"", StringComparison.Ordinal);
return $"\"{value}\""; return $"\"{value}\"";
} }
} }
@@ -23,9 +23,6 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
o.PoolInitialFill = 1; o.PoolInitialFill = 1;
}); });
private readonly string _workingDirPath = workingDirPath;
private readonly bool _reuse = reuse;
// File paths of the previously downloaded assets // File paths of the previously downloaded assets
private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal); private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal);
@@ -35,7 +32,7 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
) )
{ {
var fileName = GetFileNameFromUrl(url); var fileName = GetFileNameFromUrl(url);
var filePath = Path.Combine(_workingDirPath, fileName); var filePath = Path.Combine(workingDirPath, fileName);
using var _ = await Locker.LockAsync(filePath, cancellationToken); using var _ = await Locker.LockAsync(filePath, cancellationToken);
@@ -43,10 +40,10 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
return cachedFilePath; return cachedFilePath;
// Reuse existing files if we're allowed to // Reuse existing files if we're allowed to
if (_reuse && File.Exists(filePath)) if (reuse && File.Exists(filePath))
return _previousPathsByUrl[url] = filePath; return _previousPathsByUrl[url] = filePath;
Directory.CreateDirectory(_workingDirPath); Directory.CreateDirectory(workingDirPath);
await Http.ResiliencePipeline.ExecuteAsync( await Http.ResiliencePipeline.ExecuteAsync(
async innerCancellationToken => async innerCancellationToken =>
@@ -60,9 +57,7 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
try try
{ {
var lastModified = response var lastModified = response
.Content .Content.Headers.TryGetValue("Last-Modified")
.Headers
.TryGetValue("Last-Modified")
?.Pipe( ?.Pipe(
s => s =>
DateTimeOffset.TryParse( DateTimeOffset.TryParse(
@@ -8,6 +8,7 @@ using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting.Filtering; using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning; using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils; using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting; namespace DiscordChatExporter.Core.Exporting;
@@ -39,9 +40,9 @@ public partial class ExportRequest
public bool ShouldReuseAssets { get; } public bool ShouldReuseAssets { get; }
public string Locale { get; } public string? Locale { get; }
public CultureInfo CultureInfo { get; } public CultureInfo? CultureInfo { get; }
public bool IsUtcNormalizationEnabled { get; } public bool IsUtcNormalizationEnabled { get; }
@@ -58,7 +59,7 @@ public partial class ExportRequest
bool shouldFormatMarkdown, bool shouldFormatMarkdown,
bool shouldDownloadAssets, bool shouldDownloadAssets,
bool shouldReuseAssets, bool shouldReuseAssets,
string locale, string? locale,
bool isUtcNormalizationEnabled bool isUtcNormalizationEnabled
) )
{ {
@@ -83,7 +84,7 @@ public partial class ExportRequest
? FormatPath(assetsDirPath, Guild, Channel, After, Before) ? FormatPath(assetsDirPath, Guild, Channel, After, Before)
: $"{OutputFilePath}_Files{Path.DirectorySeparatorChar}"; : $"{OutputFilePath}_Files{Path.DirectorySeparatorChar}";
CultureInfo = CultureInfo.GetCultureInfo(Locale); CultureInfo = Locale?.Pipe(CultureInfo.GetCultureInfo);
} }
} }
@@ -182,9 +183,10 @@ public partial class ExportRequest
=> before?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) => before?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
?? "", ?? "",
"%d" "%d"
=> DateTimeOffset => DateTimeOffset.Now.ToString(
.Now "yyyy-MM-dd",
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture
),
"%%" => "%", "%%" => "%",
_ => m.Value _ => m.Value
@@ -3,28 +3,17 @@ using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class BinaryExpressionMessageFilter : MessageFilter internal class BinaryExpressionMessageFilter(
MessageFilter first,
MessageFilter second,
BinaryExpressionKind kind
) : MessageFilter
{ {
private readonly MessageFilter _first;
private readonly MessageFilter _second;
private readonly BinaryExpressionKind _kind;
public BinaryExpressionMessageFilter(
MessageFilter first,
MessageFilter second,
BinaryExpressionKind kind
)
{
_first = first;
_second = second;
_kind = kind;
}
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
_kind switch kind switch
{ {
BinaryExpressionKind.Or => _first.IsMatch(message) || _second.IsMatch(message), BinaryExpressionKind.Or => first.IsMatch(message) || second.IsMatch(message),
BinaryExpressionKind.And => _first.IsMatch(message) && _second.IsMatch(message), BinaryExpressionKind.And => first.IsMatch(message) && second.IsMatch(message),
_ => throw new InvalidOperationException($"Unknown binary expression kind '{_kind}'.") _ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'.")
}; };
} }
@@ -4,12 +4,8 @@ using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class ContainsMessageFilter : MessageFilter internal class ContainsMessageFilter(string text) : MessageFilter
{ {
private readonly string _text;
public ContainsMessageFilter(string text) => _text = text;
// Match content within word boundaries, between spaces, or as the whole input. // Match content within word boundaries, between spaces, or as the whole input.
// For example, "max" shouldn't match on content "our maximum effort", // For example, "max" shouldn't match on content "our maximum effort",
// but should match on content "our max effort". // but should match on content "our max effort".
@@ -20,20 +16,18 @@ internal class ContainsMessageFilter : MessageFilter
!string.IsNullOrWhiteSpace(content) !string.IsNullOrWhiteSpace(content)
&& Regex.IsMatch( && Regex.IsMatch(
content, content,
@"(?:\b|\s|^)" + Regex.Escape(_text) + @"(?:\b|\s|$)", @"(?:\b|\s|^)" + Regex.Escape(text) + @"(?:\b|\s|$)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
); );
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
IsMatch(message.Content) IsMatch(message.Content)
|| message || message.Embeds.Any(
.Embeds e =>
.Any( IsMatch(e.Title)
e => || IsMatch(e.Author?.Name)
IsMatch(e.Title) || IsMatch(e.Description)
|| IsMatch(e.Author?.Name) || IsMatch(e.Footer?.Text)
|| IsMatch(e.Description) || e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
|| IsMatch(e.Footer?.Text) );
|| e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
);
} }
@@ -3,15 +3,11 @@ using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class FromMessageFilter : MessageFilter internal class FromMessageFilter(string value) : MessageFilter
{ {
private readonly string _value;
public FromMessageFilter(string value) => _value = value;
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
string.Equals(_value, message.Author.Name, StringComparison.OrdinalIgnoreCase) string.Equals(value, message.Author.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(_value, message.Author.DisplayName, StringComparison.OrdinalIgnoreCase) || string.Equals(value, message.Author.DisplayName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(_value, message.Author.FullName, StringComparison.OrdinalIgnoreCase) || string.Equals(value, message.Author.FullName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(_value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase); || string.Equals(value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase);
} }
@@ -1,30 +1,31 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown.Parsing;
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class HasMessageFilter : MessageFilter internal class HasMessageFilter(MessageContentMatchKind kind) : MessageFilter
{ {
private readonly MessageContentMatchKind _kind;
public HasMessageFilter(MessageContentMatchKind kind) => _kind = kind;
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
_kind switch kind switch
{ {
MessageContentMatchKind.Link MessageContentMatchKind.Link => MarkdownParser.ExtractLinks(message.Content).Any(),
=> Regex.IsMatch(message.Content, "https?://\\S*[^\\.,:;\"\'\\s]"),
MessageContentMatchKind.Embed => message.Embeds.Any(), MessageContentMatchKind.Embed => message.Embeds.Any(),
MessageContentMatchKind.File => message.Attachments.Any(), MessageContentMatchKind.File => message.Attachments.Any(),
MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo), MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo),
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage), MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio), MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
MessageContentMatchKind.Pin => message.IsPinned, MessageContentMatchKind.Pin => message.IsPinned,
MessageContentMatchKind.Invite
=> MarkdownParser
.ExtractLinks(message.Content)
.Select(l => l.Url)
.Select(Invite.TryGetCodeFromUrl)
.Any(c => !string.IsNullOrWhiteSpace(c)),
_ _
=> throw new InvalidOperationException( => throw new InvalidOperationException(
$"Unknown message content match kind '{_kind}'." $"Unknown message content match kind '{kind}'."
) )
}; };
} }
@@ -4,20 +4,14 @@ using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class MentionsMessageFilter : MessageFilter internal class MentionsMessageFilter(string value) : MessageFilter
{ {
private readonly string _value;
public MentionsMessageFilter(string value) => _value = value;
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
message message.MentionedUsers.Any(
.MentionedUsers user =>
.Any( string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase)
user => || string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase)
string.Equals(_value, user.Name, StringComparison.OrdinalIgnoreCase) || string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(_value, user.DisplayName, StringComparison.OrdinalIgnoreCase) || string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
|| string.Equals(_value, user.FullName, StringComparison.OrdinalIgnoreCase) );
|| string.Equals(_value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
);
} }
@@ -8,5 +8,6 @@ internal enum MessageContentMatchKind
Video, Video,
Image, Image,
Sound, Sound,
Pin Pin,
Invite
} }
@@ -2,11 +2,7 @@
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class NegatedMessageFilter : MessageFilter internal class NegatedMessageFilter(MessageFilter filter) : MessageFilter
{ {
private readonly MessageFilter _filter; public override bool IsMatch(Message message) => !filter.IsMatch(message);
public NegatedMessageFilter(MessageFilter filter) => _filter = filter;
public override bool IsMatch(Message message) => !_filter.IsMatch(message);
} }
@@ -61,18 +61,28 @@ internal static class FilterGrammar
.IgnoreThen( .IgnoreThen(
Parse.OneOf( Parse.OneOf(
Span.EqualToIgnoreCase("link") Span.EqualToIgnoreCase("link")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Link)), .IgnoreThen(Parse.Return(MessageContentMatchKind.Link))
.Try(),
Span.EqualToIgnoreCase("embed") Span.EqualToIgnoreCase("embed")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)), .IgnoreThen(Parse.Return(MessageContentMatchKind.Embed))
.Try(),
Span.EqualToIgnoreCase("file") Span.EqualToIgnoreCase("file")
.IgnoreThen(Parse.Return(MessageContentMatchKind.File)), .IgnoreThen(Parse.Return(MessageContentMatchKind.File))
.Try(),
Span.EqualToIgnoreCase("video") Span.EqualToIgnoreCase("video")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Video)), .IgnoreThen(Parse.Return(MessageContentMatchKind.Video))
.Try(),
Span.EqualToIgnoreCase("image") Span.EqualToIgnoreCase("image")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Image)), .IgnoreThen(Parse.Return(MessageContentMatchKind.Image))
.Try(),
Span.EqualToIgnoreCase("sound") Span.EqualToIgnoreCase("sound")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)), .IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)),
Span.EqualToIgnoreCase("pin").IgnoreThen(Parse.Return(MessageContentMatchKind.Pin)) Span.EqualToIgnoreCase("pin")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Pin))
.Try(),
Span.EqualToIgnoreCase("invite")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Invite))
.Try()
) )
) )
.Select(k => (MessageFilter)new HasMessageFilter(k)) .Select(k => (MessageFilter)new HasMessageFilter(k))
@@ -4,23 +4,13 @@ using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class ReactionMessageFilter : MessageFilter internal class ReactionMessageFilter(string value) : MessageFilter
{ {
private readonly string _value;
public ReactionMessageFilter(string value) => _value = value;
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
message message.Reactions.Any(
.Reactions r =>
.Any( string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase)
r => || string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
string.Equals( || string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
_value, );
r.Emoji.Id?.ToString(),
StringComparison.OrdinalIgnoreCase
)
|| string.Equals(_value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(_value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
);
} }
@@ -18,16 +18,12 @@ internal partial class HtmlMarkdownVisitor(
bool isJumbo bool isJumbo
) : MarkdownVisitor ) : MarkdownVisitor
{ {
private readonly ExportContext _context = context;
private readonly StringBuilder _buffer = buffer;
private readonly bool _isJumbo = isJumbo;
protected override ValueTask VisitTextAsync( protected override ValueTask VisitTextAsync(
TextNode text, TextNode text,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append(HtmlEncode(text.Text)); buffer.Append(HtmlEncode(text.Text));
return default; return default;
} }
@@ -92,9 +88,9 @@ internal partial class HtmlMarkdownVisitor(
) )
}; };
_buffer.Append(openingTag); buffer.Append(openingTag);
await VisitAsync(formatting.Children, cancellationToken); await VisitAsync(formatting.Children, cancellationToken);
_buffer.Append(closingTag); buffer.Append(closingTag);
} }
protected override async ValueTask VisitHeadingAsync( protected override async ValueTask VisitHeadingAsync(
@@ -102,14 +98,14 @@ internal partial class HtmlMarkdownVisitor(
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append( buffer.Append(
// lang=html // lang=html
$"<h{heading.Level}>" $"<h{heading.Level}>"
); );
await VisitAsync(heading.Children, cancellationToken); await VisitAsync(heading.Children, cancellationToken);
_buffer.Append( buffer.Append(
// lang=html // lang=html
$"</h{heading.Level}>" $"</h{heading.Level}>"
); );
@@ -120,14 +116,14 @@ internal partial class HtmlMarkdownVisitor(
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append( buffer.Append(
// lang=html // lang=html
"<ul>" "<ul>"
); );
await VisitAsync(list.Items, cancellationToken); await VisitAsync(list.Items, cancellationToken);
_buffer.Append( buffer.Append(
// lang=html // lang=html
"</ul>" "</ul>"
); );
@@ -138,14 +134,14 @@ internal partial class HtmlMarkdownVisitor(
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append( buffer.Append(
// lang=html // lang=html
"<li>" "<li>"
); );
await VisitAsync(listItem.Children, cancellationToken); await VisitAsync(listItem.Children, cancellationToken);
_buffer.Append( buffer.Append(
// lang=html // lang=html
"</li>" "</li>"
); );
@@ -156,7 +152,7 @@ internal partial class HtmlMarkdownVisitor(
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append( buffer.Append(
// lang=html // lang=html
$""" $"""
<code class="chatlog__markdown-pre chatlog__markdown-pre--inline">{HtmlEncode(inlineCodeBlock.Code)}</code> <code class="chatlog__markdown-pre chatlog__markdown-pre--inline">{HtmlEncode(inlineCodeBlock.Code)}</code>
@@ -175,7 +171,7 @@ internal partial class HtmlMarkdownVisitor(
? $"language-{multiLineCodeBlock.Language}" ? $"language-{multiLineCodeBlock.Language}"
: "nohighlight"; : "nohighlight";
_buffer.Append( buffer.Append(
// lang=html // lang=html
$""" $"""
<code class="chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightClass}">{HtmlEncode(multiLineCodeBlock.Code)}</code> <code class="chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightClass}">{HtmlEncode(multiLineCodeBlock.Code)}</code>
@@ -196,7 +192,7 @@ internal partial class HtmlMarkdownVisitor(
.Groups[1] .Groups[1]
.Value; .Value;
_buffer.Append( buffer.Append(
!string.IsNullOrWhiteSpace(linkedMessageId) !string.IsNullOrWhiteSpace(linkedMessageId)
// lang=html // lang=html
? $"""<a href="{HtmlEncode(link.Url)}" onclick="scrollToMessage(event, '{linkedMessageId}')">""" ? $"""<a href="{HtmlEncode(link.Url)}" onclick="scrollToMessage(event, '{linkedMessageId}')">"""
@@ -206,7 +202,7 @@ internal partial class HtmlMarkdownVisitor(
await VisitAsync(link.Children, cancellationToken); await VisitAsync(link.Children, cancellationToken);
_buffer.Append( buffer.Append(
// lang=html // lang=html
"</a>" "</a>"
); );
@@ -218,9 +214,9 @@ internal partial class HtmlMarkdownVisitor(
) )
{ {
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated); var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = _isJumbo ? "chatlog__emoji--large" : ""; var jumboClass = isJumbo ? "chatlog__emoji--large" : "";
_buffer.Append( buffer.Append(
// lang=html // lang=html
$""" $"""
<img <img
@@ -228,7 +224,7 @@ internal partial class HtmlMarkdownVisitor(
class="chatlog__emoji {jumboClass}" class="chatlog__emoji {jumboClass}"
alt="{emoji.Name}" alt="{emoji.Name}"
title="{emoji.Code}" title="{emoji.Code}"
src="{await _context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}"> src="{await context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}">
""" """
); );
} }
@@ -240,7 +236,7 @@ internal partial class HtmlMarkdownVisitor(
{ {
if (mention.Kind == MentionKind.Everyone) if (mention.Kind == MentionKind.Everyone)
{ {
_buffer.Append( buffer.Append(
// lang=html // lang=html
""" """
<span class="chatlog__markdown-mention">@everyone</span> <span class="chatlog__markdown-mention">@everyone</span>
@@ -249,7 +245,7 @@ internal partial class HtmlMarkdownVisitor(
} }
else if (mention.Kind == MentionKind.Here) else if (mention.Kind == MentionKind.Here)
{ {
_buffer.Append( buffer.Append(
// lang=html // lang=html
""" """
<span class="chatlog__markdown-mention">@here</span> <span class="chatlog__markdown-mention">@here</span>
@@ -262,13 +258,13 @@ internal partial class HtmlMarkdownVisitor(
// which means they need to be populated on demand. // which means they need to be populated on demand.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/304 // https://github.com/Tyrrrz/DiscordChatExporter/issues/304
if (mention.TargetId is not null) if (mention.TargetId is not null)
await _context.PopulateMemberAsync(mention.TargetId.Value, cancellationToken); await context.PopulateMemberAsync(mention.TargetId.Value, cancellationToken);
var member = mention.TargetId?.Pipe(_context.TryGetMember); var member = mention.TargetId?.Pipe(context.TryGetMember);
var fullName = member?.User.FullName ?? "Unknown"; var fullName = member?.User.FullName ?? "Unknown";
var displayName = member?.DisplayName ?? member?.User.DisplayName ?? "Unknown"; var displayName = member?.DisplayName ?? member?.User.DisplayName ?? "Unknown";
_buffer.Append( buffer.Append(
// lang=html // lang=html
$""" $"""
<span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(displayName)}</span> <span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(displayName)}</span>
@@ -277,11 +273,11 @@ internal partial class HtmlMarkdownVisitor(
} }
else if (mention.Kind == MentionKind.Channel) else if (mention.Kind == MentionKind.Channel)
{ {
var channel = mention.TargetId?.Pipe(_context.TryGetChannel); var channel = mention.TargetId?.Pipe(context.TryGetChannel);
var symbol = channel?.IsVoice == true ? "🔊" : "#"; var symbol = channel?.IsVoice == true ? "🔊" : "#";
var name = channel?.Name ?? "deleted-channel"; var name = channel?.Name ?? "deleted-channel";
_buffer.Append( buffer.Append(
// lang=html // lang=html
$""" $"""
<span class="chatlog__markdown-mention">{symbol}{HtmlEncode(name)}</span> <span class="chatlog__markdown-mention">{symbol}{HtmlEncode(name)}</span>
@@ -290,7 +286,7 @@ internal partial class HtmlMarkdownVisitor(
} }
else if (mention.Kind == MentionKind.Role) else if (mention.Kind == MentionKind.Role)
{ {
var role = mention.TargetId?.Pipe(_context.TryGetRole); var role = mention.TargetId?.Pipe(context.TryGetRole);
var name = role?.Name ?? "deleted-role"; var name = role?.Name ?? "deleted-role";
var color = role?.Color; var color = role?.Color;
@@ -300,7 +296,7 @@ internal partial class HtmlMarkdownVisitor(
""" """
: null; : null;
_buffer.Append( buffer.Append(
// lang=html // lang=html
$""" $"""
<span class="chatlog__markdown-mention" style="{style}">@{HtmlEncode(name)}</span> <span class="chatlog__markdown-mention" style="{style}">@{HtmlEncode(name)}</span>
@@ -315,14 +311,14 @@ internal partial class HtmlMarkdownVisitor(
) )
{ {
var formatted = timestamp.Instant is not null var formatted = timestamp.Instant is not null
? _context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g") ? context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g")
: "Invalid date"; : "Invalid date";
var formattedLong = timestamp.Instant is not null var formattedLong = timestamp.Instant is not null
? _context.FormatDate(timestamp.Instant.Value, "f") ? context.FormatDate(timestamp.Instant.Value, "f")
: ""; : "";
_buffer.Append( buffer.Append(
// lang=html // lang=html
$""" $"""
<span class="chatlog__markdown-timestamp" title="{HtmlEncode(formattedLong)}">{HtmlEncode(formatted)}</span> <span class="chatlog__markdown-timestamp" title="{HtmlEncode(formattedLong)}">{HtmlEncode(formatted)}</span>
@@ -13,7 +13,6 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
: MessageWriter(stream, context) : MessageWriter(stream, context)
{ {
private readonly TextWriter _writer = new StreamWriter(stream); private readonly TextWriter _writer = new StreamWriter(stream);
private readonly string _themeName = themeName;
private readonly HtmlMinifier _minifier = new(); private readonly HtmlMinifier _minifier = new();
private readonly List<Message> _messageGroup = new(); private readonly List<Message> _messageGroup = new();
@@ -74,11 +73,9 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
{ {
await _writer.WriteLineAsync( await _writer.WriteLineAsync(
Minify( Minify(
await new PreambleTemplate await new PreambleTemplate { Context = Context, ThemeName = themeName }.RenderAsync(
{ cancellationToken
Context = Context, )
ThemeName = _themeName
}.RenderAsync(cancellationToken)
) )
); );
} }
@@ -15,18 +15,19 @@ namespace DiscordChatExporter.Core.Exporting;
internal class JsonMessageWriter(Stream stream, ExportContext context) internal class JsonMessageWriter(Stream stream, ExportContext context)
: MessageWriter(stream, context) : MessageWriter(stream, context)
{ {
private readonly Utf8JsonWriter _writer = new Utf8JsonWriter( private readonly Utf8JsonWriter _writer =
stream, new(
new JsonWriterOptions stream,
{ new JsonWriterOptions
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450 {
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // https://github.com/Tyrrrz/DiscordChatExporter/issues/450
Indented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
// Validation errors may mask actual failures Indented = true,
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413 // Validation errors may mask actual failures
SkipValidation = true // https://github.com/Tyrrrz/DiscordChatExporter/issues/413
} SkipValidation = true
); }
);
private async ValueTask<string> FormatMarkdownAsync( private async ValueTask<string> FormatMarkdownAsync(
string markdown, string markdown,
@@ -445,14 +446,12 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteStartArray("users"); _writer.WriteStartArray("users");
await foreach ( await foreach (
var user in Context var user in Context.Discord.GetMessageReactionsAsync(
.Discord Context.Request.Channel.Id,
.GetMessageReactionsAsync( message.Id,
Context.Request.Channel.Id, reaction.Emoji,
message.Id, cancellationToken
reaction.Emoji, )
cancellationToken
)
) )
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
@@ -8,8 +8,6 @@ namespace DiscordChatExporter.Core.Exporting;
internal partial class MessageExporter(ExportContext context) : IAsyncDisposable internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
{ {
private readonly ExportContext _context = context;
private int _partitionIndex; private int _partitionIndex;
private MessageWriter? _writer; private MessageWriter? _writer;
@@ -39,10 +37,10 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
// Ensure that the partition limit has not been reached // Ensure that the partition limit has not been reached
if ( if (
_writer is not null _writer is not null
&& _context && context.Request.PartitionLimit.IsReached(
.Request _writer.MessagesWritten,
.PartitionLimit _writer.BytesWritten
.IsReached(_writer.MessagesWritten, _writer.BytesWritten) )
) )
{ {
await ResetWriterAsync(cancellationToken); await ResetWriterAsync(cancellationToken);
@@ -53,10 +51,10 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
if (_writer is not null) if (_writer is not null)
return _writer; return _writer;
Directory.CreateDirectory(_context.Request.OutputDirPath); Directory.CreateDirectory(context.Request.OutputDirPath);
var filePath = GetPartitionFilePath(_context.Request.OutputFilePath, _partitionIndex); var filePath = GetPartitionFilePath(context.Request.OutputFilePath, _partitionIndex);
var writer = CreateMessageWriter(filePath, _context.Request.Format, _context); var writer = CreateMessageWriter(filePath, context.Request.Format, context);
await writer.WritePreambleAsync(cancellationToken); await writer.WritePreambleAsync(cancellationToken);
return _writer = writer; return _writer = writer;
@@ -1,11 +1,7 @@
namespace DiscordChatExporter.Core.Exporting.Partitioning; namespace DiscordChatExporter.Core.Exporting.Partitioning;
internal class FileSizePartitionLimit : PartitionLimit internal class FileSizePartitionLimit(long limit) : PartitionLimit
{ {
private readonly long _limit;
public FileSizePartitionLimit(long limit) => _limit = limit;
public override bool IsReached(long messagesWritten, long bytesWritten) => public override bool IsReached(long messagesWritten, long bytesWritten) =>
bytesWritten >= _limit; bytesWritten >= limit;
} }
@@ -1,11 +1,7 @@
namespace DiscordChatExporter.Core.Exporting.Partitioning; namespace DiscordChatExporter.Core.Exporting.Partitioning;
internal class MessageCountPartitionLimit : PartitionLimit internal class MessageCountPartitionLimit(long limit) : PartitionLimit
{ {
private readonly long _limit;
public MessageCountPartitionLimit(long limit) => _limit = limit;
public override bool IsReached(long messagesWritten, long bytesWritten) => public override bool IsReached(long messagesWritten, long bytesWritten) =>
messagesWritten >= _limit; messagesWritten >= limit;
} }
@@ -1,7 +1,6 @@
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown; using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing; using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@@ -11,15 +10,12 @@ namespace DiscordChatExporter.Core.Exporting;
internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBuilder buffer) internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBuilder buffer)
: MarkdownVisitor : MarkdownVisitor
{ {
private readonly ExportContext _context = context;
private readonly StringBuilder _buffer = buffer;
protected override ValueTask VisitTextAsync( protected override ValueTask VisitTextAsync(
TextNode text, TextNode text,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append(text.Text); buffer.Append(text.Text);
return default; return default;
} }
@@ -28,7 +24,7 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append(emoji.IsCustomEmoji ? $":{emoji.Name}:" : emoji.Name); buffer.Append(emoji.IsCustomEmoji ? $":{emoji.Name}:" : emoji.Name);
return default; return default;
} }
@@ -40,11 +36,11 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
{ {
if (mention.Kind == MentionKind.Everyone) if (mention.Kind == MentionKind.Everyone)
{ {
_buffer.Append("@everyone"); buffer.Append("@everyone");
} }
else if (mention.Kind == MentionKind.Here) else if (mention.Kind == MentionKind.Here)
{ {
_buffer.Append("@here"); buffer.Append("@here");
} }
else if (mention.Kind == MentionKind.User) else if (mention.Kind == MentionKind.User)
{ {
@@ -52,30 +48,30 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
// which means they need to be populated on demand. // which means they need to be populated on demand.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/304 // https://github.com/Tyrrrz/DiscordChatExporter/issues/304
if (mention.TargetId is not null) if (mention.TargetId is not null)
await _context.PopulateMemberAsync(mention.TargetId.Value, cancellationToken); await context.PopulateMemberAsync(mention.TargetId.Value, cancellationToken);
var member = mention.TargetId?.Pipe(_context.TryGetMember); var member = mention.TargetId?.Pipe(context.TryGetMember);
var displayName = member?.DisplayName ?? member?.User.DisplayName ?? "Unknown"; var displayName = member?.DisplayName ?? member?.User.DisplayName ?? "Unknown";
_buffer.Append($"@{displayName}"); buffer.Append($"@{displayName}");
} }
else if (mention.Kind == MentionKind.Channel) else if (mention.Kind == MentionKind.Channel)
{ {
var channel = mention.TargetId?.Pipe(_context.TryGetChannel); var channel = mention.TargetId?.Pipe(context.TryGetChannel);
var name = channel?.Name ?? "deleted-channel"; var name = channel?.Name ?? "deleted-channel";
_buffer.Append($"#{name}"); buffer.Append($"#{name}");
// Voice channel marker // Voice channel marker
if (channel?.IsVoice == true) if (channel?.IsVoice == true)
_buffer.Append(" [voice]"); buffer.Append(" [voice]");
} }
else if (mention.Kind == MentionKind.Role) else if (mention.Kind == MentionKind.Role)
{ {
var role = mention.TargetId?.Pipe(_context.TryGetRole); var role = mention.TargetId?.Pipe(context.TryGetRole);
var name = role?.Name ?? "deleted-role"; var name = role?.Name ?? "deleted-role";
_buffer.Append($"@{name}"); buffer.Append($"@{name}");
} }
} }
@@ -84,9 +80,9 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append( buffer.Append(
timestamp.Instant is not null timestamp.Instant is not null
? _context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g") ? context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g")
: "Invalid date" : "Invalid date"
); );
@@ -8,5 +8,5 @@ internal record LinkNode(string Url, IReadOnlyList<MarkdownNode> Children)
IContainerNode IContainerNode
{ {
public LinkNode(string url) public LinkNode(string url)
: this(url, new[] { new TextNode(url) }) { } : this(url, [new TextNode(url)]) { }
} }
@@ -2,15 +2,8 @@
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class AggregateMatcher<T> : IMatcher<T> internal class AggregateMatcher<T>(IReadOnlyList<IMatcher<T>> matchers) : IMatcher<T>
{ {
private readonly IReadOnlyList<IMatcher<T>> _matchers;
public AggregateMatcher(IReadOnlyList<IMatcher<T>> matchers)
{
_matchers = matchers;
}
public AggregateMatcher(params IMatcher<T>[] matchers) public AggregateMatcher(params IMatcher<T>[] matchers)
: this((IReadOnlyList<IMatcher<T>>)matchers) { } : this((IReadOnlyList<IMatcher<T>>)matchers) { }
@@ -19,7 +12,7 @@ internal class AggregateMatcher<T> : IMatcher<T>
ParsedMatch<T>? earliestMatch = null; ParsedMatch<T>? earliestMatch = null;
// Try to match the input with each matcher and get the match with the lowest start index // Try to match the input with each matcher and get the match with the lowest start index
foreach (var matcher in _matchers) foreach (var matcher in matchers)
{ {
// Try to match // Try to match
var match = matcher.TryMatch(segment); var match = matcher.TryMatch(segment);
@@ -1,14 +1,8 @@
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class ParsedMatch<T> internal class ParsedMatch<T>(StringSegment segment, T value)
{ {
public StringSegment Segment { get; } public StringSegment Segment { get; } = segment;
public T Value { get; } public T Value { get; } = value;
public ParsedMatch(StringSegment segment, T value)
{
Segment = segment;
Value = value;
}
} }
@@ -3,20 +3,11 @@ using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class RegexMatcher<T> : IMatcher<T> internal class RegexMatcher<T>(Regex regex, Func<StringSegment, Match, T?> transform) : IMatcher<T>
{ {
private readonly Regex _regex;
private readonly Func<StringSegment, Match, T?> _transform;
public RegexMatcher(Regex regex, Func<StringSegment, Match, T?> transform)
{
_regex = regex;
_transform = transform;
}
public ParsedMatch<T>? TryMatch(StringSegment segment) public ParsedMatch<T>? TryMatch(StringSegment segment)
{ {
var match = _regex.Match(segment.Source, segment.StartIndex, segment.Length); var match = regex.Match(segment.Source, segment.StartIndex, segment.Length);
if (!match.Success) if (!match.Success)
return null; return null;
@@ -25,11 +16,11 @@ internal class RegexMatcher<T> : IMatcher<T>
// Which is super weird because regex.Match(string, int) takes the whole input in context. // Which is super weird because regex.Match(string, int) takes the whole input in context.
// So in order to properly account for ^/$ regex tokens, we need to make sure that // So in order to properly account for ^/$ regex tokens, we need to make sure that
// the expression also matches on the bigger part of the input. // the expression also matches on the bigger part of the input.
if (!_regex.IsMatch(segment.Source[..segment.EndIndex], segment.StartIndex)) if (!regex.IsMatch(segment.Source[..segment.EndIndex], segment.StartIndex))
return null; return null;
var segmentMatch = segment.Relocate(match); var segmentMatch = segment.Relocate(match);
var value = _transform(segmentMatch, match); var value = transform(segmentMatch, match);
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null; return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
} }
@@ -2,37 +2,24 @@
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class StringMatcher<T> : IMatcher<T> internal class StringMatcher<T>(
string needle,
StringComparison comparison,
Func<StringSegment, T?> transform
) : IMatcher<T>
{ {
private readonly string _needle;
private readonly StringComparison _comparison;
private readonly Func<StringSegment, T?> _transform;
public StringMatcher(
string needle,
StringComparison comparison,
Func<StringSegment, T?> transform
)
{
_needle = needle;
_comparison = comparison;
_transform = transform;
}
public StringMatcher(string needle, Func<StringSegment, T> transform) public StringMatcher(string needle, Func<StringSegment, T> transform)
: this(needle, StringComparison.Ordinal, transform) { } : this(needle, StringComparison.Ordinal, transform) { }
public ParsedMatch<T>? TryMatch(StringSegment segment) public ParsedMatch<T>? TryMatch(StringSegment segment)
{ {
var index = segment var index = segment.Source.IndexOf(needle, segment.StartIndex, segment.Length, comparison);
.Source
.IndexOf(_needle, segment.StartIndex, segment.Length, _comparison);
if (index < 0) if (index < 0)
return null; return null;
var segmentMatch = segment.Relocate(index, _needle.Length); var segmentMatch = segment.Relocate(index, needle.Length);
var value = _transform(segmentMatch); var value = transform(segmentMatch);
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null; return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
} }
+9
View File
@@ -0,0 +1,9 @@
using System;
namespace DiscordChatExporter.Core.Utils;
public static class Docker
{
public static bool IsRunningInContainer { get; } =
Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true";
}
@@ -1,5 +1,4 @@
using System; using System;
using System.Collections.Generic;
using System.Text; using System.Text;
namespace DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Core.Utils.Extensions;
@@ -27,16 +26,6 @@ public static class StringExtensions
return builder.ToString(); return builder.ToString();
} }
public static IEnumerable<Rune> GetRunes(this string str)
{
var lastIndex = 0;
while (lastIndex < str.Length && Rune.TryGetRuneAt(str, lastIndex, out var rune))
{
yield return rune;
lastIndex += rune.Utf16SequenceLength;
}
}
public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true) 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; where T : struct, Enum => Enum.TryParse<T>(str, ignoreCase, out var result) ? result : null;
@@ -2,4 +2,4 @@
namespace DiscordChatExporter.Gui.Behaviors; namespace DiscordChatExporter.Gui.Behaviors;
public class ChannelMultiSelectionListBoxBehavior : MultiSelectionListBoxBehavior<Channel> { } public class ChannelMultiSelectionListBoxBehavior : MultiSelectionListBoxBehavior<Channel>;
@@ -10,7 +10,12 @@ public class ChannelToGroupKeyConverter : IValueConverter
{ {
public static ChannelToGroupKeyConverter Instance { get; } = new(); public static ChannelToGroupKeyConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) => public object? Convert(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
) =>
value switch value switch
{ {
Channel { IsThread: true, Parent: not null } channel Channel { IsThread: true, Parent: not null } channel
@@ -22,9 +27,9 @@ public class ChannelToGroupKeyConverter : IValueConverter
}; };
public object ConvertBack( public object ConvertBack(
object value, object? value,
Type targetType, Type targetType,
object parameter, object? parameter,
CultureInfo culture CultureInfo culture
) => throw new NotSupportedException(); ) => throw new NotSupportedException();
} }
@@ -9,15 +9,20 @@ public class DateTimeOffsetToDateTimeConverter : IValueConverter
{ {
public static DateTimeOffsetToDateTimeConverter Instance { get; } = new(); public static DateTimeOffsetToDateTimeConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) => public object? Convert(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
) =>
value is DateTimeOffset dateTimeOffsetValue value is DateTimeOffset dateTimeOffsetValue
? dateTimeOffsetValue.DateTime ? dateTimeOffsetValue.DateTime
: default(DateTime?); : default(DateTime?);
public object? ConvertBack( public object? ConvertBack(
object value, object? value,
Type targetType, Type targetType,
object parameter, object? parameter,
CultureInfo culture CultureInfo culture
) => ) =>
value is DateTime dateTimeValue value is DateTime dateTimeValue
@@ -10,13 +10,17 @@ public class ExportFormatToStringConverter : IValueConverter
{ {
public static ExportFormatToStringConverter Instance { get; } = new(); public static ExportFormatToStringConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) => public object? Convert(
value is ExportFormat exportFormatValue ? exportFormatValue.GetDisplayName() : default; object? value,
Type targetType,
object? parameter,
CultureInfo culture
) => value is ExportFormat exportFormatValue ? exportFormatValue.GetDisplayName() : default;
public object ConvertBack( public object ConvertBack(
object value, object? value,
Type targetType, Type targetType,
object parameter, object? parameter,
CultureInfo culture CultureInfo culture
) => throw new NotSupportedException(); ) => throw new NotSupportedException();
} }
@@ -9,13 +9,13 @@ public class InverseBoolConverter : IValueConverter
{ {
public static InverseBoolConverter Instance { get; } = new(); public static InverseBoolConverter Instance { get; } = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is false; value is false;
public object ConvertBack( public object ConvertBack(
object value, object? value,
Type targetType, Type targetType,
object parameter, object? parameter,
CultureInfo culture CultureInfo culture
) => value is false; ) => value is false;
} }
@@ -9,13 +9,15 @@ public class LocaleToDisplayNameConverter : IValueConverter
{ {
public static LocaleToDisplayNameConverter Instance { get; } = new(); public static LocaleToDisplayNameConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) => public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is string locale ? CultureInfo.GetCultureInfo(locale).DisplayName : null; value is string locale && !string.IsNullOrWhiteSpace(locale)
? CultureInfo.GetCultureInfo(locale).DisplayName
: "System default";
public object ConvertBack( public object ConvertBack(
object value, object? value,
Type targetType, Type targetType,
object parameter, object? parameter,
CultureInfo culture CultureInfo culture
) => throw new NotSupportedException(); ) => throw new NotSupportedException();
} }
@@ -10,13 +10,17 @@ public class SnowflakeToDateTimeOffsetConverter : IValueConverter
{ {
public static SnowflakeToDateTimeOffsetConverter Instance { get; } = new(); public static SnowflakeToDateTimeOffsetConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) => public object? Convert(
value is Snowflake snowflake ? snowflake.ToDate() : null; object? value,
Type targetType,
object? parameter,
CultureInfo culture
) => value is Snowflake snowflake ? snowflake.ToDate() : null;
public object ConvertBack( public object ConvertBack(
object value, object? value,
Type targetType, Type targetType,
object parameter, object? parameter,
CultureInfo culture CultureInfo culture
) => throw new NotSupportedException(); ) => throw new NotSupportedException();
} }
@@ -9,13 +9,17 @@ public class TimeSpanToDateTimeConverter : IValueConverter
{ {
public static TimeSpanToDateTimeConverter Instance { get; } = new(); public static TimeSpanToDateTimeConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) => public object? Convert(
value is TimeSpan timeSpanValue ? DateTime.Today.Add(timeSpanValue) : default(DateTime?); object? value,
Type targetType,
object? parameter,
CultureInfo culture
) => value is TimeSpan timeSpanValue ? DateTime.Today.Add(timeSpanValue) : default(DateTime?);
public object? ConvertBack( public object? ConvertBack(
object value, object? value,
Type targetType, Type targetType,
object parameter, object? parameter,
CultureInfo culture CultureInfo culture
) => value is DateTime dateTimeValue ? dateTimeValue.TimeOfDay : default(TimeSpan?); ) => value is DateTime dateTimeValue ? dateTimeValue.TimeOfDay : default(TimeSpan?);
} }
@@ -14,9 +14,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Cogwheel" Version="2.0.4" /> <PackageReference Include="Cogwheel" Version="2.0.4" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.2" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" /> <PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" /> <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.2" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="MaterialDesignColors" Version="2.1.4" /> <PackageReference Include="MaterialDesignColors" Version="2.1.4" />
<PackageReference Include="MaterialDesignThemes" Version="4.9.0" /> <PackageReference Include="MaterialDesignThemes" Version="4.9.0" />
@@ -1,5 +1,4 @@
using System; using System;
using System.Globalization;
using System.IO; using System.IO;
using Cogwheel; using Cogwheel;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
@@ -8,7 +7,8 @@ using Microsoft.Win32;
namespace DiscordChatExporter.Gui.Services; namespace DiscordChatExporter.Gui.Services;
public partial class SettingsService : SettingsBase public partial class SettingsService()
: SettingsBase(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.dat"))
{ {
public bool IsUkraineSupportMessageEnabled { get; set; } = true; public bool IsUkraineSupportMessageEnabled { get; set; } = true;
@@ -20,7 +20,7 @@ public partial class SettingsService : SettingsBase
public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None; public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None;
public string Locale { get; set; } = CultureInfo.CurrentCulture.Name; public string? Locale { get; set; }
public bool IsUtcNormalizationEnabled { get; set; } public bool IsUtcNormalizationEnabled { get; set; }
@@ -44,9 +44,6 @@ public partial class SettingsService : SettingsBase
public string? LastAssetsDirPath { get; set; } public string? LastAssetsDirPath { get; set; }
public SettingsService()
: base(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.dat")) { }
public override void Save() public override void Save()
{ {
// Clear the token if it's not supposed to be persisted // Clear the token if it's not supposed to be persisted
@@ -67,8 +64,7 @@ public partial class SettingsService
try try
{ {
return Registry return Registry
.CurrentUser .CurrentUser.OpenSubKey(
.OpenSubKey(
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
false false
) )
@@ -6,27 +6,20 @@ using Onova.Services;
namespace DiscordChatExporter.Gui.Services; namespace DiscordChatExporter.Gui.Services;
public class UpdateService : IDisposable public class UpdateService(SettingsService settingsService) : IDisposable
{ {
private readonly IUpdateManager _updateManager = new UpdateManager( private readonly IUpdateManager _updateManager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"), new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new ZipPackageExtractor() new ZipPackageExtractor()
); );
private readonly SettingsService _settingsService;
private Version? _updateVersion; private Version? _updateVersion;
private bool _updatePrepared; private bool _updatePrepared;
private bool _updaterLaunched; private bool _updaterLaunched;
public UpdateService(SettingsService settingsService)
{
_settingsService = settingsService;
}
public async ValueTask<Version?> CheckForUpdatesAsync() public async ValueTask<Version?> CheckForUpdatesAsync()
{ {
if (!_settingsService.IsAutoUpdateEnabled) if (!settingsService.IsAutoUpdateEnabled)
return null; return null;
var check = await _updateManager.CheckForUpdatesAsync(); var check = await _updateManager.CheckForUpdatesAsync();
@@ -35,7 +28,7 @@ public class UpdateService : IDisposable
public async ValueTask PrepareUpdateAsync(Version version) public async ValueTask PrepareUpdateAsync(Version version)
{ {
if (!_settingsService.IsAutoUpdateEnabled) if (!settingsService.IsAutoUpdateEnabled)
return; return;
try try
@@ -55,7 +48,7 @@ public class UpdateService : IDisposable
public void FinalizeUpdate(bool needRestart) public void FinalizeUpdate(bool needRestart)
{ {
if (!_settingsService.IsAutoUpdateEnabled) if (!settingsService.IsAutoUpdateEnabled)
return; return;
if (_updateVersion is null || !_updatePrepared || _updaterLaunched) if (_updateVersion is null || !_updatePrepared || _updaterLaunched)
@@ -235,8 +235,7 @@ public class DashboardViewModel : PropertyChangedBase
var exporter = new ChannelExporter(_discord); var exporter = new ChannelExporter(_discord);
var channelProgressPairs = dialog var channelProgressPairs = dialog
.Channels! .Channels!.Select(c => new { Channel = c, Progress = _progressMuxer.CreateInput() })
.Select(c => new { Channel = c, Progress = _progressMuxer.CreateInput() })
.ToArray(); .ToArray();
var successfulExportCount = 0; var successfulExportCount = 0;
@@ -1,33 +1,31 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using DiscordChatExporter.Core.Utils.Extensions;
using DiscordChatExporter.Gui.Models; using DiscordChatExporter.Gui.Models;
using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.ViewModels.Framework; using DiscordChatExporter.Gui.ViewModels.Framework;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs; namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
public class SettingsViewModel : DialogScreen public class SettingsViewModel(SettingsService settingsService) : DialogScreen
{ {
private readonly SettingsService _settingsService;
public bool IsAutoUpdateEnabled public bool IsAutoUpdateEnabled
{ {
get => _settingsService.IsAutoUpdateEnabled; get => settingsService.IsAutoUpdateEnabled;
set => _settingsService.IsAutoUpdateEnabled = value; set => settingsService.IsAutoUpdateEnabled = value;
} }
public bool IsDarkModeEnabled public bool IsDarkModeEnabled
{ {
get => _settingsService.IsDarkModeEnabled; get => settingsService.IsDarkModeEnabled;
set => _settingsService.IsDarkModeEnabled = value; set => settingsService.IsDarkModeEnabled = value;
} }
public bool IsTokenPersisted public bool IsTokenPersisted
{ {
get => _settingsService.IsTokenPersisted; get => settingsService.IsTokenPersisted;
set => _settingsService.IsTokenPersisted = value; set => settingsService.IsTokenPersisted = value;
} }
public IReadOnlyList<ThreadInclusionMode> AvailableThreadInclusions { get; } = public IReadOnlyList<ThreadInclusionMode> AvailableThreadInclusions { get; } =
@@ -35,14 +33,15 @@ public class SettingsViewModel : DialogScreen
public ThreadInclusionMode ThreadInclusionMode public ThreadInclusionMode ThreadInclusionMode
{ {
get => _settingsService.ThreadInclusionMode; get => settingsService.ThreadInclusionMode;
set => _settingsService.ThreadInclusionMode = value; set => settingsService.ThreadInclusionMode = value;
} }
// These items have to be non-nullable because WPF ComboBox doesn't allow a null value to be selected
public IReadOnlyList<string> AvailableLocales { get; } = new[] public IReadOnlyList<string> AvailableLocales { get; } = new[]
{ {
// Current locale // Current locale (maps to null downstream)
CultureInfo.CurrentCulture.Name, "",
// Locales supported by the Discord app // Locales supported by the Discord app
"da-DK", "da-DK",
"de-DE", "de-DE",
@@ -75,23 +74,23 @@ public class SettingsViewModel : DialogScreen
"ko-KR" "ko-KR"
}.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); }.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
// This has to be non-nullable because WPF ComboBox doesn't allow a null value to be selected
public string Locale public string Locale
{ {
get => _settingsService.Locale; get => settingsService.Locale ?? "";
set => _settingsService.Locale = value; // Important to reduce empty strings to nulls, because empty strings don't correspond to valid cultures
set => settingsService.Locale = value.NullIfWhiteSpace();
} }
public bool IsUtcNormalizationEnabled public bool IsUtcNormalizationEnabled
{ {
get => _settingsService.IsUtcNormalizationEnabled; get => settingsService.IsUtcNormalizationEnabled;
set => _settingsService.IsUtcNormalizationEnabled = value; set => settingsService.IsUtcNormalizationEnabled = value;
} }
public int ParallelLimit public int ParallelLimit
{ {
get => _settingsService.ParallelLimit; get => settingsService.ParallelLimit;
set => _settingsService.ParallelLimit = Math.Clamp(value, 1, 10); set => settingsService.ParallelLimit = Math.Clamp(value, 1, 10);
} }
public SettingsViewModel(SettingsService settingsService) => _settingsService = settingsService;
} }
@@ -8,19 +8,13 @@ using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Framework; namespace DiscordChatExporter.Gui.ViewModels.Framework;
public class DialogManager : IDisposable public class DialogManager(IViewManager viewManager) : IDisposable
{ {
private readonly IViewManager _viewManager;
private readonly SemaphoreSlim _dialogLock = new(1, 1); private readonly SemaphoreSlim _dialogLock = new(1, 1);
public DialogManager(IViewManager viewManager)
{
_viewManager = viewManager;
}
public async ValueTask<T?> ShowDialogAsync<T>(DialogScreen<T> dialogScreen) public async ValueTask<T?> ShowDialogAsync<T>(DialogScreen<T> dialogScreen)
{ {
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen); var view = viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
void OnDialogOpened(object? openSender, DialogOpenedEventArgs openArgs) void OnDialogOpened(object? openSender, DialogOpenedEventArgs openArgs)
{ {
@@ -16,4 +16,4 @@ public abstract class DialogScreen<T> : PropertyChangedBase
} }
} }
public abstract class DialogScreen : DialogScreen<bool?> { } public abstract class DialogScreen : DialogScreen<bool?>;
@@ -166,44 +166,55 @@
<Run <Run
FontSize="16" FontSize="16"
FontWeight="SemiBold" FontWeight="SemiBold"
Text="To get token for your personal account:" /> Text="To get the token for your personal account:" />
<LineBreak /> <LineBreak />
<Run Text="* Automating user accounts is technically against TOS —" /> <Run Text="* Automating user accounts is technically against TOS —" />
<Run FontWeight="SemiBold" Text="use at your own risk" /><Run Text="!" /> <Run FontWeight="SemiBold" Text="use at your own risk" /><Run Text="!" />
<LineBreak /> <LineBreak />
<Run Text="1. Open Discord in your" /> <Run Text="1. Open Discord in your" />
<Hyperlink Command="{s:Action OpenDiscord}"> <Hyperlink Command="{s:Action OpenDiscord}">
<Run Text="web browser" /> <Run Text="web browser" />
</Hyperlink> </Hyperlink>
<Run Text="and login" /> <Run Text="and login" />
<LineBreak /> <LineBreak />
<Run Text="2. Open any server or direct message channel" /> <Run Text="2. Open any server or direct message channel" />
<LineBreak /> <LineBreak />
<Run Text="3. Press" /> <Run Text="3. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" /> <Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" /> <Run Text="to show developer tools" />
<LineBreak /> <LineBreak />
<Run Text="4. Navigate to the" /> <Run Text="4. Navigate to the" />
<Run FontWeight="SemiBold" Text="Network" /> <Run FontWeight="SemiBold" Text="Network" />
<Run Text="tab" /> <Run Text="tab" />
<LineBreak /> <LineBreak />
<Run Text="5. Press" /> <Run Text="5. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+R" /> <Run FontWeight="SemiBold" Text="Ctrl+R" />
<Run Text="to reload" /> <Run Text="to reload" />
<LineBreak /> <LineBreak />
<Run Text="6. Switch between random channels to trigger network requests" /> <Run Text="6. Switch between random channels to trigger network requests" />
<LineBreak /> <LineBreak />
<Run Text="7. Search for a request that starts with" /> <Run Text="7. Search for a request that starts with" />
<Run FontWeight="SemiBold" Text="messages" /> <Run FontWeight="SemiBold" Text="messages" />
<LineBreak /> <LineBreak />
<Run Text="8. Select the" /> <Run Text="8. Select the" />
<Run FontWeight="SemiBold" Text="Headers" /> <Run FontWeight="SemiBold" Text="Headers" />
<Run Text="tab on the right" /> <Run Text="tab on the right" />
<LineBreak /> <LineBreak />
<Run Text="9. Scroll down to the" /> <Run Text="9. Scroll down to the" />
<Run FontWeight="SemiBold" Text="Request Headers" /> <Run FontWeight="SemiBold" Text="Request Headers" />
<Run Text="section" /> <Run Text="section" />
<LineBreak /> <LineBreak />
<Run Text="10. Copy the value of the" /> <Run Text="10. Copy the value of the" />
<Run FontWeight="SemiBold" Text="authorization" /> <Run FontWeight="SemiBold" Text="authorization" />
<Run Text="header" /> <Run Text="header" />
@@ -220,25 +231,30 @@
<Run <Run
FontSize="16" FontSize="16"
FontWeight="SemiBold" FontWeight="SemiBold"
Text="To get token for a bot account:" /> Text="To get the token for your bot:" />
<LineBreak /> <LineBreak />
<Run Text="1. Open Discord" /> <Run Text="1. Open Discord" />
<Hyperlink Command="{s:Action OpenDiscordDeveloperPortal}"> <Hyperlink Command="{s:Action OpenDiscordDeveloperPortal}">
<Run Text="developer portal" /> <Run Text="developer portal" />
</Hyperlink> </Hyperlink>
<LineBreak /> <LineBreak />
<Run Text="2. Open your application's settings" /> <Run Text="2. Open your application's settings" />
<LineBreak /> <LineBreak />
<Run Text="3. Navigate to the" /> <Run Text="3. Navigate to the" />
<Run FontWeight="SemiBold" Text="Bot" /> <Run FontWeight="SemiBold" Text="Bot" />
<Run Text="section on the left" /> <Run Text="section on the left" />
<LineBreak /> <LineBreak />
<Run Text="4. Under" /> <Run Text="4. Under" />
<Run FontWeight="SemiBold" Text="Token" /> <Run FontWeight="SemiBold" Text="Token" />
<Run Text="click" /> <Run Text="click" />
<Run FontWeight="SemiBold" Text="Copy" /> <Run FontWeight="SemiBold" Text="Copy" />
<LineBreak /> <LineBreak />
<Run Text="* Your bot needs to have" />
<Run Text="* Your bot needs to have the" />
<Run FontWeight="SemiBold" Text="Message Content Intent" /> <Run FontWeight="SemiBold" Text="Message Content Intent" />
<Run Text="enabled to read messages" /> <Run Text="enabled to read messages" />
<LineBreak /> <LineBreak />
@@ -89,17 +89,18 @@
<TextBox.ToolTip> <TextBox.ToolTip>
<TextBlock> <TextBlock>
<Run Text="Output file or directory path." /> <Run Text="Output file or directory path." />
<Run Text="If a directory is specified, file names will be generated automatically based on the channel names and other parameters." /> <Run Text="If a directory is specified, file names will be generated automatically based on the channel names and export parameters." />
<Run Text="Directory path should end with a slash to avoid ambiguity." /> <Run Text="Directory paths must end with a slash to avoid ambiguity." />
<Run Text="Supports template tokens, see the documentation for more info." />
<LineBreak /> <LineBreak />
<LineBreak /> <LineBreak />
<Run Text="Available template tokens:" /> <Run Text="Available template tokens:" />
<LineBreak /> <LineBreak />
<Run FontWeight="SemiBold" Text="%g" /> <Run FontWeight="SemiBold" Text="%g" />
<Run Text="— guild ID" /> <Run Text="— server ID" />
<LineBreak /> <LineBreak />
<Run FontWeight="SemiBold" Text="%G" /> <Run FontWeight="SemiBold" Text="%G" />
<Run Text="— guild name" /> <Run Text="— server name" />
<LineBreak /> <LineBreak />
<Run FontWeight="SemiBold" Text="%t" /> <Run FontWeight="SemiBold" Text="%t" />
<Run Text="— category ID" /> <Run Text="— category ID" />
@@ -232,7 +233,7 @@
materialDesign:HintAssist.IsFloating="True" materialDesign:HintAssist.IsFloating="True"
Style="{DynamicResource MaterialDesignOutlinedTextBox}" Style="{DynamicResource MaterialDesignOutlinedTextBox}"
Text="{Binding MessageFilterValue}" Text="{Binding MessageFilterValue}"
ToolTip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')" /> ToolTip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image'). See the documentation for more info." />
<!-- Markdown formatting --> <!-- Markdown formatting -->
<Grid Margin="16,8" ToolTip="Process markdown, mentions, and other special tokens"> <Grid Margin="16,8" ToolTip="Process markdown, mentions, and other special tokens">
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2017-2023 Oleksii Holub Copyright (c) 2017-2024 Oleksii Holub
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+4 -3
View File
@@ -90,12 +90,13 @@ The following table lists all available download options:
</table> </table>
> **Important**: > **Important**:
> To run **DiscordChatExporter** on macOS and Linux, you need to make sure that **.NET 8.0 Runtime** is installed. > To run **DiscordChatExporter**, you need to make sure that the **.NET 8.0 Runtime** is installed.
> You can download it here: > You can download it here:
> >
> - [.NET 8.0 Runtime for **macOS x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-macos-x64-installer) > - [.NET 8.0 Runtime for **macOS x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-macos-x64-installer)
> - [.NET 8.0 Runtime for **macOS arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-macos-arm64-installer) > - [.NET 8.0 Runtime for **macOS arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-macos-arm64-installer)
> - [.NET 8.0 Runtime for **Linux**](https://learn.microsoft.com/dotnet/core/install/linux) (find the correct download for your distro) > - [.NET 8.0 Runtime for **Linux**](https://learn.microsoft.com/dotnet/core/install/linux) (find the correct download for your distro)
> - On Windows, the runtime should be installed automatically when you run the application for the first time
> >
> This should not be necessary if you install **DiscordChatExporter** using a package manager, as it should take care of the dependencies for you. > This should not be necessary if you install **DiscordChatExporter** using a package manager, as it should take care of the dependencies for you.
> This is also not necessary if you are running **DiscordChatExporter** via Docker, because the image already contains the runtime. > This is also not necessary if you are running **DiscordChatExporter** via Docker, because the image already contains the runtime.
@@ -108,11 +109,11 @@ The following table lists all available download options:
- Graphical user interface (Windows) - Graphical user interface (Windows)
- Command-line interface (Windows, Linux, macOS) - Command-line interface (Windows, Linux, macOS)
- Authentication via both user and bot tokens - Authentication via either a user or a bot token
- Multiple output formats: HTML (dark/light), TXT, CSV, JSON - Multiple output formats: HTML (dark/light), TXT, CSV, JSON
- Support for markdown, attachments, embeds, emoji, and other rich media features - Support for markdown, attachments, embeds, emoji, and other rich media features
- File partitioning, date ranges, message filtering, and other export options - File partitioning, date ranges, message filtering, and other export options
- Self-contained exports that don't require internet - Self-contained exports that can be viewed offline
## Screenshots ## Screenshots