Compare commits

...

32 Commits

Author SHA1 Message Date
Tyrrrz 38be44debb Update version 2022-10-21 19:21:57 +03:00
Tyrrrz 4b243f2252 Add related projects to readme 2022-10-21 19:18:12 +03:00
Oleksii Holub a31175b2e8 Update the dockerfile 2022-10-16 16:34:31 +03:00
BamButz 913255f04f Modify dockerfile to use non-root user (#952) 2022-10-16 16:27:21 +03:00
Tyrrrz 873d055191 Update NuGet packages 2022-10-12 23:01:38 +03:00
Tyrrrz dabed24c16 Lock the background UI when a dialog is open 2022-10-10 01:03:37 +03:00
Tyrrrz fdf421df3c Update version 2022-10-08 15:38:07 +03:00
Tyrrrz 2259f19c44 Remove message content intent check due to false positives 2022-10-08 15:35:06 +03:00
Tyrrrz 8695942328 Enforce a higher version of .NET runtime
Closes #941
2022-10-02 13:42:46 +03:00
Tyrrrz 84e656ccfc Update version 2022-09-24 20:40:51 +03:00
Tyrrrz 2ba0c3b38e More refactoring around system notifications 2022-09-24 19:54:42 +03:00
Tyrrrz 7467f0aeb6 Don't fail on unknown embed kinds
Closes #936
2022-09-19 23:55:17 +03:00
Tyrrrz e38479d463 Make lowercasing in system notifications a bit safer
Closes #935
2022-09-19 16:30:19 +03:00
Tyrrrz 06a4b6a8e6 Add checks for message content intent on bot accounts
Related to #918
2022-09-16 23:04:18 +03:00
Tyrrrz cc1ad8b435 Retry on SSL exceptions 2022-09-16 22:47:02 +03:00
Tyrrrz f8dac2c9d0 Simplify token kind resolution
Extra request which makes it less efficient, but much simpler code
2022-09-16 22:46:55 +03:00
Tyrrrz b8cea0d140 Unlock dotnet version on CI 2022-09-16 18:01:53 +03:00
Tyrrrz 7996a38c5c Update version 2022-09-16 17:22:53 +03:00
Tyrrrz 9d052f9404 Improve wording in CLI exception when output path is ambiguous
Related to #929
2022-09-14 21:59:22 +03:00
Tyrrrz 3078351670 Add fallback content for thread start messages 2022-09-14 20:17:04 +03:00
Tyrrrz 4f41c4fff3 Fix styles for system notifications in HTML 2022-09-14 20:14:30 +03:00
Tyrrrz a80ee2943f Refactor 2022-09-14 02:06:11 +03:00
gan-of-culture 8612d2c84a Add HTML formatting for system messages (#926) 2022-09-13 23:51:10 +03:00
Tyrrrz 57c849d0f8 More refactoring 2022-09-13 23:27:54 +03:00
Tyrrrz 9c2a26aa82 Fix tests 2022-09-13 18:32:31 +03:00
Tyrrrz f376028d76 Add tests for gifv embeds 2022-09-13 18:09:56 +03:00
Tyrrrz da87f3c774 Hide message content if it only contains a link to a GIFV 2022-09-13 18:05:41 +03:00
Tyrrrz 61516feafa Refactor 2022-09-13 17:33:28 +03:00
gan-of-culture 8d209cd67a Improve GIF playback (#927) 2022-09-13 16:08:57 +03:00
Tyrrrz c58d7e752b Update readme 2022-09-12 16:13:28 +03:00
Ivv fcc1605455 Mention nix package in readme (#925) 2022-09-12 15:59:15 +03:00
gan-of-culture f28d662a71 Support GIF embeds when exporting to HTML (#919) 2022-09-10 23:48:52 +03:00
30 changed files with 769 additions and 508 deletions
+2 -4
View File
@@ -18,8 +18,7 @@ jobs:
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@v2 uses: actions/setup-dotnet@v2
with: with:
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176 dotnet-version: 6.0.x
dotnet-version: 6.0.100
- name: Run tests - name: Run tests
# Tests need access to secrets, so we can't run them against PRs because of limited trust # Tests need access to secrets, so we can't run them against PRs because of limited trust
@@ -50,8 +49,7 @@ jobs:
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@v2 uses: actions/setup-dotnet@v2
with: with:
# Fixed version, workaround for https://github.com/dotnet/core/issues/7176 dotnet-version: 6.0.x
dotnet-version: 6.0.100
- name: Publish (CLI) - name: Publish (CLI)
run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release run: dotnet publish DiscordChatExporter.Cli/ -o DiscordChatExporter.Cli/bin/Publish/ --configuration Release
+1
View File
@@ -5,6 +5,7 @@
*.sln.docstates *.sln.docstates
.idea/ .idea/
.vs/ .vs/
.vscode/
# Build results # Build results
[Dd]ebug/ [Dd]ebug/
+23
View File
@@ -1,3 +1,26 @@
### v2.36.3 (21-Oct-2022)
- [GUI] Fixed an issue where opening a dialog did not prevent user interactions with background UI elements using keyboard. This sometimes caused the application to crash in weird ways.
- [Docker] Fixed an issue where DiscordChatExporter was running as root inside the container, making the exported files inaccessible to the host. (Thanks [@Benjamin Just](https://github.com/BamButz))
### v2.36.2 (08-Oct-2022)
- Removed the message content intent check because the heuristics turned out to be not reliable enough.
- Fixed an issue where the runtime manifest included the wrong target runtime version. This resulted in the application failing to launch for some users when upgrading from earlier versions.
### v2.36.1 (24-Sep-2022)
- Added a check which will trigger an error if the provided bot account does not have the message content intent enabled. Note, however, that this check is based on heuristics which may result in false negatives.
- Fixed an issue where certain transient HTTP errors were not retried.
- Fixed an issue which caused the export process to fail with the `IndexOutOfRangeException` exception on certain automated messages.
- Fixed an issue which caused the export process to fail on unrecognized embed types.
### v2.36 (16-Sep-2022)
- [HTML] Added support for rendering GIFV embeds. They will now render as videos that automatically play when you hover your mouse over them. (Thanks [@gan-of-culture](https://github.com/gan-of-culture))
- [HTML] Added support for rendering system notification messages, such as when a user joins a server or when a messages gets pinned. Previously, such messages were rendered as regular text messages, but now they look similar to how they're presented in the Discord client. (Thanks [@gan-of-culture](https://github.com/gan-of-culture))
- [CLI] Improved the wording of the error message that's shown when exporting multiple channels and the provided output path is ambiguous. It now mentions that you can resolve the ambiguity by adding a slash at the end of the path.
### v2.35.2 (08-Sep-2022) ### v2.35.2 (08-Sep-2022)
- Updated usage guide to mention the need to enable "Message Content Intent" to use a bot as a medium for exporting messages. - Updated usage guide to mention the need to enable "Message Content Intent" to use a bot as a medium for exporting messages.
+9 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<Version>2.35.2</Version> <Version>2.36.3</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright> <Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
@@ -10,4 +10,12 @@
<WarningsAsErrors>nullable</WarningsAsErrors> <WarningsAsErrors>nullable</WarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<!--
Even though the project builds against .NET 6, some dependencies
apparently rely on a specific version of the runtime.
-->
<PropertyGroup>
<RuntimeFrameworkVersion>6.0.9</RuntimeFrameworkVersion>
</PropertyGroup>
</Project> </Project>
@@ -15,7 +15,7 @@
<PackageReference Include="FluentAssertions" Version="6.7.0" /> <PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.0.1" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="2.0.1" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="System.Reactive" Version="5.0.0" /> <PackageReference Include="System.Reactive" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" />
@@ -54,8 +54,9 @@ public class EmbedSpecs : IClassFixture<ExportWrapperFixture>
message message
.QuerySelectorAll("img") .QuerySelectorAll("img")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.Where(s => s?.EndsWith("i.redd.it/f8w05ja8s4e61.png") ?? false)
.Should() .Should()
.Contain("https://i.redd.it/f8w05ja8s4e61.png"); .ContainSingle();
} }
[Fact] [Fact]
@@ -74,6 +75,38 @@ public class EmbedSpecs : IClassFixture<ExportWrapperFixture>
content.Should().BeNullOrEmpty(); content.Should().BeNullOrEmpty();
} }
[Fact]
public async Task Message_containing_a_gifv_link_is_rendered_with_a_video_embed()
{
// Act
var message = await _exportWrapper.GetMessageAsHtmlAsync(
ChannelIds.EmbedTestCases,
Snowflake.Parse("1019234520349814794")
);
// Assert
message
.QuerySelectorAll("source")
.Select(e => e.GetAttribute("src"))
.Where(s => s?.EndsWith("media.tenor.com/DDAJeW6BQKkAAAPo/tooncasm-test-copy.mp4") ?? false)
.Should()
.ContainSingle();
}
[Fact]
public async Task Message_containing_a_gifv_link_and_nothing_else_is_rendered_without_text_content()
{
// Act
var message = await _exportWrapper.GetMessageAsHtmlAsync(
ChannelIds.EmbedTestCases,
Snowflake.Parse("1019234520349814794")
);
// Assert
var content = message.QuerySelector(".chatlog__content")?.Text();
content.Should().BeNullOrEmpty();
}
[Fact] [Fact]
public async Task Message_containing_a_Spotify_track_link_is_rendered_with_a_track_embed() public async Task Message_containing_a_Spotify_track_link_is_rendered_with_a_track_embed()
{ {
+6 -2
View File
@@ -7,12 +7,16 @@ COPY Directory.Build.props ./
COPY DiscordChatExporter.Core ./DiscordChatExporter.Core COPY DiscordChatExporter.Core ./DiscordChatExporter.Core
COPY DiscordChatExporter.Cli ./DiscordChatExporter.Cli COPY DiscordChatExporter.Cli ./DiscordChatExporter.Cli
RUN dotnet publish DiscordChatExporter.Cli -c Release -o ./publish RUN dotnet publish DiscordChatExporter.Cli --configuration Release --output ./publish
# Run # Run
FROM mcr.microsoft.com/dotnet/runtime:6.0 AS run FROM mcr.microsoft.com/dotnet/runtime:6.0 AS run
RUN useradd dce
USER dce
COPY --from=build ./publish ./ COPY --from=build ./publish ./
WORKDIR ./out WORKDIR ./out
ENTRYPOINT ["dotnet", "/DiscordChatExporter.Cli.dll"]
ENTRYPOINT ["dotnet", "../DiscordChatExporter.Cli.dll"]
@@ -121,7 +121,8 @@ public abstract class ExportCommandBase : TokenCommandBase
if (!isValidOutputPath) if (!isValidOutputPath)
{ {
throw new CommandException( throw new CommandException(
"Attempted to export multiple channels, but the output path is neither a directory nor a template." "Attempted to export multiple channels, but the output path is neither a directory nor a template. " +
"If the provided output path is meant to be treated as a directory, make sure it ends with a slash."
); );
} }
@@ -7,7 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="2.3.0" /> <PackageReference Include="CliFx" Version="2.3.0" />
<PackageReference Include="Spectre.Console" Version="0.44.0" /> <PackageReference Include="Spectre.Console" Version="0.45.0" />
<PackageReference Include="Gress" Version="2.0.1" /> <PackageReference Include="Gress" Version="2.0.1" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" /> <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
@@ -11,6 +11,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object // https://discord.com/developers/docs/resources/channel#embed-object
public partial record Embed( public partial record Embed(
string? Title, string? Title,
EmbedKind Kind,
string? Url, string? Url,
DateTimeOffset? Timestamp, DateTimeOffset? Timestamp,
Color? Color, Color? Color,
@@ -19,11 +20,9 @@ public partial record Embed(
IReadOnlyList<EmbedField> Fields, IReadOnlyList<EmbedField> Fields,
EmbedImage? Thumbnail, EmbedImage? Thumbnail,
IReadOnlyList<EmbedImage> Images, IReadOnlyList<EmbedImage> Images,
EmbedVideo? Video,
EmbedFooter? Footer) EmbedFooter? Footer)
{ {
public PlainImageEmbedProjection? TryGetPlainImage() =>
PlainImageEmbedProjection.TryResolve(this);
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() => public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() =>
SpotifyTrackEmbedProjection.TryResolve(this); SpotifyTrackEmbedProjection.TryResolve(this);
@@ -36,9 +35,20 @@ public partial record Embed
public static Embed Parse(JsonElement json) public static Embed Parse(JsonElement json)
{ {
var title = json.GetPropertyOrNull("title")?.GetStringOrNull(); var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
var kind =
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>() ??
EmbedKind.Rich;
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset(); var timestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset();
var color = json.GetPropertyOrNull("color")?.GetInt32OrNull()?.Pipe(System.Drawing.Color.FromArgb).ResetAlpha();
var color = json
.GetPropertyOrNull("color")?
.GetInt32OrNull()?
.Pipe(System.Drawing.Color.FromArgb)
.ResetAlpha();
var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse); var author = json.GetPropertyOrNull("author")?.Pipe(EmbedAuthor.Parse);
var description = json.GetPropertyOrNull("description")?.GetStringOrNull(); var description = json.GetPropertyOrNull("description")?.GetStringOrNull();
@@ -59,10 +69,13 @@ public partial record Embed
json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).Enumerate().ToArray() ?? json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).Enumerate().ToArray() ??
Array.Empty<EmbedImage>(); Array.Empty<EmbedImage>();
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse); var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse);
return new Embed( return new Embed(
title, title,
kind,
url, url,
timestamp, timestamp,
color, color,
@@ -71,6 +84,7 @@ public partial record Embed
fields, fields,
thumbnail, thumbnail,
images, images,
video,
footer footer
); );
} }
@@ -0,0 +1,11 @@
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object-embed-types
public enum EmbedKind
{
Rich,
Image,
Video,
Gifv,
Link
}
@@ -0,0 +1,22 @@
using JsonExtensions.Reading;
using System.Text.Json;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://discord.com/developers/docs/resources/channel#embed-object-embed-video-structure
public record EmbedVideo(
string? Url,
string? ProxyUrl,
int? Width,
int? Height)
{
public static EmbedVideo Parse(JsonElement json)
{
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
var proxyUrl = json.GetPropertyOrNull("proxy_url")?.GetNonWhiteSpaceStringOrNull();
var width = json.GetPropertyOrNull("width")?.GetInt32OrNull();
var height = json.GetPropertyOrNull("height")?.GetInt32OrNull();
return new EmbedVideo(url, proxyUrl, width, height);
}
}
@@ -1,33 +0,0 @@
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
public record PlainImageEmbedProjection(string Url)
{
public static PlainImageEmbedProjection? TryResolve(Embed embed)
{
if (string.IsNullOrWhiteSpace(embed.Url))
return null;
// Has to be an embed without any data (except URL and image)
if (!string.IsNullOrWhiteSpace(embed.Title) ||
embed.Timestamp is not null ||
embed.Author is not null ||
!string.IsNullOrWhiteSpace(embed.Description) ||
embed.Fields.Any() ||
embed.Footer is not null)
{
return null;
}
// Has to be an image file
var fileName = Regex.Match(embed.Url, @".+/([^?]*)").Groups[1].Value;
if (string.IsNullOrWhiteSpace(fileName) || !FileFormat.IsImage(Path.GetExtension(fileName)))
return null;
return new PlainImageEmbedProjection(embed.Url);
}
}
@@ -21,6 +21,9 @@ public partial record SpotifyTrackEmbedProjection
public static SpotifyTrackEmbedProjection? TryResolve(Embed embed) public static SpotifyTrackEmbedProjection? TryResolve(Embed embed)
{ {
if (embed.Kind != EmbedKind.Link)
return null;
if (string.IsNullOrWhiteSpace(embed.Url)) if (string.IsNullOrWhiteSpace(embed.Url))
return null; return null;
@@ -42,6 +42,9 @@ public partial record YouTubeVideoEmbedProjection
public static YouTubeVideoEmbedProjection? TryResolve(Embed embed) public static YouTubeVideoEmbedProjection? TryResolve(Embed embed)
{ {
if (embed.Kind != EmbedKind.Video)
return null;
if (string.IsNullOrWhiteSpace(embed.Url)) if (string.IsNullOrWhiteSpace(embed.Url))
return null; return null;
@@ -44,7 +44,7 @@ public record Message(
{ {
var embed = embeds[i]; var embed = embeds[i];
if (embed.Url?.Contains("://twitter.com/") == true) if (embed.Url?.Contains("://twitter.com/", StringComparison.OrdinalIgnoreCase) == true)
{ {
// Find embeds with the same URL that only contain a single image and nothing else // Find embeds with the same URL that only contain a single image and nothing else
var trailingEmbeds = embeds var trailingEmbeds = embeds
@@ -107,6 +107,7 @@ public record Message(
MessageKind.ChannelNameChange => "Changed the channel name.", MessageKind.ChannelNameChange => "Changed the channel name.",
MessageKind.ChannelIconChange => "Changed the channel icon.", MessageKind.ChannelIconChange => "Changed the channel icon.",
MessageKind.ChannelPinnedMessage => "Pinned a message.", MessageKind.ChannelPinnedMessage => "Pinned a message.",
MessageKind.ThreadCreated => "Started a thread.",
MessageKind.GuildMemberJoin => "Joined the server.", MessageKind.GuildMemberJoin => "Joined the server.",
_ => json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "" _ => json.GetPropertyOrNull("content")?.GetStringOrNull() ?? ""
}; };
@@ -11,5 +11,11 @@ public enum MessageKind
ChannelIconChange = 5, ChannelIconChange = 5,
ChannelPinnedMessage = 6, ChannelPinnedMessage = 6,
GuildMemberJoin = 7, GuildMemberJoin = 7,
ThreadCreated = 18,
Reply = 19 Reply = 19
} }
public static class MessageKindExtensions
{
public static bool IsSystemNotification(this MessageKind c) => (int)c is >= 1 and <= 18;
}
@@ -22,14 +22,16 @@ public class DiscordClient
private readonly string _token; private readonly string _token;
private readonly Uri _baseUri = new("https://discord.com/api/v9/", UriKind.Absolute); private readonly Uri _baseUri = new("https://discord.com/api/v9/", UriKind.Absolute);
private TokenKind _tokenKind = TokenKind.Unknown; private TokenKind? _resolvedTokenKind;
public DiscordClient(string token) => _token = token; public DiscordClient(string token) => _token = token;
private async ValueTask<HttpResponseMessage> GetResponseAsync( private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url, string url,
bool isBot, TokenKind tokenKind,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken =>
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url)); using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
@@ -37,50 +39,50 @@ public class DiscordClient
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828 // https://github.com/Tyrrrz/DiscordChatExporter/issues/828
request.Headers.TryAddWithoutValidation( request.Headers.TryAddWithoutValidation(
"Authorization", "Authorization",
isBot ? $"Bot {_token}" : _token tokenKind == TokenKind.Bot
? $"Bot {_token}"
: _token
); );
return await Http.Client.SendAsync( return await Http.Client.SendAsync(
request, request,
HttpCompletionOption.ResponseHeadersRead, HttpCompletionOption.ResponseHeadersRead,
innerCancellationToken
);
}, cancellationToken);
}
private async ValueTask<TokenKind> GetTokenKindAsync(CancellationToken cancellationToken = default)
{
// Try authenticating as a user
using var userResponse = await GetResponseAsync(
"users/@me",
TokenKind.User,
cancellationToken cancellationToken
); );
if (userResponse.StatusCode != HttpStatusCode.Unauthorized)
return TokenKind.User;
// Try authenticating as a bot
using var botResponse = await GetResponseAsync(
"users/@me",
TokenKind.Bot,
cancellationToken
);
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
return TokenKind.Bot;
throw DiscordChatExporterException.Unauthorized();
} }
private async ValueTask<HttpResponseMessage> GetResponseAsync( private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url, string url,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
return await Http.ResponseResiliencePolicy.ExecuteAsync(async innerCancellationToken => var tokenKind = _resolvedTokenKind ??= await GetTokenKindAsync(cancellationToken);
{ return await GetResponseAsync(url, tokenKind, cancellationToken);
if (_tokenKind == TokenKind.User)
return await GetResponseAsync(url, false, innerCancellationToken);
if (_tokenKind == TokenKind.Bot)
return await GetResponseAsync(url, true, innerCancellationToken);
// Try to authenticate as user
var userResponse = await GetResponseAsync(url, false, innerCancellationToken);
if (userResponse.StatusCode != HttpStatusCode.Unauthorized)
{
_tokenKind = TokenKind.User;
return userResponse;
}
userResponse.Dispose();
// Otherwise, try to authenticate as bot
var botResponse = await GetResponseAsync(url, true, innerCancellationToken);
if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
{
_tokenKind = TokenKind.Bot;
return botResponse;
}
// The token is probably invalid altogether.
// Return the last response anyway, upstream should handle the error.
return botResponse;
}, cancellationToken);
} }
private async ValueTask<JsonElement> GetJsonResponseAsync( private async ValueTask<JsonElement> GetJsonResponseAsync(
@@ -2,7 +2,6 @@
public enum TokenKind public enum TokenKind
{ {
Unknown,
User, User,
Bot Bot
} }
@@ -7,7 +7,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Gress" Version="2.0.1" /> <PackageReference Include="Gress" Version="2.0.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="MiniRazor.CodeGen" Version="2.2.1" /> <PackageReference Include="MiniRazor.CodeGen" Version="2.2.2" />
<PackageReference Include="Polly" Version="7.2.3" /> <PackageReference Include="Polly" Version="7.2.3" />
<PackageReference Include="Superpower" Version="3.0.0" /> <PackageReference Include="Superpower" Version="3.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.12.0" /> <PackageReference Include="WebMarkupMin.Core" Version="2.12.0" />
@@ -42,7 +42,7 @@ internal class ExportContext
{ {
"unix" => date.ToUnixTimeSeconds().ToString(), "unix" => date.ToUnixTimeSeconds().ToString(),
"unixms" => date.ToUnixTimeMilliseconds().ToString(), "unixms" => date.ToUnixTimeMilliseconds().ToString(),
var dateFormat => date.ToLocalString(dateFormat) var format => date.ToLocalString(format)
}; };
public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id); public Member? TryGetMember(Snowflake id) => Members.FirstOrDefault(m => m.Id == id);
@@ -0,0 +1,22 @@
using System;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds;
namespace DiscordChatExporter.Core.Exporting.Writers.Html;
internal static class MessageExtensions
{
// Message content is hidden if it's a link to an embedded media
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
public static bool IsContentHidden(this Message message)
{
if (message.Embeds.Count != 1)
return false;
var embed = message.Embeds[0];
return
string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase) &&
embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
}
}
@@ -10,8 +10,6 @@
@inherits MiniRazor.TemplateBase<MessageGroupTemplateContext> @inherits MiniRazor.TemplateBase<MessageGroupTemplateContext>
@{ @{
var firstMessage = Model.Messages.First();
ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url); ValueTask<string> ResolveUrlAsync(string url) => Model.ExportContext.ResolveMediaUrlAsync(url);
string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date); string FormatDate(DateTimeOffset date) => Model.ExportContext.FormatDate(date);
@@ -20,6 +18,8 @@
string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false); string FormatEmbedMarkdown(string markdown) => Model.FormatMarkdown(markdown, false);
var firstMessage = Model.Messages.First();
var userMember = Model.ExportContext.TryGetMember(firstMessage.Author.Id); var userMember = Model.ExportContext.TryGetMember(firstMessage.Author.Id);
var userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id); var userColor = Model.ExportContext.TryGetUserColor(firstMessage.Author.Id);
@@ -48,19 +48,50 @@
{ {
var isFirst = i == 0; var isFirst = i == 0;
// Hide message content if it only contains a link to an image which is embedded, and nothing else
var isContentHidden =
message.Embeds.Count == 1 &&
message.Content.Trim() == PlainImageEmbedProjection.TryResolve(message.Embeds.Single())?.Url;
var isReferencedContentHidden =
message.ReferencedMessage is not null &&
message.ReferencedMessage.Embeds.Count == 1 &&
message.ReferencedMessage.Content.Trim() == PlainImageEmbedProjection.TryResolve(message.ReferencedMessage.Embeds.Single())?.Url;
<div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id"> <div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
<div class="chatlog__message"> <div class="chatlog__message">
@{/* Left side */} @{/* System notification */}
@if (message.Kind.IsSystemNotification())
{
// System notifications are grouped even if the message author is different.
// That's why we have to update the user values with the author of the current message.
userMember = Model.ExportContext.TryGetMember(message.Author.Id);
userColor = Model.ExportContext.TryGetUserColor(message.Author.Id);
userNick = message.Author.IsBot
? message.Author.Name
: userMember?.Nick ?? message.Author.Name;
<div class="chatlog__message-aside">
<svg class="chatlog__system-notification-icon"><use href="#@message.Kind.ToString().ToDashCase().ToLowerInvariant()-icon"></use></svg>
</div>
<div class="chatlog__message-primary">
@{/* Author name */}
<span class="chatlog__system-notification-author" style="@(userColor is not null ? $"color: rgb({userColor.Value.R}, {userColor.Value.G}, {userColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@userNick</span>
@{/* System notification content */}
<span class="chatlog__system-notification-content">
@if (message.Kind == MessageKind.ChannelPinnedMessage && message.Reference is not null)
{
<span> pinned</span>
<a class="chatlog__system-notification-link" href="#chatlog__message-container-@message.Reference.MessageId"> a message</a>
<span> to this channel.</span>
}
else
{
<span>@message.Content.ToLowerInvariant()</span>
}
</span>
@{/* Timestamp */}
<span class="chatlog__system-notification-timestamp">
<a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
</span>
</div>
}
// Regular message
else
{
<div class="chatlog__message-aside"> <div class="chatlog__message-aside">
@if (isFirst) @if (isFirst)
{ {
@@ -79,7 +110,6 @@
} }
</div> </div>
@{/* Right side */}
<div class="chatlog__message-primary"> <div class="chatlog__message-primary">
@if (isFirst) @if (isFirst)
{ {
@@ -93,7 +123,7 @@
<div class="chatlog__reference-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div> <div class="chatlog__reference-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserNick</div>
<div class="chatlog__reference-content"> <div class="chatlog__reference-content">
<span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')"> <span class="chatlog__reference-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !isReferencedContentHidden) @if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden())
{ {
<!--wmm:ignore-->@Raw(FormatEmbedMarkdown(message.ReferencedMessage.Content))<!--/wmm:ignore--> <!--wmm:ignore-->@Raw(FormatEmbedMarkdown(message.ReferencedMessage.Content))<!--/wmm:ignore-->
} }
@@ -140,11 +170,11 @@
} }
@{/* Content */} @{/* Content */}
@if (!string.IsNullOrWhiteSpace(message.Content) || message.EditedTimestamp is not null) @if ((!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden()) || message.EditedTimestamp is not null)
{ {
<div class="chatlog__content chatlog__markdown"> <div class="chatlog__content chatlog__markdown">
@{/* Text */} @{/* Text */}
@if (!isContentHidden) @if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden())
{ {
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatMarkdown(message.Content))<!--/wmm:ignore--></span> <span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Raw(FormatMarkdown(message.Content))<!--/wmm:ignore--></span>
} }
@@ -208,17 +238,8 @@
@{/* Embeds */} @{/* Embeds */}
@foreach (var embed in message.Embeds) @foreach (var embed in message.Embeds)
{ {
// Plain image embed
if (embed.TryGetPlainImage() is { } plainImageEmbed)
{
<div class="chatlog__embed">
<a href="@await ResolveUrlAsync(plainImageEmbed.Url)">
<img class="chatlog__embed-plainimage" src="@await ResolveUrlAsync(plainImageEmbed.Url)" alt="Embedded image" loading="lazy">
</a>
</div>
}
// Spotify embed // Spotify embed
else if (embed.TryGetSpotifyTrack() is { } spotifyTrackEmbed) if (embed.TryGetSpotifyTrack() is { } spotifyTrackEmbed)
{ {
<div class="chatlog__embed"> <div class="chatlog__embed">
<div class="chatlog__embed-spotify-container"> <div class="chatlog__embed-spotify-container">
@@ -294,7 +315,25 @@
</div> </div>
</div> </div>
} }
// Generic embed // Generic image embed
else if (embed.Kind == EmbedKind.Image && !string.IsNullOrWhiteSpace(embed.Url))
{
<div class="chatlog__embed">
<a href="@await ResolveUrlAsync(embed.Url)">
<img class="chatlog__embed-generic-image" src="@await ResolveUrlAsync(embed.Url)" alt="Embedded image" loading="lazy">
</a>
</div>
}
// Generic gifv embed
else if (embed.Kind == EmbedKind.Gifv && !string.IsNullOrWhiteSpace(embed.Video?.Url))
{
<div class="chatlog__embed">
<video class="chatlog__embed-generic-gifv" loop width="@embed.Video.Width" height="@embed.Video.Height" onmouseover="this.play()" onmouseout="this.pause()">
<source src="@await ResolveUrlAsync(embed.Video.ProxyUrl ?? embed.Video.Url)" alt="Embedded video">
</video>
</div>
}
// Rich embed
else else
{ {
<div class="chatlog__embed"> <div class="chatlog__embed">
@@ -480,6 +519,7 @@
</div> </div>
} }
</div> </div>
}
</div> </div>
</div> </div>
} }
@@ -69,6 +69,7 @@
font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif; font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 17px; font-size: 17px;
font-weight: @Themed("400", "500"); font-weight: @Themed("400", "500");
scroll-behavior: smooth;
} }
a { a {
@@ -254,6 +255,38 @@
unicode-bidi: bidi-override; unicode-bidi: bidi-override;
} }
.chatlog__system-notification-icon {
width: 18px;
height: 18px;
}
.chatlog__system-notification-author {
font-weight: @Themed("500", "600");
color: @Themed("#ffffff", "#2f3136");
}
.chatlog__system-notification-content {
color: @Themed("#96989d", "#5e6772")
}
.chatlog__system-notification-link {
font-weight: 500;
color: @Themed("#ffffff", "#2f3136");
}
.chatlog__system-notification-timestamp {
margin-left: 0.3rem;
color: @Themed("#a3a6aa", "#5e6772");
font-size: 0.75rem;
font-weight: 500;
direction: ltr;
unicode-bidi: bidi-override;
}
.chatlog__system-notification-timestamp a {
color: inherit;
}
.chatlog__header { .chatlog__header {
margin-bottom: 0.1rem; margin-bottom: 0.1rem;
} }
@@ -286,7 +319,7 @@
} }
.chatlog__timestamp a { .chatlog__timestamp a {
color: @Themed("#a3a6aa", "#5e6772"); color: inherit;
} }
.chatlog__content { .chatlog__content {
@@ -540,7 +573,14 @@
font-weight: 500; font-weight: 500;
} }
.chatlog__embed-plainimage { .chatlog__embed-generic-image {
max-width: 45vw;
max-height: 500px;
vertical-align: top;
border-radius: 3px;
}
.chatlog__embed-generic-gifv {
max-width: 45vw; max-width: 45vw;
max-height: 500px; max-height: 500px;
vertical-align: top; vertical-align: top;
@@ -779,13 +819,24 @@
</script> </script>
@{/* Icons */} @{/* Icons */}
<svg style="display: none"> <svg style="display: none" xmlns="http://www.w3.org/2000/svg">
<defs>
<symbol id="attachment-icon" viewBox="0 0 720 960"> <symbol id="attachment-icon" viewBox="0 0 720 960">
<path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z"/> <path fill="#f4f5fb" d="M50,935a25,25,0,0,1-25-25V50A25,25,0,0,1,50,25H519.6L695,201.32V910a25,25,0,0,1-25,25Z" />
<path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z"/> <path fill="#7789c4" d="M509.21,50,670,211.63V910H50V50H509.21M530,0H50A50,50,0,0,0,0,50V910a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50h0V191Z" />
<path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z"/> <path fill="#f4f5fb" d="M530,215a25,25,0,0,1-25-25V50a25,25,0,0,1,16.23-23.41L693.41,198.77A25,25,0,0,1,670,215Z" />
<path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z"/> <path fill="#7789c4" d="M530,70.71,649.29,190H530V70.71M530,0a50,50,0,0,0-50,50V190a50,50,0,0,0,50,50H670a50,50,0,0,0,50-50Z" />
</symbol> </symbol>
<symbol id="channel-pinned-message-icon" viewBox="0 0 18 18">
<path fill="#b9bbbe" d="m16.908 8.39684-8.29587-8.295827-1.18584 1.184157 1.18584 1.18584-4.14834 4.1475v.00167l-1.18583-1.18583-1.185 1.18583 3.55583 3.55502-4.740831 4.74 1.185001 1.185 4.74083-4.74 3.55581 3.555 1.185-1.185-1.185-1.185 4.1475-4.14836h.0009l1.185 1.185z" />
</symbol>
<symbol id="call-icon" viewBox="0 0 18 18">
<path fill="#3ba55c" fill-rule="evenodd" d="M17.7163041 15.36645368c-.0190957.02699568-1.9039523 2.6680735-2.9957762 2.63320406-3.0676659-.09785935-6.6733809-3.07188394-9.15694343-5.548738C3.08002193 9.9740657.09772497 6.3791404 0 3.3061316v-.024746C0 2.2060575 2.61386252.3152347 2.64082114.2972376c.7110335-.4971705 1.4917101-.3149497 1.80959713.1372281.19320342.2744561 2.19712724 3.2811005 2.42290565 3.6489167.09884826.1608492.14714912.3554431.14714912.5702838 0 .2744561-.07975258.5770327-.23701117.8751101-.1527655.2902036-.65262318 1.1664385-.89862055 1.594995.2673396.3768148.94804468 1.26429792 2.351016 2.66357424 1.39173858 1.39027775 2.28923588 2.07641807 2.67002628 2.34187563.4302146-.2452108 1.3086162-.74238132 1.5972981-.89423205.5447887-.28682915 1.0907006-.31944893 1.4568885-.08661115.3459689.2182151 3.3383754 2.21027167 3.6225641 2.41611376.2695862.19234426.4144887.5399137.4144887.91672846 0 .2969525-.089862.61190215-.2808189.88523346" />
</symbol>
<symbol id="guild-member-join-icon" viewBox="0 0 18 18">
<path fill="#3ba55c" d="m0 8h14.2l-3.6-3.6 1.4-1.4 6 6-6 6-1.4-1.4 3.6-3.6h-14.2" />
</symbol>
</defs>
</svg> </svg>
</head> </head>
<body> <body>
@@ -25,17 +25,46 @@ internal class HtmlMessageWriter : MessageWriter
_themeName = themeName; _themeName = themeName;
} }
private bool CanJoinGroup(Message message) => private bool CanJoinGroup(Message message)
// First message in the group can always join {
_messageGroup.LastOrDefault() is not { } lastMessage || // If the group is empty, any message can join it
// Must be from the same author if (_messageGroup.LastOrDefault() is not { } lastMessage)
lastMessage.Author.Id == message.Author.Id && return true;
// Author's name must not have changed between messages
string.Equals(lastMessage.Author.FullName, message.Author.FullName, StringComparison.Ordinal) && // Reply messages cannot join existing groups because they need to appear first
// Duration between messages must be 7 minutes or less if (message.Kind == MessageKind.Reply)
(message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes <= 7 && return false;
// Other message must not be a reply
message.Reference is null; // Grouping for system notifications
if (message.Kind.IsSystemNotification())
{
// Can only be grouped with other system notifications
if (!lastMessage.Kind.IsSystemNotification())
return false;
}
// Grouping for normal messages
else
{
// Can only be grouped with other normal messages
if (lastMessage.Kind.IsSystemNotification())
return false;
// Messages must be within 7 minutes of each other
if ((message.Timestamp - lastMessage.Timestamp).Duration().TotalMinutes > 7)
return false;
// Messages must be from the same author
if (message.Author.Id != lastMessage.Author.Id)
return false;
// If the user changed their name after the last message, their new messages
// cannot join an existing group.
if (!string.Equals(message.Author.FullName, lastMessage.Author.FullName, StringComparison.Ordinal))
return false;
}
return true;
}
// Use <!--wmm:ignore--> to preserve blocks of code inside the templates // Use <!--wmm:ignore--> to preserve blocks of code inside the templates
private string Minify(string html) => _minifier private string Minify(string html) => _minifier
@@ -77,7 +106,7 @@ internal class HtmlMessageWriter : MessageWriter
await base.WriteMessageAsync(message, cancellationToken); await base.WriteMessageAsync(message, cancellationToken);
// If the message can be grouped, buffer it for now // If the message can be grouped, buffer it for now
if (CanJoinGroup( message)) if (CanJoinGroup(message))
{ {
_messageGroup.Add(message); _messageGroup.Add(message);
} }
@@ -1,5 +1,7 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Core.Utils.Extensions;
@@ -25,6 +27,14 @@ public static class StringExtensions
} }
} }
public static string ToDashCase(this string str) =>
Regex.Replace(str, @"(\p{Ll})(\p{Lu})", "$1-$2");
public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true) where T : struct, Enum =>
Enum.TryParse<T>(str, ignoreCase, out var result)
? result
: null;
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) => public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
builder.Length > 0 builder.Length > 0
? builder.Append(value) ? builder.Append(value)
+2 -1
View File
@@ -3,6 +3,7 @@ using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Sockets; using System.Net.Sockets;
using System.Security.Authentication;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using Polly; using Polly;
@@ -21,7 +22,7 @@ public static class Http
private static bool IsRetryableException(Exception exception) => private static bool IsRetryableException(Exception exception) =>
exception.GetSelfAndChildren().Any(ex => exception.GetSelfAndChildren().Any(ex =>
ex is TimeoutException or SocketException || ex is TimeoutException or SocketException or AuthenticationException ||
ex is HttpRequestException hrex && IsRetryableStatusCode(hrex.TryGetStatusCode() ?? HttpStatusCode.OK) ex is HttpRequestException hrex && IsRetryableStatusCode(hrex.TryGetStatusCode() ?? HttpStatusCode.OK)
); );
@@ -14,14 +14,14 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Gress" Version="2.0.1" /> <PackageReference Include="Gress" Version="2.0.1" />
<PackageReference Include="MaterialDesignColors" Version="2.0.6" /> <PackageReference Include="MaterialDesignColors" Version="2.0.9" />
<PackageReference Include="MaterialDesignThemes" Version="4.5.0" /> <PackageReference Include="MaterialDesignThemes" Version="4.6.1" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" /> <PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
<PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" /> <PackageReference Include="Ookii.Dialogs.Wpf" Version="5.0.1" />
<PackageReference Include="Onova" Version="2.6.2" /> <PackageReference Include="Onova" Version="2.6.2" />
<PackageReference Include="Stylet" Version="1.3.6" /> <PackageReference Include="Stylet" Version="1.3.6" />
<PackageReference Include="Tyrrrz.Settings" Version="1.3.4" /> <PackageReference Include="Tyrrrz.Settings" Version="1.3.4" />
<PackageReference Include="PropertyChanged.Fody" Version="3.4.1" PrivateAssets="all" /> <PackageReference Include="PropertyChanged.Fody" Version="4.0.4" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" /> <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.3.1" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
+3 -1
View File
@@ -7,6 +7,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet" xmlns:s="https://github.com/canton7/Stylet"
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels" xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
Width="600" Width="600"
Height="550" Height="550"
MinWidth="325" MinWidth="325"
@@ -21,10 +22,11 @@
</Window.TaskbarItemInfo> </Window.TaskbarItemInfo>
<materialDesign:DialogHost <materialDesign:DialogHost
x:Name="DialogHost"
Loaded="{s:Action OnViewFullyLoaded}" Loaded="{s:Action OnViewFullyLoaded}"
SnackbarMessageQueue="{Binding Notifications}" SnackbarMessageQueue="{Binding Notifications}"
Style="{DynamicResource MaterialDesignEmbeddedDialogHost}"> Style="{DynamicResource MaterialDesignEmbeddedDialogHost}">
<Grid> <Grid IsEnabled="{Binding IsOpen, ElementName=DialogHost, Converter={x:Static converters:InverseBoolConverter.Instance}}">
<ContentControl s:View.Model="{Binding Dashboard}" /> <ContentControl s:View.Model="{Binding Dashboard}" />
<materialDesign:Snackbar MessageQueue="{Binding Notifications}" /> <materialDesign:Snackbar MessageQueue="{Binding Notifications}" />
</Grid> </Grid>
+13 -3
View File
@@ -65,6 +65,7 @@ The following table lists all available download options:
<li>🟠 <a href="https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml">CI build</a> (<code>DiscordChatExporter.CLI.zip</code>)</li> <li>🟠 <a href="https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml">CI build</a> (<code>DiscordChatExporter.CLI.zip</code>)</li>
<li>🐋 <a href="https://hub.docker.com/r/tyrrrz/discordchatexporter">Docker</a> (<code>tyrrrz/discordchatexporter</code>)</li> <li>🐋 <a href="https://hub.docker.com/r/tyrrrz/discordchatexporter">Docker</a> (<code>tyrrrz/discordchatexporter</code>)</li>
<li>📦 <a href="https://aur.archlinux.org/packages/discord-chat-exporter-cli">AUR</a> (<code>discord-chat-exporter-cli</code>)</li> <li>📦 <a href="https://aur.archlinux.org/packages/discord-chat-exporter-cli">AUR</a> (<code>discord-chat-exporter-cli</code>)</li>
<li>📦 <a href="https://search.nixos.org/packages?query=discordchatexporter-cli">Nix</a> (<code>discordchatexporter-cli</code>)</li>
</ul> </ul>
</td> </td>
<td> <td>
@@ -79,15 +80,19 @@ The following table lists all available download options:
</table> </table>
> **Note**: > **Note**:
> **DiscordChatExporter**'s AUR package is maintained by the community. > AUR and Nix packages linked above are maintained by the community.
> If you have any issues with them, please contact the corresponding maintainers.
> **Warning**: > **Warning**:
> To run **DiscordChatExporter** on macOS or Linux, you will need to additionally install **.NET Runtime v6**: > To run **DiscordChatExporter** on macOS and Linux, you need to make sure that **.NET Runtime v6** is installed.
> You can download it here:
>
> - [.NET Runtime v6 for **macOS x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-6.0.6-macos-x64-installer) > - [.NET Runtime v6 for **macOS x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-6.0.6-macos-x64-installer)
> - [.NET Runtime v6 for **macOS Arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-6.0.6-macos-arm64-installer) > - [.NET Runtime v6 for **macOS Arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-6.0.6-macos-arm64-installer)
> - [.NET Runtime v6 for **Linux**](https://docs.microsoft.com/en-us/dotnet/core/install/linux) (find the correct download for your distro) > - [.NET Runtime v6 for **Linux**](https://docs.microsoft.com/en-us/dotnet/core/install/linux) (find the correct download for your distro)
> >
> This is not required if you installed **DiscordChatExporter** using a package manager, or if you plan to run **DiscordChatExporter** via Docker. > This should not be necessary if you install **DiscordChatExporter** using a package manager, as it should take care of the dependencies for you.
> This is also not necessary if you are running **DiscordChatExporter** via Docker, because the image already contains the runtime.
## Features ## Features
@@ -103,3 +108,8 @@ The following table lists all available download options:
![channel list](.assets/list.png) ![channel list](.assets/list.png)
![rendered output](.assets/output.png) ![rendered output](.assets/output.png)
## Related projects
- [**Chat Analytics**](https://github.com/mlomb/chat-analytics) — solution for analyzing chat patterns of Discord users, using exports produced by **DiscordChatExporter**.
- [**DiscordChatExporter-frontend**](https://github.com/slatinsky/DiscordChatExporter-frontend) — convenient viewer for exports produced by **DiscordChatExporter**.