mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-11 00:11:55 +02:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b8985a920 | |||
| 014712b747 | |||
| d8e43d89be | |||
| 4e69ff317b | |||
| 057beaacd6 | |||
| 982ba6a76c | |||
| dccd9a2f08 | |||
| 182f24846b | |||
| 777f92a3f1 | |||
| d4be307fb1 | |||
| 2bbb585931 | |||
| 73080ecfa6 | |||
| 0745d49f44 | |||
| c16e14e9b6 | |||
| 8e36002ae7 | |||
| a2c5d2e2dd | |||
| 4e3deb409c | |||
| 512f181be2 | |||
| e04eb890e6 | |||
| 1ad42ee48b | |||
| b8a67617bc | |||
| 062427d143 | |||
| 5f343a46d1 | |||
| b3a1cbf635 | |||
| 619fe9ccf7 | |||
| 174b92cbb0 |
+1
-1
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user