Compare commits

..

51 Commits

Author SHA1 Message Date
Tyrrrz e216c07580 Update version 2023-01-10 22:40:44 +02:00
Lucas LaBuff d1244208b6 Switch emoji CDN to jsDelivr (#985) 2023-01-10 22:36:31 +02:00
Tyrrrz 6e56f29404 Update Ukraine support message 2023-01-10 22:25:35 +02:00
Tyrrrz 4edcdf0955 Ignore race conditions when closing dialogs 2023-01-09 11:46:56 +02:00
Tyrrrz 7d458cd3fd Update version 2023-01-08 18:42:35 +02:00
Oleksii Holub 57eb32d9c0 Update dockerfile 2023-01-05 14:20:49 +02:00
Tyrrrz 506bc0176b Fix incorrect replace order 2023-01-03 10:44:31 +02:00
Kuba_Z2 67b31c5f68 Remove regex check in Snowflake.TryParse (#980) 2023-01-03 10:41:59 +02:00
Tyrrrz d9c06bacda Trim dots at the end of filenames on Windows
Closes #977
2023-01-03 10:34:53 +02:00
Velithris db45b49af7 Use Alpine as the base image in docker, publish a self-contained app (#974) 2022-12-31 08:37:18 +02:00
Tyrrrz fc07f8da6c Fix the build badge in the readme 2022-12-16 20:54:39 +02:00
Tyrrrz b138908eb3 Re-add rate limit margins 2022-12-15 20:40:28 +02:00
Tyrrrz 2c4f812d4f Add link to Discord rate limit docs in comments 2022-12-15 20:19:12 +02:00
Tyrrrz 324d6bab20 Don't add margin for rate limit delays 2022-12-15 20:18:32 +02:00
Tyrrrz e5d5f1c85f Cleanup 2022-12-15 20:08:14 +02:00
Tyrrrz 49c4b12512 Smarter rate limit handling 2022-12-15 19:55:50 +02:00
Tyrrrz 57e2dc9313 Correct a comment 2022-12-13 03:15:28 +02:00
Oleksii Holub 07b72b9023 Update NuGet packages 2022-12-08 22:19:19 +02:00
Oleksii Holub f3830247fe Make use of the required keyword 2022-12-08 03:20:28 +02:00
Tyrrrz a8f89ec292 Remove unneeded project props 2022-12-08 01:49:32 +02:00
Tyrrrz 0bdbd44492 Update NuGet packages 2022-12-08 01:44:47 +02:00
Tyrrrz db23971b08 Use net7.0 2022-12-08 01:44:26 +02:00
m1337v 90bf4975b9 Update Readme.md to link to the latest .NET Runtime version for MacOS (#965) 2022-11-16 12:09:40 +02:00
Tyrrrz c2f9868746 More checks 2022-11-15 12:43:07 +02:00
Oleksii Holub ec840e76ae Simplify anti-russian checks 2022-11-15 01:36:23 +02:00
Oleksii Holub b672c30071 Pass cancellation token to markdown visitor 2022-11-03 19:13:50 +02:00
Oleksii Holub e752269467 Migrate from MiniRazor to RazorBlade 2022-11-03 18:56:45 +02:00
Tyrrrz 5b1b720503 Update Ukraine support links 2022-11-01 22:35:40 +02:00
Oleksii Holub 3dc3e3fa01 Update NuGet packages 2022-11-01 18:43:08 +02:00
Oleksii Holub 001ea7e495 Add region-based sanctions 2022-11-01 18:42:42 +02:00
Tyrrrz 9639b6c550 Update version 2022-10-29 17:57:34 +03:00
Tyrrrz 950658c737 Update NuGet packages 2022-10-29 17:42:57 +03:00
Tyrrrz 560a069c35 Refactor, rename the concept of "download media" to "download assets", including related mentions 2022-10-29 17:39:53 +03:00
Roberto Blázquez 1131f8659d Download emoji used in messages (#957) 2022-10-24 22:38:06 +03:00
Tyrrrz 38be44debb Update version 2022-10-21 19:21:57 +03:00
Tyrrrz 4b243f2252 Add related projects to readme 2022-10-21 19:18:12 +03:00
Oleksii Holub a31175b2e8 Update the dockerfile 2022-10-16 16:34:31 +03:00
BamButz 913255f04f Modify dockerfile to use non-root user (#952) 2022-10-16 16:27:21 +03:00
Tyrrrz 873d055191 Update NuGet packages 2022-10-12 23:01:38 +03:00
Tyrrrz dabed24c16 Lock the background UI when a dialog is open 2022-10-10 01:03:37 +03:00
Tyrrrz fdf421df3c Update version 2022-10-08 15:38:07 +03:00
Tyrrrz 2259f19c44 Remove message content intent check due to false positives 2022-10-08 15:35:06 +03:00
Tyrrrz 8695942328 Enforce a higher version of .NET runtime
Closes #941
2022-10-02 13:42:46 +03:00
Tyrrrz 84e656ccfc Update version 2022-09-24 20:40:51 +03:00
Tyrrrz 2ba0c3b38e More refactoring around system notifications 2022-09-24 19:54:42 +03:00
Tyrrrz 7467f0aeb6 Don't fail on unknown embed kinds
Closes #936
2022-09-19 23:55:17 +03:00
Tyrrrz e38479d463 Make lowercasing in system notifications a bit safer
Closes #935
2022-09-19 16:30:19 +03:00
Tyrrrz 06a4b6a8e6 Add checks for message content intent on bot accounts
Related to #918
2022-09-16 23:04:18 +03:00
Tyrrrz cc1ad8b435 Retry on SSL exceptions 2022-09-16 22:47:02 +03:00
Tyrrrz f8dac2c9d0 Simplify token kind resolution
Extra request which makes it less efficient, but much simpler code
2022-09-16 22:46:55 +03:00
Tyrrrz b8cea0d140 Unlock dotnet version on CI 2022-09-16 18:01:53 +03:00
59 changed files with 929 additions and 516 deletions
+3 -5
View File
@@ -18,8 +18,7 @@ jobs:
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@v2 uses: actions/setup-dotnet@v2
with: with:
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176 dotnet-version: 7.0.x
dotnet-version: 6.0.100
- name: Run tests - name: Run tests
# 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
@@ -50,8 +49,7 @@ jobs:
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@v2 uses: actions/setup-dotnet@v2
with: with:
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176 dotnet-version: 7.0.x
dotnet-version: 6.0.100
- name: Publish (CLI) - name: 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
@@ -151,4 +149,4 @@ jobs:
payload: | payload: |
{ {
"content": "**DiscordChatExporter** new version released!\nVersion: `${{ steps.get_version.outputs.tag }}`\nChangelog: <https://github.com/Tyrrrz/DiscordChatExporter/blob/${{ steps.get_version.outputs.tag }}/Changelog.md>" "content": "**DiscordChatExporter** new version released!\nVersion: `${{ steps.get_version.outputs.tag }}`\nChangelog: <https://github.com/Tyrrrz/DiscordChatExporter/blob/${{ steps.get_version.outputs.tag }}/Changelog.md>"
} }
+35
View File
@@ -1,3 +1,38 @@
### v2.37.1 (10-Jan-2023)
- [HTML] Fixed an issue where the export failed because it failed to resolve standard emoji image assets. Switched to a different CDN provider to mitigate this. (Thanks [@Lucas LaBuff](https://github.com/96-LB))
- [GUI] Fixed an application crash that happened when the user attempted to close the same dialog multiple times in a very short time.
### v2.37 (08-Jan-2023)
- Switched from .NET 6.0 to .NET 7.0. If running on Windows, the application should update all required prerequisites automatically. Alternatively, you can download the latest version of the runtime for your system [here](https://dotnet.microsoft.com/download/dotnet/7.0).
- Updated rate limit handling to adjust automatically based on the response headers. This may result in exports taking longer than before on bot accounts, but it makes it less likely for Discord to throttle you. Exports with user accounts shouldn't be affected too much by this change.
- Optimized snowflake parsing by removing unnecessary checks. (Thanks [@Kuba_Z2](https://github.com/KubaZ2))
- [Docker] Optimized the Docker image size by using the Alpine base image and publishing a self-contained distribution. (Thanks [@Velithris](https://github.com/Zireael-N))
- Fixed an issue where exporting a channel whose name ends with a dot would fail on Windows with default output path. The dots are now trimmed in file and directory names to match the behavior of Windows Explorer.
### v2.36.4 (29-Oct-2022)
- Changed all mentions of "media" in the context of "download media" or "reuse media" to "assets". CLI options will retain their existing names for backwards compatibility.
- [HTML] Fixed an issue which prevented emoji used inside message content from being downloaded when "download assets" option is enabled. (Thanks [@Roberto Blázquez](https://github.com/xBaank))
### v2.36.3 (21-Oct-2022)
- [GUI] Fixed an issue where opening a dialog did not prevent user interactions with background UI elements using keyboard. This sometimes caused the application to crash in weird ways.
- [Docker] Fixed an issue where DiscordChatExporter was running as root inside the container, making the exported files inaccessible to the host. (Thanks [@Benjamin Just](https://github.com/BamButz))
### v2.36.2 (08-Oct-2022)
- Removed the message content intent check because the heuristics turned out to be not reliable enough.
- Fixed an issue where the runtime manifest included the wrong target runtime version. This resulted in the application failing to launch for some users when upgrading from earlier versions.
### v2.36.1 (24-Sep-2022)
- Added a check which will trigger an error if the provided bot account does not have the message content intent enabled. Note, however, that this check is based on heuristics which may result in false negatives.
- Fixed an issue where certain transient HTTP errors were not retried.
- Fixed an issue which caused the export process to fail with the `IndexOutOfRangeException` exception on certain automated messages.
- Fixed an issue which caused the export process to fail on unrecognized embed types.
### v2.36 (16-Sep-2022) ### v2.36 (16-Sep-2022)
- [HTML] Added support for rendering GIFV embeds. They will now render as videos that automatically play when you hover your mouse over them. (Thanks [@gan-of-culture](https://github.com/gan-of-culture)) - [HTML] Added support for rendering GIFV embeds. They will now render as videos that automatically play when you hover your mouse over them. (Thanks [@gan-of-culture](https://github.com/gan-of-culture))
+2 -2
View File
@@ -1,8 +1,8 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<Version>2.36</Version> <Version>2.37.1</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright> <Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
@@ -12,14 +12,14 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="0.17.1" /> <PackageReference Include="AngleSharp" Version="0.17.1" />
<PackageReference Include="FluentAssertions" Version="6.7.0" /> <PackageReference Include="FluentAssertions" Version="6.8.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.0.1" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="2.0.1" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.0" />
<PackageReference Include="System.Reactive" Version="5.0.0" /> <PackageReference Include="System.Reactive" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" />
<PackageReference Include="coverlet.collector" Version="3.1.2" PrivateAssets="all" /> <PackageReference Include="coverlet.collector" Version="3.2.0" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -36,7 +36,7 @@ public class SelfContainedSpecs : IClassFixture<TempOutputFixture>
ChannelIds = new[] { ChannelIds.SelfContainedTestCases }, ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
ShouldDownloadMedia = true ShouldDownloadAssets = true
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
+34 -8
View File
@@ -1,5 +1,7 @@
# Build # -- Build
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build FROM mcr.microsoft.com/dotnet/sdk:7.0-alpine AS build
WORKDIR /build
COPY favicon.ico ./ COPY favicon.ico ./
COPY NuGet.config ./ COPY NuGet.config ./
@@ -7,12 +9,36 @@ COPY Directory.Build.props ./
COPY DiscordChatExporter.Core ./DiscordChatExporter.Core COPY DiscordChatExporter.Core ./DiscordChatExporter.Core
COPY DiscordChatExporter.Cli ./DiscordChatExporter.Cli COPY DiscordChatExporter.Cli ./DiscordChatExporter.Cli
RUN dotnet publish DiscordChatExporter.Cli -c Release -o ./publish RUN dotnet publish DiscordChatExporter.Cli \
--self-contained \
--use-current-runtime \
--configuration Release \
--output ./publish
# Run # -- Run
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS run FROM mcr.microsoft.com/dotnet/runtime-deps:7.0-alpine
COPY --from=build ./publish ./ # Alpine dotnet image doesn't include timezone data, which is needed
# for certain date/time operations.
RUN apk add --no-cache tzdata
WORKDIR ./out # Create a non-root user to run the app, so that the output files
ENTRYPOINT ["dotnet", "/DiscordChatExporter.Cli.dll"] # can be accessed by the host.
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851
RUN adduser \
--disabled-password \
--no-create-home \
dce
USER dce
COPY --from=build /build/publish /opt/dce
# Need to keep this as /out for backwards compatibility with documentation.
# A lot of people have this directory mounted in their scripts files, so
# changing it would break existing workflows.
WORKDIR /out
# Add the app directory to PATH so that it's easier to debug using a shell
ENV PATH="$PATH:/opt/dce"
ENTRYPOINT ["DiscordChatExporter.Cli"]
@@ -31,7 +31,7 @@ public abstract class ExportCommandBase : TokenCommandBase
public string OutputPath public string OutputPath
{ {
get => _outputPath; get => _outputPath;
// Handle ~/ in paths on *nix systems // Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903 // https://github.com/Tyrrrz/DiscordChatExporter/pull/903
init => _outputPath = Path.GetFullPath(value); init => _outputPath = Path.GetFullPath(value);
} }
@@ -76,15 +76,15 @@ public abstract class ExportCommandBase : TokenCommandBase
[CommandOption( [CommandOption(
"media", "media",
Description = "Download referenced media content." Description = "Download assets referenced by the export (user avatars, attached files, embedded images, etc.)."
)] )]
public bool ShouldDownloadMedia { get; init; } public bool ShouldDownloadAssets { get; init; }
[CommandOption( [CommandOption(
"reuse-media", "reuse-media",
Description = "Reuse already existing media content to skip redundant downloads." Description = "Reuse previously downloaded assets to avoid redundant requests."
)] )]
public bool ShouldReuseMedia { get; init; } public bool ShouldReuseAssets { get; init; }
[CommandOption( [CommandOption(
"dateformat", "dateformat",
@@ -92,14 +92,20 @@ public abstract class ExportCommandBase : TokenCommandBase
)] )]
public string DateFormat { get; init; } = "dd-MMM-yy hh:mm tt"; public string DateFormat { get; init; } = "dd-MMM-yy hh:mm tt";
[CommandOption(
"fuck-russia",
Description = "Don't print the Support Ukraine message to the console."
)]
public bool IsUkraineSupportMessageDisabled { get; init; }
private ChannelExporter? _channelExporter; private ChannelExporter? _channelExporter;
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord); protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord);
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels) protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels)
{ {
// Reuse media option should only be used when the media option is set. // Reuse assets option should only be used when the download assets option is set.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425 // https://github.com/Tyrrrz/DiscordChatExporter/issues/425
if (ShouldReuseMedia && !ShouldDownloadMedia) if (ShouldReuseAssets && !ShouldDownloadAssets)
{ {
throw new CommandException( throw new CommandException(
"Option --reuse-media cannot be used without --media." "Option --reuse-media cannot be used without --media."
@@ -158,8 +164,8 @@ public abstract class ExportCommandBase : TokenCommandBase
Before, Before,
PartitionLimit, PartitionLimit,
MessageFilter, MessageFilter,
ShouldDownloadMedia, ShouldDownloadAssets,
ShouldReuseMedia, ShouldReuseAssets,
DateFormat DateFormat
); );
@@ -234,12 +240,20 @@ public abstract class ExportCommandBase : TokenCommandBase
public override ValueTask ExecuteAsync(IConsole console) public override ValueTask ExecuteAsync(IConsole console)
{ {
// War in Ukraine message // Support Ukraine callout
console.Output.WriteLine("========================================================================"); if (!IsUkraineSupportMessageDisabled)
console.Output.WriteLine("|| Ukraine is at war! Support my country in its fight for freedom~ ||"); {
console.Output.WriteLine("|| Learn more on my website: https://tyrrrz.me ||"); console.Output.WriteLine("┌────────────────────────────────────────────────────────────────────┐");
console.Output.WriteLine("========================================================================"); console.Output.WriteLine("│ Thank you for supporting Ukraine <3 │");
console.Output.WriteLine(""); console.Output.WriteLine("│ │");
console.Output.WriteLine("│ As Russia wages a genocidal war against my country, │");
console.Output.WriteLine("│ I'm grateful to everyone who continues to │");
console.Output.WriteLine("│ stand with Ukraine in our fight for freedom. │");
console.Output.WriteLine("│ │");
console.Output.WriteLine("│ Learn more: https://tyrrrz.me/ukraine │");
console.Output.WriteLine("└────────────────────────────────────────────────────────────────────┘");
console.Output.WriteLine("");
}
return default; return default;
} }
@@ -12,11 +12,10 @@ public abstract class TokenCommandBase : ICommand
[CommandOption( [CommandOption(
"token", "token",
't', 't',
IsRequired = true,
EnvironmentVariable = "DISCORD_TOKEN", EnvironmentVariable = "DISCORD_TOKEN",
Description = "Authentication token." Description = "Authentication token."
)] )]
public string Token { get; init; } = ""; public required string Token { get; init; }
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")] [Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
[CommandOption( [CommandOption(
@@ -15,10 +15,9 @@ public class ExportChannelsCommand : ExportCommandBase
[CommandOption( [CommandOption(
"channel", "channel",
'c', 'c',
IsRequired = true,
Description = "Channel ID(s)." Description = "Channel ID(s)."
)] )]
public IReadOnlyList<Snowflake> ChannelIds { get; init; } = Array.Empty<Snowflake>(); public required IReadOnlyList<Snowflake> ChannelIds { get; init; }
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
@@ -15,10 +15,9 @@ public class ExportGuildCommand : ExportCommandBase
[CommandOption( [CommandOption(
"guild", "guild",
'g', 'g',
IsRequired = true,
Description = "Guild ID." Description = "Guild ID."
)] )]
public Snowflake GuildId { get; init; } public required Snowflake GuildId { get; init; }
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
@@ -16,10 +16,9 @@ public class GetChannelsCommand : TokenCommandBase
[CommandOption( [CommandOption(
"guild", "guild",
'g', 'g',
IsRequired = true,
Description = "Guild ID." Description = "Guild ID."
)] )]
public Snowflake GuildId { get; init; } public required Snowflake GuildId { get; init; }
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
@@ -6,10 +6,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="2.3.0" /> <PackageReference Include="CliFx" Version="2.3.1" />
<PackageReference Include="Spectre.Console" Version="0.44.0" /> <PackageReference Include="Spectre.Console" Version="0.45.0" />
<PackageReference Include="Gress" Version="2.0.1" /> <PackageReference Include="Gress" Version="2.0.1" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" /> <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.4.0" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+51
View File
@@ -0,0 +1,51 @@
using System;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Microsoft.Win32;
namespace DiscordChatExporter.Cli;
public static class Sanctions
{
[ModuleInitializer]
internal static void Verify()
{
var isSkipped = string.Equals(
Environment.GetEnvironmentVariable("RUSNI"),
"PYZDA",
StringComparison.OrdinalIgnoreCase
);
if (isSkipped)
return;
var locale = CultureInfo.CurrentCulture.Name;
var region =
RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? Registry.CurrentUser
.OpenSubKey(@"Control Panel\International\Geo", false)?
.GetValue("Name") as string
: null;
var isSanctioned =
locale.EndsWith("-ru", StringComparison.OrdinalIgnoreCase) ||
locale.EndsWith("-by", StringComparison.OrdinalIgnoreCase) ||
string.Equals(region, "ru", StringComparison.OrdinalIgnoreCase) ||
string.Equals(region, "by", StringComparison.OrdinalIgnoreCase);
if (!isSanctioned)
return;
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine(
"You cannot use this software on the territory of a terrorist state. " +
"Set the environment variable `RUSNI=PYZDA` if you wish to override this check."
);
Console.ResetColor();
Environment.Exit(0xFACC);
}
}
@@ -1,5 +1,6 @@
using System; using System;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace DiscordChatExporter.Core.Discord.Data.Common; namespace DiscordChatExporter.Core.Discord.Data.Common;
@@ -39,7 +40,8 @@ public readonly partial record struct FileSize(long TotalBytes)
} }
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public override string ToString() => $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}"; public override string ToString() =>
string.Create(CultureInfo.InvariantCulture, $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}");
} }
public partial record struct FileSize public partial record struct FileSize
@@ -37,7 +37,7 @@ public partial record Embed
var title = json.GetPropertyOrNull("title")?.GetStringOrNull(); var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
var kind = var kind =
json.GetPropertyOrNull("type")?.GetStringOrNull()?.Pipe(s => Enum.Parse<EmbedKind>(s, true)) ?? json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>() ??
EmbedKind.Rich; EmbedKind.Rich;
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
@@ -7,6 +7,5 @@ public enum EmbedKind
Image, Image,
Video, Video,
Gifv, Gifv,
Article,
Link Link
} }
@@ -45,7 +45,7 @@ public partial record Emoji
: $"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/v/latest/svg/{GetTwemojiId(name)}.svg"; $"https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/{GetTwemojiId(name)}.svg";
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated) public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
{ {
@@ -17,6 +17,5 @@ public enum MessageKind
public static class MessageKindExtensions public static class MessageKindExtensions
{ {
public static bool IsSystemNotification(this MessageKind c) => public static bool IsSystemNotification(this MessageKind c) => (int)c is >= 1 and <= 18;
c is not MessageKind.Default and not MessageKind.Reply;
} }
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
@@ -22,65 +23,99 @@ public class DiscordClient
private readonly string _token; private readonly string _token;
private readonly Uri _baseUri = new("https://discord.com/api/v9/", UriKind.Absolute); private readonly Uri _baseUri = new("https://discord.com/api/v9/", UriKind.Absolute);
private TokenKind _tokenKind = TokenKind.Unknown; private TokenKind? _resolvedTokenKind;
public DiscordClient(string token) => _token = token; public DiscordClient(string token) => _token = token;
private async ValueTask<HttpResponseMessage> GetResponseAsync( private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url, string url,
bool isBot, TokenKind tokenKind,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url)); return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
// Don't validate because token can have invalid characters // Don't validate because the token can have special characters
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828 // https://github.com/Tyrrrz/DiscordChatExporter/issues/828
request.Headers.TryAddWithoutValidation( request.Headers.TryAddWithoutValidation(
"Authorization", "Authorization",
isBot ? $"Bot {_token}" : _token tokenKind == TokenKind.Bot
); ? $"Bot {_token}"
: _token
);
return await Http.Client.SendAsync( var response = await Http.Client.SendAsync(
request, request,
HttpCompletionOption.ResponseHeadersRead, HttpCompletionOption.ResponseHeadersRead,
innerCancellationToken
);
// If this was the last request available before hitting the rate limit,
// wait out the reset time so that future requests can succeed.
// This may add an unnecessary delay in case the user doesn't intend to
// make any more requests, but implementing a smarter solution would
// require properly keeping track of Discord's global/per-route/per-resource
// rate limits and that's just way too much effort.
// https://discord.com/developers/docs/topics/rate-limits
var remainingRequestCount = response
.Headers
.TryGetValue("X-RateLimit-Remaining")?
.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
var resetAfterDelay = response
.Headers
.TryGetValue("X-RateLimit-Reset-After")?
.Pipe(s => TimeSpan.FromSeconds(double.Parse(s, CultureInfo.InvariantCulture)));
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
{
var delay =
// Adding a small buffer to the reset time reduces the chance of getting
// rate limited again, because it allows for more requests to be released.
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
// Sometimes Discord returns an absurdly high value for the reset time, which
// is not actually enforced by the server. So we cap it at a reasonable value.
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
await Task.Delay(delay, innerCancellationToken);
}
return response;
}, cancellationToken);
}
private async ValueTask<TokenKind> GetTokenKindAsync(CancellationToken cancellationToken = default)
{
// Try authenticating as a user
using var userResponse = await GetResponseAsync(
"users/@me",
TokenKind.User,
cancellationToken cancellationToken
); );
if (userResponse.StatusCode != HttpStatusCode.Unauthorized)
return TokenKind.User;
// Try authenticating as a bot
using var botResponse = await GetResponseAsync(
"users/@me",
TokenKind.Bot,
cancellationToken
);
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
return TokenKind.Bot;
throw DiscordChatExporterException.Unauthorized();
} }
private async ValueTask<HttpResponseMessage> GetResponseAsync( private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url, string url,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken => var tokenKind = _resolvedTokenKind ??= await GetTokenKindAsync(cancellationToken);
{ return await GetResponseAsync(url, tokenKind, cancellationToken);
if (_tokenKind == TokenKind.User)
return await GetResponseAsync(url, false, innerCancellationToken);
if (_tokenKind == TokenKind.Bot)
return await GetResponseAsync(url, true, 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);
} }
private async ValueTask<JsonElement> GetJsonResponseAsync( private async ValueTask<JsonElement> GetJsonResponseAsync(
@@ -1,7 +1,6 @@
using System; using System;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Discord; namespace DiscordChatExporter.Core.Discord;
@@ -29,7 +28,7 @@ public partial record struct Snowflake
return null; return null;
// As number // As number
if (Regex.IsMatch(str, @"^\d+$") && ulong.TryParse(str, NumberStyles.Number, formatProvider, out var value)) if (ulong.TryParse(str, NumberStyles.None, formatProvider, out var value))
{ {
return new Snowflake(value); return new Snowflake(value);
} }
@@ -2,7 +2,6 @@
public enum TokenKind public enum TokenKind
{ {
Unknown,
User, User,
Bot Bot
} }
@@ -7,14 +7,14 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Gress" Version="2.0.1" /> <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.2" />
<PackageReference Include="Polly" Version="7.2.3" /> <PackageReference Include="Polly" Version="7.2.3" />
<PackageReference Include="RazorBlade" Version="0.4.1" />
<PackageReference Include="Superpower" Version="3.0.0" /> <PackageReference Include="Superpower" Version="3.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.12.0" /> <PackageReference Include="WebMarkupMin.Core" Version="2.13.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<AdditionalFiles Include="Exporting\Writers\Html\*.cshtml" IsRazorTemplate="true" /> <RazorBlade Include="Exporting\Writers\Html\*.cshtml" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -12,18 +12,18 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting; namespace DiscordChatExporter.Core.Exporting;
internal partial class MediaDownloader internal partial class ExportAssetDownloader
{ {
private readonly string _workingDirPath; private readonly string _workingDirPath;
private readonly bool _reuseMedia; private readonly bool _reuse;
// File paths of already downloaded media // File paths of the previously downloaded assets
private readonly Dictionary<string, string> _pathCache = new(StringComparer.Ordinal); private readonly Dictionary<string, string> _pathCache = new(StringComparer.Ordinal);
public MediaDownloader(string workingDirPath, bool reuseMedia) public ExportAssetDownloader(string workingDirPath, bool reuse)
{ {
_workingDirPath = workingDirPath; _workingDirPath = workingDirPath;
_reuseMedia = reuseMedia; _reuse = reuse;
} }
public async ValueTask<string> DownloadAsync(string url, CancellationToken cancellationToken = default) public async ValueTask<string> DownloadAsync(string url, CancellationToken cancellationToken = default)
@@ -35,7 +35,7 @@ internal partial class MediaDownloader
var filePath = Path.Combine(_workingDirPath, fileName); var filePath = Path.Combine(_workingDirPath, fileName);
// Reuse existing files if we're allowed to // Reuse existing files if we're allowed to
if (!_reuseMedia || !File.Exists(filePath)) if (!_reuse || !File.Exists(filePath))
{ {
Directory.CreateDirectory(_workingDirPath); Directory.CreateDirectory(_workingDirPath);
@@ -76,7 +76,7 @@ internal partial class MediaDownloader
} }
} }
internal partial class MediaDownloader internal partial class ExportAssetDownloader
{ {
private static string GetUrlHash(string url) private static string GetUrlHash(string url)
{ {
@@ -14,7 +14,7 @@ namespace DiscordChatExporter.Core.Exporting;
internal class ExportContext internal class ExportContext
{ {
private readonly MediaDownloader _mediaDownloader; private readonly ExportAssetDownloader _assetDownloader;
public ExportRequest Request { get; } public ExportRequest Request { get; }
@@ -35,14 +35,14 @@ internal class ExportContext
Channels = channels; Channels = channels;
Roles = roles; Roles = roles;
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath, request.ShouldReuseMedia); _assetDownloader = new ExportAssetDownloader(request.OutputAssetsDirPath, request.ShouldReuseAssets);
} }
public string FormatDate(DateTimeOffset date) => Request.DateFormat switch public string FormatDate(DateTimeOffset date) => Request.DateFormat switch
{ {
"unix" => date.ToUnixTimeSeconds().ToString(), "unix" => date.ToUnixTimeSeconds().ToString(),
"unixms" => date.ToUnixTimeMilliseconds().ToString(), "unixms" => date.ToUnixTimeMilliseconds().ToString(),
var dateFormat => date.ToLocalString(dateFormat) var format => date.ToLocalString(format)
}; };
public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id); public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id);
@@ -63,14 +63,14 @@ internal class ExportContext
.FirstOrDefault(); .FirstOrDefault();
} }
public async ValueTask<string> ResolveMediaUrlAsync(string url, CancellationToken cancellationToken = default) public async ValueTask<string> ResolveAssetUrlAsync(string url, CancellationToken cancellationToken = default)
{ {
if (!Request.ShouldDownloadMedia) if (!Request.ShouldDownloadAssets)
return url; return url;
try try
{ {
var filePath = await _mediaDownloader.DownloadAsync(url, cancellationToken); var filePath = await _assetDownloader.DownloadAsync(url, cancellationToken);
// We want relative path so that the output files can be copied around without breaking. // We want relative path so that the output files can be copied around without breaking.
// Base directory path may be null if the file is stored at the root or relative to working directory. // Base directory path may be null if the file is stored at the root or relative to working directory.
@@ -19,8 +19,8 @@ public partial record ExportRequest(
Snowflake? Before, Snowflake? Before,
PartitionLimit PartitionLimit, PartitionLimit PartitionLimit,
MessageFilter MessageFilter, MessageFilter MessageFilter,
bool ShouldDownloadMedia, bool ShouldDownloadAssets,
bool ShouldReuseMedia, bool ShouldReuseAssets,
string DateFormat) string DateFormat)
{ {
private string? _outputBaseFilePath; private string? _outputBaseFilePath;
@@ -35,50 +35,11 @@ public partial record ExportRequest(
public string OutputBaseDirPath => Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath; public string OutputBaseDirPath => Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath;
public string OutputMediaDirPath => $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}"; public string OutputAssetsDirPath => $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}";
} }
public partial record ExportRequest public partial record ExportRequest
{ {
private static string GetOutputBaseFilePath(
Guild guild,
Channel channel,
string outputPath,
ExportFormat format,
Snowflake? after = null,
Snowflake? before = null)
{
// Formats path
outputPath = Regex.Replace(outputPath, "%.", m =>
PathEx.EscapeFileName(m.Value switch
{
"%g" => guild.Id.ToString(),
"%G" => guild.Name,
"%t" => channel.Category.Id.ToString(),
"%T" => channel.Category.Name,
"%c" => channel.Id.ToString(),
"%C" => channel.Name,
"%p" => channel.Position?.ToString() ?? "0",
"%P" => channel.Category.Position?.ToString() ?? "0",
"%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%b" => before?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"),
"%%" => "%",
_ => m.Value
})
);
// Output is a directory
if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath)))
{
var fileName = GetDefaultOutputFileName(guild, channel, format, after, before);
return Path.Combine(outputPath, fileName);
}
// Output is a file
return outputPath;
}
public static string GetDefaultOutputFileName( public static string GetDefaultOutputFileName(
Guild guild, Guild guild,
Channel channel, Channel channel,
@@ -120,4 +81,45 @@ public partial record ExportRequest
return PathEx.EscapeFileName(buffer.ToString()); return PathEx.EscapeFileName(buffer.ToString());
} }
private static string GetOutputBaseFilePath(
Guild guild,
Channel channel,
string outputPath,
ExportFormat format,
Snowflake? after = null,
Snowflake? before = null)
{
// Format path
var actualOutputPath = Regex.Replace(
outputPath,
"%.",
m => PathEx.EscapeFileName(m.Value switch
{
"%g" => guild.Id.ToString(),
"%G" => guild.Name,
"%t" => channel.Category.Id.ToString(),
"%T" => channel.Category.Name,
"%c" => channel.Id.ToString(),
"%C" => channel.Name,
"%p" => channel.Position?.ToString() ?? "0",
"%P" => channel.Category.Position?.ToString() ?? "0",
"%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%b" => before?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"),
"%%" => "%",
_ => m.Value
})
);
// Output is a directory
if (Directory.Exists(actualOutputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(actualOutputPath)))
{
var fileName = GetDefaultOutputFileName(guild, channel, format, after, before);
return Path.Combine(actualOutputPath, fileName);
}
// Output is a file
return actualOutputPath;
}
} }
@@ -19,8 +19,10 @@ internal partial class CsvMessageWriter : MessageWriter
_writer = new StreamWriter(stream); _writer = new StreamWriter(stream);
} }
private string FormatMarkdown(string? markdown) => private ValueTask<string> FormatMarkdownAsync(
PlainTextMarkdownVisitor.Format(Context, markdown ?? ""); string markdown,
CancellationToken cancellationToken = default) =>
PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken);
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) => public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) =>
await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions"); await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
@@ -37,7 +39,7 @@ internal partial class CsvMessageWriter : MessageWriter
buffer buffer
.AppendIfNotEmpty(',') .AppendIfNotEmpty(',')
.Append(await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken)); .Append(await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken));
} }
await _writer.WriteAsync(CsvEncode(buffer.ToString())); await _writer.WriteAsync(CsvEncode(buffer.ToString()));
@@ -84,7 +86,7 @@ internal partial class CsvMessageWriter : MessageWriter
await _writer.WriteAsync(','); await _writer.WriteAsync(',');
// Message content // Message content
await _writer.WriteAsync(CsvEncode(FormatMarkdown(message.Content))); await _writer.WriteAsync(CsvEncode(await FormatMarkdownAsync(message.Content, cancellationToken)));
await _writer.WriteAsync(','); await _writer.WriteAsync(',');
// Attachments // Attachments
@@ -1,39 +1,52 @@
@using System @using System
@using System.Collections.Generic
@using System.Linq @using System.Linq
@using System.Threading
@using System.Threading.Tasks @using System.Threading.Tasks
@using DiscordChatExporter.Core.Discord.Data @using DiscordChatExporter.Core.Discord.Data
@using DiscordChatExporter.Core.Discord.Data.Embeds @using DiscordChatExporter.Core.Discord.Data.Embeds
@using DiscordChatExporter.Core.Exporting.Writers.Html; @using DiscordChatExporter.Core.Exporting
@using DiscordChatExporter.Core.Exporting.Writers.Html
@using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
@using DiscordChatExporter.Core.Utils.Extensions @using DiscordChatExporter.Core.Utils.Extensions
@namespace DiscordChatExporter.Core.Exporting.Writers.Html @inherits RazorBlade.HtmlTemplate
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
@functions {
public required ExportContext ExportContext { get; init; }
public required IReadOnlyList<Message> Messages { get; init; }
}
@{ @{
var firstMessage = Model.Messages.First(); ValueTask<string> ResolveAssetUrlAsync(string url) =>
ExportContext.ResolveAssetUrlAsync(url, CancellationToken);
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url); string FormatDate(DateTimeOffset date) =>
ExportContext.FormatDate(date);
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date); ValueTask<string> FormatMarkdownAsync(string markdown) =>
HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown, true, CancellationToken);
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown); ValueTask<string> FormatEmbedMarkdownAsync(string markdown) =>
HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown, false, CancellationToken);
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false); var firstMessage = Messages.First();
var userMember = Model.ExportContext.TryGetMember(firstMessage.Author.Id); var userMember = ExportContext.TryGetMember(firstMessage.Author.Id);
var userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id); var userColor = ExportContext.TryGetUserColor(firstMessage.Author.Id);
var userNick = firstMessage.Author.IsBot var userNick = firstMessage.Author.IsBot
? firstMessage.Author.Name ? firstMessage.Author.Name
: userMember?.Nick ?? firstMessage.Author.Name; : userMember?.Nick ?? firstMessage.Author.Name;
var referencedUserMember = firstMessage.ReferencedMessage is not null var referencedUserMember = firstMessage.ReferencedMessage is not null
? Model.ExportContext.TryGetMember(firstMessage.ReferencedMessage.Author.Id) ? ExportContext.TryGetMember(firstMessage.ReferencedMessage.Author.Id)
: null; : null;
var referencedUserColor = firstMessage.ReferencedMessage is not null var referencedUserColor = firstMessage.ReferencedMessage is not null
? Model.ExportContext.TryGetUserColor(firstMessage.ReferencedMessage.Author.Id) ? ExportContext.TryGetUserColor(firstMessage.ReferencedMessage.Author.Id)
: null; : null;
var referencedUserNick = firstMessage.ReferencedMessage is not null var referencedUserNick = firstMessage.ReferencedMessage is not null
@@ -44,7 +57,7 @@
} }
<div class="chatlog__message-group"> <div class="chatlog__message-group">
@foreach (var (message, i) in Model.Messages.WithIndex()) @foreach (var (message, i) in Messages.WithIndex())
{ {
var isFirst = i == 0; var isFirst = i == 0;
@@ -55,36 +68,38 @@
{ {
// System notifications are grouped even if the message author is different. // System notifications are grouped even if the message author is different.
// That's why we have to update the user values with the author of the current message. // That's why we have to update the user values with the author of the current message.
userMember = Model.ExportContext.TryGetMember(message.Author.Id); userMember = ExportContext.TryGetMember(message.Author.Id);
userColor = Model.ExportContext.TryGetUserColor(message.Author.Id); userColor = ExportContext.TryGetUserColor(message.Author.Id);
userNick = message.Author.IsBot userNick = message.Author.IsBot
? message.Author.Name ? message.Author.Name
: userMember?.Nick ?? message.Author.Name; : userMember?.Nick ?? message.Author.Name;
<div class="chatlog__message-aside"> <div class="chatlog__message-aside">
<svg class=chatlog__system-notification-icon><use href="#@message.Kind.ToString().ToDashCase().ToLowerInvariant()-icon"></use></svg> <svg class="chatlog__system-notification-icon"><use href="#@message.Kind.ToString().ToDashCase().ToLowerInvariant()-icon"></use></svg>
</div> </div>
<div class="chatlog__message-primary"> <div class="chatlog__message-primary">
<div class="chatlog__header"> @{/* Author name */}
@{/* Author name */} <span class="chatlog__system-notification-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>
<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>
@{/* System notification content */} @{/* System notification content */}
@if(message.Kind == MessageKind.ChannelPinnedMessage) <span class="chatlog__system-notification-content">
@if (message.Kind == MessageKind.ChannelPinnedMessage && message.Reference is not null)
{ {
<span class="chatlog__system-notification"> pinned</span> <span> pinned</span>
<span class="chatlog__system-notification-reference-link chatlog__reference-link" onclick="scrollToMessage(event, '@message.Reference?.MessageId')"> a message</span> <a class="chatlog__system-notification-link" href="#chatlog__message-container-@message.Reference.MessageId"> a message</a>
<span class="chatlog__system-notification"> to this channel.</span> <span> to this channel.</span>
} }
else else
{ {
<span class="chatlog__system-notification">@(char.ToLowerInvariant(message.Content[0]) + message.Content[1..])</span> <span>@message.Content.ToLowerInvariant()</span>
} }
</span>
@{/* Timestamp */} @{/* Timestamp */}
<span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span> <span class="chatlog__system-notification-timestamp">
</div> <a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
</span>
</div> </div>
} }
// Regular message // Regular message
@@ -100,7 +115,7 @@
} }
// Avatar // Avatar
<img class="chatlog__avatar" src="@await ResolveUrlAsync(message.Author.AvatarUrl)" alt="Avatar" loading="lazy"> <img class="chatlog__avatar" src="@await ResolveAssetUrlAsync(message.Author.AvatarUrl)" alt="Avatar" loading="lazy">
} }
else else
{ {
@@ -117,13 +132,13 @@
<div class="chatlog__reference"> <div class="chatlog__reference">
@if (message.ReferencedMessage is not null) @if (message.ReferencedMessage is not null)
{ {
<img class="chatlog__reference-avatar" src="@await ResolveUrlAsync(message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy"> <img class="chatlog__reference-avatar" src="@await ResolveAssetUrlAsync(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-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"> <div class="chatlog__reference-content">
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')"> <span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden()) @if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden())
{ {
<!--wmm:ignore-->@Raw(FormatEmbedMarkdown(message.ReferencedMessage.Content))<!--/wmm:ignore--> <!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(message.ReferencedMessage.Content))<!--/wmm:ignore-->
} }
else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any()) else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any())
{ {
@@ -168,13 +183,13 @@
} }
@{/* Content */} @{/* Content */}
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null) @if ((!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden()) || message.EditedTimestamp is not null)
{ {
<div class="chatlog__content chatlog__markdown"> <div class="chatlog__content chatlog__markdown">
@{/* Text */} @{/* Text */}
@if (!message.IsContentHidden()) @if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden())
{ {
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatMarkdown(message.Content))<!--/wmm:ignore--></span> <span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.Content))<!--/wmm:ignore--></span>
} }
@{/* Edited timestamp */} @{/* Edited timestamp */}
@@ -198,20 +213,20 @@
@{/* Attachment preview */} @{/* Attachment preview */}
@if (attachment.IsImage) @if (attachment.IsImage)
{ {
<a href="@await ResolveUrlAsync(attachment.Url)"> <a href="@await ResolveAssetUrlAsync(attachment.Url)">
<img class="chatlog__attachment-media" src="@await ResolveUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy"> <img class="chatlog__attachment-media" src="@await ResolveAssetUrlAsync(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="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)"> <source src="@await ResolveAssetUrlAsync(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="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)"> <source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
</audio> </audio>
} }
else else
@@ -221,7 +236,7 @@
<use href="#attachment-icon"/> <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 ResolveAssetUrlAsync(attachment.Url)">
@attachment.FileName @attachment.FileName
</a> </a>
</div> </div>
@@ -268,7 +283,7 @@
<div class="chatlog__embed-author-container"> <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 ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
} }
@if (!string.IsNullOrWhiteSpace(embed.Author.Name)) @if (!string.IsNullOrWhiteSpace(embed.Author.Name))
@@ -294,12 +309,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="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div> <div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
</a> </a>
} }
else else
{ {
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div> <div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
} }
</div> </div>
} }
@@ -317,8 +332,8 @@
else if (embed.Kind == EmbedKind.Image && !string.IsNullOrWhiteSpace(embed.Url)) else if (embed.Kind == EmbedKind.Image && !string.IsNullOrWhiteSpace(embed.Url))
{ {
<div class="chatlog__embed"> <div class="chatlog__embed">
<a href="@await ResolveUrlAsync(embed.Url)"> <a href="@await ResolveAssetUrlAsync(embed.Url)">
<img class="chatlog__embed-generic-image" src="@await ResolveUrlAsync(embed.Url)" alt="Embedded image" loading="lazy"> <img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embed.Url)" alt="Embedded image" loading="lazy">
</a> </a>
</div> </div>
} }
@@ -327,7 +342,7 @@
{ {
<div class="chatlog__embed"> <div class="chatlog__embed">
<video class="chatlog__embed-generic-gifv" loop width="@embed.Video.Width" height="@embed.Video.Height" onmouseover="this.play()" onmouseout="this.pause()"> <video class="chatlog__embed-generic-gifv" loop width="@embed.Video.Width" height="@embed.Video.Height" onmouseover="this.play()" onmouseout="this.pause()">
<source src="@await ResolveUrlAsync(embed.Video.ProxyUrl ?? embed.Video.Url)" alt="Embedded video"> <source src="@await ResolveAssetUrlAsync(embed.Video.ProxyUrl ?? embed.Video.Url)" alt="Embedded video">
</video> </video>
</div> </div>
} }
@@ -354,7 +369,7 @@
<div class="chatlog__embed-author-container"> <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 ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
} }
@if (!string.IsNullOrWhiteSpace(embed.Author.Name)) @if (!string.IsNullOrWhiteSpace(embed.Author.Name))
@@ -380,12 +395,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="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div> <div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
</a> </a>
} }
else else
{ {
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Title))<!--/wmm:ignore--></div> <div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
} }
</div> </div>
} }
@@ -394,7 +409,7 @@
@if (!string.IsNullOrWhiteSpace(embed.Description)) @if (!string.IsNullOrWhiteSpace(embed.Description))
{ {
<div class="chatlog__embed-description"> <div class="chatlog__embed-description">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(embed.Description))<!--/wmm:ignore--></div> <div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Description))<!--/wmm:ignore--></div>
</div> </div>
} }
@@ -408,14 +423,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="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(field.Name))<!--/wmm:ignore--></div> <div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(field.Name))<!--/wmm:ignore--></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="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatEmbedMarkdown(field.Value))<!--/wmm:ignore--></div> <div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(field.Value))<!--/wmm:ignore--></div>
</div> </div>
} }
</div> </div>
@@ -428,8 +443,8 @@
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url)) @if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url))
{ {
<div class="chatlog__embed-thumbnail-container"> <div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)"> <a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy"> <img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
</a> </a>
</div> </div>
} }
@@ -444,8 +459,8 @@
if (!string.IsNullOrWhiteSpace(image.Url)) if (!string.IsNullOrWhiteSpace(image.Url))
{ {
<div class="chatlog__embed-image-container"> <div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)"> <a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy"> <img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
</a> </a>
</div> </div>
} }
@@ -460,7 +475,7 @@
@{/* Footer icon */} @{/* Footer icon */}
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl)) @if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
{ {
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy"> <img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
} }
<span class="chatlog__embed-footer-text"> <span class="chatlog__embed-footer-text">
@@ -494,11 +509,11 @@
<div class="chatlog__sticker" title="@sticker.Name"> <div class="chatlog__sticker" title="@sticker.Name">
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated) @if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated)
{ {
<img class="chatlog__sticker--media" src="@await ResolveUrlAsync(sticker.SourceUrl)" alt="Sticker"> <img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
} }
else if (sticker.Format == StickerFormat.Lottie) else if (sticker.Format == StickerFormat.Lottie)
{ {
<div class="chatlog__sticker--media" data-source="@await ResolveUrlAsync(sticker.SourceUrl)"></div> <div class="chatlog__sticker--media" data-source="@await ResolveAssetUrlAsync(sticker.SourceUrl)"></div>
} }
</div> </div>
} }
@@ -510,7 +525,7 @@
@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="chatlog__emoji chatlog__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 ResolveAssetUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
<span class="chatlog__reaction-count">@reaction.Count</span> <span class="chatlog__reaction-count">@reaction.Count</span>
</div> </div>
} }
@@ -1,21 +0,0 @@
using System.Collections.Generic;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
internal class MessageGroupTemplateContext
{
public ExportContext ExportContext { get; }
public IReadOnlyList<Message> Messages { get; }
public MessageGroupTemplateContext(ExportContext exportContext, IReadOnlyList<Message> messages)
{
ExportContext = exportContext;
Messages = messages;
}
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
}
@@ -1,15 +1,23 @@
@using DiscordChatExporter.Core.Exporting.Writers.Html; @using System.Threading
@using DiscordChatExporter.Core.Exporting
@namespace DiscordChatExporter.Core.Exporting.Writers.Html @inherits RazorBlade.HtmlTemplate
@inherits MiniRazor.TemplateBase<PostambleTemplateContext>
@{/* Close elements opened by preamble */} @functions {
public required ExportContext ExportContext { get; init; }
public required long MessagesWritten { get; init; }
}
@{
/* Close elements opened by preamble */
}
<!--wmm:ignore--> <!--wmm:ignore-->
</div> </div>
<!--/wmm:ignore--> <!--/wmm:ignore-->
<div class="postamble"> <div class="postamble">
<div class="postamble__entry">Exported @Model.MessagesWritten.ToString("N0") message(s)</div> <div class="postamble__entry">Exported @MessagesWritten.ToString("N0") message(s)</div>
</div> </div>
</body> </body>
@@ -1,14 +0,0 @@
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
internal class PostambleTemplateContext
{
public ExportContext ExportContext { get; }
public long MessagesWritten { get; }
public PostambleTemplateContext(ExportContext exportContext, long messagesWritten)
{
ExportContext = exportContext;
MessagesWritten = messagesWritten;
}
}
@@ -1,62 +1,72 @@
@using System @using System
@using System.Threading
@using System.Threading.Tasks @using System.Threading.Tasks
@using DiscordChatExporter.Core.Exporting.Writers.Html; @using DiscordChatExporter.Core.Exporting
@using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors
@namespace DiscordChatExporter.Core.Exporting.Writers.Html @inherits RazorBlade.HtmlTemplate
@inherits MiniRazor.TemplateBase<PreambleTemplateContext>
@functions {
public required ExportContext ExportContext { get; init; }
public required string ThemeName { get; init; }
}
@{ @{
string Themed(string darkVariant, string lightVariant) => string Themed(string darkVariant, string lightVariant) =>
string.Equals(Model.ThemeName, "Dark", StringComparison.OrdinalIgnoreCase) string.Equals(ThemeName, "Dark", StringComparison.OrdinalIgnoreCase)
? darkVariant ? darkVariant
: lightVariant; : lightVariant;
string GetFontUrl(int weight) => string GetFontUrl(int weight) =>
$"https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-{weight}.woff"; $"https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-{weight}.woff";
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url, CancellationToken); ValueTask<string> ResolveAssetUrlAsync(string url) =>
ExportContext.ResolveAssetUrlAsync(url, CancellationToken);
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date); string FormatDate(DateTimeOffset date) =>
ExportContext.FormatDate(date);
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown); ValueTask<string> FormatMarkdownAsync(string markdown) =>
HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown, true, CancellationToken);
} }
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>@Model.ExportContext.Request.Guild.Name - @Model.ExportContext.Request.Channel.Name</title> <title>@ExportContext.Request.Guild.Name - @ExportContext.Request.Channel.Name</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width"> <meta name="viewport" content="width=device-width">
@{/* Styling */} @{/* Styling */}
<style> <style>
@@font-face { @@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(300))); src: url(@await ResolveAssetUrlAsync(GetFontUrl(300)));
font-family: Whitney; font-family: Whitney;
font-weight: 300; font-weight: 300;
} }
@@font-face { @@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(400))); src: url(@await ResolveAssetUrlAsync(GetFontUrl(400)));
font-family: Whitney; font-family: Whitney;
font-weight: 400; font-weight: 400;
} }
@@font-face { @@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(500))); src: url(@await ResolveAssetUrlAsync(GetFontUrl(500)));
font-family: Whitney; font-family: Whitney;
font-weight: 500; font-weight: 500;
} }
@@font-face { @@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(600))); src: url(@await ResolveAssetUrlAsync(GetFontUrl(600)));
font-family: Whitney; font-family: Whitney;
font-weight: 600; font-weight: 600;
} }
@@font-face { @@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(700))); src: url(@await ResolveAssetUrlAsync(GetFontUrl(700)));
font-family: Whitney; font-family: Whitney;
font-weight: 700; font-weight: 700;
} }
@@ -69,6 +79,7 @@
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");
scroll-behavior: smooth;
} }
a { a {
@@ -254,18 +265,36 @@
unicode-bidi: bidi-override; unicode-bidi: bidi-override;
} }
.chatlog__system-notification { .chatlog__system-notification-icon {
width: 18px;
height: 18px;
}
.chatlog__system-notification-author {
font-weight: @Themed("500", "600");
color: @Themed("#ffffff", "#2f3136");
}
.chatlog__system-notification-content {
color: @Themed("#96989d", "#5e6772") color: @Themed("#96989d", "#5e6772")
} }
.chatlog__system-notification-reference-link { .chatlog__system-notification-link {
font-weight: 500; font-weight: 500;
color: @Themed("#ffffff", "#2f3136"); color: @Themed("#ffffff", "#2f3136");
} }
.chatlog__system-notification-icon { .chatlog__system-notification-timestamp {
width: 18px; margin-left: 0.3rem;
height: 18px; color: @Themed("#a3a6aa", "#5e6772");
font-size: 0.75rem;
font-weight: 500;
direction: ltr;
unicode-bidi: bidi-override;
}
.chatlog__system-notification-timestamp a {
color: inherit;
} }
.chatlog__header { .chatlog__header {
@@ -300,7 +329,7 @@
} }
.chatlog__timestamp a { .chatlog__timestamp a {
color: @Themed("#a3a6aa", "#5e6772"); color: inherit;
} }
.chatlog__content { .chatlog__content {
@@ -733,8 +762,8 @@
</style> </style>
@{/* Syntax highlighting */} @{/* Syntax highlighting */}
<link rel="stylesheet" href="@await ResolveUrlAsync($"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-{Model.ThemeName.ToLowerInvariant()}.min.css")"> <link rel="stylesheet" href="@await ResolveAssetUrlAsync($"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/styles/solarized-{ThemeName.ToLowerInvariant()}.min.css")">
<script src="@await ResolveUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js")"></script> <script src="@await ResolveAssetUrlAsync("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('.chatlog__markdown-pre--multiline').forEach(e => hljs.highlightBlock(e)); document.querySelectorAll('.chatlog__markdown-pre--multiline').forEach(e => hljs.highlightBlock(e));
@@ -742,7 +771,7 @@
</script> </script>
@{/* Lottie animation support */} @{/* Lottie animation support */}
<script src="@await ResolveUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.8.1/lottie.min.js")"></script> <script src="@await ResolveAssetUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.8.1/lottie.min.js")"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.chatlog__sticker--media[data-source]').forEach(e => { document.querySelectorAll('.chatlog__sticker--media[data-source]').forEach(e => {
@@ -824,31 +853,31 @@
<div class="preamble"> <div class="preamble">
<div class="preamble__guild-icon-container"> <div class="preamble__guild-icon-container">
<img class="preamble__guild-icon" src="@await ResolveUrlAsync(Model.ExportContext.Request.Guild.IconUrl)" alt="Guild icon" loading="lazy"> <img class="preamble__guild-icon" src="@await ResolveAssetUrlAsync(ExportContext.Request.Guild.IconUrl)" alt="Guild icon" loading="lazy">
</div> </div>
<div class="preamble__entries-container"> <div class="preamble__entries-container">
<div class="preamble__entry">@Model.ExportContext.Request.Guild.Name</div> <div class="preamble__entry">@ExportContext.Request.Guild.Name</div>
<div class="preamble__entry">@Model.ExportContext.Request.Channel.Category.Name / @Model.ExportContext.Request.Channel.Name</div> <div class="preamble__entry">@ExportContext.Request.Channel.Category.Name / @ExportContext.Request.Channel.Name</div>
@if (!string.IsNullOrWhiteSpace(Model.ExportContext.Request.Channel.Topic)) @if (!string.IsNullOrWhiteSpace(ExportContext.Request.Channel.Topic))
{ {
<div class="preamble__entry preamble__entry--small">@Raw(FormatMarkdown(Model.ExportContext.Request.Channel.Topic))</div> <div class="preamble__entry preamble__entry--small">@Html.Raw(await FormatMarkdownAsync(ExportContext.Request.Channel.Topic))</div>
} }
@if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null) @if (ExportContext.Request.After is not null || 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 (ExportContext.Request.After is not null && ExportContext.Request.Before is not null)
{ {
@($"Between {FormatDate(Model.ExportContext.Request.After.Value.ToDate())} and {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}") @($"Between {FormatDate(ExportContext.Request.After.Value.ToDate())} and {FormatDate(ExportContext.Request.Before.Value.ToDate())}")
} }
else if (Model.ExportContext.Request.After is not null) else if (ExportContext.Request.After is not null)
{ {
@($"After {FormatDate(Model.ExportContext.Request.After.Value.ToDate())}") @($"After {FormatDate(ExportContext.Request.After.Value.ToDate())}")
} }
else if (Model.ExportContext.Request.Before is not null) else if (ExportContext.Request.Before is not null)
{ {
@($"Before {FormatDate(Model.ExportContext.Request.Before.Value.ToDate())}") @($"Before {FormatDate(ExportContext.Request.Before.Value.ToDate())}")
} }
</div> </div>
} }
@@ -1,19 +0,0 @@
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
internal class PreambleTemplateContext
{
public ExportContext ExportContext { get; }
public string ThemeName { get; }
public PreambleTemplateContext(ExportContext exportContext, string themeName)
{
ExportContext = exportContext;
ThemeName = themeName;
}
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
}
@@ -71,15 +71,16 @@ internal class HtmlMessageWriter : MessageWriter
.Minify(html, false) .Minify(html, false)
.MinifiedContent; .MinifiedContent;
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) public override async ValueTask WritePreambleAsync(
CancellationToken cancellationToken = default)
{ {
var templateContext = new PreambleTemplateContext(Context, _themeName);
// We are not writing directly to output because Razor
// does not actually do asynchronous writes to stream.
await _writer.WriteLineAsync( await _writer.WriteLineAsync(
Minify( Minify(
await PreambleTemplate.RenderAsync(templateContext, cancellationToken) await new PreambleTemplate
{
ExportContext = Context,
ThemeName = _themeName
}.RenderAsync(cancellationToken)
) )
); );
} }
@@ -88,13 +89,13 @@ internal class HtmlMessageWriter : MessageWriter
IReadOnlyList<Message> messages, IReadOnlyList<Message> messages,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var templateContext = new MessageGroupTemplateContext(Context, messages);
// We are not writing directly to output because Razor
// does not actually do asynchronous writes to stream.
await _writer.WriteLineAsync( await _writer.WriteLineAsync(
Minify( Minify(
await MessageGroupTemplate.RenderAsync(templateContext, cancellationToken) await new MessageGroupTemplate
{
ExportContext = Context,
Messages = messages
}.RenderAsync(cancellationToken)
) )
); );
} }
@@ -126,13 +127,13 @@ internal class HtmlMessageWriter : MessageWriter
if (_messageGroup.Any()) if (_messageGroup.Any())
await WriteMessageGroupAsync(_messageGroup, cancellationToken); await WriteMessageGroupAsync(_messageGroup, cancellationToken);
var templateContext = new PostambleTemplateContext(Context, MessagesWritten);
// We are not writing directly to output because Razor
// does not actually do asynchronous writes to stream.
await _writer.WriteLineAsync( await _writer.WriteLineAsync(
Minify( Minify(
await PostambleTemplate.RenderAsync(templateContext, cancellationToken) await new PostambleTemplate
{
ExportContext = Context,
MessagesWritten = MessagesWritten
}.RenderAsync(cancellationToken)
) )
); );
} }
@@ -29,8 +29,10 @@ internal class JsonMessageWriter : MessageWriter
}); });
} }
private string FormatMarkdown(string? markdown) => private ValueTask<string> FormatMarkdownAsync(
PlainTextMarkdownVisitor.Format(Context, markdown ?? ""); string markdown,
CancellationToken cancellationToken = default) =>
PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken);
private async ValueTask WriteAttachmentAsync( private async ValueTask WriteAttachmentAsync(
Attachment attachment, Attachment attachment,
@@ -39,7 +41,7 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("id", attachment.Id.ToString()); _writer.WriteString("id", attachment.Id.ToString());
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken)); _writer.WriteString("url", await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken));
_writer.WriteString("fileName", attachment.FileName); _writer.WriteString("fileName", attachment.FileName);
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes); _writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
@@ -57,7 +59,12 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("url", embedAuthor.Url); _writer.WriteString("url", embedAuthor.Url);
if (!string.IsNullOrWhiteSpace(embedAuthor.IconUrl)) if (!string.IsNullOrWhiteSpace(embedAuthor.IconUrl))
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedAuthor.IconProxyUrl ?? embedAuthor.IconUrl, cancellationToken)); {
_writer.WriteString(
"iconUrl",
await Context.ResolveAssetUrlAsync(embedAuthor.IconProxyUrl ?? embedAuthor.IconUrl, cancellationToken)
);
}
_writer.WriteEndObject(); _writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
@@ -70,7 +77,12 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteStartObject(); _writer.WriteStartObject();
if (!string.IsNullOrWhiteSpace(embedImage.Url)) if (!string.IsNullOrWhiteSpace(embedImage.Url))
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken)); {
_writer.WriteString(
"url",
await Context.ResolveAssetUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken)
);
}
_writer.WriteNumber("width", embedImage.Width); _writer.WriteNumber("width", embedImage.Width);
_writer.WriteNumber("height", embedImage.Height); _writer.WriteNumber("height", embedImage.Height);
@@ -88,7 +100,12 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("text", embedFooter.Text); _writer.WriteString("text", embedFooter.Text);
if (!string.IsNullOrWhiteSpace(embedFooter.IconUrl)) if (!string.IsNullOrWhiteSpace(embedFooter.IconUrl))
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(embedFooter.IconProxyUrl ?? embedFooter.IconUrl, cancellationToken)); {
_writer.WriteString(
"iconUrl",
await Context.ResolveAssetUrlAsync(embedFooter.IconProxyUrl ?? embedFooter.IconUrl, cancellationToken)
);
}
_writer.WriteEndObject(); _writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
@@ -100,8 +117,8 @@ internal class JsonMessageWriter : MessageWriter
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("name", FormatMarkdown(embedField.Name)); _writer.WriteString("name", await FormatMarkdownAsync(embedField.Name, cancellationToken));
_writer.WriteString("value", FormatMarkdown(embedField.Value)); _writer.WriteString("value", await FormatMarkdownAsync(embedField.Value, cancellationToken));
_writer.WriteBoolean("isInline", embedField.IsInline); _writer.WriteBoolean("isInline", embedField.IsInline);
_writer.WriteEndObject(); _writer.WriteEndObject();
@@ -114,10 +131,10 @@ internal class JsonMessageWriter : MessageWriter
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("title", FormatMarkdown(embed.Title)); _writer.WriteString("title", await FormatMarkdownAsync(embed.Title ?? "", cancellationToken));
_writer.WriteString("url", embed.Url); _writer.WriteString("url", embed.Url);
_writer.WriteString("timestamp", embed.Timestamp); _writer.WriteString("timestamp", embed.Timestamp);
_writer.WriteString("description", FormatMarkdown(embed.Description)); _writer.WriteString("description", await FormatMarkdownAsync(embed.Description ?? "", cancellationToken));
if (embed.Color is not null) if (embed.Color is not null)
_writer.WriteString("color", embed.Color.Value.ToHex()); _writer.WriteString("color", embed.Color.Value.ToHex());
@@ -176,7 +193,7 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("id", sticker.Id.ToString()); _writer.WriteString("id", sticker.Id.ToString());
_writer.WriteString("name", sticker.Name); _writer.WriteString("name", sticker.Name);
_writer.WriteString("format", sticker.Format.ToString()); _writer.WriteString("format", sticker.Format.ToString());
_writer.WriteString("sourceUrl", await Context.ResolveMediaUrlAsync(sticker.SourceUrl, cancellationToken)); _writer.WriteString("sourceUrl", await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken));
_writer.WriteEndObject(); _writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
@@ -193,7 +210,7 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("id", reaction.Emoji.Id.ToString()); _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.ResolveAssetUrlAsync(reaction.Emoji.ImageUrl, cancellationToken));
_writer.WriteEndObject(); _writer.WriteEndObject();
_writer.WriteNumber("count", reaction.Count); _writer.WriteNumber("count", reaction.Count);
@@ -227,7 +244,7 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteStartObject("guild"); _writer.WriteStartObject("guild");
_writer.WriteString("id", Context.Request.Guild.Id.ToString()); _writer.WriteString("id", Context.Request.Guild.Id.ToString());
_writer.WriteString("name", Context.Request.Guild.Name); _writer.WriteString("name", Context.Request.Guild.Name);
_writer.WriteString("iconUrl", await Context.ResolveMediaUrlAsync(Context.Request.Guild.IconUrl, cancellationToken)); _writer.WriteString("iconUrl", await Context.ResolveAssetUrlAsync(Context.Request.Guild.IconUrl, cancellationToken));
_writer.WriteEndObject(); _writer.WriteEndObject();
// Channel // Channel
@@ -268,7 +285,7 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteBoolean("isPinned", message.IsPinned); _writer.WriteBoolean("isPinned", message.IsPinned);
// Content // Content
_writer.WriteString("content", FormatMarkdown(message.Content)); _writer.WriteString("content", await FormatMarkdownAsync(message.Content, cancellationToken));
// Author // Author
_writer.WriteStartObject("author"); _writer.WriteStartObject("author");
@@ -278,7 +295,7 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("nickname", Context.TryGetMember(message.Author.Id)?.Nick ?? message.Author.Name); _writer.WriteString("nickname", Context.TryGetMember(message.Author.Id)?.Nick ?? message.Author.Name);
_writer.WriteString("color", Context.TryGetUserColor(message.Author.Id)?.ToHex()); _writer.WriteString("color", Context.TryGetUserColor(message.Author.Id)?.ToHex());
_writer.WriteBoolean("isBot", message.Author.IsBot); _writer.WriteBoolean("isBot", message.Author.IsBot);
_writer.WriteString("avatarUrl", await Context.ResolveMediaUrlAsync(message.Author.AvatarUrl, cancellationToken)); _writer.WriteString("avatarUrl", await Context.ResolveAssetUrlAsync(message.Author.AvatarUrl, cancellationToken));
_writer.WriteEndObject(); _writer.WriteEndObject();
// Attachments // Attachments
@@ -3,6 +3,8 @@ using System.Linq;
using System.Net; using System.Net;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
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;
@@ -23,15 +25,19 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
_isJumbo = isJumbo; _isJumbo = isJumbo;
} }
protected override MarkdownNode VisitText(TextNode text) protected override async ValueTask<MarkdownNode> VisitTextAsync(
TextNode text,
CancellationToken cancellationToken = default)
{ {
_buffer.Append(HtmlEncode(text.Text)); _buffer.Append(HtmlEncode(text.Text));
return base.VisitText(text); return await base.VisitTextAsync(text, cancellationToken);
} }
protected override MarkdownNode VisitFormatting(FormattingNode formatting) protected override async ValueTask<MarkdownNode> VisitFormattingAsync(
FormattingNode formatting,
CancellationToken cancellationToken = default)
{ {
var (tagOpen, tagClose) = formatting.Kind switch var (openingTag, closingTag) = formatting.Kind switch
{ {
FormattingKind.Bold => ( FormattingKind.Bold => (
"<strong>", "<strong>",
@@ -66,24 +72,28 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
_ => throw new InvalidOperationException($"Unknown formatting kind '{formatting.Kind}'.") _ => throw new InvalidOperationException($"Unknown formatting kind '{formatting.Kind}'.")
}; };
_buffer.Append(tagOpen); _buffer.Append(openingTag);
var result = base.VisitFormatting(formatting); var result = await base.VisitFormattingAsync(formatting, cancellationToken);
_buffer.Append(tagClose); _buffer.Append(closingTag);
return result; return result;
} }
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) protected override async ValueTask<MarkdownNode> VisitInlineCodeBlockAsync(
InlineCodeBlockNode inlineCodeBlock,
CancellationToken cancellationToken = default)
{ {
_buffer _buffer
.Append("<code class=\"chatlog__markdown-pre chatlog__markdown-pre--inline\">") .Append("<code class=\"chatlog__markdown-pre chatlog__markdown-pre--inline\">")
.Append(HtmlEncode(inlineCodeBlock.Code)) .Append(HtmlEncode(inlineCodeBlock.Code))
.Append("</code>"); .Append("</code>");
return base.VisitInlineCodeBlock(inlineCodeBlock); return await base.VisitInlineCodeBlockAsync(inlineCodeBlock, cancellationToken);
} }
protected override MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) protected override async ValueTask<MarkdownNode> VisitMultiLineCodeBlockAsync(
MultiLineCodeBlockNode multiLineCodeBlock,
CancellationToken cancellationToken = default)
{ {
var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language) var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
? $"language-{multiLineCodeBlock.Language}" ? $"language-{multiLineCodeBlock.Language}"
@@ -94,10 +104,12 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
.Append(HtmlEncode(multiLineCodeBlock.Code)) .Append(HtmlEncode(multiLineCodeBlock.Code))
.Append("</code>"); .Append("</code>");
return base.VisitMultiLineCodeBlock(multiLineCodeBlock); return await base.VisitMultiLineCodeBlockAsync(multiLineCodeBlock, cancellationToken);
} }
protected override MarkdownNode VisitLink(LinkNode link) protected override async ValueTask<MarkdownNode> VisitLinkAsync(
LinkNode link,
CancellationToken cancellationToken = default)
{ {
// Try to extract message ID if the link refers to a Discord message // Try to extract message ID if the link refers to a Discord message
var linkedMessageId = Regex.Match( var linkedMessageId = Regex.Match(
@@ -111,24 +123,35 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
: $"<a href=\"{HtmlEncode(link.Url)}\">" : $"<a href=\"{HtmlEncode(link.Url)}\">"
); );
var result = base.VisitLink(link); var result = await base.VisitLinkAsync(link, cancellationToken);
_buffer.Append("</a>"); _buffer.Append("</a>");
return result; return result;
} }
protected override MarkdownNode VisitEmoji(EmojiNode emoji) protected override async ValueTask<MarkdownNode> VisitEmojiAsync(
EmojiNode emoji,
CancellationToken cancellationToken = default)
{ {
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated); var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = _isJumbo ? "chatlog__emoji--large" : ""; var jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
_buffer _buffer.Append(
.Append($"<img loading=\"lazy\" class=\"chatlog__emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">"); $"<img " +
$"loading=\"lazy\" " +
$"class=\"chatlog__emoji {jumboClass}\" " +
$"alt=\"{emoji.Name}\" " +
$"title=\"{emoji.Code}\" " +
$"src=\"{await _context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}\"" +
$">"
);
return base.VisitEmoji(emoji); return await base.VisitEmojiAsync(emoji, cancellationToken);
} }
protected override MarkdownNode VisitMention(MentionNode mention) protected override async ValueTask<MarkdownNode> VisitMentionAsync(
MentionNode mention,
CancellationToken cancellationToken = default)
{ {
if (mention.Kind == MentionKind.Everyone) if (mention.Kind == MentionKind.Everyone)
{ {
@@ -183,10 +206,12 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
.Append("</span>"); .Append("</span>");
} }
return base.VisitMention(mention); return await base.VisitMentionAsync(mention, cancellationToken);
} }
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp) protected override async ValueTask<MarkdownNode> VisitUnixTimestampAsync(
UnixTimestampNode timestamp,
CancellationToken cancellationToken = default)
{ {
var dateString = timestamp.Date is not null var dateString = timestamp.Date is not null
? _context.FormatDate(timestamp.Date.Value) ? _context.FormatDate(timestamp.Date.Value)
@@ -202,7 +227,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
.Append(HtmlEncode(dateString)) .Append(HtmlEncode(dateString))
.Append("</span>"); .Append("</span>");
return base.VisitUnixTimestamp(timestamp); return await base.VisitUnixTimestampAsync(timestamp, cancellationToken);
} }
} }
@@ -210,7 +235,11 @@ internal partial class HtmlMarkdownVisitor
{ {
private static string HtmlEncode(string text) => WebUtility.HtmlEncode(text); private static string HtmlEncode(string text) => WebUtility.HtmlEncode(text);
public static string Format(ExportContext context, string markdown, bool isJumboAllowed = true) public static async ValueTask<string> FormatAsync(
ExportContext context,
string markdown,
bool isJumboAllowed = true,
CancellationToken cancellationToken = default)
{ {
var nodes = MarkdownParser.Parse(markdown); var nodes = MarkdownParser.Parse(markdown);
@@ -220,7 +249,8 @@ internal partial class HtmlMarkdownVisitor
var buffer = new StringBuilder(); var buffer = new StringBuilder();
new HtmlMarkdownVisitor(context, buffer, isJumbo).Visit(nodes); await new HtmlMarkdownVisitor(context, buffer, isJumbo)
.VisitAsync(nodes, cancellationToken);
return buffer.ToString(); return buffer.ToString();
} }
@@ -1,4 +1,6 @@
using System.Text; using System.Text;
using System.Threading;
using System.Threading.Tasks;
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;
@@ -16,13 +18,17 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_buffer = buffer; _buffer = buffer;
} }
protected override MarkdownNode VisitText(TextNode text) protected override async ValueTask<MarkdownNode> VisitTextAsync(
TextNode text,
CancellationToken cancellationToken = default)
{ {
_buffer.Append(text.Text); _buffer.Append(text.Text);
return base.VisitText(text); return await base.VisitTextAsync(text, cancellationToken);
} }
protected override MarkdownNode VisitEmoji(EmojiNode emoji) protected override async ValueTask<MarkdownNode> VisitEmojiAsync(
EmojiNode emoji,
CancellationToken cancellationToken = default)
{ {
_buffer.Append( _buffer.Append(
emoji.IsCustomEmoji emoji.IsCustomEmoji
@@ -30,10 +36,12 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
: emoji.Name : emoji.Name
); );
return base.VisitEmoji(emoji); return await base.VisitEmojiAsync(emoji, cancellationToken);
} }
protected override MarkdownNode VisitMention(MentionNode mention) protected override async ValueTask<MarkdownNode> VisitMentionAsync(
MentionNode mention,
CancellationToken cancellationToken = default)
{ {
if (mention.Kind == MentionKind.Everyone) if (mention.Kind == MentionKind.Everyone)
{ {
@@ -69,10 +77,12 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_buffer.Append($"@{name}"); _buffer.Append($"@{name}");
} }
return base.VisitMention(mention); return await base.VisitMentionAsync(mention, cancellationToken);
} }
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp) protected override async ValueTask<MarkdownNode> VisitUnixTimestampAsync(
UnixTimestampNode timestamp,
CancellationToken cancellationToken = default)
{ {
_buffer.Append( _buffer.Append(
timestamp.Date is not null timestamp.Date is not null
@@ -80,18 +90,22 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
: "Invalid date" : "Invalid date"
); );
return base.VisitUnixTimestamp(timestamp); return await base.VisitUnixTimestampAsync(timestamp, cancellationToken);
} }
} }
internal partial class PlainTextMarkdownVisitor internal partial class PlainTextMarkdownVisitor
{ {
public static string Format(ExportContext context, string markdown) public static async ValueTask<string> FormatAsync(
ExportContext context,
string markdown,
CancellationToken cancellationToken = default)
{ {
var nodes = MarkdownParser.ParseMinimal(markdown); var nodes = MarkdownParser.ParseMinimal(markdown);
var buffer = new StringBuilder(); var buffer = new StringBuilder();
new PlainTextMarkdownVisitor(context, buffer).Visit(nodes); await new PlainTextMarkdownVisitor(context, buffer)
.VisitAsync(nodes, cancellationToken);
return buffer.ToString(); return buffer.ToString();
} }
@@ -19,8 +19,10 @@ internal class PlainTextMessageWriter : MessageWriter
_writer = new StreamWriter(stream); _writer = new StreamWriter(stream);
} }
private string FormatMarkdown(string? markdown) => private ValueTask<string> FormatMarkdownAsync(
PlainTextMarkdownVisitor.Format(Context, markdown ?? ""); string markdown,
CancellationToken cancellationToken = default) =>
PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken);
private async ValueTask WriteMessageHeaderAsync(Message message) private async ValueTask WriteMessageHeaderAsync(Message message)
{ {
@@ -48,7 +50,9 @@ internal class PlainTextMessageWriter : MessageWriter
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken)); await _writer.WriteLineAsync(
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
);
} }
await _writer.WriteLineAsync(); await _writer.WriteLineAsync();
@@ -65,37 +69,73 @@ internal class PlainTextMessageWriter : MessageWriter
await _writer.WriteLineAsync("{Embed}"); await _writer.WriteLineAsync("{Embed}");
if (!string.IsNullOrWhiteSpace(embed.Author?.Name)) if (!string.IsNullOrWhiteSpace(embed.Author?.Name))
{
await _writer.WriteLineAsync(embed.Author.Name); await _writer.WriteLineAsync(embed.Author.Name);
}
if (!string.IsNullOrWhiteSpace(embed.Url)) if (!string.IsNullOrWhiteSpace(embed.Url))
{
await _writer.WriteLineAsync(embed.Url); await _writer.WriteLineAsync(embed.Url);
}
if (!string.IsNullOrWhiteSpace(embed.Title)) if (!string.IsNullOrWhiteSpace(embed.Title))
await _writer.WriteLineAsync(FormatMarkdown(embed.Title)); {
await _writer.WriteLineAsync(
await FormatMarkdownAsync(embed.Title, cancellationToken)
);
}
if (!string.IsNullOrWhiteSpace(embed.Description)) if (!string.IsNullOrWhiteSpace(embed.Description))
await _writer.WriteLineAsync(FormatMarkdown(embed.Description)); {
await _writer.WriteLineAsync(
await FormatMarkdownAsync(embed.Description, cancellationToken)
);
}
foreach (var field in embed.Fields) foreach (var field in embed.Fields)
{ {
if (!string.IsNullOrWhiteSpace(field.Name)) if (!string.IsNullOrWhiteSpace(field.Name))
await _writer.WriteLineAsync(FormatMarkdown(field.Name)); {
await _writer.WriteLineAsync(
await FormatMarkdownAsync(field.Name, cancellationToken)
);
}
if (!string.IsNullOrWhiteSpace(field.Value)) if (!string.IsNullOrWhiteSpace(field.Value))
await _writer.WriteLineAsync(FormatMarkdown(field.Value)); {
await _writer.WriteLineAsync(
await FormatMarkdownAsync(field.Value, cancellationToken)
);
}
} }
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url)) if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url, cancellationToken)); {
await _writer.WriteLineAsync(
await Context.ResolveAssetUrlAsync(
embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url,
cancellationToken
)
);
}
foreach (var image in embed.Images) foreach (var image in embed.Images)
{ {
if (!string.IsNullOrWhiteSpace(image.Url)) if (!string.IsNullOrWhiteSpace(image.Url))
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(image.ProxyUrl ?? image.Url, cancellationToken)); {
await _writer.WriteLineAsync(
await Context.ResolveAssetUrlAsync(
image.ProxyUrl ?? image.Url,
cancellationToken
)
);
}
} }
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text)) if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
{
await _writer.WriteLineAsync(embed.Footer.Text); await _writer.WriteLineAsync(embed.Footer.Text);
}
await _writer.WriteLineAsync(); await _writer.WriteLineAsync();
} }
@@ -114,7 +154,9 @@ internal class PlainTextMessageWriter : MessageWriter
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(sticker.SourceUrl, cancellationToken)); await _writer.WriteLineAsync(
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
);
} }
await _writer.WriteLineAsync(); await _writer.WriteLineAsync();
@@ -136,7 +178,9 @@ internal class PlainTextMessageWriter : MessageWriter
await _writer.WriteAsync(reaction.Emoji.Name); await _writer.WriteAsync(reaction.Emoji.Name);
if (reaction.Count > 1) if (reaction.Count > 1)
{
await _writer.WriteAsync($" ({reaction.Count})"); await _writer.WriteAsync($" ({reaction.Count})");
}
await _writer.WriteAsync(' '); await _writer.WriteAsync(' ');
} }
@@ -151,13 +195,19 @@ internal class PlainTextMessageWriter : MessageWriter
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category.Name} / {Context.Request.Channel.Name}"); await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category.Name} / {Context.Request.Channel.Name}");
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic)) if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
{
await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}"); await _writer.WriteLineAsync($"Topic: {Context.Request.Channel.Topic}");
}
if (Context.Request.After is not null) if (Context.Request.After is not null)
{
await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value.ToDate())}"); await _writer.WriteLineAsync($"After: {Context.FormatDate(Context.Request.After.Value.ToDate())}");
}
if (Context.Request.Before is not null) if (Context.Request.Before is not null)
{
await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value.ToDate())}"); await _writer.WriteLineAsync($"Before: {Context.FormatDate(Context.Request.Before.Value.ToDate())}");
}
await _writer.WriteLineAsync(new string('=', 62)); await _writer.WriteLineAsync(new string('=', 62));
await _writer.WriteLineAsync(); await _writer.WriteLineAsync();
@@ -174,7 +224,11 @@ internal class PlainTextMessageWriter : MessageWriter
// Content // Content
if (!string.IsNullOrWhiteSpace(message.Content)) if (!string.IsNullOrWhiteSpace(message.Content))
await _writer.WriteLineAsync(FormatMarkdown(message.Content)); {
await _writer.WriteLineAsync(
await FormatMarkdownAsync(message.Content, cancellationToken)
);
}
await _writer.WriteLineAsync(); await _writer.WriteLineAsync();
@@ -1,56 +1,94 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal abstract class MarkdownVisitor internal abstract class MarkdownVisitor
{ {
protected virtual MarkdownNode VisitText(TextNode text) => protected virtual ValueTask<MarkdownNode> VisitTextAsync(
text; TextNode text,
CancellationToken cancellationToken = default) =>
new(text);
protected virtual MarkdownNode VisitFormatting(FormattingNode formatting) protected virtual async ValueTask<MarkdownNode> VisitFormattingAsync(
FormattingNode formatting,
CancellationToken cancellationToken = default)
{ {
Visit(formatting.Children); await VisitAsync(formatting.Children, cancellationToken);
return formatting; return formatting;
} }
protected virtual MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) => protected virtual ValueTask<MarkdownNode> VisitInlineCodeBlockAsync(
inlineCodeBlock; InlineCodeBlockNode inlineCodeBlock,
CancellationToken cancellationToken = default) =>
new(inlineCodeBlock);
protected virtual MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) => protected virtual ValueTask<MarkdownNode> VisitMultiLineCodeBlockAsync(
multiLineCodeBlock; MultiLineCodeBlockNode multiLineCodeBlock,
CancellationToken cancellationToken = default) =>
new(multiLineCodeBlock);
protected virtual MarkdownNode VisitLink(LinkNode link) protected virtual async ValueTask<MarkdownNode> VisitLinkAsync(
LinkNode link,
CancellationToken cancellationToken = default)
{ {
Visit(link.Children); await VisitAsync(link.Children, cancellationToken);
return link; return link;
} }
protected virtual MarkdownNode VisitEmoji(EmojiNode emoji) => protected virtual ValueTask<MarkdownNode> VisitEmojiAsync(
emoji; EmojiNode emoji,
CancellationToken cancellationToken = default) =>
new(emoji);
protected virtual MarkdownNode VisitMention(MentionNode mention) => protected virtual ValueTask<MarkdownNode> VisitMentionAsync(
mention; MentionNode mention,
CancellationToken cancellationToken = default) =>
new(mention);
protected virtual MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp) => protected virtual ValueTask<MarkdownNode> VisitUnixTimestampAsync(
timestamp; UnixTimestampNode timestamp,
CancellationToken cancellationToken = default) =>
new(timestamp);
public MarkdownNode Visit(MarkdownNode node) => node switch public async ValueTask<MarkdownNode> VisitAsync(
{ MarkdownNode node,
TextNode text => VisitText(text), CancellationToken cancellationToken = default) => node switch
FormattingNode formatting => VisitFormatting(formatting), {
InlineCodeBlockNode inlineCodeBlock => VisitInlineCodeBlock(inlineCodeBlock), TextNode text =>
MultiLineCodeBlockNode multiLineCodeBlock => VisitMultiLineCodeBlock(multiLineCodeBlock), await VisitTextAsync(text, cancellationToken),
LinkNode link => VisitLink(link),
EmojiNode emoji => VisitEmoji(emoji),
MentionNode mention => VisitMention(mention),
UnixTimestampNode timestamp => VisitUnixTimestamp(timestamp),
_ => throw new ArgumentOutOfRangeException(nameof(node))
};
public void Visit(IEnumerable<MarkdownNode> nodes) FormattingNode formatting =>
await VisitFormattingAsync(formatting, cancellationToken),
InlineCodeBlockNode inlineCodeBlock =>
await VisitInlineCodeBlockAsync(inlineCodeBlock, cancellationToken),
MultiLineCodeBlockNode multiLineCodeBlock =>
await VisitMultiLineCodeBlockAsync(multiLineCodeBlock, cancellationToken),
LinkNode link =>
await VisitLinkAsync(link, cancellationToken),
EmojiNode emoji =>
await VisitEmojiAsync(emoji, cancellationToken),
MentionNode mention =>
await VisitMentionAsync(mention, cancellationToken),
UnixTimestampNode timestamp =>
await VisitUnixTimestampAsync(timestamp, cancellationToken),
_ => throw new ArgumentOutOfRangeException(nameof(node))
};
public async ValueTask VisitAsync(
IEnumerable<MarkdownNode> nodes,
CancellationToken cancellationToken = default)
{ {
foreach (var node in nodes) foreach (var node in nodes)
Visit(node); await VisitAsync(node, cancellationToken);
} }
} }
@@ -1,9 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Core.Utils.Extensions;
@@ -32,13 +28,4 @@ public static class ExceptionExtensions
PopulateChildren(exception, children); PopulateChildren(exception, children);
return children; return children;
} }
public static HttpStatusCode? TryGetStatusCode(this HttpRequestException ex) =>
// This is extremely frail, but there's no other way
Regex
.Match(ex.Message, @": (\d+) \(")
.Groups[1]
.Value
.NullIfWhiteSpace()?
.Pipe(s => (HttpStatusCode) int.Parse(s, CultureInfo.InvariantCulture));
} }
@@ -1,11 +1,24 @@
using System.Net.Http.Headers; using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Core.Utils.Extensions;
public static class HttpExtensions public static class HttpExtensions
{ {
public static string? TryGetValue(this HttpContentHeaders headers, string name) => public static string? TryGetValue(this HttpHeaders headers, string name) =>
headers.TryGetValues(name, out var values) headers.TryGetValues(name, out var values)
? string.Concat(values) ? string.Concat(values)
: null; : null;
public static HttpStatusCode? TryGetStatusCode(this HttpRequestException ex) =>
// This is extremely frail, but there's no other way
Regex
.Match(ex.Message, @": (\d+) \(")
.Groups[1]
.Value
.NullIfWhiteSpace()?
.Pipe(s => (HttpStatusCode) int.Parse(s, CultureInfo.InvariantCulture));
} }
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@@ -29,6 +30,11 @@ public static class StringExtensions
public static string ToDashCase(this string str) => public static string ToDashCase(this string str) =>
Regex.Replace(str, @"(\p{Ll})(\p{Lu})", "$1-$2"); Regex.Replace(str, @"(\p{Ll})(\p{Lu})", "$1-$2");
public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true) where T : struct, Enum =>
Enum.TryParse<T>(str, ignoreCase, out var result)
? result
: null;
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) => public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
builder.Length > 0 builder.Length > 0
? builder.Append(value) ? builder.Append(value)
@@ -0,0 +1,17 @@
using System;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class TimeSpanExtensions
{
public static TimeSpan Clamp(this TimeSpan value, TimeSpan min, TimeSpan max)
{
if (value < min)
return min;
if (value > max)
return max;
return value;
}
}
+6 -12
View File
@@ -3,6 +3,7 @@ using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Sockets; using System.Net.Sockets;
using System.Security.Authentication;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using Polly; using Polly;
@@ -21,7 +22,7 @@ public static class Http
private static bool IsRetryableException(Exception exception) => private static bool IsRetryableException(Exception exception) =>
exception.GetSelfAndChildren().Any(ex => exception.GetSelfAndChildren().Any(ex =>
ex is TimeoutException or SocketException || ex is TimeoutException or SocketException or AuthenticationException ||
ex is HttpRequestException hrex && IsRetryableStatusCode(hrex.TryGetStatusCode() ?? HttpStatusCode.OK) ex is HttpRequestException hrex && IsRetryableStatusCode(hrex.TryGetStatusCode() ?? HttpStatusCode.OK)
); );
@@ -38,18 +39,11 @@ public static class Http
8, 8,
(i, result, _) => (i, result, _) =>
{ {
// If rate-limited, use retry-after as a guide // If rate-limited, use retry-after header as the guide
if (result.Result?.StatusCode == HttpStatusCode.TooManyRequests) if (result.Result.Headers.RetryAfter?.Delta is { } retryAfter)
{ {
// Only start respecting retry-after after a few attempts, because // Add some buffer just in case
// Discord often sends unreasonable (20+ minutes) retry-after return retryAfter + TimeSpan.FromSeconds(1);
// on the very first request.
if (i > 3)
{
var retryAfterDelay = result.Result.Headers.RetryAfter?.Delta;
if (retryAfterDelay is not null)
return retryAfterDelay.Value + TimeSpan.FromSeconds(1); // margin just in case
}
} }
return TimeSpan.FromSeconds(Math.Pow(2, i) + 1); return TimeSpan.FromSeconds(Math.Pow(2, i) + 1);
+9
View File
@@ -1,5 +1,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
namespace DiscordChatExporter.Core.Utils; namespace DiscordChatExporter.Core.Utils;
@@ -15,6 +16,14 @@ public static class PathEx
foreach (var c in path) foreach (var c in path)
buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_'); buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_');
// File names cannot end with a dot on Windows
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
while (buffer.Length > 0 && buffer[^1] == '.')
buffer.Remove(buffer.Length - 1, 1);
}
return buffer.ToString(); return buffer.ToString();
} }
+27 -23
View File
@@ -9,37 +9,41 @@ using System.Windows;
using System.Windows.Threading; using System.Windows.Threading;
#endif #endif
namespace DiscordChatExporter.Gui namespace DiscordChatExporter.Gui;
public class Bootstrapper : Bootstrapper<RootViewModel>
{ {
public class Bootstrapper : Bootstrapper<RootViewModel> protected override void OnStart()
{ {
protected override void OnStart() base.OnStart();
{
base.OnStart();
// Set default theme // Set default theme
// (preferred theme will be chosen later, once the settings are loaded) // (preferred theme will be set later, once the settings are loaded)
App.SetLightTheme(); App.SetLightTheme();
} }
protected override void ConfigureIoC(IStyletIoCBuilder builder) protected override void ConfigureIoC(IStyletIoCBuilder builder)
{ {
base.ConfigureIoC(builder); base.ConfigureIoC(builder);
// Bind settings as singleton // Bind settings as singleton
builder.Bind<SettingsService>().ToSelf().InSingletonScope(); builder.Bind<SettingsService>().ToSelf().InSingletonScope();
// Bind view model factory // Bind view model factory
builder.Bind<IViewModelFactory>().ToAbstractFactory(); builder.Bind<IViewModelFactory>().ToAbstractFactory();
} }
#if !DEBUG #if !DEBUG
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e) protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e)
{ {
base.OnUnhandledException(e); base.OnUnhandledException(e);
MessageBox.Show(e.Exception.ToString(), "Error occured", MessageBoxButton.OK, MessageBoxImage.Error); MessageBox.Show(
} e.Exception.ToString(),
#endif "Error occured",
MessageBoxButton.OK,
MessageBoxImage.Error
);
} }
#endif
} }
@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>$(TargetFramework)-windows</TargetFramework>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>$(TargetFramework)-windows</TargetFramework>
<AssemblyName>DiscordChatExporter</AssemblyName> <AssemblyName>DiscordChatExporter</AssemblyName>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<ApplicationIcon>../favicon.ico</ApplicationIcon> <ApplicationIcon>../favicon.ico</ApplicationIcon>
@@ -14,15 +14,15 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Gress" Version="2.0.1" /> <PackageReference Include="Gress" Version="2.0.1" />
<PackageReference Include="MaterialDesignColors" Version="2.0.6" /> <PackageReference Include="MaterialDesignColors" Version="2.0.9" />
<PackageReference Include="MaterialDesignThemes" Version="4.5.0" /> <PackageReference Include="MaterialDesignThemes" Version="4.6.1" />
<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.1" /> <PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
<PackageReference Include="Onova" Version="2.6.2" /> <PackageReference Include="Onova" Version="2.6.3" />
<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.1" PrivateAssets="all" /> <PackageReference Include="PropertyChanged.Fody" Version="4.1.0" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" /> <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.4.0" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+48
View File
@@ -0,0 +1,48 @@
using System;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Windows;
using Microsoft.Win32;
namespace DiscordChatExporter.Gui;
public static class Sanctions
{
[ModuleInitializer]
internal static void Verify()
{
var isSkipped = string.Equals(
Environment.GetEnvironmentVariable("RUSNI"),
"PYZDA",
StringComparison.OrdinalIgnoreCase
);
if (isSkipped)
return;
var locale = CultureInfo.CurrentCulture.Name;
var region = Registry.CurrentUser
.OpenSubKey(@"Control Panel\International\Geo", false)?
.GetValue("Name") as string;
var isSanctioned =
locale.EndsWith("-ru", StringComparison.OrdinalIgnoreCase) ||
locale.EndsWith("-by", StringComparison.OrdinalIgnoreCase) ||
string.Equals(region, "ru", StringComparison.OrdinalIgnoreCase) ||
string.Equals(region, "by", StringComparison.OrdinalIgnoreCase);
if (!isSanctioned)
return;
MessageBox.Show(
"You cannot use this software on the territory of a terrorist state. " +
"Set the environment variable `RUSNI=PYZDA` if you wish to override this check.",
"Sanctioned region",
MessageBoxButton.OK,
MessageBoxImage.Error
);
Environment.Exit(0xFACC);
}
}
@@ -6,6 +6,8 @@ namespace DiscordChatExporter.Gui.Services;
public partial class SettingsService : SettingsManager public partial class SettingsService : SettingsManager
{ {
public bool IsUkraineSupportMessageEnabled { get; set; } = true;
public bool IsAutoUpdateEnabled { get; set; } = true; public bool IsAutoUpdateEnabled { get; set; } = true;
public bool IsDarkModeEnabled { get; set; } = IsDarkModeEnabledByDefault(); public bool IsDarkModeEnabled { get; set; } = IsDarkModeEnabledByDefault();
@@ -16,7 +18,7 @@ public partial class SettingsService : SettingsManager
public int ParallelLimit { get; set; } = 1; public int ParallelLimit { get; set; } = 1;
public bool ShouldReuseMedia { get; set; } public bool ShouldReuseAssets { get; set; }
public string? LastToken { get; set; } public string? LastToken { get; set; }
@@ -26,7 +28,7 @@ public partial class SettingsService : SettingsManager
public string? LastMessageFilterValue { get; set; } public string? LastMessageFilterValue { get; set; }
public bool LastShouldDownloadMedia { get; set; } public bool LastShouldDownloadAssets { get; set; }
public SettingsService() public SettingsService()
{ {
@@ -186,8 +186,8 @@ public class DashboardViewModel : PropertyChangedBase
dialog.Before?.Pipe(Snowflake.FromDate), dialog.Before?.Pipe(Snowflake.FromDate),
dialog.PartitionLimit, dialog.PartitionLimit,
dialog.MessageFilter, dialog.MessageFilter,
dialog.ShouldDownloadMedia, dialog.ShouldDownloadAssets,
_settingsService.ShouldReuseMedia, _settingsService.ShouldReuseAssets,
_settingsService.DateFormat _settingsService.DateFormat
); );
@@ -59,7 +59,7 @@ public class ExportSetupViewModel : DialogScreen
? MessageFilter.Parse(MessageFilterValue) ? MessageFilter.Parse(MessageFilterValue)
: MessageFilter.Null; : MessageFilter.Null;
public bool ShouldDownloadMedia { get; set; } public bool ShouldDownloadAssets { get; set; }
public bool IsAdvancedSectionDisplayed { get; set; } public bool IsAdvancedSectionDisplayed { get; set; }
@@ -72,7 +72,7 @@ public class ExportSetupViewModel : DialogScreen
SelectedFormat = _settingsService.LastExportFormat; SelectedFormat = _settingsService.LastExportFormat;
PartitionLimitValue = _settingsService.LastPartitionLimitValue; PartitionLimitValue = _settingsService.LastPartitionLimitValue;
MessageFilterValue = _settingsService.LastMessageFilterValue; MessageFilterValue = _settingsService.LastMessageFilterValue;
ShouldDownloadMedia = _settingsService.LastShouldDownloadMedia; ShouldDownloadAssets = _settingsService.LastShouldDownloadAssets;
// Show the "advanced options" section 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.
@@ -81,7 +81,7 @@ public class ExportSetupViewModel : DialogScreen
Before != default || Before != default ||
!string.IsNullOrWhiteSpace(PartitionLimitValue) || !string.IsNullOrWhiteSpace(PartitionLimitValue) ||
!string.IsNullOrWhiteSpace(MessageFilterValue) || !string.IsNullOrWhiteSpace(MessageFilterValue) ||
ShouldDownloadMedia != default; ShouldDownloadAssets != default;
} }
public void ToggleAdvancedSection() => IsAdvancedSectionDisplayed = !IsAdvancedSectionDisplayed; public void ToggleAdvancedSection() => IsAdvancedSectionDisplayed = !IsAdvancedSectionDisplayed;
@@ -92,7 +92,7 @@ public class ExportSetupViewModel : DialogScreen
_settingsService.LastExportFormat = SelectedFormat; _settingsService.LastExportFormat = SelectedFormat;
_settingsService.LastPartitionLimitValue = PartitionLimitValue; _settingsService.LastPartitionLimitValue = PartitionLimitValue;
_settingsService.LastMessageFilterValue = MessageFilterValue; _settingsService.LastMessageFilterValue = MessageFilterValue;
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia; _settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
// If single channel - prompt file path // If single channel - prompt file path
if (IsSingleChannel) if (IsSingleChannel)
@@ -38,10 +38,10 @@ public class SettingsViewModel : DialogScreen
set => _settingsService.ParallelLimit = Math.Clamp(value, 1, 10); set => _settingsService.ParallelLimit = Math.Clamp(value, 1, 10);
} }
public bool ShouldReuseMedia public bool ShouldReuseAssets
{ {
get => _settingsService.ShouldReuseMedia; get => _settingsService.ShouldReuseAssets;
set => _settingsService.ShouldReuseMedia = value; set => _settingsService.ShouldReuseAssets = value;
} }
public SettingsViewModel(SettingsService settingsService) => public SettingsViewModel(SettingsService settingsService) =>
@@ -27,7 +27,15 @@ public class DialogManager : IDisposable
{ {
void OnScreenClosed(object? closeSender, EventArgs args) void OnScreenClosed(object? closeSender, EventArgs args)
{ {
openArgs.Session.Close(); try
{
openArgs.Session.Close();
}
catch (InvalidOperationException)
{
// Race condition: dialog is already being closed
}
dialogScreen.Closed -= OnScreenClosed; dialogScreen.Closed -= OnScreenClosed;
} }
dialogScreen.Closed += OnScreenClosed; dialogScreen.Closed += OnScreenClosed;
@@ -17,9 +17,9 @@ public class RootViewModel : Screen, IHandle<NotificationMessage>, IDisposable
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 SnackbarMessageQueue Notifications { get; } = new(TimeSpan.FromSeconds(5)); public SnackbarMessageQueue Notifications { get; } = new(TimeSpan.FromSeconds(5));
public DashboardViewModel Dashboard { get; } public DashboardViewModel Dashboard { get; }
public RootViewModel( public RootViewModel(
@@ -33,31 +33,40 @@ public class RootViewModel : Screen, IHandle<NotificationMessage>, IDisposable
_dialogManager = dialogManager; _dialogManager = dialogManager;
_settingsService = settingsService; _settingsService = settingsService;
_updateService = updateService; _updateService = updateService;
eventAggregator.Subscribe(this); eventAggregator.Subscribe(this);
Dashboard = _viewModelFactory.CreateDashboardViewModel(); Dashboard = _viewModelFactory.CreateDashboardViewModel();
DisplayName = $"{App.Name} v{App.VersionString}"; DisplayName = $"{App.Name} v{App.VersionString}";
} }
private async Task ShowWarInUkraineMessageAsync() private async Task ShowUkraineSupportMessageAsync()
{ {
var dialog = _viewModelFactory.CreateMessageBoxViewModel( if (!_settingsService.IsUkraineSupportMessageEnabled)
"Ukraine is at war!", @" return;
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(), var dialog = _viewModelFactory.CreateMessageBoxViewModel(
"LEARN MORE", "CLOSE" "Thank you for supporting Ukraine!",
"""
As Russia wages a genocidal war against my country, I'm grateful to everyone who continues to stand with Ukraine in our fight for freedom.
Click LEARN MORE to find ways that you can help.
""",
"LEARN MORE",
"CANCEL"
); );
// Disable this message in the future
_settingsService.IsUkraineSupportMessageEnabled = false;
_settingsService.Save();
if (await _dialogManager.ShowDialogAsync(dialog) == true) if (await _dialogManager.ShowDialogAsync(dialog) == true)
{ {
ProcessEx.StartShellExecute("https://tyrrrz.me"); ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter");
} }
} }
private async ValueTask CheckForUpdatesAsync() private async ValueTask CheckForUpdatesAsync()
{ {
try try
@@ -84,10 +93,10 @@ Press LEARN MORE to find ways that you can help.".Trim(),
Notifications.Enqueue("Failed to perform application update"); Notifications.Enqueue("Failed to perform application update");
} }
} }
public async void OnViewFullyLoaded() public async void OnViewFullyLoaded()
{ {
await ShowWarInUkraineMessageAsync(); await ShowUkraineSupportMessageAsync();
await CheckForUpdatesAsync(); await CheckForUpdatesAsync();
} }
@@ -115,7 +124,7 @@ Press LEARN MORE to find ways that you can help.".Trim(),
_updateService.FinalizeUpdate(false); _updateService.FinalizeUpdate(false);
} }
public void Handle(NotificationMessage message) => public void Handle(NotificationMessage message) =>
Notifications.Enqueue(message.Text); Notifications.Enqueue(message.Text);
public void Dispose() => Notifications.Dispose(); public void Dispose() => Notifications.Dispose();
@@ -181,7 +181,7 @@
<LineBreak /> <LineBreak />
<Run Text="7. Select the" /> <Run Text="7. Select the" />
<Run FontWeight="SemiBold" Text="Headers" /> <Run FontWeight="SemiBold" Text="Headers" />
<Run Text=" tab on the right" /> <Run Text="tab on the right" />
<LineBreak /> <LineBreak />
<Run Text="8. Scroll down to the" /> <Run Text="8. Scroll down to the" />
<Run FontWeight="SemiBold" Text="Request Headers" /> <Run FontWeight="SemiBold" Text="Request Headers" />
@@ -167,8 +167,8 @@
Text="{Binding MessageFilterValue}" Text="{Binding MessageFilterValue}"
ToolTip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')." /> ToolTip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')." />
<!-- Download media --> <!-- Download assets -->
<Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)"> <Grid Margin="16,16" ToolTip="Download assets referenced by the export (user avatars, attached files, embedded images, etc.)">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
@@ -177,12 +177,12 @@
<TextBlock <TextBlock
Grid.Column="0" Grid.Column="0"
VerticalAlignment="Center" VerticalAlignment="Center"
Text="Download media" /> Text="Download assets" />
<ToggleButton <ToggleButton
Grid.Column="1" Grid.Column="1"
HorizontalAlignment="Right" HorizontalAlignment="Right"
VerticalAlignment="Center" VerticalAlignment="Center"
IsChecked="{Binding ShouldDownloadMedia}" /> IsChecked="{Binding ShouldDownloadAssets}" />
</Grid> </Grid>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -82,20 +82,20 @@
IsChecked="{Binding IsTokenPersisted}" /> IsChecked="{Binding IsTokenPersisted}" />
</DockPanel> </DockPanel>
<!-- Reuse media --> <!-- Reuse assets -->
<DockPanel <DockPanel
Margin="16,8" Margin="16,8"
Background="Transparent" Background="Transparent"
LastChildFill="False" LastChildFill="False"
ToolTip="Reuse already existing media content to skip redundant downloads"> ToolTip="Reuse previously downloaded assets to avoid redundant requests">
<TextBlock <TextBlock
VerticalAlignment="Center" VerticalAlignment="Center"
DockPanel.Dock="Left" DockPanel.Dock="Left"
Text="Reuse downloaded media" /> Text="Reuse downloaded assets" />
<ToggleButton <ToggleButton
VerticalAlignment="Center" VerticalAlignment="Center"
DockPanel.Dock="Right" DockPanel.Dock="Right"
IsChecked="{Binding ShouldReuseMedia}" /> IsChecked="{Binding ShouldReuseAssets}" />
</DockPanel> </DockPanel>
<!-- Date format --> <!-- Date format -->
+3 -1
View File
@@ -7,6 +7,7 @@
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:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels" xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
Width="600" Width="600"
Height="550" Height="550"
MinWidth="325" MinWidth="325"
@@ -21,10 +22,11 @@
</Window.TaskbarItemInfo> </Window.TaskbarItemInfo>
<materialDesign:DialogHost <materialDesign:DialogHost
x:Name="DialogHost"
Loaded="{s:Action OnViewFullyLoaded}" Loaded="{s:Action OnViewFullyLoaded}"
SnackbarMessageQueue="{Binding Notifications}" SnackbarMessageQueue="{Binding Notifications}"
Style="{DynamicResource MaterialDesignEmbeddedDialogHost}"> Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
<Grid> <Grid IsEnabled="{Binding IsOpen, ElementName=DialogHost, Converter={x:Static converters:InverseBoolConverter.Instance}}">
<ContentControl s:View.Model="{Binding Dashboard}" /> <ContentControl s:View.Model="{Binding Dashboard}" />
<materialDesign:Snackbar MessageQueue="{Binding Notifications}" /> <materialDesign:Snackbar MessageQueue="{Binding Notifications}" />
</Grid> </Grid>
+1 -1
View File
@@ -1,5 +1,5 @@
DiscordChatExporter DiscordChatExporter
Copyright (C) 2017-2022 Oleksii Holub Copyright (C) 2017-2023 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
+10 -5
View File
@@ -1,7 +1,7 @@
# DiscordChatExporter # DiscordChatExporter
[![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://vshymanskyy.github.io/StandWithUkraine) [![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://vshymanskyy.github.io/StandWithUkraine)
[![Build](https://img.shields.io/github/workflow/status/Tyrrrz/DiscordChatExporter/main/master)](https://github.com/Tyrrrz/DiscordChatExporter/actions) [![Build](https://img.shields.io/github/actions/workflow/status/Tyrrrz/DiscordChatExporter/main.yml?branch=master)](https://github.com/Tyrrrz/DiscordChatExporter/actions)
[![Coverage](https://img.shields.io/codecov/c/github/Tyrrrz/DiscordChatExporter/master)](https://codecov.io/gh/Tyrrrz/DiscordChatExporter) [![Coverage](https://img.shields.io/codecov/c/github/Tyrrrz/DiscordChatExporter/master)](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
[![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases) [![Release](https://img.shields.io/github/release/Tyrrrz/DiscordChatExporter.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases) [![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
@@ -84,12 +84,12 @@ The following table lists all available download options:
> If you have any issues with them, please contact the corresponding maintainers. > If you have any issues with them, please contact the corresponding maintainers.
> **Warning**: > **Warning**:
> To run **DiscordChatExporter** on macOS and Linux, you need to make sure that **.NET Runtime v6** is installed. > To run **DiscordChatExporter** on macOS and Linux, you need to make sure that **.NET 7.0 Runtime** is installed.
> You can download it here: > You can download it here:
> >
> - [.NET Runtime v6 for **macOS x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-6.0.6-macos-x64-installer) > - [.NET 7.0 Runtime for **macOS x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/sdk-7.0.101-macos-x64-installer)
> - [.NET Runtime v6 for **macOS Arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-6.0.6-macos-arm64-installer) > - [.NET 7.0 Runtime for **macOS Arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/sdk-7.0.101-macos-arm64-installer)
> - [.NET Runtime v6 for **Linux**](https://docs.microsoft.com/en-us/dotnet/core/install/linux) (find the correct download for your distro) > - [.NET 7.0 Runtime for **Linux**](https://docs.microsoft.com/en-us/dotnet/core/install/linux) (find the correct download for your distro)
> >
> This should not be necessary if you install **DiscordChatExporter** using a package manager, as it should take care of the dependencies for you. > This should not be necessary if you install **DiscordChatExporter** using a package manager, as it should take care of the dependencies for you.
> This is also not necessary if you are running **DiscordChatExporter** via Docker, because the image already contains the runtime. > This is also not necessary if you are running **DiscordChatExporter** via Docker, because the image already contains the runtime.
@@ -108,3 +108,8 @@ The following table lists all available download options:
![channel list](.assets/list.png) ![channel list](.assets/list.png)
![rendered output](.assets/output.png) ![rendered output](.assets/output.png)
## Related projects
- [**Chat Analytics**](https://github.com/mlomb/chat-analytics) — solution for analyzing chat patterns of Discord users, using exports produced by **DiscordChatExporter**.
- [**DiscordChatExporter-frontend**](https://github.com/slatinsky/DiscordChatExporter-frontend) — convenient viewer for exports produced by **DiscordChatExporter**.