mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-08 07:04:40 +02:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7f6ac9494 | |||
| 481958f4d3 | |||
| 5e3502cbc5 | |||
| 3b03c30865 | |||
| c9e16a7f74 | |||
| f9b6793d47 | |||
| cfbd267d3a | |||
| 2b6f01bf6f | |||
| a44d43eefc | |||
| 0e138bac7d | |||
| 71876febf8 | |||
| 489c786c1e | |||
| eb0c61e173 | |||
| 9341c21963 | |||
| 704c483074 | |||
| 1f0e667085 | |||
| 0b72b73d1d | |||
| 32dd1689d0 | |||
| 2e4d19671a | |||
| 5b4d88ca26 | |||
| 65cc3954b6 | |||
| ffe5544f9c | |||
| bf1ad07a9c | |||
| 338566c1d6 | |||
| 91ea7a443c | |||
| aea96d5eba | |||
| e29f08264c | |||
| ad84ecf6a4 | |||
| ae57a0ff7c | |||
| 1daff4178d | |||
| 3a2b119618 | |||
| 5f27426f44 | |||
| a893ebda68 | |||
| 9221966171 | |||
| 67325b610a | |||
| 3186ce90c8 | |||
| 168d7c8ec9 | |||
| daa1b3f6b3 | |||
| e2780d6666 | |||
| b996215e36 | |||
| 08d9af534b | |||
| bef110a193 | |||
| 12555e576f | |||
| afb4bc67ae | |||
| f80a5fe644 | |||
| b7dca87fb9 | |||
| 8ecc2940b0 | |||
| 407c2bd2ae | |||
| 5b563d4f00 | |||
| ecb615ecf1 | |||
| 7c212ef893 | |||
| 36b4a45f3a | |||
| 51cc132e5d | |||
| cf83cbd89c | |||
| d51d0d4872 | |||
| 2467caac9f | |||
| bd98f4cb6a | |||
| fc7191d74c | |||
| 6c484d1f88 | |||
| c36b8df8e7 | |||
| 0408096746 | |||
| 40d833cd57 | |||
| 0de1f47310 | |||
| e31ff55e33 | |||
| 81525a1076 | |||
| 65ab003ff5 | |||
| 2156c6cd0c | |||
| e97151cd19 | |||
| 271a478a0e | |||
| b40a88c4d3 |
@@ -20,7 +20,8 @@ jobs:
|
|||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@v1.7.2
|
uses: actions/setup-dotnet@v1.7.2
|
||||||
with:
|
with:
|
||||||
dotnet-version: 6.0.x
|
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176
|
||||||
|
dotnet-version: 6.0.100
|
||||||
|
|
||||||
- name: Build & publish (CLI)
|
- name: Build & publish (CLI)
|
||||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ jobs:
|
|||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@v1.7.2
|
uses: actions/setup-dotnet@v1.7.2
|
||||||
with:
|
with:
|
||||||
dotnet-version: 6.0.x
|
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176
|
||||||
|
dotnet-version: 6.0.100
|
||||||
|
|
||||||
- name: Build & test
|
- name: Build & test
|
||||||
# Tests need access to secrets, so we can't run them against PRs because of limited trust
|
# Tests need access to secrets, so we can't run them against PRs because of limited trust
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
if: ${{ github.event_name != 'pull_request' }}
|
||||||
run: dotnet test --configuration Release --logger GitHubActions
|
run: dotnet test --configuration Release --logger GitHubActions --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||||
env:
|
env:
|
||||||
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
|
DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }}
|
||||||
DISCORD_TOKEN_BOT: true
|
DISCORD_TOKEN_BOT: true
|
||||||
@@ -44,4 +45,4 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v1
|
uses: actions/upload-artifact@v1
|
||||||
with:
|
with:
|
||||||
name: DiscordChatExporter
|
name: DiscordChatExporter
|
||||||
path: DiscordChatExporter.Gui/bin/Publish/
|
path: DiscordChatExporter.Gui/bin/Publish/
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 45 KiB |
+39
-1
@@ -1,6 +1,44 @@
|
|||||||
|
### v2.34 (10-Apr-2022)
|
||||||
|
|
||||||
|
- [HTML] Reworked layout to be more consistent with Discord.
|
||||||
|
- [HTML] Changed user profile picture dimensions from `128x128` to `512x512`. (Thanks [@Chris Helder](https://github.com/TheDude53))
|
||||||
|
- [HTML] Improved image scaling for Chromium-based browsers. This should result in higher quality images across the entire export.
|
||||||
|
- [HTML] Hovering over messages will now show short timestamps on the left, indicating the time that the individual messages of the group were sent.
|
||||||
|
- [HTML] Using "download media" option will now also download scripts, styles, fonts, and other similar resources used by the export.
|
||||||
|
- [HTML] Image attachments will now use the provided description as alt text.
|
||||||
|
- [HTML] Fixed a few layout inconsistencies related to embeds.
|
||||||
|
- [GUI] Added a few clickable links to the usage guide to make the process of locating tokens easier.
|
||||||
|
|
||||||
|
### v2.33.2 (07-Mar-2022)
|
||||||
|
|
||||||
|
- Actually fixed it this time.
|
||||||
|
|
||||||
|
### v2.33.1 (07-Mar-2022)
|
||||||
|
|
||||||
|
- Fixed an issue where the application silently failed to run if the system didn't have .NET Runtime 6.0.2 installed. If you continue seeing this issue, please uninstall all existing .NET runtimes from your computer and then try running the application again.
|
||||||
|
|
||||||
|
### v2.33 (06-Mar-2022)
|
||||||
|
|
||||||
|
- Added messages informing about war in Ukraine and available ways to help.
|
||||||
|
- Added support for rendering stickers in HTML and JSON. Lottie-based stickers currently cannot be displayed in HTML exports (see [#803](https://github.com/Tyrrrz/DiscordChatExporter/issues/803)).
|
||||||
|
- Added a new `reaction:` message filter which can be used to check if someone reacted to a message with a specific emoji. You can either pass the emoji name (e.g. `reaction:LUL`) or its ID (e.g. `reaction:41771983429993937`).
|
||||||
|
- [GUI] Added auto-detection for dark mode. If your system is configured to prefer dark mode in applications, DiscordChatExporter will use it by default instead of light mode.
|
||||||
|
- Fixed an issue which caused the export process to crash when downloading media files with extremely long file extensions. (Thanks [@Tomlacko](https://github.com/Tomlacko))
|
||||||
|
- Fixed an issue which caused the export process to crash on invalid mentions.
|
||||||
|
- [GUI] Fixed an issue where the time pickers used to specify export ranges always displayed time in 12-hour format, instead of respecting the system locale.
|
||||||
|
|
||||||
|
### v2.32 (27-Jan-2022)
|
||||||
|
|
||||||
|
- Token kind (user or bot) is now detected automatically. Removed the button to switch token kind in GUI. Option `-b|--bot` in CLI is now deprecated and does nothing.
|
||||||
|
- Updated user token extraction guide to reflect the fact that devtools are no longer accessible in the desktop version of Discord client. The recommended workaround is to open Discord in browser. (Thanks [@Dhananjay-JSR](https://github.com/Dhananjay-JSR))
|
||||||
|
- Fixed an issue which caused an error when parsing invalid date timestamps. Such timestamps are now rendered as "Invalid date", similarly to how the Discord client does it.
|
||||||
|
- Fixed an issue which caused an error when parsing certain embed fields.
|
||||||
|
- Fixed an issue which caused an error when parsing a mention to a user that has no username.
|
||||||
|
- [GUI] Minor visual fixes.
|
||||||
|
|
||||||
### v2.31.1 (15-Dec-2021)
|
### v2.31.1 (15-Dec-2021)
|
||||||
|
|
||||||
- Fixed an issue which caused an exception when parsing certain custom emoji reactions.
|
- Fixed an issue which caused an error when parsing certain custom emoji reactions.
|
||||||
- [GUI] Improved user interface.
|
- [GUI] Improved user interface.
|
||||||
|
|
||||||
### v2.31 (06-Dec-2021)
|
### v2.31 (06-Dec-2021)
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
<TargetFramework>net6.0</TargetFramework>
|
||||||
<Version>2.31.1</Version>
|
<Version>2.34</Version>
|
||||||
<Company>Tyrrrz</Company>
|
<Company>Tyrrrz</Company>
|
||||||
<Copyright>Copyright (c) Alexey Golub</Copyright>
|
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<WarningsAsErrors>nullable</WarningsAsErrors>
|
<WarningsAsErrors>nullable</WarningsAsErrors>
|
||||||
|
|||||||
@@ -1,28 +1,26 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
<CollectCoverage>true</CollectCoverage>
|
|
||||||
<CoverletOutputFormat>opencover</CoverletOutputFormat>
|
|
||||||
<NoWarn>$(NoWarn);xUnit1013</NoWarn>
|
<NoWarn>$(NoWarn);xUnit1013</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
<None Include="*.secret" CopyToOutputDirectory="PreserveNewest" />
|
<None Include="*.secret" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="0.16.1" />
|
<PackageReference Include="AngleSharp" Version="0.16.1" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.2.0" />
|
<PackageReference Include="FluentAssertions" Version="6.6.0" />
|
||||||
<PackageReference Include="GitHubActionsTestLogger" Version="1.2.0" />
|
<PackageReference Include="GitHubActionsTestLogger" Version="1.3.0" PrivateAssets="all" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
|
||||||
<PackageReference Include="coverlet.msbuild" Version="3.1.0" PrivateAssets="all" />
|
|
||||||
<PackageReference Include="System.Reactive" Version="5.0.0" />
|
<PackageReference Include="System.Reactive" Version="5.0.0" />
|
||||||
<PackageReference Include="xunit" Version="2.4.1" />
|
<PackageReference Include="xunit" Version="2.4.1" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" PrivateAssets="all" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3" PrivateAssets="all" />
|
||||||
|
<PackageReference Include="coverlet.collector" Version="3.1.2" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -36,8 +36,7 @@ public class ExportWrapperFixture : IDisposable
|
|||||||
{
|
{
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
TokenValue = Secrets.DiscordToken,
|
Token = Secrets.DiscordToken,
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { channelId },
|
ChannelIds = new[] { channelId },
|
||||||
ExportFormat = format,
|
ExportFormat = format,
|
||||||
OutputPath = filePath
|
OutputPath = filePath
|
||||||
|
|||||||
@@ -22,24 +22,5 @@ internal static class Secrets
|
|||||||
throw new InvalidOperationException("Discord token not provided for tests.");
|
throw new InvalidOperationException("Discord token not provided for tests.");
|
||||||
});
|
});
|
||||||
|
|
||||||
private static readonly Lazy<bool> IsDiscordTokenBotLazy = new(() =>
|
|
||||||
{
|
|
||||||
var fromEnvironment = Environment.GetEnvironmentVariable("DISCORD_TOKEN_BOT");
|
|
||||||
if (!string.IsNullOrWhiteSpace(fromEnvironment))
|
|
||||||
return string.Equals(fromEnvironment, "true", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
var secretFilePath = Path.Combine(
|
|
||||||
Path.GetDirectoryName(typeof(Secrets).Assembly.Location) ?? Directory.GetCurrentDirectory(),
|
|
||||||
"DiscordTokenBot.secret"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (File.Exists(secretFilePath))
|
|
||||||
return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
public static string DiscordToken => DiscordTokenLazy.Value;
|
public static string DiscordToken => DiscordTokenLazy.Value;
|
||||||
|
|
||||||
public static bool IsDiscordTokenBot => IsDiscordTokenBotLazy.Value;
|
|
||||||
}
|
}
|
||||||
@@ -27,8 +27,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
|
|||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
TokenValue = Secrets.DiscordToken,
|
Token = Secrets.DiscordToken,
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
@@ -74,8 +73,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
|
|||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
TokenValue = Secrets.DiscordToken,
|
Token = Secrets.DiscordToken,
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
@@ -120,8 +118,7 @@ public record DateRangeSpecs(TempOutputFixture TempOutput) : IClassFixture<TempO
|
|||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
TokenValue = Secrets.DiscordToken,
|
Token = Secrets.DiscordToken,
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
TokenValue = Secrets.DiscordToken,
|
Token = Secrets.DiscordToken,
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
@@ -54,8 +53,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
TokenValue = Secrets.DiscordToken,
|
Token = Secrets.DiscordToken,
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
@@ -83,8 +81,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
TokenValue = Secrets.DiscordToken,
|
Token = Secrets.DiscordToken,
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
@@ -112,8 +109,7 @@ public record FilterSpecs(TempOutputFixture TempOutput) : IClassFixture<TempOutp
|
|||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
TokenValue = Secrets.DiscordToken,
|
Token = Secrets.DiscordToken,
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||||
ExportFormat = ExportFormat.Json,
|
ExportFormat = ExportFormat.Json,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public record AttachmentSpecs(ExportWrapperFixture ExportWrapper) : IClassFixtur
|
|||||||
Snowflake.Parse("885654862656843786")
|
Snowflake.Parse("885654862656843786")
|
||||||
);
|
);
|
||||||
|
|
||||||
var imageUrl = message.QuerySelector("img")?.GetAttribute("src");
|
var imageUrl = message.QuerySelector(".chatlog__attachment img")?.GetAttribute("src");
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Image attachment");
|
message.Text().Should().Contain("Image attachment");
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("User mention: @Tyrrrz");
|
message.Text().Trim().Should().Contain("User mention: @Tyrrrz");
|
||||||
message.InnerHtml.Should().Contain("Tyrrrz#5447");
|
message.InnerHtml.Should().Contain("Tyrrrz#5447");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("Text channel mention: #mention-tests");
|
message.Text().Trim().Should().Contain("Text channel mention: #mention-tests");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -47,7 +47,7 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("Voice channel mention: 🔊chaos-vc");
|
message.Text().Trim().Should().Contain("Voice channel mention: 🔊chaos-vc");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -60,6 +60,6 @@ public record MentionSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<E
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("Role mention: @Role 1");
|
message.Text().Trim().Should().Contain("Role mention: @Role 1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,8 +20,8 @@ public record ReplySpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<Exp
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("reply to original");
|
message.Text().Trim().Should().Contain("reply to original");
|
||||||
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should().Be("original");
|
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should().Contain("original");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -34,13 +34,13 @@ public record ReplySpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<Exp
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("reply to deleted");
|
message.Text().Trim().Should().Contain("reply to deleted");
|
||||||
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
||||||
.Be("Original message was deleted or could not be loaded.");
|
.Contain("Original message was deleted or could not be loaded.");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reply_to_a_empty_message_with_attachment_is_rendered_correctly()
|
public async Task Reply_to_an_empty_message_with_attachment_is_rendered_correctly()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
@@ -49,8 +49,8 @@ public record ReplySpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<Exp
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Trim().Should().Be("reply to attachment");
|
message.Text().Trim().Should().Contain("reply to attachment");
|
||||||
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
message.QuerySelector(".chatlog__reference-link")?.Text().Trim().Should()
|
||||||
.Be("Click to see attachment 🖼️");
|
.Contain("Click to see attachment");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Fixtures;
|
||||||
|
using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs.HtmlWriting;
|
||||||
|
|
||||||
|
public record StickerSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.StickerTestCases,
|
||||||
|
Snowflake.Parse("939670623158943754")
|
||||||
|
);
|
||||||
|
|
||||||
|
var container = message.QuerySelector("[title='rock']");
|
||||||
|
var sourceUrl = container?.QuerySelector("img")?.GetAttribute("src");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
container.Should().NotBeNull();
|
||||||
|
sourceUrl.Should().Be("https://discord.com/stickers/904215665597120572.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.StickerTestCases,
|
||||||
|
Snowflake.Parse("939670526517997590")
|
||||||
|
);
|
||||||
|
|
||||||
|
var container = message.QuerySelector("[title='Yikes']");
|
||||||
|
var sourceUrl = container?.QuerySelector("div[data-source]")?.GetAttribute("data-source");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
container.Should().NotBeNull();
|
||||||
|
sourceUrl.Should().Be("https://discord.com/stickers/816087132447178774.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Fixtures;
|
||||||
|
using DiscordChatExporter.Cli.Tests.TestData;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs.JsonWriting;
|
||||||
|
|
||||||
|
public record StickerSpecs(ExportWrapperFixture ExportWrapper) : IClassFixture<ExportWrapperFixture>
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_with_a_PNG_based_sticker_is_rendered_correctly()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
|
ChannelIds.StickerTestCases,
|
||||||
|
Snowflake.Parse("939670623158943754")
|
||||||
|
);
|
||||||
|
|
||||||
|
var sticker = message
|
||||||
|
.GetProperty("stickers")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Single();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
sticker.GetProperty("id").GetString().Should().Be("904215665597120572");
|
||||||
|
sticker.GetProperty("name").GetString().Should().Be("rock");
|
||||||
|
sticker.GetProperty("format").GetString().Should().Be("PngAnimated");
|
||||||
|
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://discord.com/stickers/904215665597120572.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Message_with_a_Lottie_based_sticker_is_rendered_correctly()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
|
ChannelIds.StickerTestCases,
|
||||||
|
Snowflake.Parse("939670526517997590")
|
||||||
|
);
|
||||||
|
|
||||||
|
var sticker = message
|
||||||
|
.GetProperty("stickers")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Single();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
sticker.GetProperty("id").GetString().Should().Be("816087132447178774");
|
||||||
|
sticker.GetProperty("name").GetString().Should().Be("Yikes");
|
||||||
|
sticker.GetProperty("format").GetString().Should().Be("Lottie");
|
||||||
|
sticker.GetProperty("sourceUrl").GetString().Should().Be("https://discord.com/stickers/816087132447178774.json");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,8 +25,7 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
|
|||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
TokenValue = Secrets.DiscordToken,
|
Token = Secrets.DiscordToken,
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||||
ExportFormat = ExportFormat.HtmlDark,
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
@@ -50,8 +49,7 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
|
|||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
TokenValue = Secrets.DiscordToken,
|
Token = Secrets.DiscordToken,
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||||
ExportFormat = ExportFormat.HtmlDark,
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
@@ -61,6 +59,6 @@ public record PartitioningSpecs(TempOutputFixture TempOutput) : IClassFixture<Te
|
|||||||
// Assert
|
// Assert
|
||||||
Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*")
|
Directory.EnumerateFiles(dirPath, fileNameWithoutExt + "*")
|
||||||
.Should()
|
.Should()
|
||||||
.HaveCount(2);
|
.HaveCount(8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,8 +25,7 @@ public record SelfContainedSpecs(TempOutputFixture TempOutput) : IClassFixture<T
|
|||||||
// Act
|
// Act
|
||||||
await new ExportChannelsCommand
|
await new ExportChannelsCommand
|
||||||
{
|
{
|
||||||
TokenValue = Secrets.DiscordToken,
|
Token = Secrets.DiscordToken,
|
||||||
IsBotToken = Secrets.IsDiscordTokenBot,
|
|
||||||
ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
|
ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
|
||||||
ExportFormat = ExportFormat.HtmlDark,
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
OutputPath = filePath,
|
OutputPath = filePath,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public static class ChannelIds
|
|||||||
|
|
||||||
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
|
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
|
||||||
|
|
||||||
|
public static Snowflake StickerTestCases { get; } = Snowflake.Parse("939668868253769729");
|
||||||
|
|
||||||
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
||||||
|
|
||||||
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using DiscordChatExporter.Core.Exceptions;
|
|||||||
using DiscordChatExporter.Core.Exporting;
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
|
using Gress;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands.Base;
|
namespace DiscordChatExporter.Cli.Commands.Base;
|
||||||
|
|
||||||
@@ -78,8 +79,7 @@ public abstract class ExportCommandBase : TokenCommandBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await progressContext.StartTaskAsync(
|
await progressContext.StartTaskAsync($"{channel.Category.Name} / {channel.Name}",
|
||||||
$"{channel.Category.Name} / {channel.Name}",
|
|
||||||
async progress =>
|
async progress =>
|
||||||
{
|
{
|
||||||
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
|
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
|
||||||
@@ -98,7 +98,11 @@ public abstract class ExportCommandBase : TokenCommandBase
|
|||||||
DateFormat
|
DateFormat
|
||||||
);
|
);
|
||||||
|
|
||||||
await Exporter.ExportChannelAsync(request, progress, innerCancellationToken);
|
await Exporter.ExportChannelAsync(
|
||||||
|
request,
|
||||||
|
progress.ToPercentageBased(),
|
||||||
|
innerCancellationToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -162,4 +166,16 @@ public abstract class ExportCommandBase : TokenCommandBase
|
|||||||
|
|
||||||
await ExecuteAsync(console, channels);
|
await ExecuteAsync(console, channels);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
// War in Ukraine message
|
||||||
|
console.Output.WriteLine("========================================================================");
|
||||||
|
console.Output.WriteLine("|| Ukraine is at war! Support my country in its fight for freedom ||");
|
||||||
|
console.Output.WriteLine("|| Learn more: https://tyrrrz.me ||");
|
||||||
|
console.Output.WriteLine("========================================================================");
|
||||||
|
console.Output.WriteLine("");
|
||||||
|
|
||||||
|
return default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Threading.Tasks;
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
@@ -9,21 +10,14 @@ namespace DiscordChatExporter.Cli.Commands.Base;
|
|||||||
public abstract class TokenCommandBase : ICommand
|
public abstract class TokenCommandBase : ICommand
|
||||||
{
|
{
|
||||||
[CommandOption("token", 't', IsRequired = true, EnvironmentVariable = "DISCORD_TOKEN", Description = "Authentication token.")]
|
[CommandOption("token", 't', IsRequired = true, EnvironmentVariable = "DISCORD_TOKEN", Description = "Authentication token.")]
|
||||||
public string TokenValue { get; init; } = "";
|
public string Token { get; init; } = "";
|
||||||
|
|
||||||
[CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "Authenticate as a bot.")]
|
[CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "This option doesn't do anything. Kept for backwards compatibility.")]
|
||||||
|
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
||||||
public bool IsBotToken { get; init; }
|
public bool IsBotToken { get; init; }
|
||||||
|
|
||||||
private AuthToken? _authToken;
|
|
||||||
private AuthToken AuthToken => _authToken ??= new AuthToken(
|
|
||||||
IsBotToken
|
|
||||||
? AuthTokenKind.Bot
|
|
||||||
: AuthTokenKind.User,
|
|
||||||
TokenValue
|
|
||||||
);
|
|
||||||
|
|
||||||
private DiscordClient? _discordClient;
|
private DiscordClient? _discordClient;
|
||||||
protected DiscordClient Discord => _discordClient ??= new DiscordClient(AuthToken);
|
protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token);
|
||||||
|
|
||||||
public abstract ValueTask ExecuteAsync(IConsole console);
|
public abstract ValueTask ExecuteAsync(IConsole console);
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,8 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
|
await base.ExecuteAsync(console);
|
||||||
|
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
var channels = new List<Channel>();
|
var channels = new List<Channel>();
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ public class ExportChannelsCommand : ExportCommandBase
|
|||||||
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID(s).")]
|
[CommandOption("channel", 'c', IsRequired = true, Description = "Channel ID(s).")]
|
||||||
public IReadOnlyList<Snowflake> ChannelIds { get; init; } = Array.Empty<Snowflake>();
|
public IReadOnlyList<Snowflake> ChannelIds { get; init; } = Array.Empty<Snowflake>();
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console) =>
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
|
{
|
||||||
|
await base.ExecuteAsync(console);
|
||||||
await base.ExecuteAsync(console, ChannelIds);
|
await base.ExecuteAsync(console, ChannelIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,8 @@ public class ExportDirectMessagesCommand : ExportCommandBase
|
|||||||
{
|
{
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
|
await base.ExecuteAsync(console);
|
||||||
|
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
await console.Output.WriteLineAsync("Fetching channels...");
|
await console.Output.WriteLineAsync("Fetching channels...");
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public class ExportGuildCommand : ExportCommandBase
|
|||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
|
await base.ExecuteAsync(console);
|
||||||
|
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
await console.Output.WriteLineAsync("Fetching channels...");
|
await console.Output.WriteLineAsync("Fetching channels...");
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public class GuideCommand : ICommand
|
|||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
console.Output.WriteLine("To get user token:");
|
console.Output.WriteLine("To get user token:");
|
||||||
|
|
||||||
console.Output.WriteLine(" 1. Open Discord");
|
console.Output.WriteLine(" 1. Open Discord in your web browser and login");
|
||||||
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
|
||||||
console.Output.WriteLine(" 3. Press Ctrl+Shift+M to toggle device toolbar");
|
console.Output.WriteLine(" 3. Press Ctrl+Shift+M to toggle device toolbar");
|
||||||
console.Output.WriteLine(" 4. Navigate to the Application tab");
|
console.Output.WriteLine(" 4. Navigate to the Application tab");
|
||||||
@@ -61,7 +61,7 @@ public class GuideCommand : ICommand
|
|||||||
|
|
||||||
// Wiki link
|
// Wiki link
|
||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
console.Output.WriteLine("For more information, check out the wiki:");
|
console.Output.WriteLine("If you have questions or issues, please refer to the wiki:");
|
||||||
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
|
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
|
||||||
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
|
console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliFx" Version="2.0.6" />
|
<PackageReference Include="CliFx" Version="2.2.2" />
|
||||||
<PackageReference Include="Spectre.Console" Version="0.43.0" />
|
<PackageReference Include="Spectre.Console" Version="0.44.0" />
|
||||||
<PackageReference Include="Gress" Version="1.2.0" />
|
<PackageReference Include="Gress" Version="2.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
using System.Net.Http.Headers;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord;
|
|
||||||
|
|
||||||
public record AuthToken(AuthTokenKind Kind, string Value)
|
|
||||||
{
|
|
||||||
public AuthenticationHeaderValue GetAuthenticationHeader() => Kind switch
|
|
||||||
{
|
|
||||||
AuthTokenKind.Bot => new AuthenticationHeaderValue("Bot", Value),
|
|
||||||
_ => new AuthenticationHeaderValue(Value)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ public partial record Attachment(
|
|||||||
Snowflake Id,
|
Snowflake Id,
|
||||||
string Url,
|
string Url,
|
||||||
string FileName,
|
string FileName,
|
||||||
|
string? Description,
|
||||||
int? Width,
|
int? Width,
|
||||||
int? Height,
|
int? Height,
|
||||||
FileSize FileSize) : IHasId
|
FileSize FileSize) : IHasId
|
||||||
@@ -36,9 +37,10 @@ public partial record Attachment
|
|||||||
var url = json.GetProperty("url").GetNonWhiteSpaceString();
|
var url = json.GetProperty("url").GetNonWhiteSpaceString();
|
||||||
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
|
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
|
||||||
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
|
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
|
||||||
var fileName = json.GetProperty("filename").GetNonWhiteSpaceString();
|
var fileName = json.GetProperty("filename").GetNonNullString();
|
||||||
|
var description = json.GetPropertyOrNull("description")?.GetNonWhiteSpaceStringOrNull();
|
||||||
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
|
var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes);
|
||||||
|
|
||||||
return new Attachment(id, url, fileName, width, height, fileSize);
|
return new Attachment(id, url, fileName, description, width, height, fileSize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,8 +11,8 @@ public record EmbedField(
|
|||||||
{
|
{
|
||||||
public static EmbedField Parse(JsonElement json)
|
public static EmbedField Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
var name = json.GetProperty("name").GetNonNullString();
|
||||||
var value = json.GetProperty("value").GetNonWhiteSpaceString();
|
var value = json.GetProperty("value").GetNonNullString();
|
||||||
var isInline = json.GetPropertyOrNull("inline")?.GetBooleanOrNull() ?? false;
|
var isInline = json.GetPropertyOrNull("inline")?.GetBooleanOrNull() ?? false;
|
||||||
|
|
||||||
return new EmbedField(name, value, isInline);
|
return new EmbedField(name, value, isInline);
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
|||||||
// https://discord.com/developers/docs/resources/emoji#emoji-object
|
// https://discord.com/developers/docs/resources/emoji#emoji-object
|
||||||
public partial record Emoji(
|
public partial record Emoji(
|
||||||
// Only present on custom emoji
|
// Only present on custom emoji
|
||||||
string? Id,
|
Snowflake? Id,
|
||||||
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
||||||
string Name,
|
string Name,
|
||||||
bool IsAnimated,
|
bool IsAnimated,
|
||||||
string ImageUrl)
|
string ImageUrl)
|
||||||
{
|
{
|
||||||
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
||||||
public string Code => !string.IsNullOrWhiteSpace(Id)
|
public string Code => Id is not null
|
||||||
? Name
|
? Name
|
||||||
: EmojiIndex.TryGetCode(Name) ?? Name;
|
: EmojiIndex.TryGetCode(Name) ?? Name;
|
||||||
}
|
}
|
||||||
@@ -32,18 +32,18 @@ public partial record Emoji
|
|||||||
.Select(r => r.Value.ToString("x"))
|
.Select(r => r.Value.ToString("x"))
|
||||||
);
|
);
|
||||||
|
|
||||||
private static string GetImageUrl(string id, bool isAnimated) => isAnimated
|
private static string GetImageUrl(Snowflake id, bool isAnimated) => isAnimated
|
||||||
? $"https://cdn.discordapp.com/emojis/{id}.gif"
|
? $"https://cdn.discordapp.com/emojis/{id}.gif"
|
||||||
: $"https://cdn.discordapp.com/emojis/{id}.png";
|
: $"https://cdn.discordapp.com/emojis/{id}.png";
|
||||||
|
|
||||||
private static string GetImageUrl(string name) =>
|
private static string GetImageUrl(string name) =>
|
||||||
$"https://twemoji.maxcdn.com/2/svg/{GetTwemojiName(name)}.svg";
|
$"https://twemoji.maxcdn.com/2/svg/{GetTwemojiName(name)}.svg";
|
||||||
|
|
||||||
public static string GetImageUrl(string? id, string? name, bool isAnimated)
|
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
|
||||||
{
|
{
|
||||||
// Custom emoji
|
// Custom emoji
|
||||||
if (!string.IsNullOrWhiteSpace(id))
|
if (id is not null)
|
||||||
return GetImageUrl(id, isAnimated);
|
return GetImageUrl(id.Value, isAnimated);
|
||||||
|
|
||||||
// Standard emoji
|
// Standard emoji
|
||||||
if (!string.IsNullOrWhiteSpace(name))
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
@@ -55,7 +55,7 @@ public partial record Emoji
|
|||||||
|
|
||||||
public static Emoji Parse(JsonElement json)
|
public static Emoji Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull();
|
var id = json.GetPropertyOrNull("id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
|
||||||
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull();
|
var name = json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull();
|
||||||
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
|
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
|
|||||||
public static Guild Parse(JsonElement json)
|
public static Guild Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
var name = json.GetProperty("name").GetNonNullString();
|
||||||
var iconHash = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull();
|
var iconHash = json.GetPropertyOrNull("icon")?.GetNonWhiteSpaceStringOrNull();
|
||||||
|
|
||||||
var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
|
var iconUrl = !string.IsNullOrWhiteSpace(iconHash)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public record Message(
|
|||||||
string Content,
|
string Content,
|
||||||
IReadOnlyList<Attachment> Attachments,
|
IReadOnlyList<Attachment> Attachments,
|
||||||
IReadOnlyList<Embed> Embeds,
|
IReadOnlyList<Embed> Embeds,
|
||||||
|
IReadOnlyList<Sticker> Stickers,
|
||||||
IReadOnlyList<Reaction> Reactions,
|
IReadOnlyList<Reaction> Reactions,
|
||||||
IReadOnlyList<User> MentionedUsers,
|
IReadOnlyList<User> MentionedUsers,
|
||||||
MessageReference? Reference,
|
MessageReference? Reference,
|
||||||
@@ -60,6 +61,10 @@ public record Message(
|
|||||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ??
|
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ??
|
||||||
Array.Empty<Embed>();
|
Array.Empty<Embed>();
|
||||||
|
|
||||||
|
var stickers =
|
||||||
|
json.GetPropertyOrNull("sticker_items")?.EnumerateArrayOrNull()?.Select(Sticker.Parse).ToArray() ??
|
||||||
|
Array.Empty<Sticker>();
|
||||||
|
|
||||||
var reactions =
|
var reactions =
|
||||||
json.GetPropertyOrNull("reactions")?.EnumerateArrayOrNull()?.Select(Reaction.Parse).ToArray() ??
|
json.GetPropertyOrNull("reactions")?.EnumerateArrayOrNull()?.Select(Reaction.Parse).ToArray() ??
|
||||||
Array.Empty<Reaction>();
|
Array.Empty<Reaction>();
|
||||||
@@ -79,6 +84,7 @@ public record Message(
|
|||||||
content,
|
content,
|
||||||
attachments,
|
attachments,
|
||||||
embeds,
|
embeds,
|
||||||
|
stickers,
|
||||||
reactions,
|
reactions,
|
||||||
mentionedUsers,
|
mentionedUsers,
|
||||||
messageReference,
|
messageReference,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public record Role(Snowflake Id, string Name, int Position, Color? Color) : IHas
|
|||||||
public static Role Parse(JsonElement json)
|
public static Role Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
var name = json.GetProperty("name").GetNonNullString();
|
||||||
var position = json.GetProperty("position").GetInt32();
|
var position = json.GetProperty("position").GetInt32();
|
||||||
|
|
||||||
var color = json
|
var color = json
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
public record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl)
|
||||||
|
{
|
||||||
|
private static string GetSourceUrl(Snowflake id, StickerFormat format)
|
||||||
|
{
|
||||||
|
var extension = format == StickerFormat.Lottie ? "json" : "png";
|
||||||
|
return $"https://discord.com/stickers/{id}.{extension}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Sticker Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
|
var name = json.GetProperty("name").GetNonWhiteSpaceString();
|
||||||
|
var format = (StickerFormat)json.GetProperty("format_type").GetInt32();
|
||||||
|
|
||||||
|
var sourceUrl = GetSourceUrl(id, format);
|
||||||
|
|
||||||
|
return new Sticker(id, name, format, sourceUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
public enum StickerFormat
|
||||||
|
{
|
||||||
|
Png = 1,
|
||||||
|
PngAnimated = 2,
|
||||||
|
Lottie = 3
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ public partial record User
|
|||||||
? "gif"
|
? "gif"
|
||||||
: "png";
|
: "png";
|
||||||
|
|
||||||
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=128";
|
return $"https://cdn.discordapp.com/avatars/{id}/{avatarHash}.{extension}?size=512";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static User Parse(JsonElement json)
|
public static User Parse(JsonElement json)
|
||||||
@@ -38,7 +38,7 @@ public partial record User
|
|||||||
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
|
||||||
var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false;
|
var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false;
|
||||||
var discriminator = json.GetProperty("discriminator").GetNonWhiteSpaceString().Pipe(int.Parse);
|
var discriminator = json.GetProperty("discriminator").GetNonWhiteSpaceString().Pipe(int.Parse);
|
||||||
var name = json.GetProperty("username").GetNonWhiteSpaceString();
|
var name = json.GetProperty("username").GetNonNullString();
|
||||||
var avatarHash = json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull();
|
var avatarHash = json.GetPropertyOrNull("avatar")?.GetNonWhiteSpaceStringOrNull();
|
||||||
|
|
||||||
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
|
var avatarUrl = !string.IsNullOrWhiteSpace(avatarHash)
|
||||||
@@ -47,4 +47,4 @@ public partial record User
|
|||||||
|
|
||||||
return new User(id, isBot, discriminator, name, avatarUrl);
|
return new User(id, isBot, discriminator, name, avatarUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -11,6 +12,7 @@ using DiscordChatExporter.Core.Discord.Data;
|
|||||||
using DiscordChatExporter.Core.Exceptions;
|
using DiscordChatExporter.Core.Exceptions;
|
||||||
using DiscordChatExporter.Core.Utils;
|
using DiscordChatExporter.Core.Utils;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
using Gress;
|
||||||
using JsonExtensions.Http;
|
using JsonExtensions.Http;
|
||||||
using JsonExtensions.Reading;
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
@@ -18,10 +20,33 @@ namespace DiscordChatExporter.Core.Discord;
|
|||||||
|
|
||||||
public class DiscordClient
|
public class DiscordClient
|
||||||
{
|
{
|
||||||
private readonly AuthToken _token;
|
private readonly string _token;
|
||||||
private readonly Uri _baseUri = new("https://discord.com/api/v8/", UriKind.Absolute);
|
private readonly Uri _baseUri = new("https://discord.com/api/v9/", UriKind.Absolute);
|
||||||
|
|
||||||
public DiscordClient(AuthToken token) => _token = token;
|
private TokenKind _tokenKind = TokenKind.Unknown;
|
||||||
|
|
||||||
|
public DiscordClient(string token) => _token = token;
|
||||||
|
|
||||||
|
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
||||||
|
string url,
|
||||||
|
bool isBot,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||||
|
|
||||||
|
// Don't validate because token can have invalid characters
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
|
||||||
|
request.Headers.TryAddWithoutValidation(
|
||||||
|
"Authorization",
|
||||||
|
isBot ? $"Bot {_token}" : _token
|
||||||
|
);
|
||||||
|
|
||||||
|
return await Http.Client.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
||||||
string url,
|
string url,
|
||||||
@@ -29,14 +54,33 @@ public class DiscordClient
|
|||||||
{
|
{
|
||||||
return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken =>
|
return await Http.ResponsePolicy.ExecuteAsync(async innerCancellationToken =>
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
if (_tokenKind == TokenKind.User)
|
||||||
request.Headers.Authorization = _token.GetAuthenticationHeader();
|
return await GetResponseAsync(url, false, innerCancellationToken);
|
||||||
|
|
||||||
return await Http.Client.SendAsync(
|
if (_tokenKind == TokenKind.Bot)
|
||||||
request,
|
return await GetResponseAsync(url, true, innerCancellationToken);
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
|
||||||
innerCancellationToken
|
// Try to authenticate as user
|
||||||
);
|
var userResponse = await GetResponseAsync(url, false, innerCancellationToken);
|
||||||
|
if (userResponse.StatusCode != HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
_tokenKind = TokenKind.User;
|
||||||
|
return userResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
userResponse.Dispose();
|
||||||
|
|
||||||
|
// Otherwise, try to authenticate as bot
|
||||||
|
var botResponse = await GetResponseAsync(url, true, innerCancellationToken);
|
||||||
|
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
_tokenKind = TokenKind.Bot;
|
||||||
|
return botResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token is probably invalid altogether.
|
||||||
|
// Return the last response anyway, upstream should handle the error.
|
||||||
|
return botResponse;
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +277,7 @@ public class DiscordClient
|
|||||||
Snowflake channelId,
|
Snowflake channelId,
|
||||||
Snowflake? after = null,
|
Snowflake? after = null,
|
||||||
Snowflake? before = null,
|
Snowflake? before = null,
|
||||||
IProgress<double>? progress = null,
|
IProgress<Percentage>? progress = null,
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Get the last message in the specified range.
|
// Get the last message in the specified range.
|
||||||
@@ -282,16 +326,13 @@ public class DiscordClient
|
|||||||
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
|
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
|
||||||
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
||||||
|
|
||||||
if (totalDuration > TimeSpan.Zero)
|
progress.Report(Percentage.FromFraction(
|
||||||
{
|
// Avoid division by zero if all messages have the exact same timestamp
|
||||||
progress.Report(exportedDuration / totalDuration);
|
// (which may be the case if there's only one message in the channel)
|
||||||
}
|
totalDuration > TimeSpan.Zero
|
||||||
// Avoid division by zero if all messages have the exact same timestamp
|
? exportedDuration / totalDuration
|
||||||
// (which may be the case if there's only one message in the channel)
|
: 1
|
||||||
else
|
));
|
||||||
{
|
|
||||||
progress.Report(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return message;
|
yield return message;
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,8 @@
|
|||||||
namespace DiscordChatExporter.Core.Discord;
|
namespace DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
public enum AuthTokenKind
|
public enum TokenKind
|
||||||
{
|
{
|
||||||
|
Unknown,
|
||||||
User,
|
User,
|
||||||
Bot
|
Bot
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,10 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Gress" Version="2.0.1" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="MiniRazor.CodeGen" Version="2.2.0" />
|
<PackageReference Include="MiniRazor.CodeGen" Version="2.2.1" />
|
||||||
<PackageReference Include="Polly" Version="7.2.2" />
|
<PackageReference Include="Polly" Version="7.2.3" />
|
||||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using DiscordChatExporter.Core.Discord.Data;
|
|||||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||||
using DiscordChatExporter.Core.Exceptions;
|
using DiscordChatExporter.Core.Exceptions;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
using Gress;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting;
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
|
|
||||||
@@ -17,11 +18,9 @@ public class ChannelExporter
|
|||||||
|
|
||||||
public ChannelExporter(DiscordClient discord) => _discord = discord;
|
public ChannelExporter(DiscordClient discord) => _discord = discord;
|
||||||
|
|
||||||
public ChannelExporter(AuthToken token) : this(new DiscordClient(token)) {}
|
|
||||||
|
|
||||||
public async ValueTask ExportChannelAsync(
|
public async ValueTask ExportChannelAsync(
|
||||||
ExportRequest request,
|
ExportRequest request,
|
||||||
IProgress<double>? progress = null,
|
IProgress<Percentage>? progress = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Build context
|
// Build context
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System.IO;
|
||||||
using System.IO;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
@@ -60,8 +59,8 @@ public partial record ExportRequest
|
|||||||
"%C" => channel.Name,
|
"%C" => channel.Name,
|
||||||
"%p" => channel.Position?.ToString() ?? "0",
|
"%p" => channel.Position?.ToString() ?? "0",
|
||||||
"%P" => channel.Category.Position?.ToString() ?? "0",
|
"%P" => channel.Category.Position?.ToString() ?? "0",
|
||||||
"%a" => (after ?? Snowflake.Zero).ToDate().ToString("yyyy-MM-dd"),
|
"%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "",
|
||||||
"%b" => (before?.ToDate() ?? DateTime.Now).ToString("yyyy-MM-dd"),
|
"%b" => before?.ToDate().ToString("yyyy-MM-dd") ?? "",
|
||||||
"%%" => "%",
|
"%%" => "%",
|
||||||
_ => m.Value
|
_ => m.Value
|
||||||
})
|
})
|
||||||
@@ -93,7 +92,7 @@ public partial record ExportRequest
|
|||||||
// Date range
|
// Date range
|
||||||
if (after is not null || before is not null)
|
if (after is not null || before is not null)
|
||||||
{
|
{
|
||||||
buffer.Append(" (");
|
buffer.Append(' ').Append('(');
|
||||||
|
|
||||||
// Both 'after' and 'before' are set
|
// Both 'after' and 'before' are set
|
||||||
if (after is not null && before is not null)
|
if (after is not null && before is not null)
|
||||||
@@ -111,11 +110,11 @@ public partial record ExportRequest
|
|||||||
buffer.Append($"before {before.Value.ToDate():yyyy-MM-dd}");
|
buffer.Append($"before {before.Value.ToDate():yyyy-MM-dd}");
|
||||||
}
|
}
|
||||||
|
|
||||||
buffer.Append(")");
|
buffer.Append(')');
|
||||||
}
|
}
|
||||||
|
|
||||||
// File extension
|
// File extension
|
||||||
buffer.Append($".{format.GetFileExtension()}");
|
buffer.Append('.').Append(format.GetFileExtension());
|
||||||
|
|
||||||
return PathEx.EscapeFileName(buffer.ToString());
|
return PathEx.EscapeFileName(buffer.ToString());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ internal static class FilterGrammar
|
|||||||
.Select(v => (MessageFilter) new MentionsMessageFilter(v))
|
.Select(v => (MessageFilter) new MentionsMessageFilter(v))
|
||||||
.Named("mentions:<value>");
|
.Named("mentions:<value>");
|
||||||
|
|
||||||
|
private static readonly TextParser<MessageFilter> ReactionFilter = Span
|
||||||
|
.EqualToIgnoreCase("reaction:")
|
||||||
|
.IgnoreThen(String)
|
||||||
|
.Select(v => (MessageFilter) new ReactionMessageFilter(v))
|
||||||
|
.Named("reaction:<value>");
|
||||||
|
|
||||||
private static readonly TextParser<MessageFilter> HasFilter = Span
|
private static readonly TextParser<MessageFilter> HasFilter = Span
|
||||||
.EqualToIgnoreCase("has:")
|
.EqualToIgnoreCase("has:")
|
||||||
.IgnoreThen(Parse.OneOf(
|
.IgnoreThen(Parse.OneOf(
|
||||||
@@ -72,6 +78,7 @@ internal static class FilterGrammar
|
|||||||
GroupedFilter,
|
GroupedFilter,
|
||||||
FromFilter,
|
FromFilter,
|
||||||
MentionsFilter,
|
MentionsFilter,
|
||||||
|
ReactionFilter,
|
||||||
HasFilter,
|
HasFilter,
|
||||||
ContainsFilter
|
ContainsFilter
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
|
|
||||||
|
internal class ReactionMessageFilter : MessageFilter
|
||||||
|
{
|
||||||
|
private readonly string _value;
|
||||||
|
|
||||||
|
public ReactionMessageFilter(string value) => _value = value;
|
||||||
|
|
||||||
|
public override bool IsMatch(Message message) => message.Reactions.Any(r =>
|
||||||
|
string.Equals(_value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(_value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(_value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -104,6 +104,14 @@ internal partial class MediaDownloader
|
|||||||
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
|
||||||
var fileExtension = Path.GetExtension(fileName);
|
var fileExtension = Path.GetExtension(fileName);
|
||||||
|
|
||||||
|
// Probably not a file extension, just a dot in a long file name
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/708
|
||||||
|
if (fileExtension.Length > 41)
|
||||||
|
{
|
||||||
|
fileNameWithoutExtension = fileName;
|
||||||
|
fileExtension = "";
|
||||||
|
}
|
||||||
|
|
||||||
return PathEx.EscapeFileName(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
|
return PathEx.EscapeFileName(fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
|
||||||
|
|
||||||
// Used for grouping contiguous messages in HTML export
|
|
||||||
internal partial class MessageGroup
|
|
||||||
{
|
|
||||||
public User Author { get; }
|
|
||||||
|
|
||||||
public DateTimeOffset Timestamp { get; }
|
|
||||||
|
|
||||||
public IReadOnlyList<Message> Messages { get; }
|
|
||||||
|
|
||||||
public MessageReference? Reference { get; }
|
|
||||||
|
|
||||||
public Message? ReferencedMessage {get; }
|
|
||||||
|
|
||||||
public MessageGroup(
|
|
||||||
User author,
|
|
||||||
DateTimeOffset timestamp,
|
|
||||||
MessageReference? reference,
|
|
||||||
Message? referencedMessage,
|
|
||||||
IReadOnlyList<Message> messages)
|
|
||||||
{
|
|
||||||
Author = author;
|
|
||||||
Timestamp = timestamp;
|
|
||||||
Reference = reference;
|
|
||||||
ReferencedMessage = referencedMessage;
|
|
||||||
Messages = messages;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal partial class MessageGroup
|
|
||||||
{
|
|
||||||
public static bool CanJoin(Message message1, Message message2) =>
|
|
||||||
// Must be from the same author
|
|
||||||
message1.Author.Id == message2.Author.Id &&
|
|
||||||
// Author's name must not have changed between messages
|
|
||||||
string.Equals(message1.Author.FullName, message2.Author.FullName, StringComparison.Ordinal) &&
|
|
||||||
// Duration between messages must be 7 minutes or less
|
|
||||||
(message2.Timestamp - message1.Timestamp).Duration().TotalMinutes <= 7 &&
|
|
||||||
// Other message must not be a reply
|
|
||||||
message2.Reference is null;
|
|
||||||
|
|
||||||
public static MessageGroup Join(IReadOnlyList<Message> messages)
|
|
||||||
{
|
|
||||||
var first = messages.First();
|
|
||||||
|
|
||||||
return new MessageGroup(
|
|
||||||
first.Author,
|
|
||||||
first.Timestamp,
|
|
||||||
first.Reference,
|
|
||||||
first.ReferencedMessage,
|
|
||||||
messages
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +1,146 @@
|
|||||||
@using System
|
@using System
|
||||||
@using System.Linq
|
@using System.Linq
|
||||||
@using System.Threading.Tasks
|
@using System.Threading.Tasks
|
||||||
|
@using DiscordChatExporter.Core.Discord.Data
|
||||||
@using DiscordChatExporter.Core.Exporting.Writers.Html;
|
@using DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||||
|
@using DiscordChatExporter.Core.Utils.Extensions
|
||||||
|
|
||||||
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
@namespace DiscordChatExporter.Core.Exporting.Writers.Html
|
||||||
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
|
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
|
||||||
|
|
||||||
@{
|
@{
|
||||||
|
var firstMessage = Model.Messages.First();
|
||||||
|
|
||||||
|
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
|
||||||
|
|
||||||
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||||
|
|
||||||
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
|
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
|
||||||
|
|
||||||
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
|
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
|
||||||
|
|
||||||
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
|
var userMember = Model.ExportContext.TryGetMember(firstMessage.Author.Id);
|
||||||
|
|
||||||
var userMember = Model.ExportContext.TryGetMember(Model.MessageGroup.Author.Id);
|
var userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id);
|
||||||
|
|
||||||
var userColor = Model.ExportContext.TryGetUserColor(Model.MessageGroup.Author.Id);
|
var userNick = firstMessage.Author.IsBot
|
||||||
|
? firstMessage.Author.Name
|
||||||
|
: userMember?.Nick ?? firstMessage.Author.Name;
|
||||||
|
|
||||||
var userColorStyle = userColor is not null
|
var referencedUserMember = firstMessage.ReferencedMessage is not null
|
||||||
? $"color: rgb({userColor?.R},{userColor?.G},{userColor?.B})"
|
? Model.ExportContext.TryGetMember(firstMessage.ReferencedMessage.Author.Id)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
var userNick = Model.MessageGroup.Author.IsBot
|
var referencedUserColor = firstMessage.ReferencedMessage is not null
|
||||||
? Model.MessageGroup.Author.Name
|
? Model.ExportContext.TryGetUserColor(firstMessage.ReferencedMessage.Author.Id)
|
||||||
: userMember?.Nick ?? Model.MessageGroup.Author.Name;
|
|
||||||
|
|
||||||
var referencedUserMember = Model.MessageGroup.ReferencedMessage is not null
|
|
||||||
? Model.ExportContext.TryGetMember(Model.MessageGroup.ReferencedMessage.Author.Id)
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
var referencedUserColor = Model.MessageGroup.ReferencedMessage is not null
|
var referencedUserNick = firstMessage.ReferencedMessage is not null
|
||||||
? Model.ExportContext.TryGetUserColor(Model.MessageGroup.ReferencedMessage.Author.Id)
|
? firstMessage.ReferencedMessage.Author.IsBot
|
||||||
: null;
|
? firstMessage.ReferencedMessage.Author.Name
|
||||||
|
: referencedUserMember?.Nick ?? firstMessage.ReferencedMessage.Author.Name
|
||||||
var referencedUserColorStyle = referencedUserColor is not null
|
|
||||||
? $"color: rgb({referencedUserColor?.R},{referencedUserColor?.G},{referencedUserColor?.B})"
|
|
||||||
: null;
|
|
||||||
|
|
||||||
var referencedUserNick = Model.MessageGroup.ReferencedMessage is not null
|
|
||||||
? Model.MessageGroup.ReferencedMessage.Author.IsBot
|
|
||||||
? Model.MessageGroup.ReferencedMessage.Author.Name
|
|
||||||
: referencedUserMember?.Nick ?? Model.MessageGroup.ReferencedMessage.Author.Name
|
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="chatlog__message-group">
|
<div class="chatlog__message-group">
|
||||||
@{/* Referenced message */}
|
@for (var i = 0; i < Model.Messages.Count; i++)
|
||||||
@if (Model.MessageGroup.Reference is not null)
|
{
|
||||||
{
|
var isFirst = i == 0;
|
||||||
<div class="chatlog__reference-symbol"></div>
|
var message = Model.Messages[i];
|
||||||
<div class="chatlog__reference">
|
|
||||||
@if (Model.MessageGroup.ReferencedMessage is not null)
|
|
||||||
{
|
|
||||||
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
|
||||||
<span class="chatlog__reference-name" title="@Model.MessageGroup.ReferencedMessage.Author.FullName" style="@referencedUserColorStyle">@referencedUserNick</span>
|
|
||||||
<div class="chatlog__reference-content">
|
|
||||||
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@Model.MessageGroup.ReferencedMessage.Id')">
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Model.MessageGroup.ReferencedMessage.Content))
|
|
||||||
{
|
|
||||||
@Raw(FormatEmbedMarkdown(Model.MessageGroup.ReferencedMessage.Content))
|
|
||||||
}
|
|
||||||
else if (Model.MessageGroup.ReferencedMessage.Attachments.Any() || Model.MessageGroup.ReferencedMessage.Embeds.Any())
|
|
||||||
{
|
|
||||||
<em>Click to see attachment</em> <span>🖼️</span>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<em>Click to see original message</em>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
@if (Model.MessageGroup.ReferencedMessage.EditedTimestamp is not null)
|
<div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
|
||||||
{
|
<div class="chatlog__message">
|
||||||
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(Model.MessageGroup.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
|
@{/* Left side */}
|
||||||
}
|
<div class="chatlog__message-aside">
|
||||||
</div>
|
@if (isFirst)
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<span class="chatlog__reference-unknown">
|
|
||||||
Original message was deleted or could not be loaded.
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Avatar */}
|
|
||||||
<div class="chatlog__author-avatar-container">
|
|
||||||
<img class="chatlog__author-avatar" src="@await ResolveUrlAsync(Model.MessageGroup.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="chatlog__messages">
|
|
||||||
@{/* Author name */}
|
|
||||||
<span class="chatlog__author-name" title="@Model.MessageGroup.Author.FullName" data-user-id="@Model.MessageGroup.Author.Id" style="@userColorStyle">@userNick</span>
|
|
||||||
|
|
||||||
@{/* Bot tag */}
|
|
||||||
@if (Model.MessageGroup.Author.IsBot)
|
|
||||||
{
|
|
||||||
<span class="chatlog__bot-tag">BOT</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Message timestamp */}
|
|
||||||
<span class="chatlog__timestamp">@FormatDate(Model.MessageGroup.Timestamp)</span>
|
|
||||||
|
|
||||||
@{/* Messages in a group */}
|
|
||||||
@foreach (var message in Model.MessageGroup.Messages)
|
|
||||||
{
|
|
||||||
<div class="chatlog__message @(message.IsPinned ? "chatlog__message--pinned" : null)" data-message-id="@message.Id" id="message-@message.Id" title="Message sent: @FormatDate(message.Timestamp)">
|
|
||||||
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null)
|
|
||||||
{
|
{
|
||||||
<div class="chatlog__content">
|
// Reference symbol
|
||||||
<div class="markdown">
|
if (message.Reference is not null)
|
||||||
@{/* Message content */}
|
{
|
||||||
<span class="preserve-whitespace">@Raw(FormatMarkdown(message.Content))</span>
|
<div class="chatlog__reference-symbol"></div>
|
||||||
|
}
|
||||||
|
|
||||||
@{/* Edit timestamp */}
|
// Avatar
|
||||||
@if (message.EditedTimestamp is not null)
|
<img class="chatlog__avatar" src="@await ResolveUrlAsync(message.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__short-timestamp" title="@FormatDate(message.Timestamp)">@message.Timestamp.ToLocalString("t")</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@{/* Right side */}
|
||||||
|
<div class="chatlog__message-primary">
|
||||||
|
@if (isFirst)
|
||||||
|
{
|
||||||
|
// Reference
|
||||||
|
if (message.Reference is not null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__reference">
|
||||||
|
@if (message.ReferencedMessage is not null)
|
||||||
{
|
{
|
||||||
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
|
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
|
||||||
|
<div class="chatlog__reference-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div>
|
||||||
|
<div class="chatlog__reference-content">
|
||||||
|
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content))
|
||||||
|
{
|
||||||
|
@Raw(FormatEmbedMarkdown(message.ReferencedMessage.Content))
|
||||||
|
}
|
||||||
|
else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any())
|
||||||
|
{
|
||||||
|
<em>Click to see attachment</em>
|
||||||
|
<span>🖼️</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<em>Click to see original message</em>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
@if (message.ReferencedMessage.EditedTimestamp is not null)
|
||||||
|
{
|
||||||
|
<span class="chatlog__reference-edited-timestamp" title="@FormatDate(message.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__reference-unknown">
|
||||||
|
Original message was deleted or could not be loaded.
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header
|
||||||
|
<div class="chatlog__header">
|
||||||
|
@{/* Author name */}
|
||||||
|
<span class="chatlog__author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span>
|
||||||
|
|
||||||
|
@{/* Bot label */}
|
||||||
|
@if (message.Author.IsBot)
|
||||||
|
{
|
||||||
|
<span class="chatlog__bot-label">BOT</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
@{/* Timestamp */}
|
||||||
|
<span class="chatlog__timestamp">@FormatDate(message.Timestamp)</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@{/* Content */}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null)
|
||||||
|
{
|
||||||
|
<div class="chatlog__content chatlog__markdown">
|
||||||
|
@{/* Text */}
|
||||||
|
<span class="chatlog__markdown-preserve">@Raw(FormatMarkdown(message.Content))</span>
|
||||||
|
|
||||||
|
@{/* Edited timestamp */}
|
||||||
|
@if (message.EditedTimestamp is not null)
|
||||||
|
{
|
||||||
|
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,26 +158,26 @@
|
|||||||
@if (attachment.IsImage)
|
@if (attachment.IsImage)
|
||||||
{
|
{
|
||||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||||
<img class="chatlog__attachment-media" src="@await ResolveUrlAsync(attachment.Url)" alt="Image attachment" title="@($"Image: {attachment.FileName} ({attachment.FileSize})")" loading="lazy">
|
<img class="chatlog__attachment-media" src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
else if (attachment.IsVideo)
|
else if (attachment.IsVideo)
|
||||||
{
|
{
|
||||||
<video class="chatlog__attachment-media" controls>
|
<video class="chatlog__attachment-media" controls>
|
||||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Video attachment" title="@($"Video: {attachment.FileName} ({attachment.FileSize})")">
|
<source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
|
||||||
</video>
|
</video>
|
||||||
}
|
}
|
||||||
else if (attachment.IsAudio)
|
else if (attachment.IsAudio)
|
||||||
{
|
{
|
||||||
<audio class="chatlog__attachment-media" controls>
|
<audio class="chatlog__attachment-media" controls>
|
||||||
<source src="@await ResolveUrlAsync(attachment.Url)" alt="Audio attachment" title="@($"Audio: {attachment.FileName} ({attachment.FileSize})")">
|
<source src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
|
||||||
</audio>
|
</audio>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="chatlog__attachment-generic">
|
<div class="chatlog__attachment-generic">
|
||||||
<svg class="chatlog__attachment-generic-icon">
|
<svg class="chatlog__attachment-generic-icon">
|
||||||
<use href="#icon-attachment" />
|
<use href="#attachment-icon"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="chatlog__attachment-generic-name">
|
<div class="chatlog__attachment-generic-name">
|
||||||
<a href="@await ResolveUrlAsync(attachment.Url)">
|
<a href="@await ResolveUrlAsync(attachment.Url)">
|
||||||
@@ -201,7 +220,7 @@
|
|||||||
@{/* Color pill */}
|
@{/* Color pill */}
|
||||||
@if (embed.Color is not null)
|
@if (embed.Color is not null)
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div>
|
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -214,7 +233,7 @@
|
|||||||
@{/* Embed author */}
|
@{/* Embed author */}
|
||||||
@if (embed.Author is not null)
|
@if (embed.Author is not null)
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-author">
|
<div class="chatlog__embed-author-container">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||||
@@ -222,16 +241,16 @@
|
|||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||||
{
|
{
|
||||||
<span class="chatlog__embed-author-name">
|
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
{
|
||||||
{
|
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
|
||||||
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
|
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||||
}
|
</a>
|
||||||
else
|
}
|
||||||
{
|
else
|
||||||
@embed.Author.Name
|
{
|
||||||
}
|
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||||
</span>
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -243,12 +262,12 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||||
{
|
{
|
||||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -269,7 +288,7 @@
|
|||||||
@{/* Color pill */}
|
@{/* Color pill */}
|
||||||
@if (embed.Color is not null)
|
@if (embed.Color is not null)
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div>
|
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -282,7 +301,7 @@
|
|||||||
@{/* Embed author */}
|
@{/* Embed author */}
|
||||||
@if (embed.Author is not null)
|
@if (embed.Author is not null)
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-author">
|
<div class="chatlog__embed-author-container">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||||
@@ -290,16 +309,16 @@
|
|||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||||
{
|
{
|
||||||
<span class="chatlog__embed-author-name">
|
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
|
{
|
||||||
{
|
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
|
||||||
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
|
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||||
}
|
</a>
|
||||||
else
|
}
|
||||||
{
|
else
|
||||||
@embed.Author.Name
|
{
|
||||||
}
|
<div class="chatlog__embed-author">@embed.Author.Name</div>
|
||||||
</span>
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -311,12 +330,12 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||||
{
|
{
|
||||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(embed.Title))</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -325,7 +344,7 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-description">
|
<div class="chatlog__embed-description">
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(embed.Description))</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,14 +358,14 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(field.Name))
|
@if (!string.IsNullOrWhiteSpace(field.Name))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-field-name">
|
<div class="chatlog__embed-field-name">
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(field.Name))</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(field.Value))
|
@if (!string.IsNullOrWhiteSpace(field.Value))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-field-value">
|
<div class="chatlog__embed-field-value">
|
||||||
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
<div class="chatlog__markdown chatlog__markdown-preserve">@Raw(FormatEmbedMarkdown(field.Value))</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -411,6 +430,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@{/* Stickers */}
|
||||||
|
@foreach (var sticker in message.Stickers)
|
||||||
|
{
|
||||||
|
<div class="chatlog__sticker" title="@sticker.Name">
|
||||||
|
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated)
|
||||||
|
{
|
||||||
|
<img class="chatlog__sticker--media" src="@await ResolveUrlAsync(sticker.SourceUrl)" alt="Sticker">
|
||||||
|
}
|
||||||
|
else if (sticker.Format == StickerFormat.Lottie)
|
||||||
|
{
|
||||||
|
<div class="chatlog__sticker--media" data-source="@await ResolveUrlAsync(sticker.SourceUrl)"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@{/* Message reactions */}
|
@{/* Message reactions */}
|
||||||
@if (message.Reactions.Any())
|
@if (message.Reactions.Any())
|
||||||
{
|
{
|
||||||
@@ -418,13 +452,14 @@
|
|||||||
@foreach (var reaction in message.Reactions)
|
@foreach (var reaction in message.Reactions)
|
||||||
{
|
{
|
||||||
<div class="chatlog__reaction" title="@reaction.Emoji.Code">
|
<div class="chatlog__reaction" title="@reaction.Emoji.Code">
|
||||||
<img class="emoji emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
|
<img class="chatlog__emoji chatlog__emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
|
||||||
<span class="chatlog__reaction-count">@reaction.Count</span>
|
<span class="chatlog__reaction-count">@reaction.Count</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
using System.Collections.Generic;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
|
||||||
|
|
||||||
@@ -6,12 +8,12 @@ internal class MessageGroupTemplateContext
|
|||||||
{
|
{
|
||||||
public ExportContext ExportContext { get; }
|
public ExportContext ExportContext { get; }
|
||||||
|
|
||||||
public MessageGroup MessageGroup { get; }
|
public IReadOnlyList<Message> Messages { get; }
|
||||||
|
|
||||||
public MessageGroupTemplateContext(ExportContext exportContext, MessageGroup messageGroup)
|
public MessageGroupTemplateContext(ExportContext exportContext, IReadOnlyList<Message> messages)
|
||||||
{
|
{
|
||||||
ExportContext = exportContext;
|
ExportContext = exportContext;
|
||||||
MessageGroup = messageGroup;
|
Messages = messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
|
||||||
|
|||||||
@@ -11,10 +11,13 @@
|
|||||||
? darkVariant
|
? darkVariant
|
||||||
: lightVariant;
|
: lightVariant;
|
||||||
|
|
||||||
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
string GetFontUrl(int weight) =>
|
||||||
|
$"https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-{weight}.woff";
|
||||||
|
|
||||||
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url, CancellationToken);
|
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url, CancellationToken);
|
||||||
|
|
||||||
|
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
|
||||||
|
|
||||||
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
|
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,39 +32,41 @@
|
|||||||
@{/* Styling */}
|
@{/* Styling */}
|
||||||
<style>
|
<style>
|
||||||
@@font-face {
|
@@font-face {
|
||||||
|
src: url(@await ResolveUrlAsync(GetFontUrl(300)));
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-300.woff);
|
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@font-face {
|
@@font-face {
|
||||||
|
src: url(@await ResolveUrlAsync(GetFontUrl(400)));
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-400.woff);
|
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@font-face {
|
@@font-face {
|
||||||
|
src: url(@await ResolveUrlAsync(GetFontUrl(500)));
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-500.woff);
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@font-face {
|
@@font-face {
|
||||||
|
src: url(@await ResolveUrlAsync(GetFontUrl(600)));
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-600.woff);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@font-face {
|
@@font-face {
|
||||||
|
src: url(@await ResolveUrlAsync(GetFontUrl(700)));
|
||||||
font-family: Whitney;
|
font-family: Whitney;
|
||||||
src: url(https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-700.woff);
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
background-color: @Themed("#36393e", "#ffffff");
|
background-color: @Themed("#36393e", "#ffffff");
|
||||||
color: @Themed("#dcddde", "#23262a");
|
color: @Themed("#dcddde", "#23262a");
|
||||||
font-family: "Whitney", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: @Themed("400", "500");
|
font-weight: @Themed("400", "500");
|
||||||
}
|
}
|
||||||
@@ -77,115 +82,15 @@
|
|||||||
|
|
||||||
img {
|
img {
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
image-rendering: high-quality;
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
.markdown {
|
|
||||||
max-width: 100%;
|
|
||||||
line-height: 1.3;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preserve-whitespace {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spoiler-text {
|
|
||||||
background-color: @Themed("rgba(255, 255, 255, 0.1)", "rgba(0, 0, 0, 0.1)");
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spoiler-text--hidden {
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: @Themed("#202225", "#b9bbbe");
|
|
||||||
color: rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spoiler-text--hidden:hover {
|
|
||||||
background-color: @Themed("rgba(32, 34, 37, 0.8)", "rgba(185, 187, 190, 0.8)");
|
|
||||||
}
|
|
||||||
|
|
||||||
.spoiler-text--hidden::selection {
|
|
||||||
color: rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote {
|
|
||||||
display: flex;
|
|
||||||
margin: 0.05em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quote::before {
|
|
||||||
content: "";
|
|
||||||
margin-right: 0.5em;
|
|
||||||
border: 2px solid @Themed("#4f545c", "#c7ccd1");
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pre {
|
|
||||||
background-color: @Themed("#2f3136", "#f9f9f9");
|
|
||||||
font-family: "Consolas", "Courier New", Courier, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pre--multiline {
|
|
||||||
margin-top: 0.25em;
|
|
||||||
padding: 0.5em;
|
|
||||||
border: 2px solid @Themed("#282b30", "#f3f3f3");
|
|
||||||
border-radius: 5px;
|
|
||||||
color: @Themed("#b9bbbe", "#657b83");
|
|
||||||
}
|
|
||||||
|
|
||||||
@{/* Override Highlight.js styles with a higher specificity selector */}
|
|
||||||
.pre--multiline.hljs {
|
|
||||||
background-color: @Themed("#2f3136", "#f9f9f9");
|
|
||||||
color: @Themed("#b9bbbe", "#657b83");
|
|
||||||
}
|
|
||||||
|
|
||||||
.pre--inline {
|
|
||||||
padding: 2px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention {
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 0 2px;
|
|
||||||
color: @Themed("#dee0fc", "#505cdc");
|
|
||||||
background-color: @Themed("rgba(88, 101, 242, .3)", "rgba(88, 101, 242, .15)");
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mention:hover {
|
|
||||||
background-color: #5865f2;
|
|
||||||
color: #ffffff
|
|
||||||
}
|
|
||||||
|
|
||||||
.timestamp {
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 0 2px;
|
|
||||||
background-color: @Themed("rgba(255, 255, 255, 0.06)", "rgba(6, 6, 7, 0.08)");
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji {
|
|
||||||
width: 1.325em;
|
|
||||||
height: 1.325em;
|
|
||||||
margin: 0 0.06em;
|
|
||||||
vertical-align: -0.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji--small {
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emoji--large {
|
|
||||||
width: 2.8em;
|
|
||||||
height: 2.8em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.preamble {
|
.preamble {
|
||||||
display: grid;
|
display: grid;
|
||||||
margin: 0 0.3em 0.6em 0.3em;
|
|
||||||
max-width: 100%;
|
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preamble__guild-icon-container {
|
.preamble__guild-icon-container {
|
||||||
@@ -199,59 +104,114 @@
|
|||||||
|
|
||||||
.preamble__entries-container {
|
.preamble__entries-container {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
margin-left: 0.6em;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preamble__entry {
|
.preamble__entry {
|
||||||
font-size: 1.4em;
|
margin-bottom: 0.15rem;
|
||||||
color: @Themed("#ffffff", "#2f3136");
|
color: @Themed("#ffffff", "#2f3136");
|
||||||
|
font-size: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preamble__entry--small {
|
.preamble__entry--small {
|
||||||
font-size: 1em;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog {
|
.chatlog {
|
||||||
max-width: 100%;
|
padding: 1rem 0;
|
||||||
|
width: 100%;
|
||||||
|
border-top: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
|
||||||
|
border-bottom: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__message-group {
|
.chatlog__message-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message-container {
|
||||||
|
background-color: transparent;
|
||||||
|
transition: background-color 1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message-container--highlighted {
|
||||||
|
background-color: @Themed("rgba(114, 137, 218, 0.2)", "rgba(114, 137, 218, 0.2)");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message-container--pinned {
|
||||||
|
background-color: @Themed("rgba(249, 168, 37, 0.05)", "rgba(249, 168, 37, 0.05)");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message {
|
||||||
display: grid;
|
display: grid;
|
||||||
margin: 0 0.6em;
|
|
||||||
padding: 0.9em 0;
|
|
||||||
border-top: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
|
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: bidi-override;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message:hover {
|
||||||
|
background-color: @Themed("#32353b", "#fafafa");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message:hover .chatlog__short-timestamp {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message-aside {
|
||||||
|
grid-column: 1;
|
||||||
|
width: 72px;
|
||||||
|
padding: 0.15rem 0.15rem 0 0.15rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-symbol {
|
.chatlog__reference-symbol {
|
||||||
grid-column: 1;
|
height: 10px;
|
||||||
margin: 8px 4px 4px 18px;
|
margin: 6px 4px 4px 36px;
|
||||||
border-left: 2px solid @Themed("#4f545c", "#c7ccd1");
|
border-left: 2px solid @Themed("#4f545c", "#c7ccd1");
|
||||||
border-top: 2px solid @Themed("#4f545c", "#c7ccd1");
|
border-top: 2px solid @Themed("#4f545c", "#c7ccd1");
|
||||||
border-radius: 8px 0 0 0;
|
border-radius: 8px 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatlog__avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__short-timestamp {
|
||||||
|
display: none;
|
||||||
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: bidi-override;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__message-primary {
|
||||||
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.chatlog__reference {
|
.chatlog__reference {
|
||||||
display: flex;
|
display: flex;
|
||||||
grid-column: 2;
|
margin-bottom: 0.15rem;
|
||||||
margin-bottom: 0.25em;
|
align-items: center;
|
||||||
font-size: 0.875em;
|
color: @Themed("#b5b6b8", "#5f5f60");
|
||||||
|
font-size: 0.875rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
align-items: center;
|
|
||||||
color: @Themed("#b5b6b8", "#5f5f60");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-avatar {
|
.chatlog__reference-avatar {
|
||||||
border-radius: 50%;
|
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin-right: 0.25em;
|
margin-right: 0.25rem;
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-name {
|
.chatlog__reference-author {
|
||||||
margin-right: 0.3em;
|
margin-right: 0.3rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,13 +220,8 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-content a:hover {
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__reference-link {
|
.chatlog__reference-link {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: @Themed("#b5b6b8", "#5f5f60");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-link * {
|
.chatlog__reference-link * {
|
||||||
@@ -274,82 +229,75 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatlog__reference-link .hljs {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
.chatlog__reference-link:hover {
|
.chatlog__reference-link:hover {
|
||||||
color: @Themed("#ffffff", "#2f3136");
|
color: @Themed("#ffffff", "#2f3136");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-link:hover *:not(.spoiler-text) {
|
.chatlog__reference-link:hover *:not(.chatlog__markdown-spoiler) {
|
||||||
color: @Themed("#ffffff", "#2f3136");
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reference-edited-timestamp {
|
.chatlog__reference-edited-timestamp {
|
||||||
margin-left: 0.25em;
|
margin-left: 0.25rem;
|
||||||
font-size: 0.8em;
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
unicode-bidi: bidi-override;
|
font-size: 0.75rem;
|
||||||
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
|
font-weight: 500;
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__author-avatar-container {
|
|
||||||
grid-column: 1;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
margin-right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__author-avatar {
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__messages {
|
|
||||||
grid-column: 2;
|
|
||||||
min-width: 50%;
|
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
|
unicode-bidi: bidi-override;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__author-name {
|
.chatlog__header {
|
||||||
|
margin-bottom: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__author {
|
||||||
font-weight: @Themed("500", "600");
|
font-weight: @Themed("500", "600");
|
||||||
color: @Themed("#ffffff", "#2f3136");
|
color: @Themed("#ffffff", "#2f3136");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatlog__bot-label {
|
||||||
|
position: relative;
|
||||||
|
top: -0.1rem;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #5865F2;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.chatlog__timestamp {
|
.chatlog__timestamp {
|
||||||
margin-left: 0.3em;
|
margin-left: 0.3rem;
|
||||||
font-size: 0.75em;
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
unicode-bidi: bidi-override;
|
unicode-bidi: bidi-override;
|
||||||
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__message {
|
|
||||||
padding: 0.1em 0.3em;
|
|
||||||
margin: 0 -0.3em;
|
|
||||||
background-color: transparent;
|
|
||||||
transition: background-color 1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__message--highlighted {
|
|
||||||
background-color: @Themed("rgba(114, 137, 218, 0.2)", "rgba(114, 137, 218, 0.2)");
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__message--pinned {
|
|
||||||
background-color: @Themed("rgba(249, 168, 37, 0.05)", "rgba(249, 168, 37, 0.05)");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__content {
|
.chatlog__content {
|
||||||
font-size: 0.95em;
|
padding-right: 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__edited-timestamp {
|
.chatlog__edited-timestamp {
|
||||||
margin-left: 0.15em;
|
margin-left: 0.15rem;
|
||||||
font-size: 0.8em;
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__attachment {
|
.chatlog__attachment {
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 0.3em;
|
width: fit-content;
|
||||||
|
margin-top: 0.3rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -365,18 +313,18 @@
|
|||||||
|
|
||||||
.chatlog__attachment-spoiler-caption {
|
.chatlog__attachment-spoiler-caption {
|
||||||
display: none;
|
display: none;
|
||||||
z-index: 999;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
z-index: 999;
|
||||||
padding: 0.4em 0.8em;
|
padding: 0.4rem 0.8rem;
|
||||||
background-color: rgba(0, 0, 0, 0.9);
|
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
color: #dcddde;
|
color: #dcddde;
|
||||||
font-size: 0.9em;
|
font-size: 0.9rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__attachment--hidden .chatlog__attachment-spoiler-caption {
|
.chatlog__attachment--hidden .chatlog__attachment-spoiler-caption {
|
||||||
@@ -388,9 +336,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__attachment-media {
|
.chatlog__attachment-media {
|
||||||
vertical-align: top;
|
|
||||||
max-width: 45vw;
|
max-width: 45vw;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
|
vertical-align: top;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,13 +347,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__attachment-generic {
|
.chatlog__attachment-generic {
|
||||||
width: 100%;
|
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background-color: @Themed("#2f3136", "#f2f3f5");
|
|
||||||
border: 1px solid @Themed("#292b2f", "#ebedef");
|
border: 1px solid @Themed("#292b2f", "#ebedef");
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
background-color: @Themed("#2f3136", "#f2f3f5");
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,19 +379,15 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__edited-timestamp {
|
|
||||||
color: @Themed("rgba(255, 255, 255, 0.2)", "#747f8d");
|
|
||||||
}
|
|
||||||
|
|
||||||
.chatlog__embed {
|
.chatlog__embed {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 0.3em;
|
margin-top: 0.3rem;
|
||||||
max-width: 520px;
|
max-width: 520px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-color-pill {
|
.chatlog__embed-color-pill {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 0.25em;
|
width: 0.25rem;
|
||||||
border-top-left-radius: 3px;
|
border-top-left-radius: 3px;
|
||||||
border-bottom-left-radius: 3px;
|
border-bottom-left-radius: 3px;
|
||||||
}
|
}
|
||||||
@@ -455,11 +399,11 @@
|
|||||||
.chatlog__embed-content-container {
|
.chatlog__embed-content-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0.5em 0.6em;
|
padding: 0.5rem 0.6rem;
|
||||||
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
|
|
||||||
border: 1px solid @Themed("rgba(46, 48, 54, 0.6)", "rgba(204, 204, 204, 0.3)");
|
border: 1px solid @Themed("rgba(46, 48, 54, 0.6)", "rgba(204, 204, 204, 0.3)");
|
||||||
border-top-right-radius: 3px;
|
border-top-right-radius: 3px;
|
||||||
border-bottom-right-radius: 3px;
|
border-bottom-right-radius: 3px;
|
||||||
|
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-content {
|
.chatlog__embed-content {
|
||||||
@@ -471,85 +415,85 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-author {
|
.chatlog__embed-author-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 0.3em;
|
margin-bottom: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-author-icon {
|
.chatlog__embed-author-icon {
|
||||||
margin-right: 0.5em;
|
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
margin-right: 0.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-author-name {
|
.chatlog__embed-author {
|
||||||
font-size: 0.875em;
|
color: @Themed("#ffffff", "#4f545c");
|
||||||
|
font-size: 0.875rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
unicode-bidi: bidi-override;
|
unicode-bidi: bidi-override;
|
||||||
color: @Themed("#ffffff", "#4f545c")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-author-name-link {
|
.chatlog__embed-author-link {
|
||||||
color: @Themed("#ffffff", "#4f545c");
|
color: @Themed("#ffffff", "#4f545c");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-title {
|
.chatlog__embed-title {
|
||||||
margin-bottom: 0.2em;
|
margin-bottom: 0.5rem;
|
||||||
font-size: 0.875em;
|
|
||||||
font-weight: 600;
|
|
||||||
color: @Themed("#ffffff", "#4f545c");
|
color: @Themed("#ffffff", "#4f545c");
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-description {
|
.chatlog__embed-description {
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: @Themed("#dcddde", "#2e3338");
|
color: @Themed("#dcddde", "#2e3338");
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-fields {
|
.chatlog__embed-fields {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0 0.5em;
|
gap: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-field {
|
.chatlog__embed-field {
|
||||||
flex: 0;
|
flex: 0;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
max-width: 506px;
|
max-width: 506px;
|
||||||
padding-top: 0.6em;
|
padding-top: 0.6rem;
|
||||||
font-size: 0.875em;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-field--inline {
|
.chatlog__embed-field--inline {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-basis: auto;
|
flex-basis: auto;
|
||||||
min-width: 150px;
|
min-width: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-field-name {
|
.chatlog__embed-field-name {
|
||||||
margin-bottom: 0.2em;
|
margin-bottom: 0.2rem;
|
||||||
font-weight: 600;
|
|
||||||
color: @Themed("#ffffff", "#36393e");
|
color: @Themed("#ffffff", "#36393e");
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-field-value {
|
.chatlog__embed-field-value {
|
||||||
font-weight: 500;
|
|
||||||
color: @Themed("#dcddde", "#2e3338");
|
color: @Themed("#dcddde", "#2e3338");
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-thumbnail {
|
.chatlog__embed-thumbnail {
|
||||||
flex: 0;
|
flex: 0;
|
||||||
margin-left: 1.2em;
|
|
||||||
max-width: 80px;
|
max-width: 80px;
|
||||||
max-height: 80px;
|
max-height: 80px;
|
||||||
|
margin-left: 1.2rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-image-container {
|
.chatlog__embed-image-container {
|
||||||
margin-top: 0.6em;
|
margin-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-image {
|
.chatlog__embed-image {
|
||||||
@@ -559,28 +503,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-footer {
|
.chatlog__embed-footer {
|
||||||
margin-top: 0.6em;
|
margin-top: 0.6rem;
|
||||||
color: @Themed("#dcddde", "#2e3338");
|
color: @Themed("#dcddde", "#2e3338");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-footer-icon {
|
.chatlog__embed-footer-icon {
|
||||||
margin-right: 0.2em;
|
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
margin-right: 0.2rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-footer-text {
|
.chatlog__embed-footer-text {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
font-size: 0.75em;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-plainimage {
|
.chatlog__embed-plainimage {
|
||||||
vertical-align: top;
|
|
||||||
max-width: 45vw;
|
max-width: 45vw;
|
||||||
max-height: 500px;
|
max-height: 500px;
|
||||||
|
vertical-align: top;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,7 +533,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-youtube-container {
|
.chatlog__embed-youtube-container {
|
||||||
margin-top: 0.6em;
|
margin-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-youtube {
|
.chatlog__embed-youtube {
|
||||||
@@ -597,57 +541,148 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatlog__sticker {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__sticker--media {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.chatlog__reactions {
|
.chatlog__reactions {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reaction {
|
.chatlog__reaction {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
margin: 0.35rem 0.1rem 0.1rem 0;
|
||||||
margin: 0.35em 0.1em 0.1em 0;
|
padding: 0.125rem 0.375rem;
|
||||||
padding: .125rem .375rem;
|
border: 1px solid transparent;
|
||||||
background-color: @Themed("#2f3136", "#f2f3f5");
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border-color: transparent;
|
background-color: @Themed("#2f3136", "#f2f3f5");
|
||||||
border-width: 1px;
|
align-items: center;
|
||||||
border-style: solid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reaction:hover {
|
.chatlog__reaction:hover {
|
||||||
border-color: @Themed("hsla(0,0%,100%,.2)", "rgba(0, 0, 0, 0.2)");
|
border: 1px solid @Themed("hsla(0,0%,100%,.2)", "rgba(0, 0, 0, 0.2)");
|
||||||
border-width: 1px;
|
|
||||||
border-style: solid;
|
|
||||||
background-color: @Themed("transparent", "white");
|
background-color: @Themed("transparent", "white");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reaction-count {
|
.chatlog__reaction-count {
|
||||||
min-width: 9px;
|
min-width: 9px;
|
||||||
margin-left: 0.35em;
|
margin-left: 0.35rem;
|
||||||
font-size: 0.875em;
|
|
||||||
color: @Themed("#b9bbbe", "#4f5660");
|
color: @Themed("#b9bbbe", "#4f5660");
|
||||||
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__reaction:hover .chatlog__reaction-count {
|
.chatlog__reaction:hover .chatlog__reaction-count {
|
||||||
color: @Themed("#dcddde", "#2e3338");
|
color: @Themed("#dcddde", "#2e3338");
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__bot-tag {
|
.chatlog__markdown {
|
||||||
position: relative;
|
max-width: 100%;
|
||||||
top: -.1em;
|
|
||||||
margin-left: 0.3em;
|
|
||||||
padding: 0.05em 0.3em;
|
|
||||||
border-radius: 3px;
|
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
background-color: #5865F2;
|
overflow-wrap: break-word;
|
||||||
color: #ffffff;
|
}
|
||||||
font-size: 0.625em;
|
|
||||||
|
.chatlog__markdown-preserve {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-spoiler {
|
||||||
|
background-color: @Themed("rgba(255, 255, 255, 0.1)", "rgba(0, 0, 0, 0.1)");
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-spoiler--hidden {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: @Themed("#202225", "#b9bbbe");
|
||||||
|
color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-spoiler--hidden:hover {
|
||||||
|
background-color: @Themed("rgba(32, 34, 37, 0.8)", "rgba(185, 187, 190, 0.8)");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-spoiler--hidden::selection {
|
||||||
|
color: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-quote {
|
||||||
|
display: flex;
|
||||||
|
margin: 0.05rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-quote-border {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
border: 2px solid @Themed("#4f545c", "#c7ccd1");
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-pre {
|
||||||
|
background-color: @Themed("#2f3136", "#f9f9f9");
|
||||||
|
font-family: "Consolas", "Courier New", Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-pre--multiline {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 2px solid @Themed("#282b30", "#f3f3f3");
|
||||||
|
border-radius: 5px;
|
||||||
|
color: @Themed("#b9bbbe", "#657b83");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-pre--multiline.hljs {
|
||||||
|
background-color: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-pre--inline {
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-mention {
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 2px;
|
||||||
|
background-color: @Themed("rgba(88, 101, 242, .3)", "rgba(88, 101, 242, .15)");
|
||||||
|
color: @Themed("#dee0fc", "#505cdc");
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-mention:hover {
|
||||||
|
background-color: #5865f2;
|
||||||
|
color: #ffffff
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__markdown-timestamp {
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 2px;
|
||||||
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__emoji {
|
||||||
|
width: 1.325rem;
|
||||||
|
height: 1.325rem;
|
||||||
|
margin: 0 0.06rem;
|
||||||
|
vertical-align: -0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__emoji--small {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__emoji--large {
|
||||||
|
width: 2.8rem;
|
||||||
|
height: 2.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
.postamble {
|
.postamble {
|
||||||
margin: 1.4em 0.3em 0.6em 0.3em;
|
padding: 1.25rem;
|
||||||
padding: 1em;
|
|
||||||
border-top: 1px solid @Themed("rgba(255, 255, 255, 0.1)", "#eceeef");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.postamble__entry {
|
.postamble__entry {
|
||||||
@@ -656,54 +691,79 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
@{/* Syntax highlighting */}
|
@{/* Syntax highlighting */}
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-@(Model.ThemeName.ToLowerInvariant()).min.css">
|
<link rel="stylesheet" href="@await ResolveUrlAsync($"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-{Model.ThemeName.ToLowerInvariant()}.min.css")">
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js"></script>
|
<script src="@await ResolveUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js")"></script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.querySelectorAll('.pre--multiline').forEach(block => hljs.highlightBlock(block));
|
document.querySelectorAll('.chatlog__markdown-pre--multiline').forEach(e => hljs.highlightBlock(e));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
@{/* Lottie animation support */}
|
||||||
|
<script src="@await ResolveUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.8.1/lottie.min.js")"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.chatlog__sticker--media[data-source]').forEach(e => {
|
||||||
|
const imageDataUrl = e.getAttribute('data-source');
|
||||||
|
|
||||||
|
const anim = lottie.loadAnimation({
|
||||||
|
container: e,
|
||||||
|
renderer: 'svg',
|
||||||
|
loop: true,
|
||||||
|
autoplay: true,
|
||||||
|
path: imageDataUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
anim.addEventListener('data_failed', () =>
|
||||||
|
e.innerHTML = '<strong>[Sticker cannot be rendered]</strong>'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@{/* Scripts */}
|
@{/* Scripts */}
|
||||||
<script>
|
<script>
|
||||||
function scrollToMessage(event, id) {
|
function scrollToMessage(event, id) {
|
||||||
var element = document.getElementById('message-' + id);
|
var element = document.getElementById('chatlog__message-container-' + id);
|
||||||
|
if (!element)
|
||||||
|
return;
|
||||||
|
|
||||||
if (element) {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
element.classList.add('chatlog__message-container--highlighted');
|
||||||
|
|
||||||
element.classList.add('chatlog__message--highlighted');
|
window.scrollTo({
|
||||||
|
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
|
||||||
window.scrollTo({
|
window.setTimeout(function() {
|
||||||
top: element.getBoundingClientRect().top - document.body.getBoundingClientRect().top - (window.innerHeight / 2),
|
element.classList.remove('chatlog__message-container--highlighted');
|
||||||
behavior: 'smooth'
|
}, 2000);
|
||||||
});
|
|
||||||
|
|
||||||
window.setTimeout(function() {
|
|
||||||
element.classList.remove('chatlog__message--highlighted');
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSpoiler(event, element) {
|
function showSpoiler(event, element) {
|
||||||
if (element && element.classList.contains('spoiler-text--hidden')) {
|
if (!element)
|
||||||
event.preventDefault();
|
return;
|
||||||
element.classList.remove('spoiler-text--hidden');
|
|
||||||
}
|
if (element.classList.contains('chatlog__attachment--hidden')) {
|
||||||
if (element && element.classList.contains('chatlog__attachment--hidden')) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
element.classList.remove('chatlog__attachment--hidden');
|
element.classList.remove('chatlog__attachment--hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (element.classList.contains('chatlog__markdown-spoiler--hidden')) {
|
||||||
|
event.preventDefault();
|
||||||
|
element.classList.remove('chatlog__markdown-spoiler--hidden');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@{/* Icons */}
|
@{/* Icons */}
|
||||||
<svg style="display: none">
|
<svg style="display: none">
|
||||||
<symbol id="icon-attachment" viewBox="0 0 720 960">
|
<symbol id="attachment-icon" viewBox="0 0 720 960">
|
||||||
<path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z" />
|
<path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z"/>
|
||||||
<path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z" />
|
<path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z"/>
|
||||||
<path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z" />
|
<path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z"/>
|
||||||
<path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z" />
|
<path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z"/>
|
||||||
</symbol>
|
</symbol>
|
||||||
</svg>
|
</svg>
|
||||||
</head>
|
</head>
|
||||||
@@ -725,18 +785,18 @@
|
|||||||
@if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null)
|
@if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null)
|
||||||
{
|
{
|
||||||
<div class="preamble__entry preamble__entry--small">
|
<div class="preamble__entry preamble__entry--small">
|
||||||
@if (Model.ExportContext.Request.After is not null && Model.ExportContext.Request.Before is not null)
|
@if (Model.ExportContext.Request.After is not null && Model.ExportContext.Request.Before is not null)
|
||||||
{
|
{
|
||||||
@($"Between {FormatDate(Model.ExportContext.Request.After.Value.ToDate())} and {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
|
@($"Between {FormatDate(Model.ExportContext.Request.After.Value.ToDate())} and {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
|
||||||
}
|
}
|
||||||
else if (Model.ExportContext.Request.After is not null)
|
else if (Model.ExportContext.Request.After is not null)
|
||||||
{
|
{
|
||||||
@($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}")
|
@($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}")
|
||||||
}
|
}
|
||||||
else if (Model.ExportContext.Request.Before is not null)
|
else if (Model.ExportContext.Request.Before is not null)
|
||||||
{
|
{
|
||||||
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
|
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}")
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -13,7 +14,7 @@ internal class HtmlMessageWriter : MessageWriter
|
|||||||
private readonly TextWriter _writer;
|
private readonly TextWriter _writer;
|
||||||
private readonly string _themeName;
|
private readonly string _themeName;
|
||||||
|
|
||||||
private readonly List<Message> _messageGroupBuffer = new();
|
private readonly List<Message> _messageGroup = new();
|
||||||
|
|
||||||
public HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
|
public HtmlMessageWriter(Stream stream, ExportContext context, string themeName)
|
||||||
: base(stream, context)
|
: base(stream, context)
|
||||||
@@ -22,6 +23,23 @@ internal class HtmlMessageWriter : MessageWriter
|
|||||||
_themeName = themeName;
|
_themeName = themeName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool CanJoinGroup(Message message)
|
||||||
|
{
|
||||||
|
var lastMessage = _messageGroup.LastOrDefault();
|
||||||
|
if (lastMessage is null)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return
|
||||||
|
// Must be from the same author
|
||||||
|
lastMessage.Author.Id == message.Author.Id &&
|
||||||
|
// Author's name must not have changed between messages
|
||||||
|
string.Equals(lastMessage.Author.FullName, message.Author.FullName, StringComparison.Ordinal) &&
|
||||||
|
// Duration between messages must be 7 minutes or less
|
||||||
|
(message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes <= 7 &&
|
||||||
|
// Other message must not be a reply
|
||||||
|
message.Reference is null;
|
||||||
|
}
|
||||||
|
|
||||||
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
|
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var templateContext = new PreambleTemplateContext(Context, _themeName);
|
var templateContext = new PreambleTemplateContext(Context, _themeName);
|
||||||
@@ -34,10 +52,10 @@ internal class HtmlMessageWriter : MessageWriter
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask WriteMessageGroupAsync(
|
private async ValueTask WriteMessageGroupAsync(
|
||||||
MessageGroup messageGroup,
|
IReadOnlyList<Message> messages,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var templateContext = new MessageGroupTemplateContext(Context, messageGroup);
|
var templateContext = new MessageGroupTemplateContext(Context, messages);
|
||||||
|
|
||||||
// We are not writing directly to output because Razor
|
// We are not writing directly to output because Razor
|
||||||
// does not actually do asynchronous writes to stream.
|
// does not actually do asynchronous writes to stream.
|
||||||
@@ -52,31 +70,26 @@ internal class HtmlMessageWriter : MessageWriter
|
|||||||
{
|
{
|
||||||
await base.WriteMessageAsync(message, cancellationToken);
|
await base.WriteMessageAsync(message, cancellationToken);
|
||||||
|
|
||||||
// If message group is empty or the given message can be grouped, buffer the given message
|
// If the message can be grouped, buffer it for now
|
||||||
if (!_messageGroupBuffer.Any() || MessageGroup.CanJoin(_messageGroupBuffer.Last(), message))
|
if (CanJoinGroup( message))
|
||||||
{
|
{
|
||||||
_messageGroupBuffer.Add(message);
|
_messageGroup.Add(message);
|
||||||
}
|
}
|
||||||
// Otherwise, flush the group and render messages
|
// Otherwise, flush the group and render messages
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await WriteMessageGroupAsync(MessageGroup.Join(_messageGroupBuffer), cancellationToken);
|
await WriteMessageGroupAsync(_messageGroup, cancellationToken);
|
||||||
|
|
||||||
_messageGroupBuffer.Clear();
|
_messageGroup.Clear();
|
||||||
_messageGroupBuffer.Add(message);
|
_messageGroup.Add(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
|
public override async ValueTask WritePostambleAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// Flush current message group
|
// Flush current message group
|
||||||
if (_messageGroupBuffer.Any())
|
if (_messageGroup.Any())
|
||||||
{
|
await WriteMessageGroupAsync(_messageGroup, cancellationToken);
|
||||||
await WriteMessageGroupAsync(
|
|
||||||
MessageGroup.Join(_messageGroupBuffer),
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var templateContext = new PostambleTemplateContext(Context, MessagesWritten);
|
var templateContext = new PostambleTemplateContext(Context, MessagesWritten);
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,21 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteStickerAsync(
|
||||||
|
Sticker sticker,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("id", sticker.Id.ToString());
|
||||||
|
_writer.WriteString("name", sticker.Name);
|
||||||
|
_writer.WriteString("format", sticker.Format.ToString());
|
||||||
|
_writer.WriteString("sourceUrl", await Context.ResolveMediaUrlAsync(sticker.SourceUrl, cancellationToken));
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
await _writer.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask WriteReactionAsync(
|
private async ValueTask WriteReactionAsync(
|
||||||
Reaction reaction,
|
Reaction reaction,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -169,7 +184,7 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
|
|
||||||
// Emoji
|
// Emoji
|
||||||
_writer.WriteStartObject("emoji");
|
_writer.WriteStartObject("emoji");
|
||||||
_writer.WriteString("id", reaction.Emoji.Id);
|
_writer.WriteString("id", reaction.Emoji.Id.ToString());
|
||||||
_writer.WriteString("name", reaction.Emoji.Name);
|
_writer.WriteString("name", reaction.Emoji.Name);
|
||||||
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
|
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
|
||||||
_writer.WriteString("imageUrl", await Context.ResolveMediaUrlAsync(reaction.Emoji.ImageUrl, cancellationToken));
|
_writer.WriteString("imageUrl", await Context.ResolveMediaUrlAsync(reaction.Emoji.ImageUrl, cancellationToken));
|
||||||
@@ -276,6 +291,14 @@ internal class JsonMessageWriter : MessageWriter
|
|||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Stickers
|
||||||
|
_writer.WriteStartArray("stickers");
|
||||||
|
|
||||||
|
foreach (var sticker in message.Stickers)
|
||||||
|
await WriteStickerAsync(sticker, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
// Reactions
|
// Reactions
|
||||||
_writer.WriteStartArray("reactions");
|
_writer.WriteStartArray("reactions");
|
||||||
|
|
||||||
|
|||||||
+65
-30
@@ -3,7 +3,6 @@ using System.Linq;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using DiscordChatExporter.Core.Discord;
|
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Markdown;
|
using DiscordChatExporter.Core.Markdown;
|
||||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
@@ -34,14 +33,37 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
{
|
{
|
||||||
var (tagOpen, tagClose) = formatting.Kind switch
|
var (tagOpen, tagClose) = formatting.Kind switch
|
||||||
{
|
{
|
||||||
FormattingKind.Bold => ("<strong>", "</strong>"),
|
FormattingKind.Bold => (
|
||||||
FormattingKind.Italic => ("<em>", "</em>"),
|
"<strong>",
|
||||||
FormattingKind.Underline => ("<u>", "</u>"),
|
"</strong>"
|
||||||
FormattingKind.Strikethrough => ("<s>", "</s>"),
|
),
|
||||||
|
|
||||||
|
FormattingKind.Italic => (
|
||||||
|
"<em>",
|
||||||
|
"</em>"
|
||||||
|
),
|
||||||
|
|
||||||
|
FormattingKind.Underline => (
|
||||||
|
"<u>",
|
||||||
|
"</u>"
|
||||||
|
),
|
||||||
|
|
||||||
|
FormattingKind.Strikethrough => (
|
||||||
|
"<s>",
|
||||||
|
"</s>"
|
||||||
|
),
|
||||||
|
|
||||||
FormattingKind.Spoiler => (
|
FormattingKind.Spoiler => (
|
||||||
"<span class=\"spoiler-text spoiler-text--hidden\" onclick=\"showSpoiler(event, this)\">", "</span>"),
|
"<span class=\"chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden\" onclick=\"showSpoiler(event, this)\">",
|
||||||
FormattingKind.Quote => ("<div class=\"quote\">", "</div>"),
|
"</span>"
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(formatting.Kind))
|
),
|
||||||
|
|
||||||
|
FormattingKind.Quote => (
|
||||||
|
"<div class=\"chatlog__markdown-quote\"><div class=\"chatlog__markdown-quote-border\"></div><div class=\"chatlog__markdown-quote-content\">",
|
||||||
|
"</div></div>"
|
||||||
|
),
|
||||||
|
|
||||||
|
_ => throw new InvalidOperationException($"Unknown formatting kind '{formatting.Kind}'.")
|
||||||
};
|
};
|
||||||
|
|
||||||
_buffer.Append(tagOpen);
|
_buffer.Append(tagOpen);
|
||||||
@@ -54,7 +76,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
|
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
|
||||||
{
|
{
|
||||||
_buffer
|
_buffer
|
||||||
.Append("<span class=\"pre pre--inline\">")
|
.Append("<span class=\"chatlog__markdown-pre chatlog__markdown-pre--inline\">")
|
||||||
.Append(HtmlEncode(inlineCodeBlock.Code))
|
.Append(HtmlEncode(inlineCodeBlock.Code))
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
|
|
||||||
@@ -68,7 +90,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
: "nohighlight";
|
: "nohighlight";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<div class=\"pre pre--multiline {highlightCssClass}\">")
|
.Append($"<div class=\"chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightCssClass}\">")
|
||||||
.Append(HtmlEncode(multiLineCodeBlock.Code))
|
.Append(HtmlEncode(multiLineCodeBlock.Code))
|
||||||
.Append("</div>");
|
.Append("</div>");
|
||||||
|
|
||||||
@@ -85,8 +107,8 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
|
|
||||||
_buffer.Append(
|
_buffer.Append(
|
||||||
!string.IsNullOrWhiteSpace(linkedMessageId)
|
!string.IsNullOrWhiteSpace(linkedMessageId)
|
||||||
? $"<a href=\"{Uri.EscapeUriString(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">"
|
? $"<a href=\"{HtmlEncode(link.Url)}\" onclick=\"scrollToMessage(event, '{linkedMessageId}')\">"
|
||||||
: $"<a href=\"{Uri.EscapeUriString(link.Url)}\">"
|
: $"<a href=\"{HtmlEncode(link.Url)}\">"
|
||||||
);
|
);
|
||||||
|
|
||||||
var result = base.VisitLink(link);
|
var result = base.VisitLink(link);
|
||||||
@@ -98,59 +120,66 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
protected override MarkdownNode VisitEmoji(EmojiNode emoji)
|
||||||
{
|
{
|
||||||
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
||||||
var jumboClass = _isJumbo ? "emoji--large" : "";
|
var jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<img loading=\"lazy\" class=\"emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
|
.Append($"<img loading=\"lazy\" class=\"chatlog__emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
|
||||||
|
|
||||||
return base.VisitEmoji(emoji);
|
return base.VisitEmoji(emoji);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override MarkdownNode VisitMention(MentionNode mention)
|
protected override MarkdownNode VisitMention(MentionNode mention)
|
||||||
{
|
{
|
||||||
var mentionId = Snowflake.TryParse(mention.Id);
|
if (mention.Kind == MentionKind.Everyone)
|
||||||
if (mention.Kind == MentionKind.Meta)
|
|
||||||
{
|
{
|
||||||
_buffer
|
_buffer
|
||||||
.Append("<span class=\"mention\">")
|
.Append("<span class=\"chatlog__markdown-mention\">")
|
||||||
.Append("@").Append(HtmlEncode(mention.Id))
|
.Append("@everyone")
|
||||||
|
.Append("</span>");
|
||||||
|
}
|
||||||
|
else if (mention.Kind == MentionKind.Here)
|
||||||
|
{
|
||||||
|
_buffer
|
||||||
|
.Append("<span class=\"chatlog__markdown-mention\">")
|
||||||
|
.Append("@here")
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.User)
|
else if (mention.Kind == MentionKind.User)
|
||||||
{
|
{
|
||||||
var member = mentionId?.Pipe(_context.TryGetMember);
|
var member = mention.TargetId?.Pipe(_context.TryGetMember);
|
||||||
var fullName = member?.User.FullName ?? "Unknown";
|
var fullName = member?.User.FullName ?? "Unknown";
|
||||||
var nick = member?.Nick ?? "Unknown";
|
var nick = member?.Nick ?? "Unknown";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<span class=\"mention\" title=\"{HtmlEncode(fullName)}\">")
|
.Append($"<span class=\"chatlog__markdown-mention\" title=\"{HtmlEncode(fullName)}\">")
|
||||||
.Append("@").Append(HtmlEncode(nick))
|
.Append('@').Append(HtmlEncode(nick))
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
else if (mention.Kind == MentionKind.Channel)
|
||||||
{
|
{
|
||||||
var channel = mentionId?.Pipe(_context.TryGetChannel);
|
var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
|
||||||
var symbol = channel?.IsVoiceChannel == true ? "🔊" : "#";
|
var symbol = channel?.IsVoiceChannel == true ? "🔊" : "#";
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append("<span class=\"mention\">")
|
.Append("<span class=\"chatlog__markdown-mention\">")
|
||||||
.Append(symbol).Append(HtmlEncode(name))
|
.Append(symbol).Append(HtmlEncode(name))
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Role)
|
else if (mention.Kind == MentionKind.Role)
|
||||||
{
|
{
|
||||||
var role = mentionId?.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;
|
||||||
|
|
||||||
var style = color is not null
|
var style = color is not null
|
||||||
? $"color: rgb({color?.R}, {color?.G}, {color?.B}); background-color: rgba({color?.R}, {color?.G}, {color?.B}, 0.1);"
|
? $"color: rgb({color.Value.R}, {color.Value.G}, {color.Value.B}); " +
|
||||||
|
$"background-color: rgba({color.Value.R}, {color.Value.G}, {color.Value.B}, 0.1);"
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<span class=\"mention\" style=\"{style}\">")
|
.Append($"<span class=\"chatlog__markdown-mention\" style=\"{style}\">")
|
||||||
.Append("@").Append(HtmlEncode(name))
|
.Append('@').Append(HtmlEncode(name))
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,12 +188,18 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
|
|||||||
|
|
||||||
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
|
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
|
||||||
{
|
{
|
||||||
|
var dateString = timestamp.Date is not null
|
||||||
|
? _context.FormatDate(timestamp.Date.Value)
|
||||||
|
: "Invalid date";
|
||||||
|
|
||||||
// Timestamp tooltips always use full date regardless of the configured format
|
// Timestamp tooltips always use full date regardless of the configured format
|
||||||
var longDateString = timestamp.Value.ToLocalString("dddd, MMMM d, yyyy h:mm tt");
|
var longDateString = timestamp.Date is not null
|
||||||
|
? timestamp.Date.Value.ToLocalString("dddd, MMMM d, yyyy h:mm tt")
|
||||||
|
: "Invalid date";
|
||||||
|
|
||||||
_buffer
|
_buffer
|
||||||
.Append($"<span class=\"timestamp\" title=\"{HtmlEncode(longDateString)}\">")
|
.Append($"<span class=\"chatlog__markdown-timestamp\" title=\"{HtmlEncode(longDateString)}\">")
|
||||||
.Append(HtmlEncode(_context.FormatDate(timestamp.Value)))
|
.Append(HtmlEncode(dateString))
|
||||||
.Append("</span>");
|
.Append("</span>");
|
||||||
|
|
||||||
return base.VisitUnixTimestamp(timestamp);
|
return base.VisitUnixTimestamp(timestamp);
|
||||||
|
|||||||
+12
-8
@@ -1,5 +1,4 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using DiscordChatExporter.Core.Discord;
|
|
||||||
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;
|
||||||
@@ -36,21 +35,24 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
|||||||
|
|
||||||
protected override MarkdownNode VisitMention(MentionNode mention)
|
protected override MarkdownNode VisitMention(MentionNode mention)
|
||||||
{
|
{
|
||||||
var mentionId = Snowflake.TryParse(mention.Id);
|
if (mention.Kind == MentionKind.Everyone)
|
||||||
if (mention.Kind == MentionKind.Meta)
|
|
||||||
{
|
{
|
||||||
_buffer.Append($"@{mention.Id}");
|
_buffer.Append("@everyone");
|
||||||
|
}
|
||||||
|
else if (mention.Kind == MentionKind.Here)
|
||||||
|
{
|
||||||
|
_buffer.Append("@here");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.User)
|
else if (mention.Kind == MentionKind.User)
|
||||||
{
|
{
|
||||||
var member = mentionId?.Pipe(_context.TryGetMember);
|
var member = mention.TargetId?.Pipe(_context.TryGetMember);
|
||||||
var name = member?.User.Name ?? "Unknown";
|
var name = member?.User.Name ?? "Unknown";
|
||||||
|
|
||||||
_buffer.Append($"@{name}");
|
_buffer.Append($"@{name}");
|
||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
else if (mention.Kind == MentionKind.Channel)
|
||||||
{
|
{
|
||||||
var channel = mentionId?.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}");
|
||||||
@@ -61,7 +63,7 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
|||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Role)
|
else if (mention.Kind == MentionKind.Role)
|
||||||
{
|
{
|
||||||
var role = mentionId?.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}");
|
||||||
@@ -73,7 +75,9 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
|
|||||||
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
|
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp)
|
||||||
{
|
{
|
||||||
_buffer.Append(
|
_buffer.Append(
|
||||||
_context.FormatDate(timestamp.Value)
|
timestamp.Date is not null
|
||||||
|
? _context.FormatDate(timestamp.Date.Value)
|
||||||
|
: "Invalid date"
|
||||||
);
|
);
|
||||||
|
|
||||||
return base.VisitUnixTimestamp(timestamp);
|
return base.VisitUnixTimestamp(timestamp);
|
||||||
|
|||||||
@@ -98,6 +98,25 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteStickersAsync(
|
||||||
|
IReadOnlyList<Sticker> stickers,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!stickers.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync("{Stickers}");
|
||||||
|
|
||||||
|
foreach (var sticker in stickers)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(sticker.SourceUrl, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask WriteReactionsAsync(
|
private async ValueTask WriteReactionsAsync(
|
||||||
IReadOnlyList<Reaction> reactions,
|
IReadOnlyList<Reaction> reactions,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -156,9 +175,10 @@ internal class PlainTextMessageWriter : MessageWriter
|
|||||||
|
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
|
|
||||||
// Attachments, embeds, reactions
|
// Attachments, embeds, reactions, etc.
|
||||||
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
||||||
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
||||||
|
await WriteStickersAsync(message.Stickers, cancellationToken);
|
||||||
await WriteReactionsAsync(message.Reactions, cancellationToken);
|
await WriteReactionsAsync(message.Reactions, cancellationToken);
|
||||||
|
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
using DiscordChatExporter.Core.Utils;
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using DiscordChatExporter.Core.Utils;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown;
|
namespace DiscordChatExporter.Core.Markdown;
|
||||||
|
|
||||||
internal record EmojiNode(
|
internal record EmojiNode(
|
||||||
// Only present on custom emoji
|
// Only present on custom emoji
|
||||||
string? Id,
|
Snowflake? Id,
|
||||||
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
// Name of custom emoji (e.g. LUL) or actual representation of standard emoji (e.g. 🙂)
|
||||||
string Name,
|
string Name,
|
||||||
bool IsAnimated) : MarkdownNode
|
bool IsAnimated) : MarkdownNode
|
||||||
{
|
{
|
||||||
|
public bool IsCustomEmoji => Id is not null;
|
||||||
|
|
||||||
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
// Name of custom emoji (e.g. LUL) or name of standard emoji (e.g. slight_smile)
|
||||||
public string Code => !string.IsNullOrWhiteSpace(Id)
|
public string Code => IsCustomEmoji
|
||||||
? Name
|
? Name
|
||||||
: EmojiIndex.TryGetCode(Name) ?? Name;
|
: EmojiIndex.TryGetCode(Name) ?? Name;
|
||||||
|
|
||||||
public bool IsCustomEmoji => !string.IsNullOrWhiteSpace(Id);
|
|
||||||
|
|
||||||
public EmojiNode(string name)
|
public EmojiNode(string name)
|
||||||
: this(null, name, false)
|
: this(null, name, false)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
internal enum MentionKind
|
internal enum MentionKind
|
||||||
{
|
{
|
||||||
Meta,
|
Everyone,
|
||||||
|
Here,
|
||||||
User,
|
User,
|
||||||
Channel,
|
Channel,
|
||||||
Role
|
Role
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
namespace DiscordChatExporter.Core.Markdown;
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
internal record MentionNode(string Id, MentionKind Kind) : MarkdownNode;
|
namespace DiscordChatExporter.Core.Markdown;
|
||||||
|
|
||||||
|
// Null ID means it's a meta mention or an invalid mention
|
||||||
|
internal record MentionNode(Snowflake? TargetId, MentionKind Kind) : MarkdownNode;
|
||||||
@@ -16,7 +16,7 @@ internal class AggregateMatcher<T> : IMatcher<T>
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public ParsedMatch<T>? TryMatch(StringPart stringPart)
|
public ParsedMatch<T>? TryMatch(StringSegment segment)
|
||||||
{
|
{
|
||||||
ParsedMatch<T>? earliestMatch = null;
|
ParsedMatch<T>? earliestMatch = null;
|
||||||
|
|
||||||
@@ -24,19 +24,19 @@ internal class AggregateMatcher<T> : IMatcher<T>
|
|||||||
foreach (var matcher in _matchers)
|
foreach (var matcher in _matchers)
|
||||||
{
|
{
|
||||||
// Try to match
|
// Try to match
|
||||||
var match = matcher.TryMatch(stringPart);
|
var match = matcher.TryMatch(segment);
|
||||||
|
|
||||||
// If there's no match - continue
|
// If there's no match - continue
|
||||||
if (match is null)
|
if (match is null)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// If this match is earlier than previous earliest - replace
|
// If this match is earlier than previous earliest - replace
|
||||||
if (earliestMatch is null || match.StringPart.StartIndex < earliestMatch.StringPart.StartIndex)
|
if (earliestMatch is null || match.Segment.StartIndex < earliestMatch.Segment.StartIndex)
|
||||||
earliestMatch = match;
|
earliestMatch = match;
|
||||||
|
|
||||||
// If the earliest match starts at the very beginning - break,
|
// If the earliest match starts at the very beginning - break,
|
||||||
// because it's impossible to find a match earlier than that
|
// because it's impossible to find a match earlier than that
|
||||||
if (earliestMatch.StringPart.StartIndex == stringPart.StartIndex)
|
if (earliestMatch.Segment.StartIndex == segment.StartIndex)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,44 +5,57 @@ namespace DiscordChatExporter.Core.Markdown.Parsing;
|
|||||||
|
|
||||||
internal interface IMatcher<T>
|
internal interface IMatcher<T>
|
||||||
{
|
{
|
||||||
ParsedMatch<T>? TryMatch(StringPart stringPart);
|
ParsedMatch<T>? TryMatch(StringSegment segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static class MatcherExtensions
|
internal static class MatcherExtensions
|
||||||
{
|
{
|
||||||
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(this IMatcher<T> matcher,
|
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(
|
||||||
StringPart stringPart, Func<StringPart, T> transformFallback)
|
this IMatcher<T> matcher,
|
||||||
|
StringSegment segment,
|
||||||
|
Func<StringSegment, T> transformFallback)
|
||||||
{
|
{
|
||||||
// Loop through segments divided by individual matches
|
// Loop through segments divided by individual matches
|
||||||
var currentIndex = stringPart.StartIndex;
|
var currentIndex = segment.StartIndex;
|
||||||
while (currentIndex < stringPart.EndIndex)
|
while (currentIndex < segment.EndIndex)
|
||||||
{
|
{
|
||||||
// Find a match within this segment
|
// Find a match within this segment
|
||||||
var match = matcher.TryMatch(stringPart.Slice(currentIndex, stringPart.EndIndex - currentIndex));
|
var match = matcher.TryMatch(
|
||||||
|
segment.Relocate(
|
||||||
|
currentIndex,
|
||||||
|
segment.EndIndex - currentIndex
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
// If there's no match - break
|
|
||||||
if (match is null)
|
if (match is null)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// If this match doesn't start immediately at current index - transform and yield fallback first
|
// If this match doesn't start immediately at the current position - transform and yield fallback first
|
||||||
if (match.StringPart.StartIndex > currentIndex)
|
if (match.Segment.StartIndex > currentIndex)
|
||||||
{
|
{
|
||||||
var fallbackPart = stringPart.Slice(currentIndex, match.StringPart.StartIndex - currentIndex);
|
var fallbackSegment = segment.Relocate(
|
||||||
yield return new ParsedMatch<T>(fallbackPart, transformFallback(fallbackPart));
|
currentIndex,
|
||||||
|
match.Segment.StartIndex - currentIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yield match
|
|
||||||
yield return match;
|
yield return match;
|
||||||
|
|
||||||
// Shift current index to the end of the match
|
// Shift current index to the end of the match
|
||||||
currentIndex = match.StringPart.StartIndex + match.StringPart.Length;
|
currentIndex = match.Segment.StartIndex + match.Segment.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If EOL wasn't reached - transform and yield remaining part as fallback
|
// If EOL hasn't been reached - transform and yield remaining part as fallback
|
||||||
if (currentIndex < stringPart.EndIndex)
|
if (currentIndex < segment.EndIndex)
|
||||||
{
|
{
|
||||||
var fallbackPart = stringPart.Slice(currentIndex);
|
var fallbackSegment = segment.Relocate(
|
||||||
yield return new ParsedMatch<T>(fallbackPart, transformFallback(fallbackPart));
|
currentIndex,
|
||||||
|
segment.EndIndex - currentIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Utils;
|
using DiscordChatExporter.Core.Utils;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
@@ -23,7 +24,7 @@ internal static partial class MarkdownParser
|
|||||||
// Capture any character until the earliest double asterisk not followed by an asterisk
|
// Capture any character until the earliest double asterisk not followed by an asterisk
|
||||||
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("\\*\\*(.+?)\\*\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattingNode(FormattingKind.Bold, Parse(p.Slice(m.Groups[1])))
|
(s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1])))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture any character until the earliest single asterisk not preceded or followed by an asterisk
|
// Capture any character until the earliest single asterisk not preceded or followed by an asterisk
|
||||||
@@ -31,61 +32,62 @@ internal static partial class MarkdownParser
|
|||||||
// Closing asterisk must not be preceded by whitespace
|
// Closing asterisk must not be preceded by whitespace
|
||||||
private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("\\*(?!\\s)(.+?)(?<!\\s|\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattingNode(FormattingKind.Italic, Parse(p.Slice(m.Groups[1])))
|
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture any character until the earliest triple asterisk not followed by an asterisk
|
// Capture any character until the earliest triple asterisk not followed by an asterisk
|
||||||
private static readonly IMatcher<MarkdownNode> ItalicBoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> ItalicBoldFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("\\*(\\*\\*.+?\\*\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("\\*(\\*\\*.+?\\*\\*)\\*(?!\\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattingNode(FormattingKind.Italic, Parse(p.Slice(m.Groups[1]), BoldFormattingNodeMatcher))
|
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture any character except underscore until an underscore
|
// Capture any character except underscore until an underscore
|
||||||
// Closing underscore must not be followed by a word character
|
// Closing underscore must not be followed by a word character
|
||||||
private static readonly IMatcher<MarkdownNode> ItalicAltFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> ItalicAltFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("_([^_]+)_(?!\\w)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("_([^_]+)_(?!\\w)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattingNode(FormattingKind.Italic, Parse(p.Slice(m.Groups[1])))
|
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture any character until the earliest double underscore not followed by an underscore
|
// Capture any character until the earliest double underscore not followed by an underscore
|
||||||
private static readonly IMatcher<MarkdownNode> UnderlineFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> UnderlineFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattingNode(FormattingKind.Underline, Parse(p.Slice(m.Groups[1])))
|
(s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1])))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture any character until the earliest triple underscore not followed by an underscore
|
// Capture any character until the earliest triple underscore not followed by an underscore
|
||||||
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher =
|
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattingNode(FormattingKind.Italic,
|
(s, m) => new FormattingNode(FormattingKind.Italic,
|
||||||
Parse(p.Slice(m.Groups[1]), UnderlineFormattingNodeMatcher))
|
Parse(s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture any character until the earliest double tilde
|
// Capture any character until the earliest double tilde
|
||||||
private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher =
|
private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattingNode(FormattingKind.Strikethrough, Parse(p.Slice(m.Groups[1])))
|
(s, m) => new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1])))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture any character until the earliest double pipe
|
// Capture any character until the earliest double pipe
|
||||||
private static readonly IMatcher<MarkdownNode> SpoilerFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> SpoilerFormattingNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("\\|\\|(.+?)\\|\\|", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattingNode(FormattingKind.Spoiler, Parse(p.Slice(m.Groups[1])))
|
(s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(m.Groups[1])))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture any character until the end of the line
|
// Capture any character until the end of the line
|
||||||
// Opening 'greater than' character must be followed by whitespace
|
// Opening 'greater than' character must be followed by whitespace
|
||||||
|
// Text content is optional
|
||||||
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("^>\\s(.+\n?)", DefaultRegexOptions),
|
new Regex("^>\\s(.*\n?)", DefaultRegexOptions),
|
||||||
(p, m) => new FormattingNode(FormattingKind.Quote, Parse(p.Slice(m.Groups[1])))
|
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Repeatedly capture any character until the end of the line
|
// Repeatedly capture any character until the end of the line
|
||||||
// This one is tricky as it ends up producing multiple separate captures which need to be joined
|
// This one is tricky as it ends up producing multiple separate captures which need to be joined
|
||||||
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
|
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
|
||||||
new RegexMatcher<MarkdownNode>(
|
new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("(?:^>\\s(.+\n?)){2,}", DefaultRegexOptions),
|
new Regex("(?:^>\\s(.*\n?)){2,}", DefaultRegexOptions),
|
||||||
(_, m) =>
|
(_, m) =>
|
||||||
{
|
{
|
||||||
var content = string.Concat(m.Groups[1].Captures.Select(c => c.Value));
|
var content = string.Concat(m.Groups[1].Captures.Select(c => c.Value));
|
||||||
@@ -97,7 +99,7 @@ internal static partial class MarkdownParser
|
|||||||
// Opening 'greater than' characters must be followed by whitespace
|
// Opening 'greater than' characters must be followed by whitespace
|
||||||
private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("^>>>\\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
|
new Regex("^>>>\\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||||
(p, m) => new FormattingNode(FormattingKind.Quote, Parse(p.Slice(m.Groups[1])))
|
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Code blocks */
|
/* Code blocks */
|
||||||
@@ -123,31 +125,31 @@ internal static partial class MarkdownParser
|
|||||||
// Capture @everyone
|
// Capture @everyone
|
||||||
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher = new StringMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher = new StringMatcher<MarkdownNode>(
|
||||||
"@everyone",
|
"@everyone",
|
||||||
_ => new MentionNode("everyone", MentionKind.Meta)
|
_ => new MentionNode(null, MentionKind.Everyone)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture @here
|
// Capture @here
|
||||||
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher = new StringMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher = new StringMatcher<MarkdownNode>(
|
||||||
"@here",
|
"@here",
|
||||||
_ => new MentionNode("here", MentionKind.Meta)
|
_ => new MentionNode(null, MentionKind.Here)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture <@123456> or <@!123456>
|
// Capture <@123456> or <@!123456>
|
||||||
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
|
new Regex("<@!?(\\d+)>", DefaultRegexOptions),
|
||||||
(_, m) => new MentionNode(m.Groups[1].Value, MentionKind.User)
|
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture <#123456>
|
// Capture <#123456>
|
||||||
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("<#!?(\\d+)>", DefaultRegexOptions),
|
new Regex("<#!?(\\d+)>", DefaultRegexOptions),
|
||||||
(_, m) => new MentionNode(m.Groups[1].Value, MentionKind.Channel)
|
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture <@&123456>
|
// Capture <@&123456>
|
||||||
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("<@&(\\d+)>", DefaultRegexOptions),
|
new Regex("<@&(\\d+)>", DefaultRegexOptions),
|
||||||
(_, m) => new MentionNode(m.Groups[1].Value, MentionKind.Role)
|
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role)
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Emoji */
|
/* Emoji */
|
||||||
@@ -177,7 +179,11 @@ internal static partial class MarkdownParser
|
|||||||
// Capture <:lul:123456> or <a:lul:123456>
|
// Capture <:lul:123456> or <a:lul:123456>
|
||||||
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
|
new Regex("<(a)?:(.+?):(\\d+?)>", DefaultRegexOptions),
|
||||||
(_, m) => new EmojiNode(m.Groups[3].Value, m.Groups[2].Value, !string.IsNullOrWhiteSpace(m.Groups[1].Value))
|
(_, m) => new EmojiNode(
|
||||||
|
Snowflake.TryParse(m.Groups[3].Value),
|
||||||
|
m.Groups[2].Value,
|
||||||
|
!string.IsNullOrWhiteSpace(m.Groups[1].Value)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/* Links */
|
/* Links */
|
||||||
@@ -185,7 +191,7 @@ internal static partial class MarkdownParser
|
|||||||
// Capture [title](link)
|
// Capture [title](link)
|
||||||
private static readonly IMatcher<MarkdownNode> TitledLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> TitledLinkNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
|
new Regex("\\[(.+?)\\]\\((.+?)\\)", DefaultRegexOptions),
|
||||||
(p, m) => new LinkNode(m.Groups[2].Value, Parse(p.Slice(m.Groups[1])))
|
(s, m) => new LinkNode(m.Groups[2].Value, Parse(s.Relocate(m.Groups[1])))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture any non-whitespace character after http:// or https://
|
// Capture any non-whitespace character after http:// or https://
|
||||||
@@ -207,7 +213,7 @@ internal static partial class MarkdownParser
|
|||||||
// This escapes it from matching for formatting
|
// This escapes it from matching for formatting
|
||||||
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher = new StringMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher = new StringMatcher<MarkdownNode>(
|
||||||
@"¯\_(ツ)_/¯",
|
@"¯\_(ツ)_/¯",
|
||||||
p => new TextNode(p.ToString())
|
s => new TextNode(s.ToString())
|
||||||
);
|
);
|
||||||
|
|
||||||
// Capture some specific emoji that don't get rendered
|
// Capture some specific emoji that don't get rendered
|
||||||
@@ -235,7 +241,7 @@ internal static partial class MarkdownParser
|
|||||||
|
|
||||||
// Capture <t:12345678> or <t:12345678:R>
|
// Capture <t:12345678> or <t:12345678:R>
|
||||||
private static readonly IMatcher<MarkdownNode> UnixTimestampNodeMatcher = new RegexMatcher<MarkdownNode>(
|
private static readonly IMatcher<MarkdownNode> UnixTimestampNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||||
new Regex("<t:(\\d+)(?::\\w)?>", DefaultRegexOptions),
|
new Regex("<t:(-?\\d+)(?::\\w)?>", DefaultRegexOptions),
|
||||||
(_, m) =>
|
(_, m) =>
|
||||||
{
|
{
|
||||||
// TODO: support formatting parameters
|
// TODO: support formatting parameters
|
||||||
@@ -244,17 +250,19 @@ internal static partial class MarkdownParser
|
|||||||
if (!long.TryParse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture,
|
if (!long.TryParse(m.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture,
|
||||||
out var offset))
|
out var offset))
|
||||||
{
|
{
|
||||||
return null;
|
return new UnixTimestampNode(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bound check
|
try
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/681
|
|
||||||
if (offset < TimeSpan.MinValue.TotalSeconds || offset > TimeSpan.MaxValue.TotalSeconds)
|
|
||||||
{
|
{
|
||||||
return null;
|
return new UnixTimestampNode(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(offset));
|
||||||
|
}
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/681
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/766
|
||||||
|
catch (Exception ex) when (ex is ArgumentOutOfRangeException or OverflowException)
|
||||||
|
{
|
||||||
|
return new UnixTimestampNode(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new UnixTimestampNode(DateTimeOffset.UnixEpoch + TimeSpan.FromSeconds(offset));
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -321,24 +329,24 @@ internal static partial class MarkdownParser
|
|||||||
UnixTimestampNodeMatcher
|
UnixTimestampNodeMatcher
|
||||||
);
|
);
|
||||||
|
|
||||||
private static IReadOnlyList<MarkdownNode> Parse(StringPart stringPart, IMatcher<MarkdownNode> matcher) =>
|
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment, IMatcher<MarkdownNode> matcher) =>
|
||||||
matcher
|
matcher
|
||||||
.MatchAll(stringPart, p => new TextNode(p.ToString()))
|
.MatchAll(segment, s => new TextNode(s.ToString()))
|
||||||
.Select(r => r.Value)
|
.Select(r => r.Value)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static partial class MarkdownParser
|
internal static partial class MarkdownParser
|
||||||
{
|
{
|
||||||
private static IReadOnlyList<MarkdownNode> Parse(StringPart stringPart) =>
|
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment) =>
|
||||||
Parse(stringPart, AggregateNodeMatcher);
|
Parse(segment, AggregateNodeMatcher);
|
||||||
|
|
||||||
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringPart stringPart) =>
|
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringSegment segment) =>
|
||||||
Parse(stringPart, MinimalAggregateNodeMatcher);
|
Parse(segment, MinimalAggregateNodeMatcher);
|
||||||
|
|
||||||
public static IReadOnlyList<MarkdownNode> Parse(string input) =>
|
public static IReadOnlyList<MarkdownNode> Parse(string input) =>
|
||||||
Parse(new StringPart(input));
|
Parse(new StringSegment(input));
|
||||||
|
|
||||||
public static IReadOnlyList<MarkdownNode> ParseMinimal(string input) =>
|
public static IReadOnlyList<MarkdownNode> ParseMinimal(string input) =>
|
||||||
ParseMinimal(new StringPart(input));
|
ParseMinimal(new StringSegment(input));
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
internal class ParsedMatch<T>
|
internal class ParsedMatch<T>
|
||||||
{
|
{
|
||||||
public StringPart StringPart { get; }
|
public StringSegment Segment { get; }
|
||||||
|
|
||||||
public T Value { get; }
|
public T Value { get; }
|
||||||
|
|
||||||
public ParsedMatch(StringPart stringPart, T value)
|
public ParsedMatch(StringSegment segment, T value)
|
||||||
{
|
{
|
||||||
StringPart = stringPart;
|
Segment = segment;
|
||||||
Value = value;
|
Value = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,17 +6,17 @@ namespace DiscordChatExporter.Core.Markdown.Parsing;
|
|||||||
internal class RegexMatcher<T> : IMatcher<T>
|
internal class RegexMatcher<T> : IMatcher<T>
|
||||||
{
|
{
|
||||||
private readonly Regex _regex;
|
private readonly Regex _regex;
|
||||||
private readonly Func<StringPart, Match, T?> _transform;
|
private readonly Func<StringSegment, Match, T?> _transform;
|
||||||
|
|
||||||
public RegexMatcher(Regex regex, Func<StringPart, Match, T?> transform)
|
public RegexMatcher(Regex regex, Func<StringSegment, Match, T?> transform)
|
||||||
{
|
{
|
||||||
_regex = regex;
|
_regex = regex;
|
||||||
_transform = transform;
|
_transform = transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ParsedMatch<T>? TryMatch(StringPart stringPart)
|
public ParsedMatch<T>? TryMatch(StringSegment segment)
|
||||||
{
|
{
|
||||||
var match = _regex.Match(stringPart.Target, stringPart.StartIndex, stringPart.Length);
|
var match = _regex.Match(segment.Source, segment.StartIndex, segment.Length);
|
||||||
if (!match.Success)
|
if (!match.Success)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@@ -25,14 +25,14 @@ 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(stringPart.Target[..stringPart.EndIndex], stringPart.StartIndex))
|
if (!_regex.IsMatch(segment.Source[..segment.EndIndex], segment.StartIndex))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var stringPartMatch = stringPart.Slice(match.Index, match.Length);
|
var segmentMatch = segment.Relocate(match);
|
||||||
var value = _transform(stringPartMatch, match);
|
var value = _transform(segmentMatch, match);
|
||||||
|
|
||||||
return value is not null
|
return value is not null
|
||||||
? new ParsedMatch<T>(stringPartMatch, value)
|
? new ParsedMatch<T>(segmentMatch, value)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,31 +6,31 @@ internal class StringMatcher<T> : IMatcher<T>
|
|||||||
{
|
{
|
||||||
private readonly string _needle;
|
private readonly string _needle;
|
||||||
private readonly StringComparison _comparison;
|
private readonly StringComparison _comparison;
|
||||||
private readonly Func<StringPart, T?> _transform;
|
private readonly Func<StringSegment, T?> _transform;
|
||||||
|
|
||||||
public StringMatcher(string needle, StringComparison comparison, Func<StringPart, T?> transform)
|
public StringMatcher(string needle, StringComparison comparison, Func<StringSegment, T?> transform)
|
||||||
{
|
{
|
||||||
_needle = needle;
|
_needle = needle;
|
||||||
_comparison = comparison;
|
_comparison = comparison;
|
||||||
_transform = transform;
|
_transform = transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
public StringMatcher(string needle, Func<StringPart, T> transform)
|
public StringMatcher(string needle, Func<StringSegment, T> transform)
|
||||||
: this(needle, StringComparison.Ordinal, transform)
|
: this(needle, StringComparison.Ordinal, transform)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public ParsedMatch<T>? TryMatch(StringPart stringPart)
|
public ParsedMatch<T>? TryMatch(StringSegment segment)
|
||||||
{
|
{
|
||||||
var index = stringPart.Target.IndexOf(_needle, stringPart.StartIndex, stringPart.Length, _comparison);
|
var index = segment.Source.IndexOf(_needle, segment.StartIndex, segment.Length, _comparison);
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var stringPartMatch = stringPart.Slice(index, _needle.Length);
|
var segmentMatch = segment.Relocate(index, _needle.Length);
|
||||||
var value = _transform(stringPartMatch);
|
var value = _transform(segmentMatch);
|
||||||
|
|
||||||
return value is not null
|
return value is not null
|
||||||
? new ParsedMatch<T>(stringPartMatch, value)
|
? new ParsedMatch<T>(segmentMatch, value)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
|
||||||
|
|
||||||
internal readonly record struct StringPart(string Target, int StartIndex, int Length)
|
|
||||||
{
|
|
||||||
public int EndIndex => StartIndex + Length;
|
|
||||||
|
|
||||||
public StringPart(string target)
|
|
||||||
: this(target, 0, target.Length)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public StringPart Slice(int newStartIndex, int newLength) => new(Target, newStartIndex, newLength);
|
|
||||||
|
|
||||||
public StringPart Slice(int newStartIndex) => Slice(newStartIndex, EndIndex - newStartIndex);
|
|
||||||
|
|
||||||
public StringPart Slice(Capture capture) => Slice(capture.Index, capture.Length);
|
|
||||||
|
|
||||||
public override string ToString() => Target.Substring(StartIndex, Length);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
|
|
||||||
|
internal readonly record struct StringSegment(string Source, int StartIndex, int Length)
|
||||||
|
{
|
||||||
|
public int EndIndex => StartIndex + Length;
|
||||||
|
|
||||||
|
public StringSegment(string target)
|
||||||
|
: this(target, 0, target.Length)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public StringSegment Relocate(int newStartIndex, int newLength) => new(Source, newStartIndex, newLength);
|
||||||
|
|
||||||
|
public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length);
|
||||||
|
|
||||||
|
public override string ToString() => Source.Substring(StartIndex, Length);
|
||||||
|
}
|
||||||
@@ -2,4 +2,5 @@
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Core.Markdown;
|
namespace DiscordChatExporter.Core.Markdown;
|
||||||
|
|
||||||
internal record UnixTimestampNode(DateTimeOffset Value) : MarkdownNode;
|
// Null date means invalid timestamp
|
||||||
|
internal record UnixTimestampNode(DateTimeOffset? Date) : MarkdownNode;
|
||||||
@@ -47,6 +47,29 @@
|
|||||||
<Setter Property="Minimum" Value="0" />
|
<Setter Property="Minimum" Value="0" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style TargetType="{x:Type Hyperlink}">
|
||||||
|
<Setter Property="TextDecorations" Value="Underline" />
|
||||||
|
<Setter Property="FontWeight" Value="Regular" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignBody}" />
|
||||||
|
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsEnabled" Value="False">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsEnabled" Value="True">
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
</Trigger>
|
||||||
|
<MultiTrigger>
|
||||||
|
<MultiTrigger.Conditions>
|
||||||
|
<Condition Property="IsEnabled" Value="True" />
|
||||||
|
<Condition Property="IsMouseOver" Value="True" />
|
||||||
|
</MultiTrigger.Conditions>
|
||||||
|
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
|
||||||
|
</MultiTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
|
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
|
||||||
|
|
||||||
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
|
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
|
||||||
@@ -90,6 +113,10 @@
|
|||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<Style BasedOn="{StaticResource MaterialDesignContextMenu}" TargetType="{x:Type ContextMenu}">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource MaterialDesignPaper}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
|
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
|
||||||
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
|
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
|
||||||
<Setter Property="Template">
|
<Setter Property="Template">
|
||||||
|
|||||||
@@ -9,11 +9,16 @@ namespace DiscordChatExporter.Gui.Behaviors;
|
|||||||
|
|
||||||
public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
|
public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
|
||||||
{
|
{
|
||||||
public static readonly DependencyProperty SelectedItemsProperty =
|
public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register(
|
||||||
DependencyProperty.Register(nameof(SelectedItems), typeof(IList),
|
nameof(SelectedItems),
|
||||||
typeof(MultiSelectionListBoxBehavior<T>),
|
typeof(IList),
|
||||||
new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
|
typeof(MultiSelectionListBoxBehavior<T>),
|
||||||
OnSelectedItemsChanged));
|
new FrameworkPropertyMetadata(
|
||||||
|
null,
|
||||||
|
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
|
||||||
|
OnSelectedItemsChanged
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
private static void OnSelectedItemsChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
|
private static void OnSelectedItemsChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,19 +9,13 @@ 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
|
||||||
if (value is DateTimeOffset dateTimeOffsetValue)
|
? dateTimeOffsetValue.DateTime
|
||||||
return dateTimeOffsetValue.DateTime;
|
: default(DateTime?);
|
||||||
|
|
||||||
return default(DateTime?);
|
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
}
|
value is DateTime dateTimeValue
|
||||||
|
? new DateTimeOffset(dateTimeValue)
|
||||||
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
: default(DateTimeOffset?);
|
||||||
{
|
|
||||||
if (value is DateTime dateTimeValue)
|
|
||||||
return new DateTimeOffset(dateTimeValue);
|
|
||||||
|
|
||||||
return default(DateTimeOffset?);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -10,13 +10,10 @@ 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(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
{
|
value is ExportFormat exportFormatValue
|
||||||
if (value is ExportFormat exportFormatValue)
|
? exportFormatValue.GetDisplayName()
|
||||||
return exportFormatValue.GetDisplayName();
|
: default;
|
||||||
|
|
||||||
return default(string?);
|
|
||||||
}
|
|
||||||
|
|
||||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|||||||
@@ -9,19 +9,9 @@ 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;
|
||||||
if (value is bool boolValue)
|
|
||||||
return !boolValue;
|
|
||||||
|
|
||||||
return default(bool);
|
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
}
|
value is false;
|
||||||
|
|
||||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
|
||||||
{
|
|
||||||
if (value is bool boolValue)
|
|
||||||
return !boolValue;
|
|
||||||
|
|
||||||
return default(bool);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -9,19 +9,13 @@ 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(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
{
|
value is TimeSpan timeSpanValue
|
||||||
if (value is TimeSpan timeSpanValue)
|
? DateTime.Today.Add(timeSpanValue)
|
||||||
return DateTime.Today.Add(timeSpanValue);
|
: default(DateTime?);
|
||||||
|
|
||||||
return default(DateTime?);
|
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||||
}
|
value is DateTime dateTimeValue
|
||||||
|
? dateTimeValue.TimeOfDay
|
||||||
public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
: default(TimeSpan?);
|
||||||
{
|
|
||||||
if (value is DateTime dateTimeValue)
|
|
||||||
return dateTimeValue.TimeOfDay;
|
|
||||||
|
|
||||||
return default(TimeSpan?);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -13,16 +13,16 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Gress" Version="1.2.0" />
|
<PackageReference Include="Gress" Version="2.0.1" />
|
||||||
<PackageReference Include="MaterialDesignColors" Version="2.0.4" />
|
<PackageReference Include="MaterialDesignColors" Version="2.0.5" />
|
||||||
<PackageReference Include="MaterialDesignThemes" Version="4.3.0" />
|
<PackageReference Include="MaterialDesignThemes" Version="4.4.0" />
|
||||||
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
|
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
|
||||||
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.0" />
|
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
|
||||||
<PackageReference Include="Onova" Version="2.6.2" />
|
<PackageReference Include="Onova" Version="2.6.2" />
|
||||||
<PackageReference Include="Stylet" Version="1.3.6" />
|
<PackageReference Include="Stylet" Version="1.3.6" />
|
||||||
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
|
||||||
<PackageReference Include="PropertyChanged.Fody" Version="3.4.0" PrivateAssets="all" />
|
<PackageReference Include="PropertyChanged.Fody" Version="3.4.0" PrivateAssets="all" />
|
||||||
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.0.2" PrivateAssets="all" />
|
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.2.0" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using DiscordChatExporter.Core.Exporting;
|
using Microsoft.Win32;
|
||||||
using Tyrrrz.Settings;
|
using Tyrrrz.Settings;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.Services;
|
namespace DiscordChatExporter.Gui.Services;
|
||||||
|
|
||||||
public class SettingsService : SettingsManager
|
public partial class SettingsService : SettingsManager
|
||||||
{
|
{
|
||||||
public bool IsAutoUpdateEnabled { get; set; } = true;
|
public bool IsAutoUpdateEnabled { get; set; } = true;
|
||||||
|
|
||||||
public bool IsDarkModeEnabled { get; set; }
|
public bool IsDarkModeEnabled { get; set; } = IsDarkModeEnabledByDefault();
|
||||||
|
|
||||||
public bool IsTokenPersisted { get; set; } = true;
|
public bool IsTokenPersisted { get; set; } = true;
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ public class SettingsService : SettingsManager
|
|||||||
|
|
||||||
public bool ShouldReuseMedia { get; set; }
|
public bool ShouldReuseMedia { get; set; }
|
||||||
|
|
||||||
public AuthToken? LastToken { get; set; }
|
public string? LastToken { get; set; }
|
||||||
|
|
||||||
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
|
|
||||||
@@ -36,4 +36,21 @@ public class SettingsService : SettingsManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
public bool ShouldSerializeLastToken() => IsTokenPersisted;
|
public bool ShouldSerializeLastToken() => IsTokenPersisted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class SettingsService
|
||||||
|
{
|
||||||
|
private static bool IsDarkModeEnabledByDefault()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Registry.CurrentUser.OpenSubKey(
|
||||||
|
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", false
|
||||||
|
)?.GetValue("AppsUseLightTheme") is 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Utils;
|
||||||
|
|
||||||
|
internal static class Internationalization
|
||||||
|
{
|
||||||
|
public static bool Is24Hours =>
|
||||||
|
string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator) &&
|
||||||
|
string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ internal static class ProcessEx
|
|||||||
UseShellExecute = true
|
UseShellExecute = true
|
||||||
};
|
};
|
||||||
|
|
||||||
using (Process.Start(startInfo)) {}
|
using (Process.Start(startInfo))
|
||||||
|
{
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
using DiscordChatExporter.Core.Exceptions;
|
||||||
|
using DiscordChatExporter.Core.Exporting;
|
||||||
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
using DiscordChatExporter.Gui.Services;
|
||||||
|
using DiscordChatExporter.Gui.Utils;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Messages;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Framework;
|
||||||
|
using Gress;
|
||||||
|
using Gress.Completable;
|
||||||
|
using Stylet;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.ViewModels.Components;
|
||||||
|
|
||||||
|
public class DashboardViewModel : PropertyChangedBase
|
||||||
|
{
|
||||||
|
private readonly IViewModelFactory _viewModelFactory;
|
||||||
|
private readonly IEventAggregator _eventAggregator;
|
||||||
|
private readonly DialogManager _dialogManager;
|
||||||
|
private readonly SettingsService _settingsService;
|
||||||
|
|
||||||
|
private readonly AutoResetProgressMuxer _progressMuxer;
|
||||||
|
|
||||||
|
private DiscordClient? _discord;
|
||||||
|
|
||||||
|
public bool IsBusy { get; private set; }
|
||||||
|
|
||||||
|
public ProgressContainer<Percentage> Progress { get; } = new();
|
||||||
|
|
||||||
|
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
|
||||||
|
|
||||||
|
public string? Token { get; set; }
|
||||||
|
|
||||||
|
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Guild>? AvailableGuilds => GuildChannelMap?.Keys.ToArray();
|
||||||
|
|
||||||
|
public Guild? SelectedGuild { get; set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Channel>? AvailableChannels => SelectedGuild is not null
|
||||||
|
? GuildChannelMap?[SelectedGuild]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
public IReadOnlyList<Channel>? SelectedChannels { get; set; }
|
||||||
|
|
||||||
|
public DashboardViewModel(
|
||||||
|
IViewModelFactory viewModelFactory,
|
||||||
|
IEventAggregator eventAggregator,
|
||||||
|
DialogManager dialogManager,
|
||||||
|
SettingsService settingsService)
|
||||||
|
{
|
||||||
|
_viewModelFactory = viewModelFactory;
|
||||||
|
_eventAggregator = eventAggregator;
|
||||||
|
_dialogManager = dialogManager;
|
||||||
|
_settingsService = settingsService;
|
||||||
|
|
||||||
|
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
|
||||||
|
|
||||||
|
this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
|
||||||
|
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnViewLoaded()
|
||||||
|
{
|
||||||
|
if (_settingsService.LastToken is not null)
|
||||||
|
{
|
||||||
|
Token = _settingsService.LastToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void ShowSettings()
|
||||||
|
{
|
||||||
|
var dialog = _viewModelFactory.CreateSettingsViewModel();
|
||||||
|
await _dialogManager.ShowDialogAsync(dialog);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
|
||||||
|
|
||||||
|
public bool CanPopulateGuildsAndChannels =>
|
||||||
|
!IsBusy && !string.IsNullOrWhiteSpace(Token);
|
||||||
|
|
||||||
|
public async void PopulateGuildsAndChannels()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
var progress = _progressMuxer.CreateInput();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = Token?.Trim('"', ' ');
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_settingsService.LastToken = token;
|
||||||
|
|
||||||
|
var discord = new DiscordClient(token);
|
||||||
|
|
||||||
|
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
||||||
|
await foreach (var guild in discord.GetUserGuildsAsync())
|
||||||
|
{
|
||||||
|
var channels = await discord.GetGuildChannelsAsync(guild.Id);
|
||||||
|
guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
_discord = discord;
|
||||||
|
GuildChannelMap = guildChannelMap;
|
||||||
|
SelectedGuild = guildChannelMap.Keys.FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||||
|
{
|
||||||
|
_eventAggregator.Publish(
|
||||||
|
new NotificationMessage(ex.Message.TrimEnd('.'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
|
||||||
|
"Error pulling guilds and channels",
|
||||||
|
ex.ToString()
|
||||||
|
);
|
||||||
|
|
||||||
|
await _dialogManager.ShowDialogAsync(dialog);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
progress.ReportCompletion();
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanExportChannels =>
|
||||||
|
!IsBusy &&
|
||||||
|
_discord is not null &&
|
||||||
|
SelectedGuild is not null &&
|
||||||
|
SelectedChannels?.Any() is true;
|
||||||
|
|
||||||
|
public async void ExportChannels()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_discord is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
|
||||||
|
return;
|
||||||
|
|
||||||
|
var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels);
|
||||||
|
if (await _dialogManager.ShowDialogAsync(dialog) != true)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var exporter = new ChannelExporter(_discord);
|
||||||
|
|
||||||
|
var progresses = Enumerable
|
||||||
|
.Range(0, dialog.Channels!.Count)
|
||||||
|
.Select(_ => _progressMuxer.CreateInput())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var successfulExportCount = 0;
|
||||||
|
|
||||||
|
await Parallel.ForEachAsync(
|
||||||
|
dialog.Channels.Zip(progresses),
|
||||||
|
new ParallelOptions
|
||||||
|
{
|
||||||
|
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit)
|
||||||
|
},
|
||||||
|
async (tuple, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var (channel, progress) = tuple;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new ExportRequest(
|
||||||
|
dialog.Guild!,
|
||||||
|
channel,
|
||||||
|
dialog.OutputPath!,
|
||||||
|
dialog.SelectedFormat,
|
||||||
|
dialog.After?.Pipe(Snowflake.FromDate),
|
||||||
|
dialog.Before?.Pipe(Snowflake.FromDate),
|
||||||
|
dialog.PartitionLimit,
|
||||||
|
dialog.MessageFilter,
|
||||||
|
dialog.ShouldDownloadMedia,
|
||||||
|
_settingsService.ShouldReuseMedia,
|
||||||
|
_settingsService.DateFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
await exporter.ExportChannelAsync(request, progress, cancellationToken);
|
||||||
|
|
||||||
|
Interlocked.Increment(ref successfulExportCount);
|
||||||
|
}
|
||||||
|
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||||
|
{
|
||||||
|
_eventAggregator.Publish(
|
||||||
|
new NotificationMessage(ex.Message.TrimEnd('.') + $" ({channel.Name})")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
progress.ReportCompletion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify of overall completion
|
||||||
|
if (successfulExportCount > 0)
|
||||||
|
{
|
||||||
|
_eventAggregator.Publish(
|
||||||
|
new NotificationMessage($"Successfully exported {successfulExportCount} channel(s)")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
|
||||||
|
"Error exporting channel(s)",
|
||||||
|
ex.ToString()
|
||||||
|
);
|
||||||
|
|
||||||
|
await _dialogManager.ShowDialogAsync(dialog);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OpenDiscord() =>
|
||||||
|
ProcessEx.StartShellExecute("https://discord.com/app");
|
||||||
|
|
||||||
|
public void OpenDiscordDeveloperPortal() =>
|
||||||
|
ProcessEx.StartShellExecute("https://discord.com/developers/applications");
|
||||||
|
}
|
||||||
@@ -21,12 +21,11 @@ public class ExportSetupViewModel : DialogScreen
|
|||||||
|
|
||||||
public IReadOnlyList<Channel>? Channels { get; set; }
|
public IReadOnlyList<Channel>? Channels { get; set; }
|
||||||
|
|
||||||
public bool IsSingleChannel => Channels is null || Channels.Count == 1;
|
public bool IsSingleChannel => Channels?.Count == 1;
|
||||||
|
|
||||||
public string? OutputPath { get; set; }
|
public string? OutputPath { get; set; }
|
||||||
|
|
||||||
public IReadOnlyList<ExportFormat> AvailableFormats =>
|
public IReadOnlyList<ExportFormat> AvailableFormats { get; } = Enum.GetValues<ExportFormat>();
|
||||||
Enum.GetValues(typeof(ExportFormat)).Cast<ExportFormat>().ToArray();
|
|
||||||
|
|
||||||
public ExportFormat SelectedFormat { get; set; }
|
public ExportFormat SelectedFormat { get; set; }
|
||||||
|
|
||||||
@@ -75,7 +74,7 @@ public class ExportSetupViewModel : DialogScreen
|
|||||||
MessageFilterValue = _settingsService.LastMessageFilterValue;
|
MessageFilterValue = _settingsService.LastMessageFilterValue;
|
||||||
ShouldDownloadMedia = _settingsService.LastShouldDownloadMedia;
|
ShouldDownloadMedia = _settingsService.LastShouldDownloadMedia;
|
||||||
|
|
||||||
// Show the "advanced options" by default if any
|
// Show the "advanced options" section by default if any
|
||||||
// of the advanced options are set to non-default values.
|
// of the advanced options are set to non-default values.
|
||||||
IsAdvancedSectionDisplayed =
|
IsAdvancedSectionDisplayed =
|
||||||
After != default ||
|
After != default ||
|
||||||
@@ -96,12 +95,11 @@ public class ExportSetupViewModel : DialogScreen
|
|||||||
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
|
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
|
||||||
|
|
||||||
// If single channel - prompt file path
|
// If single channel - prompt file path
|
||||||
if (Channels is not null && IsSingleChannel)
|
if (IsSingleChannel)
|
||||||
{
|
{
|
||||||
var channel = Channels.Single();
|
|
||||||
var defaultFileName = ExportRequest.GetDefaultOutputFileName(
|
var defaultFileName = ExportRequest.GetDefaultOutputFileName(
|
||||||
Guild!,
|
Guild!,
|
||||||
channel,
|
Channels!.Single(),
|
||||||
SelectedFormat,
|
SelectedFormat,
|
||||||
After?.Pipe(Snowflake.FromDate),
|
After?.Pipe(Snowflake.FromDate),
|
||||||
Before?.Pipe(Snowflake.FromDate)
|
Before?.Pipe(Snowflake.FromDate)
|
||||||
|
|||||||
@@ -7,20 +7,41 @@ public class MessageBoxViewModel : DialogScreen
|
|||||||
public string? Title { get; set; }
|
public string? Title { get; set; }
|
||||||
|
|
||||||
public string? Message { get; set; }
|
public string? Message { get; set; }
|
||||||
|
|
||||||
|
public bool IsOkButtonVisible { get; set; } = true;
|
||||||
|
|
||||||
|
public string? OkButtonText { get; set; }
|
||||||
|
|
||||||
|
public bool IsCancelButtonVisible { get; set; }
|
||||||
|
|
||||||
|
public string? CancelButtonText { get; set; }
|
||||||
|
|
||||||
|
public int ButtonsCount =>
|
||||||
|
(IsOkButtonVisible ? 1 : 0) +
|
||||||
|
(IsCancelButtonVisible ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class MessageBoxViewModelExtensions
|
public static class MessageBoxViewModelExtensions
|
||||||
{
|
{
|
||||||
public static MessageBoxViewModel CreateMessageBoxViewModel(
|
public static MessageBoxViewModel CreateMessageBoxViewModel(
|
||||||
this IViewModelFactory factory,
|
this IViewModelFactory factory,
|
||||||
string title,
|
string title, string message,
|
||||||
string message)
|
string? okButtonText, string? cancelButtonText)
|
||||||
{
|
{
|
||||||
var viewModel = factory.CreateMessageBoxViewModel();
|
var viewModel = factory.CreateMessageBoxViewModel();
|
||||||
|
|
||||||
viewModel.Title = title;
|
viewModel.Title = title;
|
||||||
viewModel.Message = message;
|
viewModel.Message = message;
|
||||||
|
|
||||||
|
viewModel.IsOkButtonVisible = !string.IsNullOrWhiteSpace(okButtonText);
|
||||||
|
viewModel.OkButtonText = okButtonText;
|
||||||
|
viewModel.IsCancelButtonVisible = !string.IsNullOrWhiteSpace(cancelButtonText);
|
||||||
|
viewModel.CancelButtonText = cancelButtonText;
|
||||||
|
|
||||||
return viewModel;
|
return viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static MessageBoxViewModel CreateMessageBoxViewModel(
|
||||||
|
this IViewModelFactory factory,
|
||||||
|
string title, string message) =>
|
||||||
|
factory.CreateMessageBoxViewModel(title, message, "CLOSE", null);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MaterialDesignThemes.Wpf;
|
using MaterialDesignThemes.Wpf;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
@@ -8,9 +9,10 @@ using Stylet;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Gui.ViewModels.Framework;
|
namespace DiscordChatExporter.Gui.ViewModels.Framework;
|
||||||
|
|
||||||
public class DialogManager
|
public class DialogManager : IDisposable
|
||||||
{
|
{
|
||||||
private readonly IViewManager _viewManager;
|
private readonly IViewManager _viewManager;
|
||||||
|
private readonly SemaphoreSlim _dialogLock = new(1, 1);
|
||||||
|
|
||||||
public DialogManager(IViewManager viewManager)
|
public DialogManager(IViewManager viewManager)
|
||||||
{
|
{
|
||||||
@@ -21,20 +23,26 @@ public class DialogManager
|
|||||||
{
|
{
|
||||||
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
|
var view = _viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
|
||||||
|
|
||||||
void OnDialogOpened(object? sender, DialogOpenedEventArgs openArgs)
|
void OnDialogOpened(object? openSender, DialogOpenedEventArgs openArgs)
|
||||||
{
|
{
|
||||||
void OnScreenClosed(object? o, EventArgs closeArgs)
|
void OnScreenClosed(object? closeSender, EventArgs args)
|
||||||
{
|
{
|
||||||
openArgs.Session.Close();
|
openArgs.Session.Close();
|
||||||
dialogScreen.Closed -= OnScreenClosed;
|
dialogScreen.Closed -= OnScreenClosed;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialogScreen.Closed += OnScreenClosed;
|
dialogScreen.Closed += OnScreenClosed;
|
||||||
}
|
}
|
||||||
|
|
||||||
await DialogHost.Show(view, OnDialogOpened);
|
await _dialogLock.WaitAsync();
|
||||||
|
try
|
||||||
return dialogScreen.DialogResult;
|
{
|
||||||
|
await DialogHost.Show(view, OnDialogOpened);
|
||||||
|
return dialogScreen.DialogResult;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_dialogLock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
|
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
|
||||||
@@ -59,4 +67,9 @@ public class DialogManager
|
|||||||
|
|
||||||
return dialog.ShowDialog() == true ? dialog.SelectedPath : null;
|
return dialog.ShowDialog() == true ? dialog.SelectedPath : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_dialogLock.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
using DiscordChatExporter.Gui.ViewModels.Components;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.ViewModels.Framework;
|
namespace DiscordChatExporter.Gui.ViewModels.Framework;
|
||||||
|
|
||||||
// Used to instantiate new view models while making use of dependency injection
|
// Used to instantiate new view models while making use of dependency injection
|
||||||
public interface IViewModelFactory
|
public interface IViewModelFactory
|
||||||
{
|
{
|
||||||
|
DashboardViewModel CreateDashboardViewModel();
|
||||||
|
|
||||||
ExportSetupViewModel CreateExportSetupViewModel();
|
ExportSetupViewModel CreateExportSetupViewModel();
|
||||||
|
|
||||||
MessageBoxViewModel CreateMessageBoxViewModel();
|
MessageBoxViewModel CreateMessageBoxViewModel();
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace DiscordChatExporter.Gui.ViewModels.Messages;
|
||||||
|
|
||||||
|
public class NotificationMessage
|
||||||
|
{
|
||||||
|
public string Text { get; }
|
||||||
|
|
||||||
|
public NotificationMessage(string text) => Text = text;
|
||||||
|
}
|
||||||
@@ -1,56 +1,30 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord;
|
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
|
||||||
using DiscordChatExporter.Core.Exceptions;
|
|
||||||
using DiscordChatExporter.Core.Exporting;
|
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
|
||||||
using DiscordChatExporter.Gui.Services;
|
using DiscordChatExporter.Gui.Services;
|
||||||
using DiscordChatExporter.Gui.Utils;
|
using DiscordChatExporter.Gui.Utils;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Components;
|
||||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||||
|
using DiscordChatExporter.Gui.ViewModels.Messages;
|
||||||
using DiscordChatExporter.Gui.ViewModels.Framework;
|
using DiscordChatExporter.Gui.ViewModels.Framework;
|
||||||
using Gress;
|
|
||||||
using MaterialDesignThemes.Wpf;
|
using MaterialDesignThemes.Wpf;
|
||||||
using Stylet;
|
using Stylet;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.ViewModels;
|
namespace DiscordChatExporter.Gui.ViewModels;
|
||||||
|
|
||||||
public class RootViewModel : Screen
|
public class RootViewModel : Screen, IHandle<NotificationMessage>, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IViewModelFactory _viewModelFactory;
|
private readonly IViewModelFactory _viewModelFactory;
|
||||||
private readonly DialogManager _dialogManager;
|
private readonly DialogManager _dialogManager;
|
||||||
private readonly SettingsService _settingsService;
|
private readonly SettingsService _settingsService;
|
||||||
private readonly UpdateService _updateService;
|
private readonly UpdateService _updateService;
|
||||||
|
|
||||||
public ISnackbarMessageQueue Notifications { get; } = new SnackbarMessageQueue(TimeSpan.FromSeconds(5));
|
public SnackbarMessageQueue Notifications { get; } = new(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
public IProgressManager ProgressManager { get; } = new ProgressManager();
|
public DashboardViewModel Dashboard { get; }
|
||||||
|
|
||||||
public bool IsBusy { get; private set; }
|
|
||||||
|
|
||||||
public bool IsProgressIndeterminate { get; private set; }
|
|
||||||
|
|
||||||
public bool IsBotToken { get; set; }
|
|
||||||
|
|
||||||
public string? TokenValue { get; set; }
|
|
||||||
|
|
||||||
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? GuildChannelMap { get; set; }
|
|
||||||
|
|
||||||
public IReadOnlyList<Guild>? AvailableGuilds => GuildChannelMap?.Keys.ToArray();
|
|
||||||
|
|
||||||
public Guild? SelectedGuild { get; set; }
|
|
||||||
|
|
||||||
public IReadOnlyList<Channel>? AvailableChannels => SelectedGuild is not null
|
|
||||||
? GuildChannelMap?[SelectedGuild]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
public IReadOnlyList<Channel>? SelectedChannels { get; set; }
|
|
||||||
|
|
||||||
public RootViewModel(
|
public RootViewModel(
|
||||||
IViewModelFactory viewModelFactory,
|
IViewModelFactory viewModelFactory,
|
||||||
|
IEventAggregator eventAggregator,
|
||||||
DialogManager dialogManager,
|
DialogManager dialogManager,
|
||||||
SettingsService settingsService,
|
SettingsService settingsService,
|
||||||
UpdateService updateService)
|
UpdateService updateService)
|
||||||
@@ -59,23 +33,31 @@ public class RootViewModel : Screen
|
|||||||
_dialogManager = dialogManager;
|
_dialogManager = dialogManager;
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
_updateService = updateService;
|
_updateService = updateService;
|
||||||
|
|
||||||
|
eventAggregator.Subscribe(this);
|
||||||
|
|
||||||
|
Dashboard = _viewModelFactory.CreateDashboardViewModel();
|
||||||
|
|
||||||
DisplayName = $"{App.Name} v{App.VersionString}";
|
DisplayName = $"{App.Name} v{App.VersionString}";
|
||||||
|
|
||||||
// Update busy state when progress manager changes
|
|
||||||
ProgressManager.Bind(o => o.IsActive, (_, _) =>
|
|
||||||
IsBusy = ProgressManager.IsActive
|
|
||||||
);
|
|
||||||
|
|
||||||
ProgressManager.Bind(o => o.IsActive, (_, _) =>
|
|
||||||
IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress is <= 0 or >= 1
|
|
||||||
);
|
|
||||||
|
|
||||||
ProgressManager.Bind(o => o.Progress, (_, _) =>
|
|
||||||
IsProgressIndeterminate = ProgressManager.IsActive && ProgressManager.Progress is <= 0 or >= 1
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ShowWarInUkraineMessageAsync()
|
||||||
|
{
|
||||||
|
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
|
||||||
|
"Ukraine is at war!", @"
|
||||||
|
My country, Ukraine, has been invaded by Russian military forces in an act of aggression that can only be described as genocide.
|
||||||
|
Be on the right side of history! Consider supporting Ukraine in its fight for freedom.
|
||||||
|
|
||||||
|
Press LEARN MORE to find ways that you can help.".Trim(),
|
||||||
|
"LEARN MORE", "CLOSE"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (await _dialogManager.ShowDialogAsync(dialog) == true)
|
||||||
|
{
|
||||||
|
ProcessEx.StartShellExecute("https://tyrrrz.me");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask CheckForUpdatesAsync()
|
private async ValueTask CheckForUpdatesAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -102,19 +84,19 @@ public class RootViewModel : Screen
|
|||||||
Notifications.Enqueue("Failed to perform application update");
|
Notifications.Enqueue("Failed to perform application update");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async void OnViewFullyLoaded()
|
||||||
|
{
|
||||||
|
await ShowWarInUkraineMessageAsync();
|
||||||
|
await CheckForUpdatesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
protected override async void OnViewLoaded()
|
protected override void OnViewLoaded()
|
||||||
{
|
{
|
||||||
base.OnViewLoaded();
|
base.OnViewLoaded();
|
||||||
|
|
||||||
_settingsService.Load();
|
_settingsService.Load();
|
||||||
|
|
||||||
if (_settingsService.LastToken is not null)
|
|
||||||
{
|
|
||||||
IsBotToken = _settingsService.LastToken.Kind == AuthTokenKind.Bot;
|
|
||||||
TokenValue = _settingsService.LastToken.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_settingsService.IsDarkModeEnabled)
|
if (_settingsService.IsDarkModeEnabled)
|
||||||
{
|
{
|
||||||
App.SetDarkTheme();
|
App.SetDarkTheme();
|
||||||
@@ -123,8 +105,6 @@ public class RootViewModel : Screen
|
|||||||
{
|
{
|
||||||
App.SetLightTheme();
|
App.SetLightTheme();
|
||||||
}
|
}
|
||||||
|
|
||||||
await CheckForUpdatesAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnClose()
|
protected override void OnClose()
|
||||||
@@ -135,134 +115,8 @@ public class RootViewModel : Screen
|
|||||||
_updateService.FinalizeUpdate(false);
|
_updateService.FinalizeUpdate(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async void ShowSettings()
|
public void Handle(NotificationMessage message) =>
|
||||||
{
|
Notifications.Enqueue(message.Text);
|
||||||
var dialog = _viewModelFactory.CreateSettingsViewModel();
|
|
||||||
await _dialogManager.ShowDialogAsync(dialog);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ShowHelp() => ProcessEx.StartShellExecute(App.GitHubProjectWikiUrl);
|
public void Dispose() => Notifications.Dispose();
|
||||||
|
|
||||||
public bool CanPopulateGuildsAndChannels =>
|
|
||||||
!IsBusy && !string.IsNullOrWhiteSpace(TokenValue);
|
|
||||||
|
|
||||||
public async void PopulateGuildsAndChannels()
|
|
||||||
{
|
|
||||||
using var operation = ProgressManager.CreateOperation();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var tokenValue = TokenValue?.Trim('"', ' ');
|
|
||||||
if (string.IsNullOrWhiteSpace(tokenValue))
|
|
||||||
return;
|
|
||||||
|
|
||||||
var token = new AuthToken(
|
|
||||||
IsBotToken ? AuthTokenKind.Bot : AuthTokenKind.User,
|
|
||||||
tokenValue
|
|
||||||
);
|
|
||||||
|
|
||||||
_settingsService.LastToken = token;
|
|
||||||
|
|
||||||
var discord = new DiscordClient(token);
|
|
||||||
|
|
||||||
var guildChannelMap = new Dictionary<Guild, IReadOnlyList<Channel>>();
|
|
||||||
await foreach (var guild in discord.GetUserGuildsAsync())
|
|
||||||
{
|
|
||||||
var channels = await discord.GetGuildChannelsAsync(guild.Id);
|
|
||||||
guildChannelMap[guild] = channels.Where(c => c.IsTextChannel).ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
GuildChannelMap = guildChannelMap;
|
|
||||||
SelectedGuild = guildChannelMap.Keys.FirstOrDefault();
|
|
||||||
}
|
|
||||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
|
||||||
{
|
|
||||||
Notifications.Enqueue(ex.Message.TrimEnd('.'));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
|
|
||||||
"Error pulling guilds and channels",
|
|
||||||
ex.ToString()
|
|
||||||
);
|
|
||||||
|
|
||||||
await _dialogManager.ShowDialogAsync(dialog);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool CanExportChannels =>
|
|
||||||
!IsBusy && SelectedGuild is not null && SelectedChannels is not null && SelectedChannels.Any();
|
|
||||||
|
|
||||||
public async void ExportChannels()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var token = _settingsService.LastToken;
|
|
||||||
if (token is null || SelectedGuild is null || SelectedChannels is null || !SelectedChannels.Any())
|
|
||||||
return;
|
|
||||||
|
|
||||||
var dialog = _viewModelFactory.CreateExportSetupViewModel(SelectedGuild, SelectedChannels);
|
|
||||||
if (await _dialogManager.ShowDialogAsync(dialog) != true)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var exporter = new ChannelExporter(token);
|
|
||||||
|
|
||||||
var operations = ProgressManager.CreateOperations(dialog.Channels!.Count);
|
|
||||||
var successfulExportCount = 0;
|
|
||||||
|
|
||||||
await Parallel.ForEachAsync(
|
|
||||||
dialog.Channels.Zip(operations),
|
|
||||||
new ParallelOptions
|
|
||||||
{
|
|
||||||
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit)
|
|
||||||
},
|
|
||||||
async (tuple, cancellationToken) =>
|
|
||||||
{
|
|
||||||
var (channel, operation) = tuple;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var request = new ExportRequest(
|
|
||||||
dialog.Guild!,
|
|
||||||
channel,
|
|
||||||
dialog.OutputPath!,
|
|
||||||
dialog.SelectedFormat,
|
|
||||||
dialog.After?.Pipe(Snowflake.FromDate),
|
|
||||||
dialog.Before?.Pipe(Snowflake.FromDate),
|
|
||||||
dialog.PartitionLimit,
|
|
||||||
dialog.MessageFilter,
|
|
||||||
dialog.ShouldDownloadMedia,
|
|
||||||
_settingsService.ShouldReuseMedia,
|
|
||||||
_settingsService.DateFormat
|
|
||||||
);
|
|
||||||
|
|
||||||
await exporter.ExportChannelAsync(request, operation, cancellationToken);
|
|
||||||
|
|
||||||
Interlocked.Increment(ref successfulExportCount);
|
|
||||||
}
|
|
||||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
|
||||||
{
|
|
||||||
Notifications.Enqueue(ex.Message.TrimEnd('.'));
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
operation.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Notify of overall completion
|
|
||||||
if (successfulExportCount > 0)
|
|
||||||
Notifications.Enqueue($"Successfully exported {successfulExportCount} channel(s)");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
|
|
||||||
"Error exporting channel(s)",
|
|
||||||
ex.ToString()
|
|
||||||
);
|
|
||||||
|
|
||||||
await _dialogManager.ShowDialogAsync(dialog);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
<UserControl
|
||||||
|
x:Class="DiscordChatExporter.Gui.Views.Components.DashboardView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
|
||||||
|
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
|
||||||
|
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
|
||||||
|
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
|
||||||
|
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
xmlns:s="https://github.com/canton7/Stylet"
|
||||||
|
d:DataContext="{d:DesignInstance Type=components:DashboardViewModel}"
|
||||||
|
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
|
||||||
|
Loaded="{s:Action OnViewLoaded}"
|
||||||
|
mc:Ignorable="d">
|
||||||
|
<UserControl.Resources>
|
||||||
|
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
|
||||||
|
<CollectionViewSource.GroupDescriptions>
|
||||||
|
<PropertyGroupDescription PropertyName="Category.Name" />
|
||||||
|
</CollectionViewSource.GroupDescriptions>
|
||||||
|
<CollectionViewSource.SortDescriptions>
|
||||||
|
<componentModel:SortDescription Direction="Ascending" PropertyName="Position" />
|
||||||
|
<componentModel:SortDescription Direction="Ascending" PropertyName="Name" />
|
||||||
|
</CollectionViewSource.SortDescriptions>
|
||||||
|
</CollectionViewSource>
|
||||||
|
</UserControl.Resources>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
|
<RowDefinition Height="Auto" />
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<Grid Grid.Row="0" Background="{DynamicResource MaterialDesignDarkBackground}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Token and pull data button -->
|
||||||
|
<materialDesign:Card
|
||||||
|
Grid.Row="0"
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="12,12,0,12">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Token icon -->
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Grid.Column="0"
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Margin="8"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
|
Kind="Password" />
|
||||||
|
|
||||||
|
<!-- Token value -->
|
||||||
|
<TextBox
|
||||||
|
x:Name="TokenValueTextBox"
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="0,6,6,8"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
materialDesign:HintAssist.Hint="Token"
|
||||||
|
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
|
||||||
|
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
|
||||||
|
BorderThickness="0"
|
||||||
|
FontSize="16"
|
||||||
|
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}" />
|
||||||
|
|
||||||
|
<!-- Pull data button -->
|
||||||
|
<Button
|
||||||
|
Grid.Column="2"
|
||||||
|
Margin="0,6,6,6"
|
||||||
|
Padding="4"
|
||||||
|
Command="{s:Action PopulateGuildsAndChannels}"
|
||||||
|
IsDefault="True"
|
||||||
|
Style="{DynamicResource MaterialDesignFlatButton}"
|
||||||
|
ToolTip="Pull available guilds and channels (Enter)">
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Kind="ArrowRight" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</materialDesign:Card>
|
||||||
|
|
||||||
|
<!-- Settings button -->
|
||||||
|
<Button
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="6"
|
||||||
|
Padding="4"
|
||||||
|
Command="{s:Action ShowSettings}"
|
||||||
|
Foreground="{DynamicResource MaterialDesignDarkForeground}"
|
||||||
|
Style="{DynamicResource MaterialDesignFlatButton}"
|
||||||
|
ToolTip="Settings">
|
||||||
|
<Button.Resources>
|
||||||
|
<SolidColorBrush x:Key="MaterialDesignFlatButtonClick" Color="#4C4C4C" />
|
||||||
|
</Button.Resources>
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Kind="Settings" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<ProgressBar
|
||||||
|
Grid.Row="1"
|
||||||
|
Background="{DynamicResource MaterialDesignDarkBackground}"
|
||||||
|
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
||||||
|
Value="{Binding Progress.Current.Fraction, Mode=OneWay}" />
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<Grid
|
||||||
|
Grid.Row="2"
|
||||||
|
Background="{DynamicResource MaterialDesignCardBackground}"
|
||||||
|
IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
|
||||||
|
<Grid.Resources>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="FontWeight" Value="Light" />
|
||||||
|
</Style>
|
||||||
|
</Grid.Resources>
|
||||||
|
<!-- Placeholder / usage instructions -->
|
||||||
|
<Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||||
|
<TextBlock
|
||||||
|
Margin="32,16"
|
||||||
|
FontSize="14"
|
||||||
|
LineHeight="20">
|
||||||
|
<Run FontSize="18" Text="Please provide authentication token to continue" />
|
||||||
|
<LineBreak />
|
||||||
|
<LineBreak />
|
||||||
|
|
||||||
|
<!-- User token -->
|
||||||
|
<InlineUIContainer>
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Margin="1,0,0,-2"
|
||||||
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
|
Kind="Account" />
|
||||||
|
</InlineUIContainer>
|
||||||
|
<Run FontSize="16" Text="Authenticate using your personal account" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="1. Open Discord in your" />
|
||||||
|
<Hyperlink Command="{s:Action OpenDiscord}">
|
||||||
|
<Run Text="web browser" />
|
||||||
|
</Hyperlink>
|
||||||
|
<Run Text="and login" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="2. Press" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
|
||||||
|
<Run Text="to show developer tools" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="3. Press" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Ctrl+Shift+M" />
|
||||||
|
<Run Text="to toggle device toolbar" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="4. Navigate to the" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Application" />
|
||||||
|
<Run Text="tab" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="5. On the left, expand" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Local Storage" />
|
||||||
|
<Run Text="and select" />
|
||||||
|
<Run FontWeight="SemiBold" Text="https://discord.com" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="6. Type" />
|
||||||
|
<Run FontWeight="SemiBold" Text="token" />
|
||||||
|
<Run Text="into the" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Filter" />
|
||||||
|
<Run Text="box" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="7. If the token key does not appear, press" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Ctrl+R" />
|
||||||
|
<Run Text="to reload" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="8. Copy the value of the" />
|
||||||
|
<Run FontWeight="SemiBold" Text="token" />
|
||||||
|
<Run Text="key" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="* Automating user accounts is technically against TOS, use at your own risk!" />
|
||||||
|
<LineBreak />
|
||||||
|
<LineBreak />
|
||||||
|
|
||||||
|
<!-- Bot token -->
|
||||||
|
<InlineUIContainer>
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Margin="1,0,0,-2"
|
||||||
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
|
Kind="Robot" />
|
||||||
|
</InlineUIContainer>
|
||||||
|
<Run FontSize="16" Text="Authenticate using a bot account" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="1. Open Discord" />
|
||||||
|
<Hyperlink Command="{s:Action OpenDiscordDeveloperPortal}">
|
||||||
|
<Run Text="developer portal" />
|
||||||
|
</Hyperlink>
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="2. Open your application's settings" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="3. Navigate to the" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Bot" />
|
||||||
|
<Run Text="section on the left" />
|
||||||
|
<LineBreak />
|
||||||
|
<Run Text="4. Under" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Token" />
|
||||||
|
<Run Text="click" />
|
||||||
|
<Run FontWeight="SemiBold" Text="Copy" />
|
||||||
|
<LineBreak />
|
||||||
|
<LineBreak />
|
||||||
|
|
||||||
|
<Run FontSize="16" Text="If you have questions or issues, please refer to the" />
|
||||||
|
<Hyperlink Command="{s:Action ShowHelp}" FontSize="16">wiki</Hyperlink><Run FontSize="16" Text="." />
|
||||||
|
</TextBlock>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Guilds and channels -->
|
||||||
|
<Grid Background="{DynamicResource MaterialDesignCardBackground}" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Guilds -->
|
||||||
|
<Border
|
||||||
|
Grid.Column="0"
|
||||||
|
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||||
|
BorderThickness="0,0,1,0">
|
||||||
|
<ListBox
|
||||||
|
ItemsSource="{Binding AvailableGuilds}"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Hidden"
|
||||||
|
SelectedItem="{Binding SelectedGuild}"
|
||||||
|
SelectionMode="Single">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Grid
|
||||||
|
Margin="-8"
|
||||||
|
Background="Transparent"
|
||||||
|
Cursor="Hand"
|
||||||
|
ToolTip="{Binding Name}">
|
||||||
|
<!-- Guild icon placeholder -->
|
||||||
|
<Ellipse
|
||||||
|
Width="48"
|
||||||
|
Height="48"
|
||||||
|
Margin="12,4,12,4"
|
||||||
|
Fill="{DynamicResource MaterialDesignDivider}" />
|
||||||
|
|
||||||
|
<!-- Guild icon -->
|
||||||
|
<Ellipse
|
||||||
|
Width="48"
|
||||||
|
Height="48"
|
||||||
|
Margin="12,4,12,4"
|
||||||
|
Stroke="{DynamicResource MaterialDesignDivider}"
|
||||||
|
StrokeThickness="1">
|
||||||
|
<Ellipse.Fill>
|
||||||
|
<ImageBrush ImageSource="{Binding IconUrl}" />
|
||||||
|
</Ellipse.Fill>
|
||||||
|
</Ellipse>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Channels -->
|
||||||
|
<Border Grid.Column="1">
|
||||||
|
<ListBox
|
||||||
|
HorizontalContentAlignment="Stretch"
|
||||||
|
ItemsSource="{Binding Source={StaticResource AvailableChannelsViewSource}}"
|
||||||
|
SelectionMode="Extended"
|
||||||
|
TextSearch.TextPath="Model.Name"
|
||||||
|
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
|
||||||
|
<b:Interaction.Behaviors>
|
||||||
|
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
|
||||||
|
</b:Interaction.Behaviors>
|
||||||
|
<ListBox.GroupStyle>
|
||||||
|
<GroupStyle>
|
||||||
|
<GroupStyle.ContainerStyle>
|
||||||
|
<Style TargetType="{x:Type GroupItem}">
|
||||||
|
<Setter Property="Template">
|
||||||
|
<Setter.Value>
|
||||||
|
<ControlTemplate d:DataContext="{x:Type CollectionViewGroup}">
|
||||||
|
<Expander
|
||||||
|
Margin="0"
|
||||||
|
Padding="0"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||||
|
BorderThickness="0,0,0,1"
|
||||||
|
Header="{Binding Name}"
|
||||||
|
IsExpanded="False">
|
||||||
|
<ItemsPresenter />
|
||||||
|
</Expander>
|
||||||
|
</ControlTemplate>
|
||||||
|
</Setter.Value>
|
||||||
|
</Setter>
|
||||||
|
</Style>
|
||||||
|
</GroupStyle.ContainerStyle>
|
||||||
|
</GroupStyle>
|
||||||
|
</ListBox.GroupStyle>
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Grid Margin="-8" Background="Transparent">
|
||||||
|
<Grid.InputBindings>
|
||||||
|
<MouseBinding Command="{s:Action ExportChannels}" MouseAction="LeftDoubleClick" />
|
||||||
|
</Grid.InputBindings>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<!-- Channel icon -->
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Grid.Column="0"
|
||||||
|
Margin="16,7,0,6"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Kind="Pound" />
|
||||||
|
|
||||||
|
<!-- Channel name -->
|
||||||
|
<TextBlock
|
||||||
|
Grid.Column="1"
|
||||||
|
Margin="3,8,8,8"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontSize="14"
|
||||||
|
Text="{Binding Name, Mode=OneWay}" />
|
||||||
|
|
||||||
|
<!-- Is selected checkmark -->
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Grid.Column="2"
|
||||||
|
Width="24"
|
||||||
|
Height="24"
|
||||||
|
Margin="8,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Kind="Check"
|
||||||
|
Visibility="{Binding IsSelected, RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}}, Converter={x:Static s:BoolToVisibilityConverter.Instance}, Mode=OneWay}" />
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</Border>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Export button -->
|
||||||
|
<Button
|
||||||
|
Margin="32,24"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Command="{s:Action ExportChannels}"
|
||||||
|
Style="{DynamicResource MaterialDesignFloatingActionAccentButton}"
|
||||||
|
Visibility="{Binding CanExportChannels, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||||
|
<materialDesign:PackIcon
|
||||||
|
Width="32"
|
||||||
|
Height="32"
|
||||||
|
Kind="Download" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace DiscordChatExporter.Gui.Views.Components;
|
||||||
|
|
||||||
|
public partial class DashboardView
|
||||||
|
{
|
||||||
|
public DashboardView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:s="https://github.com/canton7/Stylet"
|
xmlns:s="https://github.com/canton7/Stylet"
|
||||||
|
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
|
||||||
Width="380"
|
Width="380"
|
||||||
d:DataContext="{d:DesignInstance Type=dialogs:ExportSetupViewModel}"
|
d:DataContext="{d:DesignInstance Type=dialogs:ExportSetupViewModel}"
|
||||||
Style="{DynamicResource MaterialDesignRoot}"
|
Style="{DynamicResource MaterialDesignRoot}"
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
</Ellipse.Fill>
|
</Ellipse.Fill>
|
||||||
</Ellipse>
|
</Ellipse>
|
||||||
|
|
||||||
<!-- Placeholder (for multiple channels) -->
|
<!-- Channel count (for multiple channels) -->
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="8,0,0,0"
|
Margin="8,0,0,0"
|
||||||
@@ -129,6 +130,7 @@
|
|||||||
Margin="16,4,16,8"
|
Margin="16,4,16,8"
|
||||||
materialDesign:HintAssist.Hint="After (time)"
|
materialDesign:HintAssist.Hint="After (time)"
|
||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
|
||||||
IsEnabled="{Binding IsAfterDateSet}"
|
IsEnabled="{Binding IsAfterDateSet}"
|
||||||
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
||||||
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
|
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
|
||||||
@@ -139,6 +141,7 @@
|
|||||||
Margin="16,4,16,8"
|
Margin="16,4,16,8"
|
||||||
materialDesign:HintAssist.Hint="Before (time)"
|
materialDesign:HintAssist.Hint="Before (time)"
|
||||||
materialDesign:HintAssist.IsFloating="True"
|
materialDesign:HintAssist.IsFloating="True"
|
||||||
|
Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
|
||||||
IsEnabled="{Binding IsBeforeDateSet}"
|
IsEnabled="{Binding IsBeforeDateSet}"
|
||||||
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}}"
|
||||||
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
|
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
|
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:s="https://github.com/canton7/Stylet"
|
xmlns:s="https://github.com/canton7/Stylet"
|
||||||
MinWidth="500"
|
xmlns:system="clr-namespace:System;assembly=System.Runtime"
|
||||||
|
Width="500"
|
||||||
d:DataContext="{d:DesignInstance Type=dialogs:MessageBoxViewModel}"
|
d:DataContext="{d:DesignInstance Type=dialogs:MessageBoxViewModel}"
|
||||||
Style="{DynamicResource MaterialDesignRoot}"
|
Style="{DynamicResource MaterialDesignRoot}"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
@@ -35,21 +36,38 @@
|
|||||||
BorderThickness="0,1">
|
BorderThickness="0,1">
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Margin="16,0,16,16"
|
Margin="16,8"
|
||||||
Text="{Binding Message}"
|
Text="{Binding Message}"
|
||||||
TextWrapping="Wrap" />
|
TextWrapping="Wrap" />
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Close -->
|
<UniformGrid
|
||||||
<Button
|
|
||||||
Grid.Row="2"
|
Grid.Row="2"
|
||||||
Margin="16"
|
Margin="16"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Right"
|
||||||
Command="{s:Action Close}"
|
Columns="{Binding ButtonsCount}">
|
||||||
Content="CLOSE"
|
<!-- OK -->
|
||||||
IsCancel="True"
|
<Button
|
||||||
IsDefault="True"
|
Command="{s:Action Close}"
|
||||||
Style="{DynamicResource MaterialDesignOutlinedButton}" />
|
Content="{Binding OkButtonText}"
|
||||||
|
IsDefault="True"
|
||||||
|
Style="{DynamicResource MaterialDesignOutlinedButton}"
|
||||||
|
Visibility="{Binding IsOkButtonVisible, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
||||||
|
<Button.CommandParameter>
|
||||||
|
<system:Boolean>True</system:Boolean>
|
||||||
|
</Button.CommandParameter>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Cancel -->
|
||||||
|
<Button
|
||||||
|
Margin="8,0,0,0"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
Command="{s:Action Close}"
|
||||||
|
Content="{Binding CancelButtonText}"
|
||||||
|
IsCancel="True"
|
||||||
|
Style="{DynamicResource MaterialDesignOutlinedButton}"
|
||||||
|
Visibility="{Binding IsCancelButtonVisible, Converter={x:Static s:BoolToVisibilityConverter.Instance}}" />
|
||||||
|
</UniformGrid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
|
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:s="https://github.com/canton7/Stylet"
|
xmlns:s="https://github.com/canton7/Stylet"
|
||||||
Width="310"
|
Width="380"
|
||||||
d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}"
|
d:DataContext="{d:DesignInstance Type=dialogs:SettingsViewModel}"
|
||||||
Style="{DynamicResource MaterialDesignRoot}"
|
Style="{DynamicResource MaterialDesignRoot}"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
@@ -24,126 +24,129 @@
|
|||||||
FontWeight="Light"
|
FontWeight="Light"
|
||||||
Text="Settings" />
|
Text="Settings" />
|
||||||
|
|
||||||
<ScrollViewer
|
<Border
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
HorizontalScrollBarVisibility="Disabled"
|
Padding="0,8"
|
||||||
VerticalScrollBarVisibility="Auto">
|
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
||||||
<StackPanel>
|
BorderThickness="0,1">
|
||||||
<!-- Auto-updates -->
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||||
<DockPanel
|
<StackPanel>
|
||||||
Margin="16,8"
|
<!-- Auto-updates -->
|
||||||
Background="Transparent"
|
<DockPanel
|
||||||
LastChildFill="False"
|
Margin="16,8"
|
||||||
ToolTip="Perform automatic updates on every launch">
|
Background="Transparent"
|
||||||
<TextBlock
|
LastChildFill="False"
|
||||||
VerticalAlignment="Center"
|
ToolTip="Perform automatic updates on every launch">
|
||||||
DockPanel.Dock="Left"
|
|
||||||
Text="Auto-updates" />
|
|
||||||
<ToggleButton
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
DockPanel.Dock="Right"
|
|
||||||
IsChecked="{Binding IsAutoUpdateEnabled}" />
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Dark mode -->
|
|
||||||
<DockPanel
|
|
||||||
Margin="16,8"
|
|
||||||
Background="Transparent"
|
|
||||||
LastChildFill="False"
|
|
||||||
ToolTip="Use darker colors in the UI">
|
|
||||||
<TextBlock
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
DockPanel.Dock="Left"
|
|
||||||
Text="Dark mode" />
|
|
||||||
<ToggleButton
|
|
||||||
x:Name="DarkModeToggleButton"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Checked="DarkModeToggleButton_Checked"
|
|
||||||
DockPanel.Dock="Right"
|
|
||||||
IsChecked="{Binding IsDarkModeEnabled}"
|
|
||||||
Unchecked="DarkModeToggleButton_Unchecked" />
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Persist token -->
|
|
||||||
<DockPanel
|
|
||||||
Margin="16,8"
|
|
||||||
Background="Transparent"
|
|
||||||
LastChildFill="False"
|
|
||||||
ToolTip="Save last used token in a file so that it can be persisted between sessions">
|
|
||||||
<TextBlock
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
DockPanel.Dock="Left"
|
|
||||||
Text="Save token" />
|
|
||||||
<ToggleButton
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
DockPanel.Dock="Right"
|
|
||||||
IsChecked="{Binding IsTokenPersisted}" />
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Reuse media -->
|
|
||||||
<DockPanel
|
|
||||||
Margin="16,8"
|
|
||||||
Background="Transparent"
|
|
||||||
LastChildFill="False"
|
|
||||||
ToolTip="Reuse already existing media content to skip redundant downloads">
|
|
||||||
<TextBlock
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
DockPanel.Dock="Left"
|
|
||||||
Text="Reuse downloaded media" />
|
|
||||||
<ToggleButton
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
DockPanel.Dock="Right"
|
|
||||||
IsChecked="{Binding ShouldReuseMedia}" />
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Date format -->
|
|
||||||
<DockPanel
|
|
||||||
Margin="16,8"
|
|
||||||
Background="Transparent"
|
|
||||||
LastChildFill="False"
|
|
||||||
ToolTip="Format used when writing dates (uses .NET date formatting rules)">
|
|
||||||
<TextBlock
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
DockPanel.Dock="Left"
|
|
||||||
Text="Date format" />
|
|
||||||
<TextBox
|
|
||||||
Width="150"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
DockPanel.Dock="Right"
|
|
||||||
Text="{Binding DateFormat}" />
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Parallel limit -->
|
|
||||||
<DockPanel
|
|
||||||
Margin="16,8"
|
|
||||||
Background="Transparent"
|
|
||||||
LastChildFill="False"
|
|
||||||
ToolTip="How many channels can be exported at the same time">
|
|
||||||
<TextBlock
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
DockPanel.Dock="Left"
|
|
||||||
Text="Parallel limit"
|
|
||||||
TextAlignment="Right" />
|
|
||||||
<StackPanel
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
DockPanel.Dock="Right"
|
|
||||||
Orientation="Horizontal">
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
Margin="10,0"
|
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontWeight="SemiBold"
|
DockPanel.Dock="Left"
|
||||||
Text="{Binding ParallelLimit}" />
|
Text="Auto-updates" />
|
||||||
<Slider
|
<ToggleButton
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
IsChecked="{Binding IsAutoUpdateEnabled}" />
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Dark mode -->
|
||||||
|
<DockPanel
|
||||||
|
Margin="16,8"
|
||||||
|
Background="Transparent"
|
||||||
|
LastChildFill="False"
|
||||||
|
ToolTip="Use darker colors in the UI">
|
||||||
|
<TextBlock
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
DockPanel.Dock="Left"
|
||||||
|
Text="Dark mode" />
|
||||||
|
<ToggleButton
|
||||||
|
x:Name="DarkModeToggleButton"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Checked="DarkModeToggleButton_Checked"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
IsChecked="{Binding IsDarkModeEnabled}"
|
||||||
|
Unchecked="DarkModeToggleButton_Unchecked" />
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Persist token -->
|
||||||
|
<DockPanel
|
||||||
|
Margin="16,8"
|
||||||
|
Background="Transparent"
|
||||||
|
LastChildFill="False"
|
||||||
|
ToolTip="Save last used token in a file so that it can be persisted between sessions">
|
||||||
|
<TextBlock
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
DockPanel.Dock="Left"
|
||||||
|
Text="Save token" />
|
||||||
|
<ToggleButton
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
IsChecked="{Binding IsTokenPersisted}" />
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Reuse media -->
|
||||||
|
<DockPanel
|
||||||
|
Margin="16,8"
|
||||||
|
Background="Transparent"
|
||||||
|
LastChildFill="False"
|
||||||
|
ToolTip="Reuse already existing media content to skip redundant downloads">
|
||||||
|
<TextBlock
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
DockPanel.Dock="Left"
|
||||||
|
Text="Reuse downloaded media" />
|
||||||
|
<ToggleButton
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
IsChecked="{Binding ShouldReuseMedia}" />
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Date format -->
|
||||||
|
<DockPanel
|
||||||
|
Margin="16,8"
|
||||||
|
Background="Transparent"
|
||||||
|
LastChildFill="False"
|
||||||
|
ToolTip="Format used when writing dates (uses .NET date formatting rules)">
|
||||||
|
<TextBlock
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
DockPanel.Dock="Left"
|
||||||
|
Text="Date format" />
|
||||||
|
<TextBox
|
||||||
Width="150"
|
Width="150"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Maximum="10"
|
DockPanel.Dock="Right"
|
||||||
Minimum="1"
|
Text="{Binding DateFormat}" />
|
||||||
Style="{DynamicResource MaterialDesignThinSlider}"
|
</DockPanel>
|
||||||
Value="{Binding ParallelLimit}" />
|
|
||||||
</StackPanel>
|
<!-- Parallel limit -->
|
||||||
</DockPanel>
|
<DockPanel
|
||||||
</StackPanel>
|
Margin="16,8"
|
||||||
</ScrollViewer>
|
Background="Transparent"
|
||||||
|
LastChildFill="False"
|
||||||
|
ToolTip="How many channels can be exported at the same time">
|
||||||
|
<TextBlock
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
DockPanel.Dock="Left"
|
||||||
|
Text="Parallel limit"
|
||||||
|
TextAlignment="Right" />
|
||||||
|
<StackPanel
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
Orientation="Horizontal">
|
||||||
|
<TextBlock
|
||||||
|
Margin="10,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Text="{Binding ParallelLimit}" />
|
||||||
|
<Slider
|
||||||
|
Width="150"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Maximum="10"
|
||||||
|
Minimum="1"
|
||||||
|
Style="{DynamicResource MaterialDesignThinSlider}"
|
||||||
|
Value="{Binding ParallelLimit}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Save button -->
|
<!-- Save button -->
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -2,11 +2,7 @@
|
|||||||
x:Class="DiscordChatExporter.Gui.Views.RootView"
|
x:Class="DiscordChatExporter.Gui.Views.RootView"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
|
|
||||||
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
|
|
||||||
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:i="http://schemas.microsoft.com/xaml/behaviors"
|
|
||||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
xmlns:s="https://github.com/canton7/Stylet"
|
xmlns:s="https://github.com/canton7/Stylet"
|
||||||
@@ -16,381 +12,21 @@
|
|||||||
MinWidth="325"
|
MinWidth="325"
|
||||||
d:DataContext="{d:DesignInstance Type=viewModels:RootViewModel}"
|
d:DataContext="{d:DesignInstance Type=viewModels:RootViewModel}"
|
||||||
Background="{DynamicResource MaterialDesignPaper}"
|
Background="{DynamicResource MaterialDesignPaper}"
|
||||||
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
|
|
||||||
Icon="/DiscordChatExporter;component/favicon.ico"
|
Icon="/DiscordChatExporter;component/favicon.ico"
|
||||||
Style="{DynamicResource MaterialDesignRoot}"
|
Style="{DynamicResource MaterialDesignRoot}"
|
||||||
WindowStartupLocation="CenterScreen"
|
WindowStartupLocation="CenterScreen"
|
||||||
mc:Ignorable="d">
|
mc:Ignorable="d">
|
||||||
<Window.TaskbarItemInfo>
|
<Window.TaskbarItemInfo>
|
||||||
<TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding ProgressManager.Progress}" />
|
<TaskbarItemInfo ProgressState="Normal" ProgressValue="{Binding Dashboard.Progress.Current.Fraction}" />
|
||||||
</Window.TaskbarItemInfo>
|
</Window.TaskbarItemInfo>
|
||||||
<Window.Resources>
|
|
||||||
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
|
|
||||||
<CollectionViewSource.GroupDescriptions>
|
|
||||||
<PropertyGroupDescription PropertyName="Category.Name" />
|
|
||||||
</CollectionViewSource.GroupDescriptions>
|
|
||||||
<CollectionViewSource.SortDescriptions>
|
|
||||||
<componentModel:SortDescription Direction="Ascending" PropertyName="Position" />
|
|
||||||
<componentModel:SortDescription Direction="Ascending" PropertyName="Name" />
|
|
||||||
</CollectionViewSource.SortDescriptions>
|
|
||||||
</CollectionViewSource>
|
|
||||||
</Window.Resources>
|
|
||||||
|
|
||||||
<materialDesign:DialogHost SnackbarMessageQueue="{Binding Notifications}" Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
|
<materialDesign:DialogHost
|
||||||
|
Loaded="{s:Action OnViewFullyLoaded}"
|
||||||
|
SnackbarMessageQueue="{Binding Notifications}"
|
||||||
|
Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<ContentControl s:View.Model="{Binding Dashboard}" />
|
||||||
<RowDefinition Height="Auto" />
|
<materialDesign:Snackbar MessageQueue="{Binding Notifications}" />
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
<RowDefinition Height="*" />
|
|
||||||
<RowDefinition Height="Auto" />
|
|
||||||
</Grid.RowDefinitions>
|
|
||||||
|
|
||||||
<!-- Toolbar -->
|
|
||||||
<Grid Grid.Row="0" Background="{DynamicResource MaterialDesignDarkBackground}">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<!-- Token and pull data button -->
|
|
||||||
<materialDesign:Card
|
|
||||||
Grid.Row="0"
|
|
||||||
Grid.Column="0"
|
|
||||||
Margin="12,12,0,12">
|
|
||||||
<Grid>
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<!-- Token type -->
|
|
||||||
<ToggleButton
|
|
||||||
Grid.Column="0"
|
|
||||||
Margin="6"
|
|
||||||
IsChecked="{Binding IsBotToken}"
|
|
||||||
Style="{DynamicResource MaterialDesignFlatActionToggleButton}"
|
|
||||||
ToolTip="Switch between user token and bot token">
|
|
||||||
<ToggleButton.Content>
|
|
||||||
<materialDesign:PackIcon
|
|
||||||
Width="24"
|
|
||||||
Height="24"
|
|
||||||
Kind="Account" />
|
|
||||||
</ToggleButton.Content>
|
|
||||||
<materialDesign:ToggleButtonAssist.OnContent>
|
|
||||||
<materialDesign:PackIcon
|
|
||||||
Width="24"
|
|
||||||
Height="24"
|
|
||||||
Kind="Robot" />
|
|
||||||
</materialDesign:ToggleButtonAssist.OnContent>
|
|
||||||
</ToggleButton>
|
|
||||||
|
|
||||||
<!-- Token value -->
|
|
||||||
<TextBox
|
|
||||||
x:Name="TokenValueTextBox"
|
|
||||||
Grid.Column="1"
|
|
||||||
Margin="2,6,6,7"
|
|
||||||
VerticalAlignment="Bottom"
|
|
||||||
materialDesign:HintAssist.Hint="Token"
|
|
||||||
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
|
|
||||||
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
|
|
||||||
BorderThickness="0"
|
|
||||||
FontSize="16"
|
|
||||||
Text="{Binding TokenValue, UpdateSourceTrigger=PropertyChanged}" />
|
|
||||||
|
|
||||||
<!-- Pull data button -->
|
|
||||||
<Button
|
|
||||||
Grid.Column="2"
|
|
||||||
Margin="0,6,6,6"
|
|
||||||
Padding="4"
|
|
||||||
Command="{s:Action PopulateGuildsAndChannels}"
|
|
||||||
IsDefault="True"
|
|
||||||
Style="{DynamicResource MaterialDesignFlatButton}"
|
|
||||||
ToolTip="Pull available guilds and channels (Enter)">
|
|
||||||
<materialDesign:PackIcon
|
|
||||||
Width="24"
|
|
||||||
Height="24"
|
|
||||||
Kind="ArrowRight" />
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
</materialDesign:Card>
|
|
||||||
|
|
||||||
<!-- Settings button -->
|
|
||||||
<Button
|
|
||||||
Grid.Column="1"
|
|
||||||
Margin="6"
|
|
||||||
Padding="4"
|
|
||||||
Command="{s:Action ShowSettings}"
|
|
||||||
Foreground="{DynamicResource MaterialDesignDarkForeground}"
|
|
||||||
Style="{DynamicResource MaterialDesignFlatButton}"
|
|
||||||
ToolTip="Settings">
|
|
||||||
<Button.Resources>
|
|
||||||
<SolidColorBrush x:Key="MaterialDesignFlatButtonClick" Color="#4C4C4C" />
|
|
||||||
</Button.Resources>
|
|
||||||
<materialDesign:PackIcon
|
|
||||||
Width="24"
|
|
||||||
Height="24"
|
|
||||||
Kind="Settings" />
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Progress bar -->
|
|
||||||
<ProgressBar
|
|
||||||
Grid.Row="1"
|
|
||||||
Background="{DynamicResource MaterialDesignDarkBackground}"
|
|
||||||
IsIndeterminate="{Binding IsProgressIndeterminate}"
|
|
||||||
Value="{Binding ProgressManager.Progress, Mode=OneWay}" />
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<Grid
|
|
||||||
Grid.Row="2"
|
|
||||||
Background="{DynamicResource MaterialDesignCardBackground}"
|
|
||||||
IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
|
|
||||||
<Grid.Resources>
|
|
||||||
<Style TargetType="TextBlock">
|
|
||||||
<Setter Property="FontWeight" Value="Light" />
|
|
||||||
</Style>
|
|
||||||
</Grid.Resources>
|
|
||||||
<!-- Placeholder / usage instructions -->
|
|
||||||
<Grid Margin="32,32,8,8" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
|
|
||||||
<!-- For user token -->
|
|
||||||
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
|
|
||||||
<TextBlock FontSize="18" Text="Please provide your user token to authorize" />
|
|
||||||
<TextBlock Margin="8,8,0,0" FontSize="14">
|
|
||||||
<Run Text="1. Open Discord" />
|
|
||||||
<LineBreak />
|
|
||||||
<Run Text="2. Press" />
|
|
||||||
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
|
|
||||||
<Run Text="to show developer tools" />
|
|
||||||
<LineBreak />
|
|
||||||
<Run Text="3. Press" />
|
|
||||||
<Run FontWeight="SemiBold" Text="Ctrl+Shift+M" />
|
|
||||||
<Run Text="to toggle device toolbar" />
|
|
||||||
<LineBreak />
|
|
||||||
<Run Text="4. Navigate to the" />
|
|
||||||
<Run FontWeight="SemiBold" Text="Application" />
|
|
||||||
<Run Text="tab" />
|
|
||||||
<LineBreak />
|
|
||||||
<Run Text="5. On the left, expand" />
|
|
||||||
<Run FontWeight="SemiBold" Text="Local Storage" />
|
|
||||||
<Run Text="and select" />
|
|
||||||
<Run FontWeight="SemiBold" Text="https://discord.com" />
|
|
||||||
<LineBreak />
|
|
||||||
<Run Text="6. Type" />
|
|
||||||
<Run FontWeight="SemiBold" Text="token" />
|
|
||||||
<Run Text="into the" />
|
|
||||||
<Run FontWeight="SemiBold" Text="Filter" />
|
|
||||||
<Run Text="box" />
|
|
||||||
<LineBreak />
|
|
||||||
<Run Text="7. If the token key does not appear, press" />
|
|
||||||
<Run FontWeight="SemiBold" Text="Ctrl+R" />
|
|
||||||
<Run Text="to reload" />
|
|
||||||
<LineBreak />
|
|
||||||
<Run Text="8. Copy the value of the" />
|
|
||||||
<Run FontWeight="SemiBold" Text="token" />
|
|
||||||
<Run Text="key" />
|
|
||||||
</TextBlock>
|
|
||||||
<TextBlock Margin="0,24,0,0" FontSize="14">
|
|
||||||
<Run Text="Automating user accounts is technically against TOS, use at your own risk." />
|
|
||||||
<LineBreak />
|
|
||||||
<Run Text="To authorize using bot token instead, click" />
|
|
||||||
<InlineUIContainer>
|
|
||||||
<materialDesign:PackIcon
|
|
||||||
Margin="1,0,0,-3"
|
|
||||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
|
||||||
Kind="Account" />
|
|
||||||
</InlineUIContainer>
|
|
||||||
<Run Text="in the text box above." />
|
|
||||||
</TextBlock>
|
|
||||||
<TextBlock Margin="0,24,0,0" FontSize="14">
|
|
||||||
<Run Text="For more information, check out the" />
|
|
||||||
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
|
|
||||||
</TextBlock>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- For bot token -->
|
|
||||||
<StackPanel Visibility="{Binding IsBotToken, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
|
||||||
<TextBlock
|
|
||||||
FontSize="18"
|
|
||||||
FontWeight="Light"
|
|
||||||
Text="Please provide your bot token to authorize" />
|
|
||||||
<TextBlock
|
|
||||||
Margin="8,8,0,0"
|
|
||||||
FontSize="14"
|
|
||||||
FontWeight="Light">
|
|
||||||
<Run Text="1. Open Discord developer portal" />
|
|
||||||
<LineBreak />
|
|
||||||
<Run Text="2. Open your application's settings" />
|
|
||||||
<LineBreak />
|
|
||||||
<Run Text="3. Navigate to the" />
|
|
||||||
<Run FontWeight="SemiBold" Text="Bot" />
|
|
||||||
<Run Text="section on the left" />
|
|
||||||
<LineBreak />
|
|
||||||
<Run Text="4. Under" />
|
|
||||||
<Run FontWeight="SemiBold" Text="Token" />
|
|
||||||
<Run Text="click" />
|
|
||||||
<Run FontWeight="SemiBold" Text="Copy" />
|
|
||||||
</TextBlock>
|
|
||||||
<TextBlock Margin="0,24,0,0" FontSize="14">
|
|
||||||
<Run Text="To authorize using user token instead, click" />
|
|
||||||
<InlineUIContainer>
|
|
||||||
<materialDesign:PackIcon
|
|
||||||
Margin="1,0,0,-1"
|
|
||||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
|
||||||
Kind="Robot" />
|
|
||||||
</InlineUIContainer>
|
|
||||||
<Run Text="in the text box above." />
|
|
||||||
</TextBlock>
|
|
||||||
<TextBlock Margin="0,24,0,0" FontSize="14">
|
|
||||||
<Run Text="For more information, check out the" />
|
|
||||||
<Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
|
|
||||||
</TextBlock>
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Guilds and channels -->
|
|
||||||
<Grid Background="{DynamicResource MaterialDesignCardBackground}" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<!-- Guilds -->
|
|
||||||
<Border
|
|
||||||
Grid.Column="0"
|
|
||||||
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
|
||||||
BorderThickness="0,0,1,0">
|
|
||||||
<ListBox
|
|
||||||
ItemsSource="{Binding AvailableGuilds}"
|
|
||||||
ScrollViewer.VerticalScrollBarVisibility="Hidden"
|
|
||||||
SelectedItem="{Binding SelectedGuild}"
|
|
||||||
SelectionMode="Single">
|
|
||||||
<ListBox.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<Grid
|
|
||||||
Margin="-8"
|
|
||||||
Background="Transparent"
|
|
||||||
Cursor="Hand"
|
|
||||||
ToolTip="{Binding Name}">
|
|
||||||
<!-- Guild icon placeholder -->
|
|
||||||
<Ellipse
|
|
||||||
Width="48"
|
|
||||||
Height="48"
|
|
||||||
Margin="12,4,12,4"
|
|
||||||
Fill="{DynamicResource MaterialDesignDivider}" />
|
|
||||||
|
|
||||||
<!-- Guild icon -->
|
|
||||||
<Ellipse
|
|
||||||
Width="48"
|
|
||||||
Height="48"
|
|
||||||
Margin="12,4,12,4"
|
|
||||||
Stroke="{DynamicResource MaterialDesignDivider}"
|
|
||||||
StrokeThickness="1">
|
|
||||||
<Ellipse.Fill>
|
|
||||||
<ImageBrush ImageSource="{Binding IconUrl}" />
|
|
||||||
</Ellipse.Fill>
|
|
||||||
</Ellipse>
|
|
||||||
</Grid>
|
|
||||||
</DataTemplate>
|
|
||||||
</ListBox.ItemTemplate>
|
|
||||||
</ListBox>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Channels -->
|
|
||||||
<Border Grid.Column="1">
|
|
||||||
<ListBox
|
|
||||||
HorizontalContentAlignment="Stretch"
|
|
||||||
ItemsSource="{Binding Source={StaticResource AvailableChannelsViewSource}}"
|
|
||||||
SelectionMode="Extended"
|
|
||||||
TextSearch.TextPath="Model.Name"
|
|
||||||
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
|
|
||||||
<i:Interaction.Behaviors>
|
|
||||||
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
|
|
||||||
</i:Interaction.Behaviors>
|
|
||||||
<ListBox.GroupStyle>
|
|
||||||
<GroupStyle>
|
|
||||||
<GroupStyle.ContainerStyle>
|
|
||||||
<Style TargetType="{x:Type GroupItem}">
|
|
||||||
<Setter Property="Template">
|
|
||||||
<Setter.Value>
|
|
||||||
<ControlTemplate d:DataContext="{x:Type CollectionViewGroup}">
|
|
||||||
<Expander
|
|
||||||
Margin="0"
|
|
||||||
Padding="0"
|
|
||||||
Background="Transparent"
|
|
||||||
BorderBrush="{DynamicResource MaterialDesignDivider}"
|
|
||||||
BorderThickness="0,0,0,1"
|
|
||||||
Header="{Binding Name}"
|
|
||||||
IsExpanded="False">
|
|
||||||
<ItemsPresenter />
|
|
||||||
</Expander>
|
|
||||||
</ControlTemplate>
|
|
||||||
</Setter.Value>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
|
||||||
</GroupStyle.ContainerStyle>
|
|
||||||
</GroupStyle>
|
|
||||||
</ListBox.GroupStyle>
|
|
||||||
<ListBox.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<Grid Margin="-8" Background="Transparent">
|
|
||||||
<Grid.InputBindings>
|
|
||||||
<MouseBinding Command="{s:Action ExportChannels}" MouseAction="LeftDoubleClick" />
|
|
||||||
</Grid.InputBindings>
|
|
||||||
<Grid.ColumnDefinitions>
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
<ColumnDefinition Width="*" />
|
|
||||||
<ColumnDefinition Width="Auto" />
|
|
||||||
</Grid.ColumnDefinitions>
|
|
||||||
|
|
||||||
<!-- Channel icon -->
|
|
||||||
<materialDesign:PackIcon
|
|
||||||
Grid.Column="0"
|
|
||||||
Margin="16,7,0,6"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Kind="Pound" />
|
|
||||||
|
|
||||||
<!-- Channel name -->
|
|
||||||
<TextBlock
|
|
||||||
Grid.Column="1"
|
|
||||||
Margin="3,8,8,8"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
FontSize="14"
|
|
||||||
Text="{Binding Name, Mode=OneWay}" />
|
|
||||||
|
|
||||||
<!-- Is selected checkmark -->
|
|
||||||
<materialDesign:PackIcon
|
|
||||||
Grid.Column="2"
|
|
||||||
Width="24"
|
|
||||||
Height="24"
|
|
||||||
Margin="8,0"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Kind="Check"
|
|
||||||
Visibility="{Binding IsSelected, RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}}, Converter={x:Static s:BoolToVisibilityConverter.Instance}, Mode=OneWay}" />
|
|
||||||
</Grid>
|
|
||||||
</DataTemplate>
|
|
||||||
</ListBox.ItemTemplate>
|
|
||||||
</ListBox>
|
|
||||||
</Border>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- Export button -->
|
|
||||||
<Button
|
|
||||||
Margin="32,24"
|
|
||||||
HorizontalAlignment="Right"
|
|
||||||
VerticalAlignment="Bottom"
|
|
||||||
Command="{s:Action ExportChannels}"
|
|
||||||
Style="{DynamicResource MaterialDesignFloatingActionAccentButton}"
|
|
||||||
Visibility="{Binding CanExportChannels, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
|
|
||||||
<materialDesign:PackIcon
|
|
||||||
Width="32"
|
|
||||||
Height="32"
|
|
||||||
Kind="Download" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- Notifications snackbar -->
|
|
||||||
<materialDesign:Snackbar MessageQueue="{Binding Notifications}" />
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</materialDesign:DialogHost>
|
</materialDesign:DialogHost>
|
||||||
</Window>
|
</Window>
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
DiscordChatExporter
|
DiscordChatExporter
|
||||||
Copyright (C) 2017-2021 Alexey Golub
|
Copyright (C) 2017-2022 Oleksii Holub
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU General Public License as published by
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
# DiscordChatExporter
|
# DiscordChatExporter
|
||||||
|
|
||||||
[](https://github.com/Tyrrrz/DiscordChatExporter/actions)
|
[](https://vshymanskyy.github.io/StandWithUkraine)
|
||||||
[](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
|
[](https://github.com/Tyrrrz/DiscordChatExporter/actions)
|
||||||
|
[](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
|
||||||
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||||
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||||
[](https://discord.gg/2SUWKFnHSm)
|
[](https://discord.gg/2SUWKFnHSm)
|
||||||
[](https://tyrrrz.me/donate)
|
[](https://tyrrrz.me/donate)
|
||||||
|
[](https://twitter.com/tyrrrz/status/1495972128977571848)
|
||||||
|
|
||||||
✅ **Project status: active**. [What does it mean?](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)
|
> 🟢 **Project status**: active<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)</sup>
|
||||||
|
|
||||||
**DiscordChatExporter** can be used to export message history from a [Discord](https://discord.com) channel to a file.
|
**DiscordChatExporter** can be used to export message history from a [Discord](https://discord.com) channel to a file.
|
||||||
It works with direct messages, group messages, and server channels, and supports Discord's dialect of markdown as well as all other rich media features.
|
It works with direct messages, group messages, and server channels, and supports Discord's dialect of markdown as well as all other rich media features.
|
||||||
|
|
||||||
❓ **If you have questions or issues, please refer to the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki)**.
|
> ❔ If you have questions or issues, **please refer to the [wiki](https://github.com/Tyrrrz/DiscordChatExporter/wiki)**.
|
||||||
|
|
||||||
💬 **If you want to chat, join my [Discord server](https://discord.gg/2SUWKFnHSm)**.
|
## Terms of use
|
||||||
|
|
||||||
|
By using this project or its source code, for any purpose and in any shape or form, you grant your **implicit agreement** to all the following statements:
|
||||||
|
|
||||||
|
- You **condemn Russia and its military aggression against Ukraine**
|
||||||
|
- You **recognize that Russia is an occupant that unlawfully invaded a sovereign state**
|
||||||
|
- You **support Ukraine's territorial integrity, including its claims over temporarily occupied territories of Crimea and Donbas**
|
||||||
|
- You **reject false narratives perpetuated by Russian state propaganda**
|
||||||
|
|
||||||
|
To learn more about the war and how you can help, [click here](https://tyrrrz.me). Glory to Ukraine! 🇺🇦
|
||||||
|
|
||||||
## Download
|
## Download
|
||||||
|
|
||||||
@@ -76,43 +87,13 @@ 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)
|
||||||
- Support for both user and bot tokens
|
- Authentication via both user and bot tokens
|
||||||
- Support for Discord's dialect of markdown
|
|
||||||
- Support for Discord's message filter syntax
|
|
||||||
- Support for attachments, embeds, emoji, and other rich media features
|
|
||||||
- Multiple output formats: HTML (dark/light), TXT, CSV, JSON
|
- Multiple output formats: HTML (dark/light), TXT, CSV, JSON
|
||||||
- File partitioning, date ranges, and other export options
|
- Support for markdown, attachments, embeds, emoji, and other rich media features
|
||||||
- Exports messages on the fly without buffering in-memory
|
- File partitioning, date ranges, message filtering, and other export options
|
||||||
|
- Self-contained exports that don't require internet
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
## Building the project locally
|
|
||||||
|
|
||||||
Prerequisites:
|
|
||||||
|
|
||||||
- [.NET 6.0 SDK](https://dotnet.microsoft.com/download/dotnet/6.0)
|
|
||||||
- _(Recommended)_ C#/.NET IDE, such as [JetBrains Rider](https://www.jetbrains.com/rider), [VS Code](https://code.visualstudio.com/docs/languages/csharp), or [Visual Studio](https://visualstudio.microsoft.com).
|
|
||||||
|
|
||||||
To build the entire solution run the following command in the root of the repository:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
> dotnet build
|
|
||||||
```
|
|
||||||
|
|
||||||
This will generate runtime artifacts for each project:
|
|
||||||
|
|
||||||
```plaintext
|
|
||||||
./DiscordChatExporter.Gui/bin/[Debug|Release]/[runtime]/*
|
|
||||||
./DiscordChatExporter.Cli/bin/[Debug|Release]/[runtime]/*
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also build and run a specific project directly.
|
|
||||||
To do that, navigate to its directory and use `dotnet run`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
> cd DiscordChatExporter.Gui
|
|
||||||
> dotnet run
|
|
||||||
```
|
|
||||||
Reference in New Issue
Block a user