mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9639b6c550 | |||
| 950658c737 | |||
| 560a069c35 | |||
| 1131f8659d | |||
| 38be44debb | |||
| 4b243f2252 | |||
| a31175b2e8 | |||
| 913255f04f | |||
| 873d055191 | |||
| dabed24c16 | |||
| fdf421df3c | |||
| 2259f19c44 | |||
| 8695942328 | |||
| 84e656ccfc | |||
| 2ba0c3b38e | |||
| 7467f0aeb6 | |||
| e38479d463 | |||
| 06a4b6a8e6 | |||
| cc1ad8b435 | |||
| f8dac2c9d0 | |||
| b8cea0d140 |
@@ -18,8 +18,7 @@ jobs:
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@v2
|
||||
with:
|
||||
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176
|
||||
dotnet-version: 6.0.100
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Run tests
|
||||
# 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
|
||||
uses: actions/setup-dotnet@v2
|
||||
with:
|
||||
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176
|
||||
dotnet-version: 6.0.100
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Publish (CLI)
|
||||
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
|
||||
@@ -151,4 +149,4 @@ jobs:
|
||||
payload: |
|
||||
{
|
||||
"content": "**DiscordChatExporter** new version released!\nVersion: `${{ steps.get_version.outputs.tag }}`\nChangelog: <https://github.com/Tyrrrz/DiscordChatExporter/blob/${{ steps.get_version.outputs.tag }}/Changelog.md>"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,25 @@
|
||||
### 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))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<Version>2.36</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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,7 +37,7 @@ public partial record Embed
|
||||
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
||||
|
||||
var kind =
|
||||
json.GetPropertyOrNull("type")?.GetStringOrNull()?.Pipe(s => Enum.Parse<EmbedKind>(s, true)) ??
|
||||
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>() ??
|
||||
EmbedKind.Rich;
|
||||
|
||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||
|
||||
@@ -7,6 +7,5 @@ public enum EmbedKind
|
||||
Image,
|
||||
Video,
|
||||
Gifv,
|
||||
Article,
|
||||
Link
|
||||
}
|
||||
@@ -17,6 +17,5 @@ public enum MessageKind
|
||||
|
||||
public static class MessageKindExtensions
|
||||
{
|
||||
public static bool IsSystemNotification(this MessageKind c) =>
|
||||
c is not MessageKind.Default and not MessageKind.Reply;
|
||||
public static bool IsSystemNotification(this MessageKind c) => (int)c is >= 1 and <= 18;
|
||||
}
|
||||
@@ -22,65 +22,67 @@ public class DiscordClient
|
||||
private readonly string _token;
|
||||
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;
|
||||
|
||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
||||
string url,
|
||||
bool isBot,
|
||||
TokenKind tokenKind,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken =>
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||
|
||||
// Don't validate because token can have invalid characters
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
"Authorization",
|
||||
isBot ? $"Bot {_token}" : _token
|
||||
);
|
||||
// Don't validate because token can have invalid characters
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
"Authorization",
|
||||
tokenKind == TokenKind.Bot
|
||||
? $"Bot {_token}"
|
||||
: _token
|
||||
);
|
||||
|
||||
return await Http.Client.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
return await Http.Client.SendAsync(
|
||||
request,
|
||||
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
|
||||
);
|
||||
|
||||
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(
|
||||
string url,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken =>
|
||||
{
|
||||
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);
|
||||
var tokenKind = _resolvedTokenKind ??= await GetTokenKindAsync(cancellationToken);
|
||||
return await GetResponseAsync(url, tokenKind, cancellationToken);
|
||||
}
|
||||
|
||||
private async ValueTask<JsonElement> GetJsonResponseAsync(
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
public enum TokenKind
|
||||
{
|
||||
Unknown,
|
||||
User,
|
||||
Bot
|
||||
}
|
||||
+7
-7
@@ -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,14 +35,14 @@ 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
|
||||
{
|
||||
"unix" => date.ToUnixTimeSeconds().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);
|
||||
@@ -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,16 +10,20 @@
|
||||
@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();
|
||||
|
||||
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 userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id);
|
||||
@@ -62,29 +66,31 @@
|
||||
: 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>
|
||||
<svg class="chatlog__system-notification-icon"><use href="#@message.Kind.ToString().ToDashCase().ToLowerInvariant()-icon"></use></svg>
|
||||
</div>
|
||||
|
||||
<div class="chatlog__message-primary">
|
||||
<div class="chatlog__header">
|
||||
@{/* Author name */}
|
||||
<span class="chatlog__author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span>
|
||||
@{/* 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 */}
|
||||
@if(message.Kind == MessageKind.ChannelPinnedMessage)
|
||||
@{/* System notification content */}
|
||||
<span class="chatlog__system-notification-content">
|
||||
@if (message.Kind == MessageKind.ChannelPinnedMessage && message.Reference is not null)
|
||||
{
|
||||
<span class="chatlog__system-notification"> pinned</span>
|
||||
<span class="chatlog__system-notification-reference-link chatlog__reference-link" onclick="scrollToMessage(event, '@message.Reference?.MessageId')"> a message</span>
|
||||
<span class="chatlog__system-notification"> to this channel.</span>
|
||||
<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 class="chatlog__system-notification">@(char.ToLowerInvariant(message.Content[0]) + message.Content[1..])</span>
|
||||
<span>@message.Content.ToLowerInvariant()</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
@{/* Timestamp */}
|
||||
<span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span>
|
||||
</div>
|
||||
@{/* Timestamp */}
|
||||
<span class="chatlog__system-notification-timestamp">
|
||||
<a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
// Regular message
|
||||
@@ -100,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
|
||||
{
|
||||
@@ -117,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())
|
||||
{
|
||||
@@ -168,13 +174,13 @@
|
||||
}
|
||||
|
||||
@{/* 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">
|
||||
@{/* Text */}
|
||||
@if (!message.IsContentHidden())
|
||||
@if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden())
|
||||
{
|
||||
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatMarkdown(message.Content))<!--/wmm:ignore--></span>
|
||||
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(await FormatMarkdownAsync(message.Content))<!--/wmm:ignore--></span>
|
||||
}
|
||||
|
||||
@{/* Edited timestamp */}
|
||||
@@ -198,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
|
||||
@@ -221,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>
|
||||
@@ -268,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))
|
||||
@@ -294,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>
|
||||
}
|
||||
@@ -317,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>
|
||||
}
|
||||
@@ -327,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>
|
||||
}
|
||||
@@ -354,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))
|
||||
@@ -380,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>
|
||||
}
|
||||
@@ -394,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>
|
||||
}
|
||||
|
||||
@@ -408,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>
|
||||
@@ -428,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>
|
||||
}
|
||||
@@ -444,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>
|
||||
}
|
||||
@@ -460,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">
|
||||
@@ -494,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>
|
||||
}
|
||||
@@ -510,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;
|
||||
}
|
||||
@@ -69,6 +72,7 @@
|
||||
font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
font-size: 17px;
|
||||
font-weight: @Themed("400", "500");
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -254,18 +258,36 @@
|
||||
unicode-bidi: bidi-override;
|
||||
}
|
||||
|
||||
.chatlog__system-notification {
|
||||
.chatlog__system-notification-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.chatlog__system-notification-author {
|
||||
font-weight: @Themed("500", "600");
|
||||
color: @Themed("#ffffff", "#2f3136");
|
||||
}
|
||||
|
||||
.chatlog__system-notification-content {
|
||||
color: @Themed("#96989d", "#5e6772")
|
||||
}
|
||||
|
||||
.chatlog__system-notification-reference-link {
|
||||
.chatlog__system-notification-link {
|
||||
font-weight: 500;
|
||||
color: @Themed("#ffffff", "#2f3136");
|
||||
}
|
||||
|
||||
.chatlog__system-notification-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
.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 {
|
||||
@@ -300,7 +322,7 @@
|
||||
}
|
||||
|
||||
.chatlog__timestamp a {
|
||||
color: @Themed("#a3a6aa", "#5e6772");
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chatlog__content {
|
||||
@@ -733,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));
|
||||
@@ -742,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 => {
|
||||
@@ -824,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>
|
||||
@@ -832,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
|
||||
|
||||
+34
-23
@@ -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();
|
||||
}
|
||||
|
||||
+11
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -29,6 +30,11 @@ 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) =>
|
||||
builder.Length > 0
|
||||
? builder.Append(value)
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Authentication;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using Polly;
|
||||
@@ -21,7 +22,7 @@ public static class Http
|
||||
|
||||
private static bool IsRetryableException(Exception exception) =>
|
||||
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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -108,3 +108,8 @@ The following table lists all available download options:
|
||||
|
||||

|
||||

|
||||
|
||||
## 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**.
|
||||
Reference in New Issue
Block a user