Compare commits

...

40 Commits

Author SHA1 Message Date
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
Tyrrrz 7996a38c5c Update version 2022-09-16 17:22:53 +03:00
Tyrrrz 9d052f9404 Improve wording in CLI exception when output path is ambiguous
Related to #929
2022-09-14 21:59:22 +03:00
Tyrrrz 3078351670 Add fallback content for thread start messages 2022-09-14 20:17:04 +03:00
Tyrrrz 4f41c4fff3 Fix styles for system notifications in HTML 2022-09-14 20:14:30 +03:00
Tyrrrz a80ee2943f Refactor 2022-09-14 02:06:11 +03:00
gan-of-culture 8612d2c84a Add HTML formatting for system messages (#926) 2022-09-13 23:51:10 +03:00
Tyrrrz 57c849d0f8 More refactoring 2022-09-13 23:27:54 +03:00
Tyrrrz 9c2a26aa82 Fix tests 2022-09-13 18:32:31 +03:00
Tyrrrz f376028d76 Add tests for gifv embeds 2022-09-13 18:09:56 +03:00
Tyrrrz da87f3c774 Hide message content if it only contains a link to a GIFV 2022-09-13 18:05:41 +03:00
Tyrrrz 61516feafa Refactor 2022-09-13 17:33:28 +03:00
gan-of-culture 8d209cd67a Improve GIF playback (#927) 2022-09-13 16:08:57 +03:00
Tyrrrz c58d7e752b Update readme 2022-09-12 16:13:28 +03:00
Ivv fcc1605455 Mention nix package in readme (#925) 2022-09-12 15:59:15 +03:00
gan-of-culture f28d662a71 Support GIF embeds when exporting to HTML (#919) 2022-09-10 23:48:52 +03:00
Tyrrrz a405ab184e Update version 2022-09-08 16:57:00 +03:00
Tyrrrz b861a69799 Recognize existing directories as valid output paths in CLI
Closes #917
2022-09-04 18:14:22 +03:00
Tyrrrz d53b4c825a Update usage guide 2022-09-02 13:29:56 +03:00
Oleksii Holub 30678e96e5 Move into maintenance mode 2022-08-26 18:01:19 +03:00
51 changed files with 1036 additions and 740 deletions
+3
View File
@@ -1,5 +1,8 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: ⚠ Feature request
url: https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md
about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.
- name: 💬 Discord server - name: 💬 Discord server
url: https://discord.gg/2SUWKFnHSm url: https://discord.gg/2SUWKFnHSm
about: Chat with the project community. about: Chat with the project community.
@@ -1,42 +0,0 @@
name: ✨ Feature request
description: Request a new feature.
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
🧐 **Guidelines:**
- Search through [existing issues](https://github.com/Tyrrrz/DiscordChatExporter/issues?q=is%3Aissue) first to ensure that this feature has not been requested before.
- Write a descriptive title for your issue. Avoid generic or vague titles such as "Some suggestions" or "Ideas for improvement".
- Keep your issue focused on one single problem. If you have multiple feature requests, please create separate issues for each of them.
- Provide as much context as possible in the details section. Include screenshots, screen recordings, links, references, or anything else you may consider relevant.
- If you want to ask a question instead of requesting a feature, please use [discussions](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new) instead.
- type: dropdown
attributes:
label: Flavor
description: Is this feature request related to specific flavor(s) of DiscordChatExporter? If not applicable, leave this field blank.
multiple: true
options:
- GUI (Graphical User Interface)
- CLI (Command Line Interface)
- type: dropdown
attributes:
label: Export format
description: Is this feature request related to specific export format(s)? If not applicable, leave this field blank.
multiple: true
options:
- HTML
- TXT
- JSON
- CSV
- type: textarea
attributes:
label: Details
description: Clear and thorough explanation of the feature you have in mind.
validations:
required: true
+2 -4
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: 6.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: 6.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
+1
View File
@@ -5,6 +5,7 @@
*.sln.docstates *.sln.docstates
.idea/ .idea/
.vs/ .vs/
.vscode/
# Build results # Build results
[Dd]ebug/ [Dd]ebug/
+33
View File
@@ -1,3 +1,36 @@
### 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)
- [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 system notification messages, such as when a user joins a server or when a messages gets pinned. Previously, such messages were rendered as regular text messages, but now they look similar to how they're presented in the Discord client. (Thanks [@gan-of-culture](https://github.com/gan-of-culture))
- [CLI] Improved the wording of the error message that's shown when exporting multiple channels and the provided output path is ambiguous. It now mentions that you can resolve the ambiguity by adding a slash at the end of the path.
### v2.35.2 (08-Sep-2022)
- Updated usage guide to mention the need to enable "Message Content Intent" to use a bot as a medium for exporting messages.
- [CLI] Fixed an issue where passing an existing directory as the output path failed when exporting multiple channels (for example, when running `exportdm`). An ambiguous output path will now always be treated as a directory path if that directory already exists. If it doesn't exist, it will be treated as a file path, matching the previous behavior.
### v2.35.1 (26-Aug-2022) ### v2.35.1 (26-Aug-2022)
- Added `~` character as an alias for `-` when negating a message filter. This should make it easier to use negated filters from CLI where the dash character already has other meanings. - Added `~` character as an alias for `-` when negating a message filter. This should make it easier to use negated filters from CLI where the dash character already has other meanings.
+9 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<Version>2.35.1</Version> <Version>2.36.4</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>
@@ -10,4 +10,12 @@
<WarningsAsErrors>nullable</WarningsAsErrors> <WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<!--
Even though the project builds against .NET 6, some dependencies
apparently rely on a specific version of the runtime.
-->
<PropertyGroup>
<RuntimeFrameworkVersion>6.0.9</RuntimeFrameworkVersion>
</PropertyGroup>
</Project> </Project>
@@ -12,10 +12,10 @@
<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.3.2" />
<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" />
@@ -54,8 +54,9 @@ public class EmbedSpecs : IClassFixture<ExportWrapperFixture>
message message
.QuerySelectorAll("img") .QuerySelectorAll("img")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.Where(s => s?.EndsWith("i.redd.it/f8w05ja8s4e61.png") ?? false)
.Should() .Should()
.Contain("https://i.redd.it/f8w05ja8s4e61.png"); .ContainSingle();
} }
[Fact] [Fact]
@@ -74,6 +75,38 @@ public class EmbedSpecs : IClassFixture<ExportWrapperFixture>
content.Should().BeNullOrEmpty(); content.Should().BeNullOrEmpty();
} }
[Fact]
public async Task Message_containing_a_gifv_link_is_rendered_with_a_video_embed()
{
// Act
var message = await _exportWrapper.GetMessageAsHtmlAsync(
ChannelIds.EmbedTestCases,
Snowflake.Parse("1019234520349814794")
);
// Assert
message
.QuerySelectorAll("source")
.Select(e => e.GetAttribute("src"))
.Where(s => s?.EndsWith("media.tenor.com/DDAJeW6BQKkAAAPo/tooncasm-test-copy.mp4") ?? false)
.Should()
.ContainSingle();
}
[Fact]
public async Task Message_containing_a_gifv_link_and_nothing_else_is_rendered_without_text_content()
{
// Act
var message = await _exportWrapper.GetMessageAsHtmlAsync(
ChannelIds.EmbedTestCases,
Snowflake.Parse("1019234520349814794")
);
// Assert
var content = message.QuerySelector(".chatlog__content")?.Text();
content.Should().BeNullOrEmpty();
}
[Fact] [Fact]
public async Task Message_containing_a_Spotify_track_link_is_rendered_with_a_track_embed() public async Task Message_containing_a_Spotify_track_link_is_rendered_with_a_track_embed()
{ {
@@ -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
+6 -2
View File
@@ -7,12 +7,16 @@ 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 --configuration Release --output ./publish
# Run # Run
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS run FROM mcr.microsoft.com/dotnet/runtime:6.0 AS run
RUN useradd dce
USER dce
COPY --from=build ./publish ./ COPY --from=build ./publish ./
WORKDIR ./out WORKDIR ./out
ENTRYPOINT ["dotnet", "/DiscordChatExporter.Cli.dll"]
ENTRYPOINT ["dotnet", "../DiscordChatExporter.Cli.dll"]
@@ -26,7 +26,7 @@ public abstract class ExportCommandBase : TokenCommandBase
[CommandOption( [CommandOption(
"output", "output",
'o', 'o',
Description = "Output file or directory path." Description = "Output file or directory path. Directory path should end in a slash."
)] )]
public string OutputPath public string OutputPath
{ {
@@ -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",
@@ -97,24 +97,36 @@ public abstract class ExportCommandBase : TokenCommandBase
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels) protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels)
{ {
var cancellationToken = console.RegisterCancellationHandler(); // 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."
); );
} }
// Make sure the user does not try to export all channels into a single file.
// Output path must either be a directory, or contain template tokens.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799 // https://github.com/Tyrrrz/DiscordChatExporter/issues/799
if (channels.Count > 1 && !PathEx.IsDirectoryPath(OutputPath) && !OutputPath.Contains('%')) // https://github.com/Tyrrrz/DiscordChatExporter/issues/917
var isValidOutputPath =
// Anything is valid when exporting a single channel
channels.Count <= 1 ||
// When using template tokens, assume the user knows what they're doing
OutputPath.Contains('%') ||
// Otherwise, require an existing directory or an unambiguous directory path
Directory.Exists(OutputPath) || PathEx.IsDirectoryPath(OutputPath);
if (!isValidOutputPath)
{ {
throw new CommandException( throw new CommandException(
"Attempted to export multiple channels, but the output path is neither a directory nor a template." "Attempted to export multiple channels, but the output path is neither a directory nor a template. " +
"If the provided output path is meant to be treated as a directory, make sure it ends with a slash."
); );
} }
var cancellationToken = console.RegisterCancellationHandler();
var errors = new ConcurrentDictionary<Channel, string>(); var errors = new ConcurrentDictionary<Channel, string>();
// Export // Export
@@ -146,8 +158,8 @@ public abstract class ExportCommandBase : TokenCommandBase
Before, Before,
PartitionLimit, PartitionLimit,
MessageFilter, MessageFilter,
ShouldDownloadMedia, ShouldDownloadAssets,
ShouldReuseMedia, ShouldReuseAssets,
DateFormat DateFormat
); );
@@ -17,13 +17,14 @@ public class GuideCommand : ICommand
console.Output.WriteLine(" * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!"); console.Output.WriteLine(" * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!");
console.Output.WriteLine(" 1. Open Discord in your web browser and login"); console.Output.WriteLine(" 1. Open Discord in your web browser and login");
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools"); console.Output.WriteLine(" 2. Open any server or direct message channel");
console.Output.WriteLine(" 3. Navigate to the Network tab"); console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
console.Output.WriteLine(" 4. Press Ctrl+R to reload"); console.Output.WriteLine(" 4. Navigate to the Network tab");
console.Output.WriteLine(" 5. Search for a request containing \"messages?limit=50\" or similar"); console.Output.WriteLine(" 5. Press Ctrl+R to reload");
console.Output.WriteLine(" 6. Select the Headers tab on the right"); console.Output.WriteLine(" 6. Search for a request containing \"messages?limit=50\" or similar");
console.Output.WriteLine(" 7. Scroll down to the Request Headers section"); console.Output.WriteLine(" 7. Select the Headers tab on the right");
console.Output.WriteLine(" 8. Copy the value of the \"authorization\" header"); console.Output.WriteLine(" 8. Scroll down to the Request Headers section");
console.Output.WriteLine(" 9. Copy the value of the \"authorization\" header");
console.Output.WriteLine(); console.Output.WriteLine();
// Bot token // Bot token
@@ -34,6 +35,7 @@ public class GuideCommand : ICommand
console.Output.WriteLine(" 2. Open your application's settings"); console.Output.WriteLine(" 2. Open your application's settings");
console.Output.WriteLine(" 3. Navigate to the Bot section on the left"); console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
console.Output.WriteLine(" 4. Under Token click Copy"); console.Output.WriteLine(" 4. Under Token click Copy");
console.Output.WriteLine(" * Your bot needs to have Message Content Intent enabled to read messages");
console.Output.WriteLine(); console.Output.WriteLine();
// Guild or channel ID // Guild or channel ID
@@ -7,7 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="2.3.0" /> <PackageReference Include="CliFx" Version="2.3.0" />
<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.3.1" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
@@ -11,6 +11,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object // https://discord.com/developers/docs/resources/channel#embed-object
public partial record Embed( public partial record Embed(
string? Title, string? Title,
EmbedKind Kind,
string? Url, string? Url,
DateTimeOffset? Timestamp, DateTimeOffset? Timestamp,
Color? Color, Color? Color,
@@ -19,11 +20,9 @@ public partial record Embed(
IReadOnlyList<EmbedField> Fields, IReadOnlyList<EmbedField> Fields,
EmbedImage? Thumbnail, EmbedImage? Thumbnail,
IReadOnlyList<EmbedImage> Images, IReadOnlyList<EmbedImage> Images,
EmbedVideo? Video,
EmbedFooter? Footer) EmbedFooter? Footer)
{ {
public PlainImageEmbedProjection? TryGetPlainImage() =>
PlainImageEmbedProjection.TryResolve(this);
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() => public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() =>
SpotifyTrackEmbedProjection.TryResolve(this); SpotifyTrackEmbedProjection.TryResolve(this);
@@ -36,9 +35,20 @@ public partial record Embed
public static Embed Parse(JsonElement json) public static Embed Parse(JsonElement json)
{ {
var title = json.GetPropertyOrNull("title")?.GetStringOrNull(); var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
var kind =
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>() ??
EmbedKind.Rich;
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset(); var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
var color = json.GetPropertyOrNull("color")?.GetInt32OrNull()?.Pipe(System.Drawing.Color.FromArgb).ResetAlpha();
var color = json
.GetPropertyOrNull("color")?
.GetInt32OrNull()?
.Pipe(System.Drawing.Color.FromArgb)
.ResetAlpha();
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse); var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
var description = json.GetPropertyOrNull("description")?.GetStringOrNull(); var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
@@ -59,10 +69,13 @@ public partial record Embed
json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).Enumerate().ToArray() ?? json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).Enumerate().ToArray() ??
Array.Empty<EmbedImage>(); Array.Empty<EmbedImage>();
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse); var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse);
return new Embed( return new Embed(
title, title,
kind,
url, url,
timestamp, timestamp,
color, color,
@@ -71,6 +84,7 @@ public partial record Embed
fields, fields,
thumbnail, thumbnail,
images, images,
video,
footer footer
); );
} }
@@ -0,0 +1,11 @@
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object-embed-types
public enum EmbedKind
{
Rich,
Image,
Video,
Gifv,
Link
}
@@ -0,0 +1,22 @@
using JsonExtensions.Reading;
using System.Text.Json;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object-embed-video-structure
public record EmbedVideo(
string? Url,
string? ProxyUrl,
int? Width,
int? Height)
{
public static EmbedVideo Parse(JsonElement json)
{
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
var proxyUrl = json.GetPropertyOrNull("proxy_url")?.GetNonWhiteSpaceStringOrNull();
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
return new EmbedVideo(url, proxyUrl, width, height);
}
}
@@ -1,33 +0,0 @@
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
public record PlainImageEmbedProjection(string Url)
{
public static PlainImageEmbedProjection? TryResolve(Embed embed)
{
if (string.IsNullOrWhiteSpace(embed.Url))
return null;
// Has to be an embed without any data (except URL and image)
if (!string.IsNullOrWhiteSpace(embed.Title) ||
embed.Timestamp is not null ||
embed.Author is not null ||
!string.IsNullOrWhiteSpace(embed.Description) ||
embed.Fields.Any() ||
embed.Footer is not null)
{
return null;
}
// Has to be an image file
var fileName = Regex.Match(embed.Url, @".+/([^?]*)").Groups[1].Value;
if (string.IsNullOrWhiteSpace(fileName) || !FileFormat.IsImage(Path.GetExtension(fileName)))
return null;
return new PlainImageEmbedProjection(embed.Url);
}
}
@@ -21,6 +21,9 @@ public partial record SpotifyTrackEmbedProjection
public static SpotifyTrackEmbedProjection? TryResolve(Embed embed) public static SpotifyTrackEmbedProjection? TryResolve(Embed embed)
{ {
if (embed.Kind != EmbedKind.Link)
return null;
if (string.IsNullOrWhiteSpace(embed.Url)) if (string.IsNullOrWhiteSpace(embed.Url))
return null; return null;
@@ -42,6 +42,9 @@ public partial record YouTubeVideoEmbedProjection
public static YouTubeVideoEmbedProjection? TryResolve(Embed embed) public static YouTubeVideoEmbedProjection? TryResolve(Embed embed)
{ {
if (embed.Kind != EmbedKind.Video)
return null;
if (string.IsNullOrWhiteSpace(embed.Url)) if (string.IsNullOrWhiteSpace(embed.Url))
return null; return null;
@@ -44,7 +44,7 @@ public record Message(
{ {
var embed = embeds[i]; var embed = embeds[i];
if (embed.Url?.Contains("://twitter.com/") == true) if (embed.Url?.Contains("://twitter.com/", StringComparison.OrdinalIgnoreCase) == true)
{ {
// Find embeds with the same URL that only contain a single image and nothing else // Find embeds with the same URL that only contain a single image and nothing else
var trailingEmbeds = embeds var trailingEmbeds = embeds
@@ -107,6 +107,7 @@ public record Message(
MessageKind.ChannelNameChange => "Changed the channel name.", MessageKind.ChannelNameChange => "Changed the channel name.",
MessageKind.ChannelIconChange => "Changed the channel icon.", MessageKind.ChannelIconChange => "Changed the channel icon.",
MessageKind.ChannelPinnedMessage => "Pinned a message.", MessageKind.ChannelPinnedMessage => "Pinned a message.",
MessageKind.ThreadCreated => "Started a thread.",
MessageKind.GuildMemberJoin => "Joined the server.", MessageKind.GuildMemberJoin => "Joined the server.",
_ => json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "" _ => json.GetPropertyOrNull("content")?.GetStringOrNull() ?? ""
}; };
@@ -11,5 +11,11 @@ public enum MessageKind
ChannelIconChange = 5, ChannelIconChange = 5,
ChannelPinnedMessage = 6, ChannelPinnedMessage = 6,
GuildMemberJoin = 7, GuildMemberJoin = 7,
ThreadCreated = 18,
Reply = 19 Reply = 19
} }
public static class MessageKindExtensions
{
public static bool IsSystemNotification(this MessageKind c) => (int)c is >= 1 and <= 18;
}
@@ -22,14 +22,16 @@ 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)
{
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken =>
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url)); using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
@@ -37,50 +39,50 @@ public class DiscordClient
// 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( return await Http.Client.SendAsync(
request, request,
HttpCompletionOption.ResponseHeadersRead, HttpCompletionOption.ResponseHeadersRead,
innerCancellationToken
);
}, 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(
@@ -2,7 +2,6 @@
public enum TokenKind public enum TokenKind
{ {
Unknown,
User, User,
Bot Bot
} }
@@ -7,7 +7,7 @@
<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.1" /> <PackageReference Include="MiniRazor.CodeGen" Version="2.2.2" />
<PackageReference Include="Polly" Version="7.2.3" /> <PackageReference Include="Polly" Version="7.2.3" />
<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.12.0" />
@@ -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,7 +35,7 @@ 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
@@ -19,8 +19,8 @@ internal partial class CsvMessageWriter : MessageWriter
_writer = new StreamWriter(stream); _writer = new StreamWriter(stream);
} }
private string FormatMarkdown(string? markdown) => private ValueTask<string> FormatMarkdownAsync(string? markdown) =>
PlainTextMarkdownVisitor.Format(Context, markdown ?? ""); PlainTextMarkdownVisitor.FormatAsync(Context, markdown ?? "");
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 +37,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 +84,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)));
await _writer.WriteAsync(','); await _writer.WriteAsync(',');
// Attachments // Attachments
@@ -0,0 +1,22 @@
using System;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds;
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
internal static class MessageExtensions
{
// Message content is hidden if it's a link to an embedded media
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
public static bool IsContentHidden(this Message message)
{
if (message.Embeds.Count != 1)
return false;
var embed = message.Embeds[0];
return
string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase) &&
embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
}
}
@@ -10,16 +10,20 @@
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext> @inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
@{ @{
ValueTask<string> ResolveAssetUrlAsync(string url) =>
Model.ExportContext.ResolveAssetUrlAsync(url);
string FormatDate(DateTimeOffset date) =>
Model.ExportContext.FormatDate(date);
ValueTask<string> FormatMarkdownAsync(string markdown) =>
Model.FormatMarkdownAsync(markdown);
ValueTask<string> FormatEmbedMarkdownAsync(string markdown) =>
Model.FormatMarkdownAsync(markdown, false);
var firstMessage = Model.Messages.First(); var firstMessage = Model.Messages.First();
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown);
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
var userMember = Model.ExportContext.TryGetMember(firstMessage.Author.Id); var userMember = Model.ExportContext.TryGetMember(firstMessage.Author.Id);
var userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id); var userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id);
@@ -48,19 +52,50 @@
{ {
var isFirst = i == 0; var isFirst = i == 0;
// Hide message content if it only contains a link to an image which is embedded, and nothing else
var isContentHidden =
message.Embeds.Count == 1 &&
message.Content.Trim() == PlainImageEmbedProjection.TryResolve(message.Embeds.Single())?.Url;
var isReferencedContentHidden =
message.ReferencedMessage is not null &&
message.ReferencedMessage.Embeds.Count == 1 &&
message.ReferencedMessage.Content.Trim() == PlainImageEmbedProjection.TryResolve(message.ReferencedMessage.Embeds.Single())?.Url;
<div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id"> <div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
<div class="chatlog__message"> <div class="chatlog__message">
@{/* Left side */} @{/* System notification */}
@if (message.Kind.IsSystemNotification())
{
// 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.
userMember = Model.ExportContext.TryGetMember(message.Author.Id);
userColor = Model.ExportContext.TryGetUserColor(message.Author.Id);
userNick = message.Author.IsBot
? message.Author.Name
: userMember?.Nick ?? message.Author.Name;
<div class="chatlog__message-aside">
<svg class="chatlog__system-notification-icon"><use href="#@message.Kind.ToString().ToDashCase().ToLowerInvariant()-icon"></use></svg>
</div>
<div class="chatlog__message-primary">
@{/* 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>
@{/* System notification content */}
<span class="chatlog__system-notification-content">
@if (message.Kind == MessageKind.ChannelPinnedMessage && message.Reference is not null)
{
<span> pinned</span>
<a class="chatlog__system-notification-link" href="#chatlog__message-container-@message.Reference.MessageId"> a message</a>
<span> to this channel.</span>
}
else
{
<span>@message.Content.ToLowerInvariant()</span>
}
</span>
@{/* Timestamp */}
<span class="chatlog__system-notification-timestamp">
<a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
</span>
</div>
}
// Regular message
else
{
<div class="chatlog__message-aside"> <div class="chatlog__message-aside">
@if (isFirst) @if (isFirst)
{ {
@@ -71,7 +106,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
{ {
@@ -79,7 +114,6 @@
} }
</div> </div>
@{/* Right side */}
<div class="chatlog__message-primary"> <div class="chatlog__message-primary">
@if (isFirst) @if (isFirst)
{ {
@@ -89,13 +123,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) && !isReferencedContentHidden) @if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden())
{ {
<!--wmm:ignore-->@Raw(FormatEmbedMarkdown(message.ReferencedMessage.Content))<!--/wmm:ignore--> <!--wmm:ignore-->@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())
{ {
@@ -140,13 +174,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 (!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-->@Raw(await FormatMarkdownAsync(message.Content))<!--/wmm:ignore--></span>
} }
@{/* Edited timestamp */} @{/* Edited timestamp */}
@@ -170,20 +204,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
@@ -193,7 +227,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>
@@ -208,17 +242,8 @@
@{/* Embeds */} @{/* Embeds */}
@foreach (var embed in message.Embeds) @foreach (var embed in message.Embeds)
{ {
// Plain image embed
if (embed.TryGetPlainImage() is { } plainImageEmbed)
{
<div class="chatlog__embed">
<a href="@await ResolveUrlAsync(plainImageEmbed.Url)">
<img class="chatlog__embed-plainimage" src="@await ResolveUrlAsync(plainImageEmbed.Url)" alt="Embedded image" loading="lazy">
</a>
</div>
}
// Spotify embed // Spotify embed
else if (embed.TryGetSpotifyTrack() is { } spotifyTrackEmbed) if (embed.TryGetSpotifyTrack() is { } spotifyTrackEmbed)
{ {
<div class="chatlog__embed"> <div class="chatlog__embed">
<div class="chatlog__embed-spotify-container"> <div class="chatlog__embed-spotify-container">
@@ -249,7 +274,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))
@@ -275,12 +300,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-->@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-->@Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
} }
</div> </div>
} }
@@ -294,7 +319,25 @@
</div> </div>
</div> </div>
} }
// Generic embed // Generic image embed
else if (embed.Kind == EmbedKind.Image && !string.IsNullOrWhiteSpace(embed.Url))
{
<div class="chatlog__embed">
<a href="@await ResolveAssetUrlAsync(embed.Url)">
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embed.Url)" alt="Embedded image" loading="lazy">
</a>
</div>
}
// Generic gifv embed
else if (embed.Kind == EmbedKind.Gifv && !string.IsNullOrWhiteSpace(embed.Video?.Url))
{
<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()">
<source src="@await ResolveAssetUrlAsync(embed.Video.ProxyUrl ?? embed.Video.Url)" alt="Embedded video">
</video>
</div>
}
// Rich embed
else else
{ {
<div class="chatlog__embed"> <div class="chatlog__embed">
@@ -317,7 +360,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))
@@ -343,12 +386,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-->@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-->@Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
} }
</div> </div>
} }
@@ -357,7 +400,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-->@Raw(await FormatEmbedMarkdownAsync(embed.Description))<!--/wmm:ignore--></div>
</div> </div>
} }
@@ -371,14 +414,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-->@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-->@Raw(await FormatEmbedMarkdownAsync(field.Value))<!--/wmm:ignore--></div>
</div> </div>
} }
</div> </div>
@@ -391,8 +434,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>
} }
@@ -407,8 +450,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>
} }
@@ -423,7 +466,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">
@@ -457,11 +500,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>
} }
@@ -473,13 +516,14 @@
@foreach (var reaction in message.Reactions) @foreach (var reaction in message.Reactions)
{ {
<div class="chatlog__reaction" title="@reaction.Emoji.Code"> <div class="chatlog__reaction" title="@reaction.Emoji.Code">
<img class="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>
} }
</div> </div>
} }
</div> </div>
}
</div> </div>
</div> </div>
} }
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors; using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
@@ -16,6 +17,6 @@ internal class MessageGroupTemplateContext
Messages = messages; Messages = messages;
} }
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) => public ValueTask<string> FormatMarkdownAsync(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed); HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown ?? "", isJumboAllowed);
} }
@@ -14,11 +14,14 @@
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) =>
Model.ExportContext.ResolveAssetUrlAsync(url, CancellationToken);
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date); string FormatDate(DateTimeOffset date) =>
Model.ExportContext.FormatDate(date);
string FormatMarkdown(string markdown) => Model.FormatMarkdown(markdown); ValueTask<string> FormatMarkdownAsync(string markdown) =>
Model.FormatMarkdownAsync(markdown);
} }
<!DOCTYPE html> <!DOCTYPE html>
@@ -32,31 +35,31 @@
@{/* 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 +72,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,6 +258,38 @@
unicode-bidi: bidi-override; unicode-bidi: bidi-override;
} }
.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")
}
.chatlog__system-notification-link {
font-weight: 500;
color: @Themed("#ffffff", "#2f3136");
}
.chatlog__system-notification-timestamp {
margin-left: 0.3rem;
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 {
margin-bottom: 0.1rem; margin-bottom: 0.1rem;
} }
@@ -286,7 +322,7 @@
} }
.chatlog__timestamp a { .chatlog__timestamp a {
color: @Themed("#a3a6aa", "#5e6772"); color: inherit;
} }
.chatlog__content { .chatlog__content {
@@ -540,7 +576,14 @@
font-weight: 500; font-weight: 500;
} }
.chatlog__embed-plainimage { .chatlog__embed-generic-image {
max-width: 45vw;
max-height: 500px;
vertical-align: top;
border-radius: 3px;
}
.chatlog__embed-generic-gifv {
max-width: 45vw; max-width: 45vw;
max-height: 500px; max-height: 500px;
vertical-align: top; vertical-align: top;
@@ -712,8 +755,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-{Model.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));
@@ -721,7 +764,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 => {
@@ -779,20 +822,31 @@
</script> </script>
@{/* Icons */} @{/* Icons */}
<svg style="display: none"> <svg style="display: none" xmlns="http://www.w3.org/2000/svg">
<defs>
<symbol id="attachment-icon" viewBox="0 0 720 960"> <symbol id="attachment-icon" viewBox="0 0 720 960">
<path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z"/> <path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z" />
<path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z"/> <path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z" />
<path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z"/> <path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z" />
<path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z"/> <path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z" />
</symbol> </symbol>
<symbol id="channel-pinned-message-icon" viewBox="0 0 18 18">
<path fill="#b9bbbe" d="m16.908 8.39684-8.29587-8.295827-1.18584 1.184157 1.18584 1.18584-4.14834 4.1475v.00167l-1.18583-1.18583-1.185 1.18583 3.55583 3.55502-4.740831 4.74 1.185001 1.185 4.74083-4.74 3.55581 3.555 1.185-1.185-1.185-1.185 4.1475-4.14836h.0009l1.185 1.185z" />
</symbol>
<symbol id="call-icon" viewBox="0 0 18 18">
<path fill="#3ba55c" fill-rule="evenodd" d="M17.7163041 15.36645368c-.0190957.02699568-1.9039523 2.6680735-2.9957762 2.63320406-3.0676659-.09785935-6.6733809-3.07188394-9.15694343-5.548738C3.08002193 9.9740657.09772497 6.3791404 0 3.3061316v-.024746C0 2.2060575 2.61386252.3152347 2.64082114.2972376c.7110335-.4971705 1.4917101-.3149497 1.80959713.1372281.19320342.2744561 2.19712724 3.2811005 2.42290565 3.6489167.09884826.1608492.14714912.3554431.14714912.5702838 0 .2744561-.07975258.5770327-.23701117.8751101-.1527655.2902036-.65262318 1.1664385-.89862055 1.594995.2673396.3768148.94804468 1.26429792 2.351016 2.66357424 1.39173858 1.39027775 2.28923588 2.07641807 2.67002628 2.34187563.4302146-.2452108 1.3086162-.74238132 1.5972981-.89423205.5447887-.28682915 1.0907006-.31944893 1.4568885-.08661115.3459689.2182151 3.3383754 2.21027167 3.6225641 2.41611376.2695862.19234426.4144887.5399137.4144887.91672846 0 .2969525-.089862.61190215-.2808189.88523346" />
</symbol>
<symbol id="guild-member-join-icon" viewBox="0 0 18 18">
<path fill="#3ba55c" d="m0 8h14.2l-3.6-3.6 1.4-1.4 6 6-6 6-1.4-1.4 3.6-3.6h-14.2" />
</symbol>
</defs>
</svg> </svg>
</head> </head>
<body> <body>
<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(Model.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">@Model.ExportContext.Request.Guild.Name</div>
@@ -800,7 +854,7 @@
@if (!string.IsNullOrWhiteSpace(Model.ExportContext.Request.Channel.Topic)) @if (!string.IsNullOrWhiteSpace(Model.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">@Raw(await FormatMarkdownAsync(Model.ExportContext.Request.Channel.Topic))</div>
} }
@if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null) @if (Model.ExportContext.Request.After is not null || Model.ExportContext.Request.Before is not null)
@@ -1,4 +1,5 @@
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors; using System.Threading.Tasks;
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
namespace DiscordChatExporter.Core.Exporting.Writers.Html; namespace DiscordChatExporter.Core.Exporting.Writers.Html;
@@ -14,6 +15,6 @@ internal class PreambleTemplateContext
ThemeName = themeName; ThemeName = themeName;
} }
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) => public ValueTask<string> FormatMarkdownAsync(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed); HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown ?? "", isJumboAllowed);
} }
@@ -25,17 +25,46 @@ internal class HtmlMessageWriter : MessageWriter
_themeName = themeName; _themeName = themeName;
} }
private bool CanJoinGroup(Message message) => private bool CanJoinGroup(Message message)
// First message in the group can always join {
_messageGroup.LastOrDefault() is not { } lastMessage || // If the group is empty, any message can join it
// Must be from the same author if (_messageGroup.LastOrDefault() is not { } lastMessage)
lastMessage.Author.Id == message.Author.Id && return true;
// Author's name must not have changed between messages
string.Equals(lastMessage.Author.FullName, message.Author.FullName, StringComparison.Ordinal) && // Reply messages cannot join existing groups because they need to appear first
// Duration between messages must be 7 minutes or less if (message.Kind == MessageKind.Reply)
(message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes <= 7 && return false;
// Other message must not be a reply
message.Reference is null; // Grouping for system notifications
if (message.Kind.IsSystemNotification())
{
// Can only be grouped with other system notifications
if (!lastMessage.Kind.IsSystemNotification())
return false;
}
// Grouping for normal messages
else
{
// Can only be grouped with other normal messages
if (lastMessage.Kind.IsSystemNotification())
return false;
// Messages must be within 7 minutes of each other
if ((message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes > 7)
return false;
// Messages must be from the same author
if (message.Author.Id != lastMessage.Author.Id)
return false;
// If the user changed their name after the last message, their new messages
// cannot join an existing group.
if (!string.Equals(message.Author.FullName, lastMessage.Author.FullName, StringComparison.Ordinal))
return false;
}
return true;
}
// Use <!--wmm:ignore--> to preserve blocks of code inside the templates // Use <!--wmm:ignore--> to preserve blocks of code inside the templates
private string Minify(string html) => _minifier private string Minify(string html) => _minifier
@@ -77,7 +106,7 @@ internal class HtmlMessageWriter : MessageWriter
await base.WriteMessageAsync(message, cancellationToken); await base.WriteMessageAsync(message, cancellationToken);
// If the message can be grouped, buffer it for now // If the message can be grouped, buffer it for now
if (CanJoinGroup( message)) if (CanJoinGroup(message))
{ {
_messageGroup.Add(message); _messageGroup.Add(message);
} }
@@ -29,8 +29,8 @@ internal class JsonMessageWriter : MessageWriter
}); });
} }
private string FormatMarkdown(string? markdown) => private ValueTask<string> FormatMarkdownAsync(string? markdown) =>
PlainTextMarkdownVisitor.Format(Context, markdown ?? ""); PlainTextMarkdownVisitor.FormatAsync(Context, markdown ?? "");
private async ValueTask WriteAttachmentAsync( private async ValueTask WriteAttachmentAsync(
Attachment attachment, Attachment attachment,
@@ -39,7 +39,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 +57,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 +75,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 +98,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 +115,8 @@ internal class JsonMessageWriter : MessageWriter
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("name", FormatMarkdown(embedField.Name)); _writer.WriteString("name", await FormatMarkdownAsync(embedField.Name));
_writer.WriteString("value", FormatMarkdown(embedField.Value)); _writer.WriteString("value", await FormatMarkdownAsync(embedField.Value));
_writer.WriteBoolean("isInline", embedField.IsInline); _writer.WriteBoolean("isInline", embedField.IsInline);
_writer.WriteEndObject(); _writer.WriteEndObject();
@@ -114,10 +129,10 @@ internal class JsonMessageWriter : MessageWriter
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("title", FormatMarkdown(embed.Title)); _writer.WriteString("title", await FormatMarkdownAsync(embed.Title));
_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));
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 +191,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 +208,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 +242,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 +283,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));
// Author // Author
_writer.WriteStartObject("author"); _writer.WriteStartObject("author");
@@ -278,7 +293,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,7 @@ 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.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 +24,15 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
_isJumbo = isJumbo; _isJumbo = isJumbo;
} }
protected override MarkdownNode VisitText(TextNode text) protected override async ValueTask<MarkdownNode> VisitTextAsync(TextNode text)
{ {
_buffer.Append(HtmlEncode(text.Text)); _buffer.Append(HtmlEncode(text.Text));
return base.VisitText(text); return await base.VisitTextAsync(text);
} }
protected override MarkdownNode VisitFormatting(FormattingNode formatting) protected override async ValueTask<MarkdownNode> VisitFormattingAsync(FormattingNode formatting)
{ {
var (tagOpen, tagClose) = formatting.Kind switch var (openingTag, closingTag) = formatting.Kind switch
{ {
FormattingKind.Bold => ( FormattingKind.Bold => (
"<strong>", "<strong>",
@@ -66,24 +67,24 @@ 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);
_buffer.Append(tagClose); _buffer.Append(closingTag);
return result; return result;
} }
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) protected override async ValueTask<MarkdownNode> VisitInlineCodeBlockAsync(InlineCodeBlockNode inlineCodeBlock)
{ {
_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);
} }
protected override MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) protected override async ValueTask<MarkdownNode> VisitMultiLineCodeBlockAsync(MultiLineCodeBlockNode multiLineCodeBlock)
{ {
var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language) var highlightCssClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
? $"language-{multiLineCodeBlock.Language}" ? $"language-{multiLineCodeBlock.Language}"
@@ -94,10 +95,10 @@ 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);
} }
protected override MarkdownNode VisitLink(LinkNode link) protected override async ValueTask<MarkdownNode> VisitLinkAsync(LinkNode link)
{ {
// 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 +112,31 @@ 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);
_buffer.Append("</a>"); _buffer.Append("</a>");
return result; return result;
} }
protected override MarkdownNode VisitEmoji(EmojiNode emoji) protected override async ValueTask<MarkdownNode> VisitEmojiAsync(EmojiNode emoji)
{ {
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated); var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = _isJumbo ? "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)}\"" +
$">"
);
return base.VisitEmoji(emoji); return await base.VisitEmojiAsync(emoji);
} }
protected override MarkdownNode VisitMention(MentionNode mention) protected override async ValueTask<MarkdownNode> VisitMentionAsync(MentionNode mention)
{ {
if (mention.Kind == MentionKind.Everyone) if (mention.Kind == MentionKind.Everyone)
{ {
@@ -183,10 +191,10 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
.Append("</span>"); .Append("</span>");
} }
return base.VisitMention(mention); return await base.VisitMentionAsync(mention);
} }
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp) protected override async ValueTask<MarkdownNode> VisitUnixTimestampAsync(UnixTimestampNode timestamp)
{ {
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 +210,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);
} }
} }
@@ -210,7 +218,10 @@ 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)
{ {
var nodes = MarkdownParser.Parse(markdown); var nodes = MarkdownParser.Parse(markdown);
@@ -220,7 +231,7 @@ 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);
return buffer.ToString(); return buffer.ToString();
} }
@@ -1,4 +1,5 @@
using System.Text; using System.Text;
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 +17,13 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_buffer = buffer; _buffer = buffer;
} }
protected override MarkdownNode VisitText(TextNode text) protected override async ValueTask<MarkdownNode> VisitTextAsync(TextNode text)
{ {
_buffer.Append(text.Text); _buffer.Append(text.Text);
return base.VisitText(text); return await base.VisitTextAsync(text);
} }
protected override MarkdownNode VisitEmoji(EmojiNode emoji) protected override async ValueTask<MarkdownNode> VisitEmojiAsync(EmojiNode emoji)
{ {
_buffer.Append( _buffer.Append(
emoji.IsCustomEmoji emoji.IsCustomEmoji
@@ -30,10 +31,10 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
: emoji.Name : emoji.Name
); );
return base.VisitEmoji(emoji); return await base.VisitEmojiAsync(emoji);
} }
protected override MarkdownNode VisitMention(MentionNode mention) protected override async ValueTask<MarkdownNode> VisitMentionAsync(MentionNode mention)
{ {
if (mention.Kind == MentionKind.Everyone) if (mention.Kind == MentionKind.Everyone)
{ {
@@ -69,10 +70,10 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_buffer.Append($"@{name}"); _buffer.Append($"@{name}");
} }
return base.VisitMention(mention); return await base.VisitMentionAsync(mention);
} }
protected override MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp) protected override async ValueTask<MarkdownNode> VisitUnixTimestampAsync(UnixTimestampNode timestamp)
{ {
_buffer.Append( _buffer.Append(
timestamp.Date is not null timestamp.Date is not null
@@ -80,18 +81,18 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
: "Invalid date" : "Invalid date"
); );
return base.VisitUnixTimestamp(timestamp); return await base.VisitUnixTimestampAsync(timestamp);
} }
} }
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)
{ {
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);
return buffer.ToString(); return buffer.ToString();
} }
@@ -19,8 +19,8 @@ internal class PlainTextMessageWriter : MessageWriter
_writer = new StreamWriter(stream); _writer = new StreamWriter(stream);
} }
private string FormatMarkdown(string? markdown) => private ValueTask<string> FormatMarkdownAsync(string? markdown) =>
PlainTextMarkdownVisitor.Format(Context, markdown ?? ""); PlainTextMarkdownVisitor.FormatAsync(Context, markdown ?? "");
private async ValueTask WriteMessageHeaderAsync(Message message) private async ValueTask WriteMessageHeaderAsync(Message message)
{ {
@@ -48,7 +48,7 @@ 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();
@@ -71,27 +71,41 @@ internal class PlainTextMessageWriter : MessageWriter
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));
if (!string.IsNullOrWhiteSpace(embed.Description)) if (!string.IsNullOrWhiteSpace(embed.Description))
await _writer.WriteLineAsync(FormatMarkdown(embed.Description)); await _writer.WriteLineAsync(await FormatMarkdownAsync(embed.Description));
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));
if (!string.IsNullOrWhiteSpace(field.Value)) if (!string.IsNullOrWhiteSpace(field.Value))
await _writer.WriteLineAsync(FormatMarkdown(field.Value)); await _writer.WriteLineAsync(await FormatMarkdownAsync(field.Value));
} }
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))
@@ -114,7 +128,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();
@@ -174,7 +190,7 @@ 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));
await _writer.WriteLineAsync(); await _writer.WriteLineAsync();
@@ -1,56 +1,57 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
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(TextNode text) =>
text; new(text);
protected virtual MarkdownNode VisitFormatting(FormattingNode formatting) protected virtual async ValueTask<MarkdownNode> VisitFormattingAsync(FormattingNode formatting)
{ {
Visit(formatting.Children); await VisitAsync(formatting.Children);
return formatting; return formatting;
} }
protected virtual MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) => protected virtual ValueTask<MarkdownNode> VisitInlineCodeBlockAsync(InlineCodeBlockNode inlineCodeBlock) =>
inlineCodeBlock; new(inlineCodeBlock);
protected virtual MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) => protected virtual ValueTask<MarkdownNode> VisitMultiLineCodeBlockAsync(MultiLineCodeBlockNode multiLineCodeBlock) =>
multiLineCodeBlock; new(multiLineCodeBlock);
protected virtual MarkdownNode VisitLink(LinkNode link) protected virtual async ValueTask<MarkdownNode> VisitLinkAsync(LinkNode link)
{ {
Visit(link.Children); await VisitAsync(link.Children);
return link; return link;
} }
protected virtual MarkdownNode VisitEmoji(EmojiNode emoji) => protected virtual ValueTask<MarkdownNode> VisitEmojiAsync(EmojiNode emoji) =>
emoji; new(emoji);
protected virtual MarkdownNode VisitMention(MentionNode mention) => protected virtual ValueTask<MarkdownNode> VisitMentionAsync(MentionNode mention) =>
mention; new(mention);
protected virtual MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp) => protected virtual ValueTask<MarkdownNode> VisitUnixTimestampAsync(UnixTimestampNode timestamp) =>
timestamp; new(timestamp);
public MarkdownNode Visit(MarkdownNode node) => node switch public async ValueTask<MarkdownNode> VisitAsync(MarkdownNode node) => node switch
{ {
TextNode text => VisitText(text), TextNode text => await VisitTextAsync(text),
FormattingNode formatting => VisitFormatting(formatting), FormattingNode formatting => await VisitFormattingAsync(formatting),
InlineCodeBlockNode inlineCodeBlock => VisitInlineCodeBlock(inlineCodeBlock), InlineCodeBlockNode inlineCodeBlock => await VisitInlineCodeBlockAsync(inlineCodeBlock),
MultiLineCodeBlockNode multiLineCodeBlock => VisitMultiLineCodeBlock(multiLineCodeBlock), MultiLineCodeBlockNode multiLineCodeBlock => await VisitMultiLineCodeBlockAsync(multiLineCodeBlock),
LinkNode link => VisitLink(link), LinkNode link => await VisitLinkAsync(link),
EmojiNode emoji => VisitEmoji(emoji), EmojiNode emoji => await VisitEmojiAsync(emoji),
MentionNode mention => VisitMention(mention), MentionNode mention => await VisitMentionAsync(mention),
UnixTimestampNode timestamp => VisitUnixTimestamp(timestamp), UnixTimestampNode timestamp => await VisitUnixTimestampAsync(timestamp),
_ => throw new ArgumentOutOfRangeException(nameof(node)) _ => throw new ArgumentOutOfRangeException(nameof(node))
}; };
public void Visit(IEnumerable<MarkdownNode> nodes) public async ValueTask VisitAsync(IEnumerable<MarkdownNode> nodes)
{ {
foreach (var node in nodes) foreach (var node in nodes)
Visit(node); await VisitAsync(node);
} }
} }
@@ -1,5 +1,7 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Core.Utils.Extensions;
@@ -25,6 +27,14 @@ public static class StringExtensions
} }
} }
public static string ToDashCase(this string str) =>
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)
+2 -1
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)
); );
@@ -14,14 +14,14 @@
<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.2" />
<PackageReference Include="Stylet" Version="1.3.6" /> <PackageReference Include="Stylet" Version="1.3.6" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" /> <PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
<PackageReference Include="PropertyChanged.Fody" Version="3.4.1" PrivateAssets="all" /> <PackageReference Include="PropertyChanged.Fody" Version="4.0.5" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" /> <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
@@ -16,7 +16,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 +26,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) =>
@@ -135,22 +135,14 @@
Grid.Row="2" Grid.Row="2"
Background="{DynamicResource MaterialDesignCardBackground}" Background="{DynamicResource MaterialDesignCardBackground}"
IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}"> IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
<Grid.Resources>
<Style TargetType="TextBlock">
<Setter Property="FontWeight" Value="Light" />
</Style>
</Grid.Resources>
<!-- Placeholder / usage instructions --> <!-- Placeholder / usage instructions -->
<Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}"> <Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock <TextBlock
Margin="32,16" Margin="32,16"
FontSize="14" FontSize="14"
FontWeight="Light"
LineHeight="20"> LineHeight="20">
<Run FontSize="18" Text="Please provide authentication token to continue" />
<LineBreak />
<LineBreak />
<!-- User token --> <!-- User token -->
<InlineUIContainer> <InlineUIContainer>
<materialDesign:PackIcon <materialDesign:PackIcon
@@ -158,7 +150,7 @@
Foreground="{DynamicResource PrimaryHueMidBrush}" Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" /> Kind="Account" />
</InlineUIContainer> </InlineUIContainer>
<Run FontSize="16" Text="To get token for your personal account:" /> <Run FontSize="16" FontWeight="SemiBold" Text="To get token for your personal account:" />
<LineBreak /> <LineBreak />
<Run Text="* Automating user accounts is technically against TOS —" /> <Run Text="* Automating user accounts is technically against TOS —" />
<Run FontWeight="SemiBold" Text="use at your own risk" /><Run Text="!" /> <Run FontWeight="SemiBold" Text="use at your own risk" /><Run Text="!" />
@@ -169,31 +161,33 @@
</Hyperlink> </Hyperlink>
<Run Text="and login" /> <Run Text="and login" />
<LineBreak /> <LineBreak />
<Run Text="2. Press" /> <Run Text="2. Open any server or direct message channel" />
<LineBreak />
<Run Text="3. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" /> <Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" /> <Run Text="to show developer tools" />
<LineBreak /> <LineBreak />
<Run Text="3. Navigate to the" /> <Run Text="4. Navigate to the" />
<Run FontWeight="SemiBold" Text="Network" /> <Run FontWeight="SemiBold" Text="Network" />
<Run Text="tab" /> <Run Text="tab" />
<LineBreak /> <LineBreak />
<Run Text="4. Press" /> <Run Text="5. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+R" /> <Run FontWeight="SemiBold" Text="Ctrl+R" />
<Run Text="to reload" /> <Run Text="to reload" />
<LineBreak /> <LineBreak />
<Run Text="5. Search for a request containing" /> <Run Text="6. Search for a request containing" />
<Run FontWeight="SemiBold" Text="messages?limit=50" /> <Run FontWeight="SemiBold" Text="messages?limit=50" />
<Run Text="or similar" /> <Run Text="or similar" />
<LineBreak /> <LineBreak />
<Run Text="6. 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="7. Scroll down to the" /> <Run Text="8. Scroll down to the" />
<Run FontWeight="SemiBold" Text="Request Headers" /> <Run FontWeight="SemiBold" Text="Request Headers" />
<Run Text="section" /> <Run Text="section" />
<LineBreak /> <LineBreak />
<Run Text="8. Copy the value of the" /> <Run Text="9. Copy the value of the" />
<Run FontWeight="SemiBold" Text="authorization" /> <Run FontWeight="SemiBold" Text="authorization" />
<Run Text="header" /> <Run Text="header" />
<LineBreak /> <LineBreak />
@@ -206,7 +200,7 @@
Foreground="{DynamicResource PrimaryHueMidBrush}" Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" /> Kind="Robot" />
</InlineUIContainer> </InlineUIContainer>
<Run FontSize="16" Text="To get token for a bot account:" /> <Run FontSize="16" FontWeight="SemiBold" Text="To get token for a bot account:" />
<LineBreak /> <LineBreak />
<Run Text="1. Open Discord" /> <Run Text="1. Open Discord" />
<Hyperlink Command="{s:Action OpenDiscordDeveloperPortal}"> <Hyperlink Command="{s:Action OpenDiscordDeveloperPortal}">
@@ -224,10 +218,14 @@
<Run Text="click" /> <Run Text="click" />
<Run FontWeight="SemiBold" Text="Copy" /> <Run FontWeight="SemiBold" Text="Copy" />
<LineBreak /> <LineBreak />
<Run Text="* Your bot needs to have" />
<Run FontWeight="SemiBold" Text="Message Content Intent" />
<Run Text="enabled to read messages" />
<LineBreak />
<LineBreak /> <LineBreak />
<Run FontSize="16" Text="If you have questions or issues, please refer to the" /> <Run Text="If you have questions or issues, please refer to the" />
<Hyperlink Command="{s:Action ShowHelp}" FontSize="16">wiki</Hyperlink><Run FontSize="16" Text="." /> <Hyperlink Command="{s:Action ShowHelp}">wiki</Hyperlink><Run Text="." />
</TextBlock> </TextBlock>
</ScrollViewer> </ScrollViewer>
</Grid> </Grid>
@@ -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>
+14 -4
View File
@@ -9,7 +9,7 @@
[![Donate](https://img.shields.io/badge/donate-$$$-8a2be2.svg)](https://tyrrrz.me/donate) [![Donate](https://img.shields.io/badge/donate-$$$-8a2be2.svg)](https://tyrrrz.me/donate)
[![Fuck Russia](https://img.shields.io/badge/fuck-russia-e4181c.svg?labelColor=000000)](https://twitter.com/tyrrrz/status/1495972128977571848) [![Fuck Russia](https://img.shields.io/badge/fuck-russia-e4181c.svg?labelColor=000000)](https://twitter.com/tyrrrz/status/1495972128977571848)
> 🟢 **Project status**: active<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)</sup> > 🟡 **Project status**: maintenance mode<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)</sup>
**DiscordChatExporter** can be used to export message history from a [Discord](https://discord.com) channel to a file. **DiscordChatExporter** can be used to export message history from a [Discord](https://discord.com) channel to a file.
It works with direct messages, group messages, and server channels, and supports Discord's dialect of markdown as well as all other rich media features. It works with direct messages, group messages, and server channels, and supports Discord's dialect of markdown as well as all other rich media features.
@@ -65,6 +65,7 @@ The following table lists all available download options:
<li>🟠 <a href="https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml">CI build</a> (<code>DiscordChatExporter.CLI.zip</code>)</li> <li>🟠 <a href="https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml">CI build</a> (<code>DiscordChatExporter.CLI.zip</code>)</li>
<li>🐋 <a href="https://hub.docker.com/r/tyrrrz/discordchatexporter">Docker</a> (<code>tyrrrz/discordchatexporter</code>)</li> <li>🐋 <a href="https://hub.docker.com/r/tyrrrz/discordchatexporter">Docker</a> (<code>tyrrrz/discordchatexporter</code>)</li>
<li>📦 <a href="https://aur.archlinux.org/packages/discord-chat-exporter-cli">AUR</a> (<code>discord-chat-exporter-cli</code>)</li> <li>📦 <a href="https://aur.archlinux.org/packages/discord-chat-exporter-cli">AUR</a> (<code>discord-chat-exporter-cli</code>)</li>
<li>📦 <a href="https://search.nixos.org/packages?query=discordchatexporter-cli">Nix</a> (<code>discordchatexporter-cli</code>)</li>
</ul> </ul>
</td> </td>
<td> <td>
@@ -79,15 +80,19 @@ The following table lists all available download options:
</table> </table>
> **Note**: > **Note**:
> **DiscordChatExporter**'s AUR package is maintained by the community. > AUR and Nix packages linked above are maintained by the community.
> If you have any issues with them, please contact the corresponding maintainers.
> **Warning**: > **Warning**:
> To run **DiscordChatExporter** on macOS or Linux, you will need to additionally install **.NET Runtime v6**: > To run **DiscordChatExporter** on macOS and Linux, you need to make sure that **.NET Runtime v6** is installed.
> 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 Runtime v6 for **macOS x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-6.0.6-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 Runtime v6 for **macOS Arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-6.0.6-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 Runtime v6 for **Linux**](https://docs.microsoft.com/en-us/dotnet/core/install/linux) (find the correct download for your distro)
> >
> This is not required if you installed **DiscordChatExporter** using a package manager, or if you plan to run **DiscordChatExporter** via Docker. > 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.
## Features ## Features
@@ -103,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**.