mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-28 16:25:06 +02:00
Compare commits
10 Commits
799dd1ad81
...
2.47.3
| Author | SHA1 | Date | |
|---|---|---|---|
| 05f8df51e9 | |||
| e2c633b004 | |||
| b11a57a825 | |||
| f502e577c2 | |||
| 8bc9fe7c72 | |||
| 97485c280b | |||
| 5a57c4202d | |||
| acac8f7bbb | |||
| 5632d6c386 | |||
| f4d1e630f7 |
@@ -14,7 +14,7 @@
|
|||||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||||
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
||||||
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.6" />
|
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.6" />
|
||||||
<PackageVersion Include="Deorcify" Version="1.1.0" />
|
<PackageVersion Include="Deorcify" Version="2.0.1" />
|
||||||
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.2" />
|
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.2" />
|
||||||
<PackageVersion Include="FluentAssertions" Version="8.10.0" />
|
<PackageVersion Include="FluentAssertions" Version="8.10.0" />
|
||||||
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.4" />
|
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.4" />
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||||
<PackageVersion Include="Onova" Version="2.6.13" />
|
<PackageVersion Include="Onova" Version="2.6.13" />
|
||||||
<PackageVersion Include="Polly" Version="8.6.6" />
|
<PackageVersion Include="Polly" Version="8.6.6" />
|
||||||
<PackageVersion Include="PowerKit" Version="1.2.0" />
|
<PackageVersion Include="PowerKit" Version="2.0.1" />
|
||||||
<PackageVersion Include="RazorBlade" Version="1.0.0" />
|
<PackageVersion Include="RazorBlade" Version="1.0.0" />
|
||||||
<PackageVersion Include="Spectre.Console" Version="0.55.2" />
|
<PackageVersion Include="Spectre.Console" Version="0.55.2" />
|
||||||
<PackageVersion Include="Superpower" Version="3.1.0" />
|
<PackageVersion Include="Superpower" Version="3.1.0" />
|
||||||
|
|||||||
@@ -14,13 +14,13 @@
|
|||||||
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
|
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
<PackageReference Include="FluentAssertions" />
|
<PackageReference Include="FluentAssertions" />
|
||||||
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
|
<PackageReference Include="GitHubActionsTestLogger" />
|
||||||
<PackageReference Include="JsonExtensions" />
|
<PackageReference Include="JsonExtensions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="PowerKit" PrivateAssets="all" />
|
<PackageReference Include="PowerKit" />
|
||||||
<PackageReference Include="xunit" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -165,6 +165,37 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
throw new CommandException("Option --media-dir cannot be used without --media.");
|
throw new CommandException("Option --media-dir cannot be used without --media.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make sure the user does not try to export multiple channels into one file.
|
||||||
|
// Output path must either be a directory or contain template tokens for this to work.
|
||||||
|
// Validate this up-front, before fetching threads, because thread fetching can take a
|
||||||
|
// long time and it's frustrating to fail only after it completes.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1549
|
||||||
|
var mayExportMultipleChannels =
|
||||||
|
// Multiple channels were provided explicitly
|
||||||
|
channels.Count > 1
|
||||||
|
// Thread inclusion can add more channels to the export
|
||||||
|
|| ThreadInclusionMode != ThreadInclusionMode.None;
|
||||||
|
|
||||||
|
var isValidOutputPath =
|
||||||
|
// Anything is valid when exporting a single channel
|
||||||
|
!mayExportMultipleChannels
|
||||||
|
// When using template tokens, assume the user knows what they're doing
|
||||||
|
|| OutputPath.Contains('%')
|
||||||
|
// Otherwise, require an existing directory or an unambiguous directory path
|
||||||
|
|| Directory.Exists(OutputPath)
|
||||||
|
|| Path.EndsInDirectorySeparator(OutputPath);
|
||||||
|
|
||||||
|
if (!isValidOutputPath)
|
||||||
|
{
|
||||||
|
throw new CommandException(
|
||||||
|
"Attempted to export multiple channels, but the output path is neither a directory nor a template. "
|
||||||
|
+ "If the provided output path is meant to be treated as a directory, make sure it ends with a slash. "
|
||||||
|
+ $"Provided output path: '{OutputPath}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
var unwrappedChannels = new List<Channel>(channels);
|
var unwrappedChannels = new List<Channel>(channels);
|
||||||
|
|
||||||
// Unwrap threads
|
// Unwrap threads
|
||||||
@@ -205,28 +236,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure the user does not try to export multiple channels into one file.
|
|
||||||
// Output path must either be a directory or contain template tokens for this to work.
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
|
||||||
var isValidOutputPath =
|
|
||||||
// Anything is valid when exporting a single channel
|
|
||||||
unwrappedChannels.Count <= 1
|
|
||||||
// When using template tokens, assume the user knows what they're doing
|
|
||||||
|| OutputPath.Contains('%')
|
|
||||||
// Otherwise, require an existing directory or an unambiguous directory path
|
|
||||||
|| Directory.Exists(OutputPath)
|
|
||||||
|| Path.EndsInDirectorySeparator(OutputPath);
|
|
||||||
|
|
||||||
if (!isValidOutputPath)
|
|
||||||
{
|
|
||||||
throw new CommandException(
|
|
||||||
"Attempted to export multiple channels, but the output path is neither a directory nor a template. "
|
|
||||||
+ "If the provided output path is meant to be treated as a directory, make sure it ends with a slash. "
|
|
||||||
+ $"Provided output path: '{OutputPath}'."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
|
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
|
||||||
var warningsByChannel = new ConcurrentDictionary<Channel, string>();
|
var warningsByChannel = new ConcurrentDictionary<Channel, string>();
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliFx" />
|
<PackageReference Include="CliFx" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
<PackageReference Include="Deorcify" />
|
||||||
<PackageReference Include="Gress" />
|
<PackageReference Include="Gress" />
|
||||||
<PackageReference Include="PowerKit" PrivateAssets="all" />
|
<PackageReference Include="PowerKit" />
|
||||||
<PackageReference Include="Spectre.Console" />
|
<PackageReference Include="Spectre.Console" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ public enum MessageKind
|
|||||||
GuildMemberJoin = 7,
|
GuildMemberJoin = 7,
|
||||||
ThreadCreated = 18,
|
ThreadCreated = 18,
|
||||||
Reply = 19,
|
Reply = 19,
|
||||||
|
ThreadStarterMessage = 21,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,8 @@ public class DiscordClient(
|
|||||||
string url,
|
string url,
|
||||||
TokenKind tokenKind,
|
TokenKind tokenKind,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
) =>
|
||||||
{
|
await Http.ResponseResiliencePipeline.ExecuteAsync(
|
||||||
return await Http.ResponseResiliencePipeline.ExecuteAsync(
|
|
||||||
async innerCancellationToken =>
|
async innerCancellationToken =>
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||||
@@ -91,7 +90,6 @@ public class DiscordClient(
|
|||||||
},
|
},
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask<TokenKind> ResolveTokenKindAsync(
|
private async ValueTask<TokenKind> ResolveTokenKindAsync(
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
@@ -364,6 +362,7 @@ public class DiscordClient(
|
|||||||
$"guilds/{guildId}/members/{memberId}",
|
$"guilds/{guildId}/members/{memberId}",
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
return response?.Pipe(j => Member.Parse(j, guildId));
|
return response?.Pipe(j => Member.Parse(j, guildId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,14 +411,12 @@ public class DiscordClient(
|
|||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(Snowflake.Parse);
|
?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
Channel? parent = null;
|
// It's possible for the parent channel to be inaccessible, despite the
|
||||||
if (parentId is not null)
|
// child channel being accessible.
|
||||||
{
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
||||||
// It's possible for the parent channel to be inaccessible, despite the
|
var parent = parentId is not null
|
||||||
// child channel being accessible.
|
? await TryGetChannelAsync(parentId.Value, cancellationToken)
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
: null;
|
||||||
parent = await TryGetChannelAsync(parentId.Value, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Channel.Parse(response.Value, parent);
|
return Channel.Parse(response.Value, parent);
|
||||||
}
|
}
|
||||||
@@ -607,8 +604,12 @@ public class DiscordClient(
|
|||||||
.SetQueryParameter("after", (after ?? Snowflake.Zero).ToString())
|
.SetQueryParameter("after", (after ?? Snowflake.Zero).ToString())
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
// Can be null on channels that the user cannot access
|
||||||
var message = response.EnumerateArray().Select(Message.Parse).FirstOrDefault();
|
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||||
|
if (response is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var message = response.Value.EnumerateArray().Select(Message.Parse).FirstOrDefault();
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
@@ -625,8 +626,39 @@ public class DiscordClient(
|
|||||||
.SetQueryParameter("before", before?.ToString())
|
.SetQueryParameter("before", before?.ToString())
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
// Can be null on channels that the user cannot access
|
||||||
return response.EnumerateArray().Select(Message.Parse).LastOrDefault();
|
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||||
|
if (response is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return response.Value.EnumerateArray().Select(Message.Parse).LastOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Message?> TryGetMessageAsync(
|
||||||
|
Snowflake channelId,
|
||||||
|
Snowflake messageId,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Use the regular message listing endpoint with the 'around' parameter instead of the
|
||||||
|
// dedicated single-message endpoint, because the latter is not accessible to user tokens.
|
||||||
|
var url = new UrlBuilder()
|
||||||
|
.SetPath($"channels/{channelId}/messages")
|
||||||
|
.SetQueryParameter("around", messageId.ToString())
|
||||||
|
.SetQueryParameter("limit", "1")
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Can be null on channels that the user cannot access
|
||||||
|
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||||
|
if (response is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// The endpoint returns messages around the requested ID, so make sure to only return
|
||||||
|
// the message that exactly matches it (it may be absent if it has been deleted).
|
||||||
|
return response
|
||||||
|
.Value.EnumerateArray()
|
||||||
|
.Select(Message.Parse)
|
||||||
|
.FirstOrDefault(m => m.Id == messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<Message> GetMessagesAsync(
|
public async IAsyncEnumerable<Message> GetMessagesAsync(
|
||||||
@@ -701,7 +733,21 @@ public class DiscordClient(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return message;
|
// Some messages, for example thread starter messages, are returned by the API as content-less references.
|
||||||
|
// Try to resolve them to the actual message so that they appear as they do in the Discord client.
|
||||||
|
var actualMessage =
|
||||||
|
message.Kind == MessageKind.ThreadStarterMessage
|
||||||
|
&& message.Reference?.ChannelId is { } referencedChannelId
|
||||||
|
&& message.Reference?.MessageId is { } referencedMessageId
|
||||||
|
? await TryGetMessageAsync(
|
||||||
|
referencedChannelId,
|
||||||
|
referencedMessageId,
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
yield return actualMessage ?? message;
|
||||||
|
|
||||||
currentAfter = message.Id;
|
currentAfter = message.Id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -769,7 +815,20 @@ public class DiscordClient(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
yield return message;
|
// Some messages, for example thread starter messages, are returned by the API as content-less references.
|
||||||
|
// Try to resolve them to the actual message so that they appear as they do in the Discord client.
|
||||||
|
var actualMessage =
|
||||||
|
message.Kind == MessageKind.ThreadStarterMessage
|
||||||
|
&& message.Reference?.ChannelId is { } referencedChannelId
|
||||||
|
&& message.Reference?.MessageId is { } referencedMessageId
|
||||||
|
? await TryGetMessageAsync(
|
||||||
|
referencedChannelId,
|
||||||
|
referencedMessageId,
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
yield return actualMessage ?? message;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentBefore = messages.Last().Id;
|
currentBefore = messages.Last().Id;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<PackageReference Include="Gress" />
|
<PackageReference Include="Gress" />
|
||||||
<PackageReference Include="JsonExtensions" />
|
<PackageReference Include="JsonExtensions" />
|
||||||
<PackageReference Include="Polly" />
|
<PackageReference Include="Polly" />
|
||||||
<PackageReference Include="PowerKit" PrivateAssets="all" />
|
<PackageReference Include="PowerKit" />
|
||||||
<PackageReference Include="RazorBlade" />
|
<PackageReference Include="RazorBlade" />
|
||||||
<PackageReference Include="Superpower" />
|
<PackageReference Include="Superpower" />
|
||||||
<PackageReference Include="WebMarkupMin.Core" />
|
<PackageReference Include="WebMarkupMin.Core" />
|
||||||
|
|||||||
@@ -38,23 +38,28 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
|||||||
return _previousPathsByUrl[url] = filePath;
|
return _previousPathsByUrl[url] = filePath;
|
||||||
|
|
||||||
// Check for a file cached by the legacy naming scheme (5-char hash) and rename it
|
// Check for a file cached by the legacy naming scheme (5-char hash) and rename it
|
||||||
// to the new naming scheme to preserve backwards compatibility with existing exports
|
// to the new naming scheme to preserve backwards compatibility with existing exports.
|
||||||
|
// This will catch both the 5-char lowercase hash and the 5-char uppercase hash variants.
|
||||||
if (reuse)
|
if (reuse)
|
||||||
{
|
{
|
||||||
var legacyFilePath = Path.Combine(workingDirPath, GetLegacyFileNameFromUrl(url));
|
var legacyFileNames = GetLegacyFileNamesFromUrl(url);
|
||||||
if (File.Exists(legacyFilePath))
|
foreach (var legacyFileName in legacyFileNames)
|
||||||
{
|
{
|
||||||
// Overwrite in case the destination file was created concurrently between our
|
var legacyFilePath = Path.Combine(workingDirPath, legacyFileName);
|
||||||
// earlier existence check and this move operation
|
if (File.Exists(legacyFilePath))
|
||||||
try
|
|
||||||
{
|
{
|
||||||
File.Move(legacyFilePath, filePath, overwrite: true);
|
// Overwrite in case the destination file was created concurrently between our
|
||||||
return _previousPathsByUrl[url] = filePath;
|
// earlier existence check and this move operation
|
||||||
}
|
try
|
||||||
catch (IOException)
|
{
|
||||||
{
|
File.Move(legacyFilePath, filePath, true);
|
||||||
// The legacy file was moved or deleted concurrently or something else happened.
|
return _previousPathsByUrl[url] = filePath;
|
||||||
// Upgrading old files is not crucial, so we can just move on.
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// The legacy file was moved or deleted concurrently or something else happened.
|
||||||
|
// Upgrading old files is not crucial, so we can just move on.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,10 +92,16 @@ internal partial class ExportAssetDownloader
|
|||||||
{
|
{
|
||||||
private static string NormalizeUrl(string url)
|
private static string NormalizeUrl(string url)
|
||||||
{
|
{
|
||||||
// Remove signature parameters from Discord CDN URLs to normalize them
|
// Remove signature parameters from Discord CDN/media URLs to normalize them
|
||||||
var uri = new Uri(url);
|
var uri = new Uri(url);
|
||||||
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
|
||||||
|
if (
|
||||||
|
!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& !string.Equals(uri.Host, "media.discordapp.net", StringComparison.OrdinalIgnoreCase)
|
||||||
|
)
|
||||||
|
{
|
||||||
return url;
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||||
query.Remove("ex");
|
query.Remove("ex");
|
||||||
@@ -137,13 +148,16 @@ internal partial class ExportAssetDownloader
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Legacy naming used a 5-char hash, kept for backwards compatibility with existing exports
|
// Legacy naming used a 5-char hash, kept for backwards compatibility with existing exports
|
||||||
private static string GetLegacyFileNameFromUrl(string url) =>
|
private static IReadOnlyList<string> GetLegacyFileNamesFromUrl(string url)
|
||||||
GetFileNameFromUrl(
|
{
|
||||||
url,
|
var hashData = SHA256.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)));
|
||||||
SHA256
|
|
||||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
return
|
||||||
.Pipe(Convert.ToHexStringLower)
|
[
|
||||||
// 5 chars = 20 bits, reaches 1% collision probability at ~145 files
|
// Lowercase variant (introduced in 2.46.1)
|
||||||
.Truncate(5)
|
GetFileNameFromUrl(url, Convert.ToHexStringLower(hashData).Truncate(5)),
|
||||||
);
|
// Uppercase variant (original)
|
||||||
|
GetFileNameFromUrl(url, Convert.ToHexString(hashData).Truncate(5)),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,10 +206,15 @@ public partial class ExportRequest
|
|||||||
{
|
{
|
||||||
var actualOutputPath = FormatPath(outputPath, guild, channel, after, before);
|
var actualOutputPath = FormatPath(outputPath, guild, channel, after, before);
|
||||||
|
|
||||||
// Output is a directory
|
// Determine whether the output path refers to a directory or a file.
|
||||||
|
// The extension-based heuristic is evaluated on the original, unsubstituted path,
|
||||||
|
// because the value of a template token (e.g. a guild or channel name) may contain
|
||||||
|
// a period that would otherwise be mistaken for a file extension, incorrectly causing
|
||||||
|
// a directory path to be treated as a file.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1502
|
||||||
if (
|
if (
|
||||||
Directory.Exists(actualOutputPath)
|
Directory.Exists(actualOutputPath)
|
||||||
|| string.IsNullOrWhiteSpace(Path.GetExtension(actualOutputPath))
|
|| string.IsNullOrWhiteSpace(Path.GetExtension(outputPath))
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var fileName = GetDefaultOutputFileName(guild, channel, format, after, before);
|
var fileName = GetDefaultOutputFileName(guild, channel, format, after, before);
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
var highlightClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
|
var highlightClass = !string.IsNullOrWhiteSpace(multiLineCodeBlock.Language)
|
||||||
? $"language-{multiLineCodeBlock.Language}"
|
? $"language-{HtmlEncode(multiLineCodeBlock.Language)}"
|
||||||
: "nohighlight";
|
: "nohighlight";
|
||||||
|
|
||||||
buffer.Append(
|
buffer.Append(
|
||||||
@@ -217,9 +217,11 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
<img
|
<img
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
class="chatlog__emoji {jumboClass}"
|
class="chatlog__emoji {jumboClass}"
|
||||||
alt="{emoji.Name}"
|
alt="{HtmlEncode(emoji.Name)}"
|
||||||
title="{emoji.Code}"
|
title="{HtmlEncode(emoji.Code)}"
|
||||||
src="{await context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)}">
|
src="{HtmlEncode(
|
||||||
|
await context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)
|
||||||
|
)}">
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -293,14 +295,8 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
var name = role?.Name ?? "deleted-role";
|
var name = role?.Name ?? "deleted-role";
|
||||||
var color = role?.Color;
|
var color = role?.Color;
|
||||||
|
|
||||||
var style = color is not null
|
var style = color is { } c
|
||||||
? $"""
|
? $"color: rgb({c.R}, {c.G}, {c.B}); background-color: rgba({c.R}, {c.G}, {c.B}, 0.1);"
|
||||||
color: rgb({color.Value.R}, {color.Value.G}, {color
|
|
||||||
.Value
|
|
||||||
.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color
|
|
||||||
.Value
|
|
||||||
.B}, 0.1);
|
|
||||||
"""
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
buffer.Append(
|
buffer.Append(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@using System.Collections.Generic
|
@using System.Collections.Generic
|
||||||
@using System.Linq
|
@using System.Linq
|
||||||
@using System.Threading.Tasks
|
@using System.Threading.Tasks
|
||||||
|
@using RazorBlade
|
||||||
@using DiscordChatExporter.Core.Discord.Data
|
@using DiscordChatExporter.Core.Discord.Data
|
||||||
@using DiscordChatExporter.Core.Discord.Data.Embeds
|
@using DiscordChatExporter.Core.Discord.Data.Embeds
|
||||||
@using DiscordChatExporter.Core.Markdown.Parsing
|
@using DiscordChatExporter.Core.Markdown.Parsing
|
||||||
@@ -23,15 +24,15 @@
|
|||||||
string FormatDate(DateTimeOffset instant, string format = "g") =>
|
string FormatDate(DateTimeOffset instant, string format = "g") =>
|
||||||
Context.FormatDate(instant, format);
|
Context.FormatDate(instant, format);
|
||||||
|
|
||||||
async ValueTask<string> FormatMarkdownAsync(string markdown) =>
|
async ValueTask<IEncodedContent> FormatMarkdownAsync(string markdown) =>
|
||||||
Context.Request.ShouldFormatMarkdown
|
Context.Request.ShouldFormatMarkdown
|
||||||
? await HtmlMarkdownVisitor.FormatAsync(Context, markdown, true, CancellationToken)
|
? Html.Raw(await HtmlMarkdownVisitor.FormatAsync(Context, markdown, true, CancellationToken))
|
||||||
: markdown;
|
: Html.Raw(Html.Encode(markdown));
|
||||||
|
|
||||||
async ValueTask<string> FormatEmbedMarkdownAsync(string markdown) =>
|
async ValueTask<IEncodedContent> FormatEmbedMarkdownAsync(string markdown) =>
|
||||||
Context.Request.ShouldFormatMarkdown
|
Context.Request.ShouldFormatMarkdown
|
||||||
? await HtmlMarkdownVisitor.FormatAsync(Context, markdown, false, CancellationToken)
|
? Html.Raw(await HtmlMarkdownVisitor.FormatAsync(Context, markdown, false, CancellationToken))
|
||||||
: markdown;
|
: Html.Raw(Html.Encode(markdown));
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="chatlog__message-group">
|
<div class="chatlog__message-group">
|
||||||
@@ -179,7 +180,7 @@
|
|||||||
<span class="chatlog__reply-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
|
<span class="chatlog__reply-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
|
||||||
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden())
|
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden())
|
||||||
{
|
{
|
||||||
<!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(message.ReferencedMessage.Content))<!--/wmm:ignore-->
|
<!--wmm:ignore-->@(await FormatEmbedMarkdownAsync(message.ReferencedMessage.Content))<!--/wmm:ignore-->
|
||||||
}
|
}
|
||||||
else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any())
|
else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any())
|
||||||
{
|
{
|
||||||
@@ -252,7 +253,7 @@
|
|||||||
@* Text *@
|
@* Text *@
|
||||||
@if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden())
|
@if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden())
|
||||||
{
|
{
|
||||||
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.Content))<!--/wmm:ignore--></span>
|
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@(await FormatMarkdownAsync(message.Content))<!--/wmm:ignore--></span>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* Edited timestamp *@
|
@* Edited timestamp *@
|
||||||
@@ -278,7 +279,7 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(message.ForwardedMessage.Content))
|
@if (!string.IsNullOrWhiteSpace(message.ForwardedMessage.Content))
|
||||||
{
|
{
|
||||||
<div class="chatlog__forwarded-content chatlog__markdown">
|
<div class="chatlog__forwarded-content chatlog__markdown">
|
||||||
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.ForwardedMessage.Content))<!--/wmm:ignore--></span>
|
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@(await FormatMarkdownAsync(message.ForwardedMessage.Content))<!--/wmm:ignore--></span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,12 +505,12 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||||
{
|
{
|
||||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -543,7 +544,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
// Generic video embed
|
// Generic video embed
|
||||||
else if (embed.Kind == EmbedKind.Video
|
else if (embed.Kind == EmbedKind.Video
|
||||||
&& !string.IsNullOrWhiteSpace(embed.Url)
|
&& !string.IsNullOrWhiteSpace(embed.Url)
|
||||||
// Twitch clips cannot be embedded in local HTML files
|
// Twitch clips cannot be embedded in local HTML files
|
||||||
&& embed.TryGetTwitchClip() is null)
|
&& embed.TryGetTwitchClip() is null)
|
||||||
@@ -624,12 +625,12 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
@if (!string.IsNullOrWhiteSpace(embed.Url))
|
||||||
{
|
{
|
||||||
<a class="chatlog__embed-title-link" href="@embed.Url">
|
<a class="chatlog__embed-title-link" href="@embed.Url">
|
||||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -638,7 +639,7 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
@if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-description">
|
<div class="chatlog__embed-description">
|
||||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Description))<!--/wmm:ignore--></div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@(await FormatEmbedMarkdownAsync(embed.Description))<!--/wmm:ignore--></div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,14 +653,14 @@
|
|||||||
@if (!string.IsNullOrWhiteSpace(field.Name))
|
@if (!string.IsNullOrWhiteSpace(field.Name))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-field-name">
|
<div class="chatlog__embed-field-name">
|
||||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(field.Name))<!--/wmm:ignore--></div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@(await FormatEmbedMarkdownAsync(field.Name))<!--/wmm:ignore--></div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(field.Value))
|
@if (!string.IsNullOrWhiteSpace(field.Value))
|
||||||
{
|
{
|
||||||
<div class="chatlog__embed-field-value">
|
<div class="chatlog__embed-field-value">
|
||||||
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(field.Value))<!--/wmm:ignore--></div>
|
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@(await FormatEmbedMarkdownAsync(field.Value))<!--/wmm:ignore--></div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@using System
|
@using System
|
||||||
@using System.Threading.Tasks
|
@using System.Threading.Tasks
|
||||||
|
@using RazorBlade
|
||||||
|
|
||||||
@inherits RazorBlade.HtmlTemplate
|
@inherits RazorBlade.HtmlTemplate
|
||||||
|
|
||||||
@@ -24,10 +25,10 @@
|
|||||||
string FormatDate(DateTimeOffset instant, string format = "g") =>
|
string FormatDate(DateTimeOffset instant, string format = "g") =>
|
||||||
Context.FormatDate(instant, format);
|
Context.FormatDate(instant, format);
|
||||||
|
|
||||||
async ValueTask<string> FormatMarkdownAsync(string markdown) =>
|
async ValueTask<IEncodedContent> FormatMarkdownAsync(string markdown) =>
|
||||||
Context.Request.ShouldFormatMarkdown
|
Context.Request.ShouldFormatMarkdown
|
||||||
? await HtmlMarkdownVisitor.FormatAsync(Context, markdown, true, CancellationToken)
|
? Html.Raw(await HtmlMarkdownVisitor.FormatAsync(Context, markdown, true, CancellationToken))
|
||||||
: markdown;
|
: Html.Raw(Html.Encode(markdown));
|
||||||
}
|
}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -748,7 +749,7 @@
|
|||||||
.chatlog__embed-spotify {
|
.chatlog__embed-spotify {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-twitch {
|
.chatlog__embed-twitch {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
@@ -1063,7 +1064,7 @@
|
|||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
|
@if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
|
||||||
{
|
{
|
||||||
<div class="preamble__entry preamble__entry--small">@Html.Raw(await FormatMarkdownAsync(Context.Request.Channel.Topic))</div>
|
<div class="preamble__entry preamble__entry--small">@(await FormatMarkdownAsync(Context.Request.Channel.Topic))</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (Context.Request.After is not null || Context.Request.Before is not null)
|
@if (Context.Request.After is not null || Context.Request.Before is not null)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
<PackageReference Include="Cogwheel" />
|
<PackageReference Include="Cogwheel" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
<PackageReference Include="Deorcify" />
|
||||||
<PackageReference Include="DialogHost.Avalonia" />
|
<PackageReference Include="DialogHost.Avalonia" />
|
||||||
<PackageReference Include="Gress" />
|
<PackageReference Include="Gress" />
|
||||||
<PackageReference Include="Markdig" />
|
<PackageReference Include="Markdig" />
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<PackageReference Include="Material.Icons.Avalonia" />
|
<PackageReference Include="Material.Icons.Avalonia" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
<PackageReference Include="Onova" />
|
<PackageReference Include="Onova" />
|
||||||
<PackageReference Include="PowerKit" PrivateAssets="all" />
|
<PackageReference Include="PowerKit" />
|
||||||
<PackageReference Include="ThisAssembly.Project" PrivateAssets="all" />
|
<PackageReference Include="ThisAssembly.Project" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user