Compare commits

..

13 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
32 changed files with 282 additions and 213 deletions
+15
View File
@@ -1,3 +1,18 @@
### 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.
+9 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Version>2.36.1</Version>
<Version>2.36.4</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion>
@@ -10,4 +10,12 @@
<WarningsAsErrors>nullable</WarningsAsErrors>
</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>
@@ -12,10 +12,10 @@
<ItemGroup>
<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="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="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" />
@@ -36,7 +36,7 @@ public class SelfContainedSpecs : IClassFixture<TempOutputFixture>
ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath,
ShouldDownloadMedia = true
ShouldDownloadAssets = true
}.ExecuteAsync(new FakeConsole());
// Assert
+6 -2
View File
@@ -7,12 +7,16 @@ COPY Directory.Build.props ./
COPY DiscordChatExporter.Core ./DiscordChatExporter.Core
COPY DiscordChatExporter.Cli ./DiscordChatExporter.Cli
RUN dotnet publish DiscordChatExporter.Cli -c Release -o ./publish
RUN dotnet publish DiscordChatExporter.Cli --configuration Release --output ./publish
# Run
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS run
RUN useradd dce
USER dce
COPY --from=build ./publish ./
WORKDIR ./out
ENTRYPOINT ["dotnet", "/DiscordChatExporter.Cli.dll"]
ENTRYPOINT ["dotnet", "../DiscordChatExporter.Cli.dll"]
@@ -76,15 +76,15 @@ public abstract class ExportCommandBase : TokenCommandBase
[CommandOption(
"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(
"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(
"dateformat",
@@ -97,9 +97,9 @@ public abstract class ExportCommandBase : TokenCommandBase
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels)
{
// Reuse media option should only be used when the media option is set.
// Reuse assets option should only be used when the download assets option is set.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
if (ShouldReuseMedia && !ShouldDownloadMedia)
if (ShouldReuseAssets && !ShouldDownloadAssets)
{
throw new CommandException(
"Option --reuse-media cannot be used without --media."
@@ -158,8 +158,8 @@ public abstract class ExportCommandBase : TokenCommandBase
Before,
PartitionLimit,
MessageFilter,
ShouldDownloadMedia,
ShouldReuseMedia,
ShouldDownloadAssets,
ShouldReuseAssets,
DateFormat
);
@@ -7,7 +7,7 @@
<ItemGroup>
<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="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" />
</ItemGroup>
@@ -10,7 +10,7 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/channel#message-object
public partial record Message(
public record Message(
Snowflake Id,
MessageKind Kind,
User Author,
@@ -26,16 +26,6 @@ public partial record Message(
IReadOnlyList<User> MentionedUsers,
MessageReference? Reference,
Message? ReferencedMessage) : IHasId
{
public bool IsEmpty =>
Kind == MessageKind.Default &&
string.IsNullOrEmpty(Content) &&
!Attachments.Any() &&
!Embeds.Any() &&
!Stickers.Any();
}
public partial record Message
{
private static IReadOnlyList<Embed> NormalizeEmbeds(IReadOnlyList<Embed> embeds)
{
@@ -323,11 +323,6 @@ public class DiscordClient
if (message.Timestamp > lastMessage.Timestamp)
yield break;
// Make sure the messages are not empty when exporting via a bot
// https://github.com/Tyrrrz/DiscordChatExporter/issues/918
if (_resolvedTokenKind == TokenKind.Bot && message.IsEmpty && !message.Author.IsBot)
throw DiscordChatExporterException.MessageContentIntentMissing();
// Report progress based on the duration of exported messages divided by total
if (progress is not null)
{
@@ -41,7 +41,4 @@ Failed to perform an HTTP request.
internal static DiscordChatExporterException ChannelIsEmpty() =>
new("No messages found for the specified period.");
internal static DiscordChatExporterException MessageContentIntentMissing() =>
new("Failed to retrieve message content because the bot doesn't have the Message Content Intent enabled.");
}
@@ -12,18 +12,18 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting;
internal partial class MediaDownloader
internal partial class ExportAssetDownloader
{
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);
public MediaDownloader(string workingDirPath, bool reuseMedia)
public ExportAssetDownloader(string workingDirPath, bool reuse)
{
_workingDirPath = workingDirPath;
_reuseMedia = reuseMedia;
_reuse = reuse;
}
public async ValueTask<string> DownloadAsync(string url, CancellationToken cancellationToken = default)
@@ -35,7 +35,7 @@ internal partial class MediaDownloader
var filePath = Path.Combine(_workingDirPath, fileName);
// Reuse existing files if we're allowed to
if (!_reuseMedia || !File.Exists(filePath))
if (!_reuse || !File.Exists(filePath))
{
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)
{
@@ -14,7 +14,7 @@ namespace DiscordChatExporter.Core.Exporting;
internal class ExportContext
{
private readonly MediaDownloader _mediaDownloader;
private readonly ExportAssetDownloader _assetDownloader;
public ExportRequest Request { get; }
@@ -35,7 +35,7 @@ internal class ExportContext
Channels = channels;
Roles = roles;
_mediaDownloader = new MediaDownloader(request.OutputMediaDirPath, request.ShouldReuseMedia);
_assetDownloader = new ExportAssetDownloader(request.OutputAssetsDirPath, request.ShouldReuseAssets);
}
public string FormatDate(DateTimeOffset date) => Request.DateFormat switch
@@ -63,14 +63,14 @@ internal class ExportContext
.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;
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.
// 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,
PartitionLimit PartitionLimit,
MessageFilter MessageFilter,
bool ShouldDownloadMedia,
bool ShouldReuseMedia,
bool ShouldDownloadAssets,
bool ShouldReuseAssets,
string DateFormat)
{
private string? _outputBaseFilePath;
@@ -35,7 +35,7 @@ public partial record ExportRequest(
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
@@ -19,8 +19,8 @@ internal partial class CsvMessageWriter : MessageWriter
_writer = new StreamWriter(stream);
}
private string FormatMarkdown(string? markdown) =>
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
private ValueTask<string> FormatMarkdownAsync(string? markdown) =>
PlainTextMarkdownVisitor.FormatAsync(Context, markdown ?? "");
public override async ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) =>
await _writer.WriteLineAsync("AuthorID,Author,Date,Content,Attachments,Reactions");
@@ -37,7 +37,7 @@ internal partial class CsvMessageWriter : MessageWriter
buffer
.AppendIfNotEmpty(',')
.Append(await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
.Append(await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken));
}
await _writer.WriteAsync(CsvEncode(buffer.ToString()));
@@ -84,7 +84,7 @@ internal partial class CsvMessageWriter : MessageWriter
await _writer.WriteAsync(',');
// Message content
await _writer.WriteAsync(CsvEncode(FormatMarkdown(message.Content)));
await _writer.WriteAsync(CsvEncode(await FormatMarkdownAsync(message.Content)));
await _writer.WriteAsync(',');
// Attachments
@@ -10,13 +10,17 @@
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
@{
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
ValueTask<string> ResolveAssetUrlAsync(string url) =>
Model.ExportContext.ResolveAssetUrlAsync(url);
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);
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
ValueTask<string> FormatEmbedMarkdownAsync(string markdown) =>
Model.FormatMarkdownAsync(markdown, false);
var firstMessage = Model.Messages.First();
@@ -102,7 +106,7 @@
}
// 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
{
@@ -119,13 +123,13 @@
<div class="chatlog__reference">
@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-content">
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
@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())
{
@@ -176,7 +180,7 @@
@{/* Text */}
@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 */}
@@ -200,20 +204,20 @@
@{/* Attachment preview */}
@if (attachment.IsImage)
{
<a href="@await ResolveUrlAsync(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">
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
<img class="chatlog__attachment-media" src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
</a>
}
else if (attachment.IsVideo)
{
<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>
}
else if (attachment.IsAudio)
{
<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>
}
else
@@ -223,7 +227,7 @@
<use href="#attachment-icon"/>
</svg>
<div class="chatlog__attachment-generic-name">
<a href="@await ResolveUrlAsync(attachment.Url)">
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
@attachment.FileName
</a>
</div>
@@ -270,7 +274,7 @@
<div class="chatlog__embed-author-container">
@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))
@@ -296,12 +300,12 @@
@if (!string.IsNullOrWhiteSpace(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>
}
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>
}
@@ -319,8 +323,8 @@
else if (embed.Kind == EmbedKind.Image && !string.IsNullOrWhiteSpace(embed.Url))
{
<div class="chatlog__embed">
<a href="@await ResolveUrlAsync(embed.Url)">
<img class="chatlog__embed-generic-image" src="@await ResolveUrlAsync(embed.Url)" alt="Embedded image" loading="lazy">
<a href="@await ResolveAssetUrlAsync(embed.Url)">
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embed.Url)" alt="Embedded image" loading="lazy">
</a>
</div>
}
@@ -329,7 +333,7 @@
{
<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 ResolveUrlAsync(embed.Video.ProxyUrl ?? embed.Video.Url)" alt="Embedded video">
<source src="@await ResolveAssetUrlAsync(embed.Video.ProxyUrl ?? embed.Video.Url)" alt="Embedded video">
</video>
</div>
}
@@ -356,7 +360,7 @@
<div class="chatlog__embed-author-container">
@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))
@@ -382,12 +386,12 @@
@if (!string.IsNullOrWhiteSpace(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>
}
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>
}
@@ -396,7 +400,7 @@
@if (!string.IsNullOrWhiteSpace(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>
}
@@ -410,14 +414,14 @@
@if (!string.IsNullOrWhiteSpace(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>
}
@if (!string.IsNullOrWhiteSpace(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>
@@ -430,8 +434,8 @@
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url))
{
<div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
</a>
</div>
}
@@ -446,8 +450,8 @@
if (!string.IsNullOrWhiteSpace(image.Url))
{
<div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)">
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
</a>
</div>
}
@@ -462,7 +466,7 @@
@{/* Footer icon */}
@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">
@@ -496,11 +500,11 @@
<div class="chatlog__sticker" title="@sticker.Name">
@if (sticker.Format is StickerFormat.Png or StickerFormat.PngAnimated)
{
<img class="chatlog__sticker--media" src="@await ResolveUrlAsync(sticker.SourceUrl)" alt="Sticker">
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
}
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>
}
@@ -512,7 +516,7 @@
@foreach (var reaction in message.Reactions)
{
<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>
</div>
}
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
@@ -16,6 +17,6 @@ internal class MessageGroupTemplateContext
Messages = messages;
}
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
public ValueTask<string> FormatMarkdownAsync(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown ?? "", isJumboAllowed);
}
@@ -14,11 +14,14 @@
string GetFontUrl(int weight) =>
$"https://cdn.jsdelivr.net/gh/Tyrrrz/DiscordFonts@master/whitney-{weight}.woff";
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url, CancellationToken);
ValueTask<string> 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>
@@ -32,31 +35,31 @@
@{/* Styling */}
<style>
@@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(300)));
src: url(@await ResolveAssetUrlAsync(GetFontUrl(300)));
font-family: Whitney;
font-weight: 300;
}
@@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(400)));
src: url(@await ResolveAssetUrlAsync(GetFontUrl(400)));
font-family: Whitney;
font-weight: 400;
}
@@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(500)));
src: url(@await ResolveAssetUrlAsync(GetFontUrl(500)));
font-family: Whitney;
font-weight: 500;
}
@@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(600)));
src: url(@await ResolveAssetUrlAsync(GetFontUrl(600)));
font-family: Whitney;
font-weight: 600;
}
@@font-face {
src: url(@await ResolveUrlAsync(GetFontUrl(700)));
src: url(@await ResolveAssetUrlAsync(GetFontUrl(700)));
font-family: Whitney;
font-weight: 700;
}
@@ -752,8 +755,8 @@
</style>
@{/* 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")">
<script src="@await ResolveUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js")"></script>
<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 ResolveAssetUrlAsync("https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.15.6/highlight.min.js")"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.chatlog__markdown-pre--multiline').forEach(e => hljs.highlightBlock(e));
@@ -761,7 +764,7 @@
</script>
@{/* 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>
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.chatlog__sticker--media[data-source]').forEach(e => {
@@ -843,7 +846,7 @@
<div class="preamble">
<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 class="preamble__entries-container">
<div class="preamble__entry">@Model.ExportContext.Request.Guild.Name</div>
@@ -851,7 +854,7 @@
@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)
@@ -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;
@@ -14,6 +15,6 @@ internal class PreambleTemplateContext
ThemeName = themeName;
}
public string FormatMarkdown(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.Format(ExportContext, markdown ?? "", isJumboAllowed);
public ValueTask<string> FormatMarkdownAsync(string? markdown, bool isJumboAllowed = true) =>
HtmlMarkdownVisitor.FormatAsync(ExportContext, markdown ?? "", isJumboAllowed);
}
@@ -29,8 +29,8 @@ internal class JsonMessageWriter : MessageWriter
});
}
private string FormatMarkdown(string? markdown) =>
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
private ValueTask<string> FormatMarkdownAsync(string? markdown) =>
PlainTextMarkdownVisitor.FormatAsync(Context, markdown ?? "");
private async ValueTask WriteAttachmentAsync(
Attachment attachment,
@@ -39,7 +39,7 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteStartObject();
_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.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
@@ -57,7 +57,12 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("url", embedAuthor.Url);
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();
await _writer.FlushAsync(cancellationToken);
@@ -70,7 +75,12 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteStartObject();
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("height", embedImage.Height);
@@ -88,7 +98,12 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("text", embedFooter.Text);
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();
await _writer.FlushAsync(cancellationToken);
@@ -100,8 +115,8 @@ internal class JsonMessageWriter : MessageWriter
{
_writer.WriteStartObject();
_writer.WriteString("name", FormatMarkdown(embedField.Name));
_writer.WriteString("value", FormatMarkdown(embedField.Value));
_writer.WriteString("name", await FormatMarkdownAsync(embedField.Name));
_writer.WriteString("value", await FormatMarkdownAsync(embedField.Value));
_writer.WriteBoolean("isInline", embedField.IsInline);
_writer.WriteEndObject();
@@ -114,10 +129,10 @@ internal class JsonMessageWriter : MessageWriter
{
_writer.WriteStartObject();
_writer.WriteString("title", FormatMarkdown(embed.Title));
_writer.WriteString("title", await FormatMarkdownAsync(embed.Title));
_writer.WriteString("url", embed.Url);
_writer.WriteString("timestamp", embed.Timestamp);
_writer.WriteString("description", FormatMarkdown(embed.Description));
_writer.WriteString("description", await FormatMarkdownAsync(embed.Description));
if (embed.Color is not null)
_writer.WriteString("color", embed.Color.Value.ToHex());
@@ -176,7 +191,7 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("id", sticker.Id.ToString());
_writer.WriteString("name", sticker.Name);
_writer.WriteString("format", sticker.Format.ToString());
_writer.WriteString("sourceUrl", await Context.ResolveMediaUrlAsync(sticker.SourceUrl, cancellationToken));
_writer.WriteString("sourceUrl", await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken));
_writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken);
@@ -193,7 +208,7 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("id", reaction.Emoji.Id.ToString());
_writer.WriteString("name", reaction.Emoji.Name);
_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.WriteNumber("count", reaction.Count);
@@ -227,7 +242,7 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteStartObject("guild");
_writer.WriteString("id", Context.Request.Guild.Id.ToString());
_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();
// Channel
@@ -268,7 +283,7 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteBoolean("isPinned", message.IsPinned);
// Content
_writer.WriteString("content", FormatMarkdown(message.Content));
_writer.WriteString("content", await FormatMarkdownAsync(message.Content));
// 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("color", Context.TryGetUserColor(message.Author.Id)?.ToHex());
_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();
// Attachments
@@ -3,6 +3,7 @@ using System.Linq;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing;
@@ -23,15 +24,15 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
_isJumbo = isJumbo;
}
protected override MarkdownNode VisitText(TextNode text)
protected override async ValueTask<MarkdownNode> VisitTextAsync(TextNode 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 => (
"<strong>",
@@ -66,24 +67,24 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
_ => throw new InvalidOperationException($"Unknown formatting kind '{formatting.Kind}'.")
};
_buffer.Append(tagOpen);
var result = base.VisitFormatting(formatting);
_buffer.Append(tagClose);
_buffer.Append(openingTag);
var result = await base.VisitFormattingAsync(formatting);
_buffer.Append(closingTag);
return result;
}
protected override MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock)
protected override async ValueTask<MarkdownNode> VisitInlineCodeBlockAsync(InlineCodeBlockNode inlineCodeBlock)
{
_buffer
.Append("<code class=\"chatlog__markdown-pre chatlog__markdown-pre--inline\">")
.Append(HtmlEncode(inlineCodeBlock.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)
? $"language-{multiLineCodeBlock.Language}"
@@ -94,10 +95,10 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
.Append(HtmlEncode(multiLineCodeBlock.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
var linkedMessageId = Regex.Match(
@@ -111,24 +112,31 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
: $"<a href=\"{HtmlEncode(link.Url)}\">"
);
var result = base.VisitLink(link);
var result = await base.VisitLinkAsync(link);
_buffer.Append("</a>");
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 jumboClass = _isJumbo ? "chatlog__emoji--large" : "";
_buffer
.Append($"<img loading=\"lazy\" class=\"chatlog__emoji {jumboClass}\" alt=\"{emoji.Name}\" title=\"{emoji.Code}\" src=\"{emojiImageUrl}\">");
_buffer.Append(
$"<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)
{
@@ -183,10 +191,10 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
.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
? _context.FormatDate(timestamp.Date.Value)
@@ -202,7 +210,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
.Append(HtmlEncode(dateString))
.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);
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);
@@ -220,7 +231,7 @@ internal partial class HtmlMarkdownVisitor
var buffer = new StringBuilder();
new HtmlMarkdownVisitor(context, buffer, isJumbo).Visit(nodes);
await new HtmlMarkdownVisitor(context, buffer, isJumbo).VisitAsync(nodes);
return buffer.ToString();
}
@@ -1,4 +1,5 @@
using System.Text;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
@@ -16,13 +17,13 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_buffer = buffer;
}
protected override MarkdownNode VisitText(TextNode text)
protected override async ValueTask<MarkdownNode> VisitTextAsync(TextNode 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(
emoji.IsCustomEmoji
@@ -30,10 +31,10 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
: 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)
{
@@ -69,10 +70,10 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_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(
timestamp.Date is not null
@@ -80,18 +81,18 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
: "Invalid date"
);
return base.VisitUnixTimestamp(timestamp);
return await base.VisitUnixTimestampAsync(timestamp);
}
}
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 buffer = new StringBuilder();
new PlainTextMarkdownVisitor(context, buffer).Visit(nodes);
await new PlainTextMarkdownVisitor(context, buffer).VisitAsync(nodes);
return buffer.ToString();
}
@@ -19,8 +19,8 @@ internal class PlainTextMessageWriter : MessageWriter
_writer = new StreamWriter(stream);
}
private string FormatMarkdown(string? markdown) =>
PlainTextMarkdownVisitor.Format(Context, markdown ?? "");
private ValueTask<string> FormatMarkdownAsync(string? markdown) =>
PlainTextMarkdownVisitor.FormatAsync(Context, markdown ?? "");
private async ValueTask WriteMessageHeaderAsync(Message message)
{
@@ -48,7 +48,7 @@ internal class PlainTextMessageWriter : MessageWriter
{
cancellationToken.ThrowIfCancellationRequested();
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(attachment.Url, cancellationToken));
await _writer.WriteLineAsync(await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken));
}
await _writer.WriteLineAsync();
@@ -71,27 +71,41 @@ internal class PlainTextMessageWriter : MessageWriter
await _writer.WriteLineAsync(embed.Url);
if (!string.IsNullOrWhiteSpace(embed.Title))
await _writer.WriteLineAsync(FormatMarkdown(embed.Title));
await _writer.WriteLineAsync(await FormatMarkdownAsync(embed.Title));
if (!string.IsNullOrWhiteSpace(embed.Description))
await _writer.WriteLineAsync(FormatMarkdown(embed.Description));
await _writer.WriteLineAsync(await FormatMarkdownAsync(embed.Description));
foreach (var field in embed.Fields)
{
if (!string.IsNullOrWhiteSpace(field.Name))
await _writer.WriteLineAsync(FormatMarkdown(field.Name));
await _writer.WriteLineAsync(await FormatMarkdownAsync(field.Name));
if (!string.IsNullOrWhiteSpace(field.Value))
await _writer.WriteLineAsync(FormatMarkdown(field.Value));
await _writer.WriteLineAsync(await FormatMarkdownAsync(field.Value));
}
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)
{
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))
@@ -114,7 +128,9 @@ internal class PlainTextMessageWriter : MessageWriter
{
cancellationToken.ThrowIfCancellationRequested();
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(sticker.SourceUrl, cancellationToken));
await _writer.WriteLineAsync(
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
);
}
await _writer.WriteLineAsync();
@@ -174,7 +190,7 @@ internal class PlainTextMessageWriter : MessageWriter
// Content
if (!string.IsNullOrWhiteSpace(message.Content))
await _writer.WriteLineAsync(FormatMarkdown(message.Content));
await _writer.WriteLineAsync(await FormatMarkdownAsync(message.Content));
await _writer.WriteLineAsync();
@@ -1,56 +1,57 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DiscordChatExporter.Core.Markdown.Parsing;
internal abstract class MarkdownVisitor
{
protected virtual MarkdownNode VisitText(TextNode text) =>
text;
protected virtual ValueTask<MarkdownNode> VisitTextAsync(TextNode 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;
}
protected virtual MarkdownNode VisitInlineCodeBlock(InlineCodeBlockNode inlineCodeBlock) =>
inlineCodeBlock;
protected virtual ValueTask<MarkdownNode> VisitInlineCodeBlockAsync(InlineCodeBlockNode inlineCodeBlock) =>
new(inlineCodeBlock);
protected virtual MarkdownNode VisitMultiLineCodeBlock(MultiLineCodeBlockNode multiLineCodeBlock) =>
multiLineCodeBlock;
protected virtual ValueTask<MarkdownNode> VisitMultiLineCodeBlockAsync(MultiLineCodeBlockNode 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;
}
protected virtual MarkdownNode VisitEmoji(EmojiNode emoji) =>
emoji;
protected virtual ValueTask<MarkdownNode> VisitEmojiAsync(EmojiNode emoji) =>
new(emoji);
protected virtual MarkdownNode VisitMention(MentionNode mention) =>
mention;
protected virtual ValueTask<MarkdownNode> VisitMentionAsync(MentionNode mention) =>
new(mention);
protected virtual MarkdownNode VisitUnixTimestamp(UnixTimestampNode timestamp) =>
timestamp;
protected virtual ValueTask<MarkdownNode> VisitUnixTimestampAsync(UnixTimestampNode timestamp) =>
new(timestamp);
public MarkdownNode Visit(MarkdownNode node) => node switch
public async ValueTask<MarkdownNode> VisitAsync(MarkdownNode node) => node switch
{
TextNode text => VisitText(text),
FormattingNode formatting => VisitFormatting(formatting),
InlineCodeBlockNode inlineCodeBlock => VisitInlineCodeBlock(inlineCodeBlock),
MultiLineCodeBlockNode multiLineCodeBlock => VisitMultiLineCodeBlock(multiLineCodeBlock),
LinkNode link => VisitLink(link),
EmojiNode emoji => VisitEmoji(emoji),
MentionNode mention => VisitMention(mention),
UnixTimestampNode timestamp => VisitUnixTimestamp(timestamp),
TextNode text => await VisitTextAsync(text),
FormattingNode formatting => await VisitFormattingAsync(formatting),
InlineCodeBlockNode inlineCodeBlock => await VisitInlineCodeBlockAsync(inlineCodeBlock),
MultiLineCodeBlockNode multiLineCodeBlock => await VisitMultiLineCodeBlockAsync(multiLineCodeBlock),
LinkNode link => await VisitLinkAsync(link),
EmojiNode emoji => await VisitEmojiAsync(emoji),
MentionNode mention => await VisitMentionAsync(mention),
UnixTimestampNode timestamp => await VisitUnixTimestampAsync(timestamp),
_ => throw new ArgumentOutOfRangeException(nameof(node))
};
public void Visit(IEnumerable<MarkdownNode> nodes)
public async ValueTask VisitAsync(IEnumerable<MarkdownNode> nodes)
{
foreach (var node in nodes)
Visit(node);
await VisitAsync(node);
}
}
@@ -14,14 +14,14 @@
<ItemGroup>
<PackageReference Include="Gress" Version="2.0.1" />
<PackageReference Include="MaterialDesignColors" Version="2.0.6" />
<PackageReference Include="MaterialDesignThemes" Version="4.5.0" />
<PackageReference Include="MaterialDesignColors" Version="2.0.9" />
<PackageReference Include="MaterialDesignThemes" Version="4.6.1" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
<PackageReference Include="Onova" Version="2.6.2" />
<PackageReference Include="Stylet" Version="1.3.6" />
<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" />
</ItemGroup>
@@ -16,7 +16,7 @@ public partial class SettingsService : SettingsManager
public int ParallelLimit { get; set; } = 1;
public bool ShouldReuseMedia { get; set; }
public bool ShouldReuseAssets { get; set; }
public string? LastToken { get; set; }
@@ -26,7 +26,7 @@ public partial class SettingsService : SettingsManager
public string? LastMessageFilterValue { get; set; }
public bool LastShouldDownloadMedia { get; set; }
public bool LastShouldDownloadAssets { get; set; }
public SettingsService()
{
@@ -186,8 +186,8 @@ public class DashboardViewModel : PropertyChangedBase
dialog.Before?.Pipe(Snowflake.FromDate),
dialog.PartitionLimit,
dialog.MessageFilter,
dialog.ShouldDownloadMedia,
_settingsService.ShouldReuseMedia,
dialog.ShouldDownloadAssets,
_settingsService.ShouldReuseAssets,
_settingsService.DateFormat
);
@@ -59,7 +59,7 @@ public class ExportSetupViewModel : DialogScreen
? MessageFilter.Parse(MessageFilterValue)
: MessageFilter.Null;
public bool ShouldDownloadMedia { get; set; }
public bool ShouldDownloadAssets { get; set; }
public bool IsAdvancedSectionDisplayed { get; set; }
@@ -72,7 +72,7 @@ public class ExportSetupViewModel : DialogScreen
SelectedFormat = _settingsService.LastExportFormat;
PartitionLimitValue = _settingsService.LastPartitionLimitValue;
MessageFilterValue = _settingsService.LastMessageFilterValue;
ShouldDownloadMedia = _settingsService.LastShouldDownloadMedia;
ShouldDownloadAssets = _settingsService.LastShouldDownloadAssets;
// Show the "advanced options" section by default if any
// of the advanced options are set to non-default values.
@@ -81,7 +81,7 @@ public class ExportSetupViewModel : DialogScreen
Before != default ||
!string.IsNullOrWhiteSpace(PartitionLimitValue) ||
!string.IsNullOrWhiteSpace(MessageFilterValue) ||
ShouldDownloadMedia != default;
ShouldDownloadAssets != default;
}
public void ToggleAdvancedSection() => IsAdvancedSectionDisplayed = !IsAdvancedSectionDisplayed;
@@ -92,7 +92,7 @@ public class ExportSetupViewModel : DialogScreen
_settingsService.LastExportFormat = SelectedFormat;
_settingsService.LastPartitionLimitValue = PartitionLimitValue;
_settingsService.LastMessageFilterValue = MessageFilterValue;
_settingsService.LastShouldDownloadMedia = ShouldDownloadMedia;
_settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
// If single channel - prompt file path
if (IsSingleChannel)
@@ -38,10 +38,10 @@ public class SettingsViewModel : DialogScreen
set => _settingsService.ParallelLimit = Math.Clamp(value, 1, 10);
}
public bool ShouldReuseMedia
public bool ShouldReuseAssets
{
get => _settingsService.ShouldReuseMedia;
set => _settingsService.ShouldReuseMedia = value;
get => _settingsService.ShouldReuseAssets;
set => _settingsService.ShouldReuseAssets = value;
}
public SettingsViewModel(SettingsService settingsService) =>
@@ -167,8 +167,8 @@
Text="{Binding MessageFilterValue}"
ToolTip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image')." />
<!-- Download media -->
<Grid Margin="16,16" ToolTip="Download referenced media content (user avatars, attached files, embedded images, etc)">
<!-- Download assets -->
<Grid Margin="16,16" ToolTip="Download assets referenced by the export (user avatars, attached files, embedded images, etc.)">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
@@ -177,12 +177,12 @@
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
Text="Download media" />
Text="Download assets" />
<ToggleButton
Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center"
IsChecked="{Binding ShouldDownloadMedia}" />
IsChecked="{Binding ShouldDownloadAssets}" />
</Grid>
</StackPanel>
</StackPanel>
@@ -82,20 +82,20 @@
IsChecked="{Binding IsTokenPersisted}" />
</DockPanel>
<!-- Reuse media -->
<!-- Reuse assets -->
<DockPanel
Margin="16,8"
Background="Transparent"
LastChildFill="False"
ToolTip="Reuse already existing media content to skip redundant downloads">
ToolTip="Reuse previously downloaded assets to avoid redundant requests">
<TextBlock
VerticalAlignment="Center"
DockPanel.Dock="Left"
Text="Reuse downloaded media" />
Text="Reuse downloaded assets" />
<ToggleButton
VerticalAlignment="Center"
DockPanel.Dock="Right"
IsChecked="{Binding ShouldReuseMedia}" />
IsChecked="{Binding ShouldReuseAssets}" />
</DockPanel>
<!-- Date format -->
+3 -1
View File
@@ -7,6 +7,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
Width="600"
Height="550"
MinWidth="325"
@@ -21,10 +22,11 @@
</Window.TaskbarItemInfo>
<materialDesign:DialogHost
x:Name="DialogHost"
Loaded="{s:Action OnViewFullyLoaded}"
SnackbarMessageQueue="{Binding Notifications}"
Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
<Grid>
<Grid IsEnabled="{Binding IsOpen, ElementName=DialogHost, Converter={x:Static converters:InverseBoolConverter.Instance}}">
<ContentControl s:View.Model="{Binding Dashboard}" />
<materialDesign:Snackbar MessageQueue="{Binding Notifications}" />
</Grid>
+5
View File
@@ -108,3 +108,8 @@ The following table lists all available download options:
![channel list](.assets/list.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**.