Compare commits

...

24 Commits

Author SHA1 Message Date
Tyrrrz 088612a78f Update version 2023-07-29 00:58:32 +03:00
Tyrrrz de531d4a97 Fix category not working in GUI
Closes #1112
2023-07-29 00:57:45 +03:00
Tyrrrz 5fc2fae28a Update version 2023-07-27 23:59:17 +03:00
Tyrrrz ddfbe51cfa Improve error reporting on unexpected HTTP status code 2023-07-25 18:59:05 +03:00
Tyrrrz bcf652edbe Don't throw when parent channel cannot be retrieved
Closes #1108
2023-07-23 16:04:53 +03:00
Tyrrrz 1f5407f032 Update version 2023-07-21 02:03:47 +03:00
Tyrrrz 83a63c2ed7 Update NuGet packages 2023-07-21 02:03:32 +03:00
Tyrrrz f33a0ac0a7 Update Onova 2023-07-18 22:12:26 +03:00
Tyrrrz 779b8984d7 Update readme 2023-07-17 00:47:30 +03:00
Tyrrrz 3e44a2e88d Re-add IHasId to Channel 2023-07-16 23:12:57 +03:00
Tyrrrz b44e6087be Update NuGet packages 2023-07-16 23:09:31 +03:00
Tyrrrz 49b96087d3 Clean up 2023-07-16 23:05:53 +03:00
Tyrrrz e175c93038 Refactor 2023-07-16 22:55:36 +03:00
Tyrrrz 0ee1107638 Make password box un-editable 2023-07-16 21:37:40 +03:00
Tyrrrz 81a6d363d1 Use favicon as avatar for Discord webhooks 2023-07-11 23:53:12 +03:00
Tyrrrz 90d71c5b9e Clean up 2023-07-10 20:40:32 +03:00
Adam Slatinský 5e9c7392db Add export timestamp in JSON format (#1102) 2023-07-09 18:22:09 +03:00
Tyrrrz c4137cf77e Hide token using password box 2023-07-09 18:13:41 +03:00
Tyrrrz 51192425b7 Expect channel parents to be null 2023-07-08 19:21:00 +03:00
William Unsworth 8776a6955b Better support for exporting threads (#1046) 2023-07-07 20:33:17 +03:00
Tyrrrz c69211797f Clean up 2023-07-03 18:35:28 +03:00
Adam Slatinský 768fb88c8c Export role details in JSON format (#1101) 2023-07-03 18:18:30 +03:00
Tyrrrz d4f62387a5 Add CLI option to include or exclude voice channels in exportall and exportguild 2023-06-23 21:23:55 +03:00
Tyrrrz bd4cfcdaf6 Update avatar resolving for users without discriminator 2023-06-21 22:05:08 +03:00
45 changed files with 411 additions and 323 deletions
+1
View File
@@ -151,5 +151,6 @@ jobs:
Content-Type: application/json; charset=UTF-8 Content-Type: application/json; charset=UTF-8
body: | body: |
{ {
"avatar_url": "https://raw.githubusercontent.com/${{ github.event.repository.full_name }}/${{ github.ref_name }}/favicon.png",
"content": "**${{ github.event.repository.name }}** new version released!\nVersion: `${{ github.ref_name }}`\nChangelog: <${{ github.event.repository.html_url }}/blob/${{ github.ref_name }}/Changelog.md>" "content": "**${{ github.event.repository.name }}** new version released!\nVersion: `${{ github.ref_name }}`\nChangelog: <${{ github.event.repository.html_url }}/blob/${{ github.ref_name }}/Changelog.md>"
} }
+24
View File
@@ -1,5 +1,29 @@
# Changelog # Changelog
## v2.40.3 (29-Jul-2023)
- GUI changes:
- Fixed an issue where guild categories were rendered with blank names.
## v2.40.2 (27-Jul-2023)
- General changes:
- Improved error reporting on unexpected HTTP errors.
- CLI changes:
- Fixed an issue which caused a crash when trying to export a private (but accessible) channel inside a private (but not accessible) category.
## v2.40.1 (21-Jul-2023)
- General changes:
- Fixed an issue where the fallback avatar (i.e. the avatar used for when the user doesn't have a custom one set) was not resolved properly for users that don't have a discriminator.
- CLI changes:
- Added the `--include-vc` option to `exportall` and `exportguild` commands, which instructs whether to include voice-channel-accompanying text channels in the export. By default, this is set to `true` to match the previous behavior. To disable the option, use `--include-vc false`.
- GUI changes:
- Replaced the blur effect applied to the token text box with a password mask.
- JSON format changes:
- Added the `exportedAt` field to the export metadata, which contains the timestamp corresponding to when the export was created. (Thanks [@slatinsky](https://github.com/slatinsky))
- Added the `roles` field to the object describing a user. This field contains an array of objects that describe the roles assigned to the user. (Thanks [@slatinsky](https://github.com/slatinsky))
## v2.40 (08-Jun-2023) ## v2.40 (08-Jun-2023)
- General changes: - General changes:
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<Version>2.40</Version> <Version>2.40.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>
@@ -12,17 +12,17 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.0.3" /> <PackageReference Include="AngleSharp" Version="1.0.4" />
<PackageReference Include="FluentAssertions" Version="6.11.0" /> <PackageReference Include="FluentAssertions" Version="6.11.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.2" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="2.3.2" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="7.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="7.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.3" />
<PackageReference Include="ReflectionMagic" Version="4.1.0" /> <PackageReference Include="ReflectionMagic" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.4.2" /> <PackageReference Include="xunit" Version="2.5.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" PrivateAssets="all" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.5.0" PrivateAssets="all" />
<PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" /> <PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
@@ -48,23 +48,22 @@ public static class ExportWrapper
var fileName = channelId.ToString() + '.' + format.GetFileExtension(); var fileName = channelId.ToString() + '.' + format.GetFileExtension();
var filePath = Path.Combine(DirPath, fileName); var filePath = Path.Combine(DirPath, fileName);
// Lock separately for each channel and format using var _ = await Locker.LockAsync(filePath);
using (await Locker.LockAsync(filePath)) using var console = new FakeConsole();
{
// Perform the export only if it hasn't been done before
if (!File.Exists(filePath))
{
await new ExportChannelsCommand
{
Token = Secrets.DiscordToken,
ChannelIds = new[] { channelId },
ExportFormat = format,
OutputPath = filePath
}.ExecuteAsync(new FakeConsole());
}
return await File.ReadAllTextAsync(filePath); // Perform the export only if it hasn't been done before
if (!File.Exists(filePath))
{
await new ExportChannelsCommand
{
Token = Secrets.DiscordToken,
ChannelIds = new[] { channelId },
ExportFormat = format,
OutputPath = filePath
}.ExecuteAsync(console);
} }
return await File.ReadAllTextAsync(filePath);
} }
public static async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId) => Html.Parse( public static async ValueTask<IHtmlDocument> ExportAsHtmlAsync(Snowflake channelId) => Html.Parse(
@@ -194,7 +194,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
try try
{ {
await progressContext.StartTaskAsync( await progressContext.StartTaskAsync(
$"{channel.Category.Name} / {channel.Name}", $"{channel.Category} / {channel.Name}",
async progress => async progress =>
{ {
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken); var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
@@ -253,7 +253,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
foreach (var (channel, error) in errors) foreach (var (channel, error) in errors)
{ {
await console.Error.WriteAsync($"{channel.Category.Name} / {channel.Name}: "); await console.Error.WriteAsync($"{channel.Category} / {channel.Name}: ");
using (console.WithForegroundColor(ConsoleColor.Red)) using (console.WithForegroundColor(ConsoleColor.Red))
await console.Error.WriteLineAsync(error); await console.Error.WriteLineAsync(error);
@@ -290,7 +290,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
foreach (var guildChannel in guildChannels) foreach (var guildChannel in guildChannels)
{ {
if (guildChannel.Category.Id == channel.Id) if (guildChannel.Parent?.Id == channel.Id)
channels.Add(guildChannel); channels.Add(guildChannel);
} }
@@ -1,5 +1,4 @@
using System; using System.Globalization;
using System.Globalization;
using CliFx.Extensibility; using CliFx.Extensibility;
namespace DiscordChatExporter.Cli.Commands.Converters; namespace DiscordChatExporter.Cli.Commands.Converters;
@@ -28,6 +28,12 @@ public class ExportAllCommand : ExportCommandBase
)] )]
public bool IncludeGuildChannels { get; init; } = true; public bool IncludeGuildChannels { get; init; } = true;
[CommandOption(
"include-vc",
Description = "Include voice channels."
)]
public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption( [CommandOption(
"data-package", "data-package",
Description = Description =
@@ -97,6 +103,8 @@ public class ExportAllCommand : ExportCommandBase
channels.RemoveAll(c => c.Kind.IsDirect()); channels.RemoveAll(c => c.Kind.IsDirect());
if (!IncludeGuildChannels) if (!IncludeGuildChannels)
channels.RemoveAll(c => c.Kind.IsGuild()); channels.RemoveAll(c => c.Kind.IsGuild());
if (!IncludeVoiceChannels)
channels.RemoveAll(c => c.Kind.IsVoice());
await base.ExecuteAsync(console, channels); await base.ExecuteAsync(console, channels);
} }
@@ -19,6 +19,12 @@ public class ExportGuildCommand : ExportCommandBase
)] )]
public required Snowflake GuildId { get; init; } public required Snowflake GuildId { get; init; }
[CommandOption(
"include-vc",
Description = "Include voice channels."
)]
public bool IncludeVoiceChannels { get; init; } = true;
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
await base.ExecuteAsync(console); await base.ExecuteAsync(console);
@@ -29,6 +35,7 @@ public class ExportGuildCommand : ExportCommandBase
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken)) var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
.Where(c => c.Kind != ChannelKind.GuildCategory) .Where(c => c.Kind != ChannelKind.GuildCategory)
.Where(c => IncludeVoiceChannels || !c.Kind.IsVoice())
.ToArray(); .ToArray();
await base.ExecuteAsync(console, channels); await base.ExecuteAsync(console, channels);
@@ -32,7 +32,7 @@ public class GetChannelsCommand : DiscordCommandBase
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken)) var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
.Where(c => c.Kind != ChannelKind.GuildCategory) .Where(c => c.Kind != ChannelKind.GuildCategory)
.OrderBy(c => c.Category.Position) .OrderBy(c => c.Parent?.Position)
.ThenBy(c => c.Name) .ThenBy(c => c.Name)
.ToArray(); .ToArray();
@@ -43,7 +43,7 @@ public class GetChannelsCommand : DiscordCommandBase
var threads = IncludeThreads var threads = IncludeThreads
? (await Discord.GetGuildThreadsAsync(GuildId, cancellationToken)).OrderBy(c => c.Name).ToArray() ? (await Discord.GetGuildThreadsAsync(GuildId, cancellationToken)).OrderBy(c => c.Name).ToArray()
: Array.Empty<ChannelThread>(); : Array.Empty<Channel>();
foreach (var channel in channels) foreach (var channel in channels)
{ {
@@ -58,22 +58,22 @@ public class GetChannelsCommand : DiscordCommandBase
// Channel category / name // Channel category / name
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync($"{channel.Category.Name} / {channel.Name}"); await console.Output.WriteLineAsync($"{channel.Category} / {channel.Name}");
var channelThreads = threads.Where(t => t.ParentId == channel.Id).ToArray(); var channelThreads = threads.Where(t => t.Parent?.Id == channel.Id).ToArray();
var channelThreadIdMaxLength = channelThreads var channelThreadIdMaxLength = channelThreads
.Select(t => t.Id.ToString().Length) .Select(t => t.Id.ToString().Length)
.OrderDescending() .OrderDescending()
.FirstOrDefault(); .FirstOrDefault();
foreach (var thread in channelThreads) foreach (var channelThread in channelThreads)
{ {
// Indent // Indent
await console.Output.WriteAsync(" * "); await console.Output.WriteAsync(" * ");
// Thread ID // Thread ID
await console.Output.WriteAsync( await console.Output.WriteAsync(
thread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ') channelThread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
); );
// Separator // Separator
@@ -82,7 +82,7 @@ public class GetChannelsCommand : DiscordCommandBase
// Thread name // Thread name
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteAsync($"Thread / {thread.Name}"); await console.Output.WriteAsync($"Thread / {channelThread.Name}");
// Separator // Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray)) using (console.WithForegroundColor(ConsoleColor.DarkGray))
@@ -90,7 +90,7 @@ public class GetChannelsCommand : DiscordCommandBase
// Thread status // Thread status
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync(thread.IsActive ? "Active" : "Archived"); await console.Output.WriteLineAsync(channelThread.IsActive ? "Active" : "Archived");
} }
} }
} }
@@ -40,7 +40,7 @@ public class GetDirectChannelsCommand : DiscordCommandBase
// Channel category / name // Channel category / name
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync($"{channel.Category.Name} / {channel.Name}"); await console.Output.WriteLineAsync($"{channel.Category} / {channel.Name}");
} }
} }
} }
@@ -11,19 +11,38 @@ public partial record Channel(
Snowflake Id, Snowflake Id,
ChannelKind Kind, ChannelKind Kind,
Snowflake GuildId, Snowflake GuildId,
ChannelCategory Category, Channel? Parent,
string Name, string Name,
int? Position, int? Position,
string? IconUrl, string? IconUrl,
string? Topic, string? Topic,
bool IsActive,
Snowflake? LastMessageId) : IHasId Snowflake? LastMessageId) : IHasId
{ {
public bool IsVoice => Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice; // Used for visual backwards-compatibility with old exports, where
// channels without a parent (i.e. mostly DM channels) or channels
// with an inaccessible parent (i.e. inside private categories) had
// a fallback category created for them.
public string Category => Parent?.Name ?? Kind switch
{
ChannelKind.GuildCategory => "Category",
ChannelKind.GuildTextChat => "Text",
ChannelKind.DirectTextChat => "Private",
ChannelKind.DirectGroupTextChat => "Group",
ChannelKind.GuildPrivateThread => "Private Thread",
ChannelKind.GuildPublicThread => "Public Thread",
ChannelKind.GuildNews => "News",
ChannelKind.GuildNewsThread => "News Thread",
_ => "Default"
};
// Only needed for WPF data binding. Don't use anywhere else.
public bool IsVoice => Kind.IsVoice();
} }
public partial record Channel public partial record Channel
{ {
public static Channel Parse(JsonElement json, ChannelCategory? categoryHint = null, int? positionHint = null) public static Channel Parse(JsonElement json, Channel? parent = null, int? positionHint = null)
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var kind = (ChannelKind)json.GetProperty("type").GetInt32(); var kind = (ChannelKind)json.GetProperty("type").GetInt32();
@@ -32,8 +51,6 @@ public partial record Channel
json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse) ?? json.GetPropertyOrNull("guild_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse) ??
Guild.DirectMessages.Id; Guild.DirectMessages.Id;
var category = categoryHint ?? ChannelCategory.CreateDefault(kind);
var name = var name =
// Guild channel // Guild channel
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ??
@@ -60,6 +77,11 @@ public partial record Channel
var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull(); var topic = json.GetPropertyOrNull("topic")?.GetStringOrNull();
var isActive = !json
.GetPropertyOrNull("thread_metadata")?
.GetPropertyOrNull("archived")?
.GetBooleanOrNull() ?? true;
var lastMessageId = json var lastMessageId = json
.GetPropertyOrNull("last_message_id")? .GetPropertyOrNull("last_message_id")?
.GetNonWhiteSpaceStringOrNull()? .GetNonWhiteSpaceStringOrNull()?
@@ -69,11 +91,12 @@ public partial record Channel
id, id,
kind, kind,
guildId, guildId,
category, parent,
name, name,
position, position,
iconUrl, iconUrl,
topic, topic,
isActive,
lastMessageId lastMessageId
); );
} }
@@ -1,37 +0,0 @@
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data;
public record ChannelCategory(Snowflake Id, string Name, int? Position) : IHasId
{
public static ChannelCategory CreateDefault(ChannelKind channelKind) => new(
Snowflake.Zero,
channelKind switch
{
ChannelKind.GuildTextChat => "Text",
ChannelKind.DirectTextChat => "Private",
ChannelKind.DirectGroupTextChat => "Group",
ChannelKind.GuildNews => "News",
_ => "Default"
},
null
);
public static ChannelCategory Parse(JsonElement json, int? positionHint = null)
{
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name =
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ??
id.ToString();
var position =
positionHint ??
json.GetPropertyOrNull("position")?.GetInt32OrNull();
return new ChannelCategory(id, name, position);
}
}
@@ -24,4 +24,7 @@ public static class ChannelKindExtensions
public static bool IsGuild(this ChannelKind kind) => public static bool IsGuild(this ChannelKind kind) =>
!kind.IsDirect(); !kind.IsDirect();
public static bool IsVoice(this ChannelKind kind) =>
kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
} }
@@ -1,46 +0,0 @@
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/channel#channel-object-example-thread-channel
public record ChannelThread(
Snowflake Id,
ChannelKind Kind,
Snowflake GuildId,
Snowflake ParentId,
string Name,
bool IsActive,
Snowflake? LastMessageId) : IHasId
{
public static ChannelThread Parse(JsonElement json)
{
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var kind = (ChannelKind)json.GetProperty("type").GetInt32();
var guildId = json.GetProperty("guild_id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var parentId = json.GetProperty("parent_id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetNonWhiteSpaceString();
var isActive = !json
.GetPropertyOrNull("thread_metadata")?
.GetPropertyOrNull("archived")?
.GetBooleanOrNull() ?? true;
var lastMessageId = json
.GetPropertyOrNull("last_message_id")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(Snowflake.Parse);
return new ChannelThread(
id,
kind,
guildId,
parentId,
name,
isActive,
lastMessageId
);
}
}
@@ -47,8 +47,8 @@ public static class ImageCdn
? $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.gif?size={size}" ? $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.gif?size={size}"
: $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.png?size={size}"; : $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.png?size={size}";
public static string GetFallbackUserAvatarUrl(int discriminator) => public static string GetFallbackUserAvatarUrl(int index = 0) =>
$"https://cdn.discordapp.com/embed/avatars/{discriminator % 5}.png"; $"https://cdn.discordapp.com/embed/avatars/{index}.png";
public static string GetMemberAvatarUrl(Snowflake guildId, Snowflake userId, string avatarHash, int size = 512) => public static string GetMemberAvatarUrl(Snowflake guildId, Snowflake userId, string avatarHash, int size = 512) =>
avatarHash.StartsWith("a_", StringComparison.Ordinal) avatarHash.StartsWith("a_", StringComparison.Ordinal)
@@ -12,7 +12,7 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
public static Guild DirectMessages { get; } = new( public static Guild DirectMessages { get; } = new(
Snowflake.Zero, Snowflake.Zero,
"Direct Messages", "Direct Messages",
ImageCdn.GetFallbackUserAvatarUrl(0) ImageCdn.GetFallbackUserAvatarUrl()
); );
public static Guild Parse(JsonElement json) public static Guild Parse(JsonElement json)
@@ -25,7 +25,7 @@ public record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
.GetPropertyOrNull("icon")? .GetPropertyOrNull("icon")?
.GetNonWhiteSpaceStringOrNull()? .GetNonWhiteSpaceStringOrNull()?
.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? .Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ??
ImageCdn.GetFallbackUserAvatarUrl(0); ImageCdn.GetFallbackUserAvatarUrl();
return new Guild(id, name, iconUrl); return new Guild(id, name, iconUrl);
} }
@@ -20,7 +20,8 @@ public partial record Member(
public partial record Member public partial record Member
{ {
public static Member CreateDefault(User user) => new(user, null, null, Array.Empty<Snowflake>()); public static Member CreateFallback(User user) =>
new(user, null, null, Array.Empty<Snowflake>());
public static Member Parse(JsonElement json, Snowflake? guildId = null) public static Member Parse(JsonElement json, Snowflake? guildId = null)
{ {
@@ -45,12 +45,14 @@ public partial record User
var name = json.GetProperty("username").GetNonNullString(); var name = json.GetProperty("username").GetNonNullString();
var displayName = json.GetPropertyOrNull("global_name")?.GetNonWhiteSpaceStringOrNull() ?? name; var displayName = json.GetPropertyOrNull("global_name")?.GetNonWhiteSpaceStringOrNull() ?? name;
var avatarIndex = discriminator % 5 ?? (int)((id.Value >> 22) % 6);
var avatarUrl = var avatarUrl =
json json
.GetPropertyOrNull("avatar")? .GetPropertyOrNull("avatar")?
.GetNonWhiteSpaceStringOrNull()? .GetNonWhiteSpaceStringOrNull()?
.Pipe(h => ImageCdn.GetUserAvatarUrl(id, h)) ?? .Pipe(h => ImageCdn.GetUserAvatarUrl(id, h)) ??
ImageCdn.GetFallbackUserAvatarUrl(discriminator ?? 0); ImageCdn.GetFallbackUserAvatarUrl(avatarIndex);
return new User(id, isBot, discriminator, name, displayName, avatarUrl); return new User(id, isBot, discriminator, name, displayName, avatarUrl);
} }
@@ -108,7 +108,7 @@ public class DiscordClient
if (botResponse.StatusCode != HttpStatusCode.Unauthorized) if (botResponse.StatusCode != HttpStatusCode.Unauthorized)
return TokenKind.Bot; return TokenKind.Bot;
throw DiscordChatExporterException.Unauthorized(); throw new DiscordChatExporterException("Authentication token is invalid.", true);
} }
private async ValueTask<HttpResponseMessage> GetResponseAsync( private async ValueTask<HttpResponseMessage> GetResponseAsync(
@@ -129,10 +129,26 @@ public class DiscordClient
{ {
throw response.StatusCode switch throw response.StatusCode switch
{ {
HttpStatusCode.Unauthorized => DiscordChatExporterException.Unauthorized(), HttpStatusCode.Unauthorized => throw new DiscordChatExporterException(
HttpStatusCode.Forbidden => DiscordChatExporterException.Forbidden(), "Authentication token is invalid.",
HttpStatusCode.NotFound => DiscordChatExporterException.NotFound(url), true
_ => DiscordChatExporterException.FailedHttpRequest(response) ),
HttpStatusCode.Forbidden => throw new DiscordChatExporterException(
$"Request to '{url}' failed: forbidden."
),
HttpStatusCode.NotFound => throw new DiscordChatExporterException(
$"Request to '{url}' failed: not found."
),
_ => throw new DiscordChatExporterException(
$"""
Request to '{url}' failed: {response.StatusCode.ToString().ToSpaceSeparatedWords().ToLowerInvariant()}.
Response content: {await response.Content.ReadAsStringAsync(cancellationToken)}
""",
true
)
}; };
} }
@@ -219,10 +235,10 @@ public class DiscordClient
.ThenBy(j => j.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse)) .ThenBy(j => j.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse))
.ToArray(); .ToArray();
var categories = channelsJson var parentsById = channelsJson
.Where(j => j.GetProperty("type").GetInt32() == (int)ChannelKind.GuildCategory) .Where(j => j.GetProperty("type").GetInt32() == (int)ChannelKind.GuildCategory)
.Select((j, index) => ChannelCategory.Parse(j, index + 1)) .Select((j, i) => Channel.Parse(j, null, i + 1))
.ToDictionary(j => j.Id.ToString(), StringComparer.Ordinal); .ToDictionary(j => j.Id);
// Discord channel positions are relative, so we need to normalize them // Discord channel positions are relative, so we need to normalize them
// so that the user may refer to them more easily in file name templates. // so that the user may refer to them more easily in file name templates.
@@ -230,21 +246,19 @@ public class DiscordClient
foreach (var channelJson in channelsJson) foreach (var channelJson in channelsJson)
{ {
var parentId = channelJson var parent = channelJson
.GetPropertyOrNull("parent_id")? .GetPropertyOrNull("parent_id")?
.GetNonWhiteSpaceStringOrNull(); .GetNonWhiteSpaceStringOrNull()?
.Pipe(Snowflake.Parse)
.Pipe(parentsById.GetValueOrDefault);
var category = !string.IsNullOrWhiteSpace(parentId) yield return Channel.Parse(channelJson, parent, position);
? categories.GetValueOrDefault(parentId)
: null;
yield return Channel.Parse(channelJson, category, position);
position++; position++;
} }
} }
} }
public async IAsyncEnumerable<ChannelThread> GetGuildThreadsAsync( public async IAsyncEnumerable<Channel> GetGuildThreadsAsync(
Snowflake guildId, Snowflake guildId,
[EnumeratorCancellation] CancellationToken cancellationToken = default) [EnumeratorCancellation] CancellationToken cancellationToken = default)
{ {
@@ -264,13 +278,14 @@ public class DiscordClient
.SetQueryParameter("offset", currentOffset.ToString()) .SetQueryParameter("offset", currentOffset.ToString())
.Build(); .Build();
// Can be null on channels that the user cannot access
var response = await TryGetJsonResponseAsync(url, cancellationToken); var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null) if (response is null)
break; break;
foreach (var threadJson in response.Value.GetProperty("threads").EnumerateArray()) foreach (var threadJson in response.Value.GetProperty("threads").EnumerateArray())
{ {
yield return ChannelThread.Parse(threadJson); yield return Channel.Parse(threadJson, channel);
currentOffset++; currentOffset++;
} }
@@ -284,9 +299,19 @@ public class DiscordClient
{ {
// Active threads // Active threads
{ {
var parentsById = channels.ToDictionary(c => c.Id);
var response = await GetJsonResponseAsync($"guilds/{guildId}/threads/active", cancellationToken); var response = await GetJsonResponseAsync($"guilds/{guildId}/threads/active", cancellationToken);
foreach (var threadJson in response.GetProperty("threads").EnumerateArray()) foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
yield return ChannelThread.Parse(threadJson); {
var parent = threadJson
.GetPropertyOrNull("parent_id")?
.GetNonWhiteSpaceStringOrNull()?
.Pipe(Snowflake.Parse)
.Pipe(parentsById.GetValueOrDefault);
yield return Channel.Parse(threadJson, parent);
}
} }
foreach (var channel in channels) foreach (var channel in channels)
@@ -299,7 +324,7 @@ public class DiscordClient
); );
foreach (var threadJson in response.GetProperty("threads").EnumerateArray()) foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
yield return ChannelThread.Parse(threadJson); yield return Channel.Parse(threadJson, channel);
} }
// Private archived threads // Private archived threads
@@ -310,7 +335,7 @@ public class DiscordClient
); );
foreach (var threadJson in response.GetProperty("threads").EnumerateArray()) foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
yield return ChannelThread.Parse(threadJson); yield return Channel.Parse(threadJson, channel);
} }
} }
} }
@@ -340,7 +365,7 @@ public class DiscordClient
return response?.Pipe(j => Member.Parse(j, guildId)); return response?.Pipe(j => Member.Parse(j, guildId));
} }
public async ValueTask<Invite?> TryGetGuildInviteAsync( public async ValueTask<Invite?> TryGetInviteAsync(
string code, string code,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -348,23 +373,6 @@ public class DiscordClient
return response?.Pipe(Invite.Parse); return response?.Pipe(Invite.Parse);
} }
public async ValueTask<ChannelCategory> GetChannelCategoryAsync(
Snowflake channelId,
CancellationToken cancellationToken = default)
{
try
{
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
return ChannelCategory.Parse(response);
}
// In some cases, Discord API returns an empty body when requesting a channel.
// Use an empty channel category as fallback for these cases.
catch (DiscordChatExporterException)
{
return new ChannelCategory(channelId, "Unknown Category", 0);
}
}
public async ValueTask<Channel> GetChannelAsync( public async ValueTask<Channel> GetChannelAsync(
Snowflake channelId, Snowflake channelId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -376,11 +384,21 @@ public class DiscordClient
.GetNonWhiteSpaceStringOrNull()? .GetNonWhiteSpaceStringOrNull()?
.Pipe(Snowflake.Parse); .Pipe(Snowflake.Parse);
var category = parentId is not null try
? await GetChannelCategoryAsync(parentId.Value, cancellationToken) {
: null; var parent = parentId is not null
? await GetChannelAsync(parentId.Value, cancellationToken)
: null;
return Channel.Parse(response, category); return Channel.Parse(response, parent);
}
// It's possible for the parent channel to be inaccessible, despite the
// child channel being accessible.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
catch (DiscordChatExporterException)
{
return Channel.Parse(response);
}
} }
private async ValueTask<Message?> TryGetLastMessageAsync( private async ValueTask<Message?> TryGetLastMessageAsync(
@@ -4,11 +4,11 @@
<PackageReference Include="AsyncKeyedLock" Version="6.2.1" /> <PackageReference Include="AsyncKeyedLock" Version="6.2.1" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Polly" Version="7.2.3" /> <PackageReference Include="Polly" Version="7.2.4" />
<PackageReference Include="RazorBlade" Version="0.4.3" /> <PackageReference Include="RazorBlade" Version="0.4.3" />
<PackageReference Include="Superpower" Version="3.0.0" /> <PackageReference Include="Superpower" Version="3.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.14.0" /> <PackageReference Include="WebMarkupMin.Core" Version="2.14.0" />
<PackageReference Include="YoutubeExplode" Version="6.2.15" /> <PackageReference Include="YoutubeExplode" Version="6.3.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,9 +1,8 @@
using System; using System;
using System.Net.Http;
namespace DiscordChatExporter.Core.Exceptions; namespace DiscordChatExporter.Core.Exceptions;
public partial class DiscordChatExporterException : Exception public class DiscordChatExporterException : Exception
{ {
public bool IsFatal { get; } public bool IsFatal { get; }
@@ -12,33 +11,4 @@ public partial class DiscordChatExporterException : Exception
{ {
IsFatal = isFatal; IsFatal = isFatal;
} }
}
public partial class DiscordChatExporterException
{
internal static DiscordChatExporterException FailedHttpRequest(HttpResponseMessage response)
{
var message = $@"
Failed to perform an HTTP request.
[Request]
{response.RequestMessage}
[Response]
{response}";
return new DiscordChatExporterException(message.Trim(), true);
}
internal static DiscordChatExporterException Unauthorized() =>
new("Authentication token is invalid.", true);
internal static DiscordChatExporterException Forbidden() =>
new("Access is forbidden.");
internal static DiscordChatExporterException NotFound(string resourceId) =>
new($"Requested resource ({resourceId}) does not exist.");
internal static DiscordChatExporterException ChannelIsEmpty() =>
new("Channel is empty or contains no messages for the specified period.");
} }
@@ -20,11 +20,19 @@ public class ChannelExporter
{ {
// Check if the channel is empty // Check if the channel is empty
if (request.Channel.LastMessageId is null) if (request.Channel.LastMessageId is null)
throw DiscordChatExporterException.ChannelIsEmpty(); {
throw new DiscordChatExporterException(
"Channel does not contain any messages."
);
}
// Check if the 'after' boundary is valid // Check if the 'after' boundary is valid
if (request.After is not null && request.Channel.LastMessageId < request.After) if (request.After is not null && request.Channel.LastMessageId < request.After)
throw DiscordChatExporterException.ChannelIsEmpty(); {
throw new DiscordChatExporterException(
"Channel does not contain any messages within the specified period."
);
}
// Build context // Build context
var context = new ExportContext(_discord, request); var context = new ExportContext(_discord, request);
@@ -50,6 +58,10 @@ public class ChannelExporter
// Throw if no messages were exported // Throw if no messages were exported
if (messageExporter.MessagesExported <= 0) if (messageExporter.MessagesExported <= 0)
throw DiscordChatExporterException.ChannelIsEmpty(); {
throw new DiscordChatExporterException(
"Channel does not contain any matching messages within the specified period."
);
}
} }
} }
@@ -38,50 +38,49 @@ internal partial class ExportAssetDownloader
var fileName = GetFileNameFromUrl(url); var fileName = GetFileNameFromUrl(url);
var filePath = Path.Combine(_workingDirPath, fileName); var filePath = Path.Combine(_workingDirPath, fileName);
using (await Locker.LockAsync(filePath, cancellationToken)) using var _ = await Locker.LockAsync(filePath, cancellationToken);
{
if (_pathCache.TryGetValue(url, out var cachedFilePath))
return cachedFilePath;
// Reuse existing files if we're allowed to if (_pathCache.TryGetValue(url, out var cachedFilePath))
if (_reuse && File.Exists(filePath)) return cachedFilePath;
return _pathCache[url] = filePath;
Directory.CreateDirectory(_workingDirPath);
await Http.ResiliencePolicy.ExecuteAsync(async () =>
{
// Download the file
using var response = await Http.Client.GetAsync(url, cancellationToken);
await using (var output = File.Create(filePath))
await response.Content.CopyToAsync(output, cancellationToken);
// Try to set the file date according to the last-modified header
try
{
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var instant)
? instant
: (DateTimeOffset?)null
);
if (lastModified is not null)
{
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
}
}
catch
{
// This can apparently fail for some reason.
// Updating the file date is not a critical task, so we'll just ignore exceptions thrown here.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
}
});
// Reuse existing files if we're allowed to
if (_reuse && File.Exists(filePath))
return _pathCache[url] = filePath; return _pathCache[url] = filePath;
}
Directory.CreateDirectory(_workingDirPath);
await Http.ResiliencePolicy.ExecuteAsync(async () =>
{
// Download the file
using var response = await Http.Client.GetAsync(url, cancellationToken);
await using (var output = File.Create(filePath))
await response.Content.CopyToAsync(output, cancellationToken);
// Try to set the file date according to the last-modified header
try
{
var lastModified = response.Content.Headers.TryGetValue("Last-Modified")?.Pipe(s =>
DateTimeOffset.TryParse(s, CultureInfo.InvariantCulture, DateTimeStyles.None, out var instant)
? instant
: (DateTimeOffset?)null
);
if (lastModified is not null)
{
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
}
}
catch
{
// This can apparently fail for some reason.
// Updating the file date is not a critical task, so we'll just ignore exceptions thrown here.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
}
});
return _pathCache[url] = filePath;
} }
} }
@@ -63,7 +63,7 @@ internal class ExportContext
// User may have been deleted since they were mentioned // User may have been deleted since they were mentioned
if (user is not null) if (user is not null)
member = Member.CreateDefault(user); member = Member.CreateFallback(user);
} }
// Store the result even if it's null, to avoid re-fetching non-existing members // Store the result even if it's null, to avoid re-fetching non-existing members
@@ -89,22 +89,17 @@ internal class ExportContext
public Role? TryGetRole(Snowflake id) => _roles.GetValueOrDefault(id); public Role? TryGetRole(Snowflake id) => _roles.GetValueOrDefault(id);
public Color? TryGetUserColor(Snowflake id) public IReadOnlyList<Role> GetUserRoles(Snowflake id) => TryGetMember(id)?
{ .RoleIds
var member = TryGetMember(id); .Select(TryGetRole)
.WhereNotNull()
.OrderByDescending(r => r.Position)
.ToArray() ?? Array.Empty<Role>();
var memberRoles = member? public Color? TryGetUserColor(Snowflake id) => GetUserRoles(id)
.RoleIds .Where(r => r.Color is not null)
.Select(TryGetRole) .Select(r => r.Color)
.WhereNotNull() .FirstOrDefault();
.ToArray();
return memberRoles?
.Where(r => r.Color is not null)
.OrderByDescending(r => r.Position)
.Select(r => r.Color)
.FirstOrDefault();
}
public async ValueTask<string> ResolveAssetUrlAsync(string url, CancellationToken cancellationToken = default) public async ValueTask<string> ResolveAssetUrlAsync(string url, CancellationToken cancellationToken = default)
{ {
@@ -117,7 +112,7 @@ internal class ExportContext
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath); var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
// Prefer relative paths so that the output files can be copied around without breaking references. // Prefer relative paths so that the output files can be copied around without breaking references.
// If the assets path is outside of the export directory, use an absolute path instead. // If the asset directory is outside of the export directory, use an absolute path instead.
var optimalFilePath = var optimalFilePath =
relativeFilePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || relativeFilePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
relativeFilePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) relativeFilePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)
@@ -102,7 +102,7 @@ public partial class ExportRequest
var buffer = new StringBuilder(); var buffer = new StringBuilder();
// Guild and channel names // Guild and channel names
buffer.Append($"{guild.Name} - {channel.Category.Name} - {channel.Name} [{channel.Id}]"); buffer.Append($"{guild.Name} - {channel.Category} - {channel.Name} [{channel.Id}]");
// Date range // Date range
if (after is not null || before is not null) if (after is not null || before is not null)
@@ -148,12 +148,12 @@ public partial class ExportRequest
{ {
"%g" => guild.Id.ToString(), "%g" => guild.Id.ToString(),
"%G" => guild.Name, "%G" => guild.Name,
"%t" => channel.Category.Id.ToString(), "%t" => channel.Parent?.Id.ToString() ?? "",
"%T" => channel.Category.Name, "%T" => channel.Parent?.Name ?? "",
"%c" => channel.Id.ToString(), "%c" => channel.Id.ToString(),
"%C" => channel.Name, "%C" => channel.Name,
"%p" => channel.Position?.ToString() ?? "0", "%p" => channel.Position?.ToString() ?? "0",
"%P" => channel.Category.Position?.ToString() ?? "0", "%P" => channel.Parent?.Position?.ToString() ?? "0",
"%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "", "%a" => after?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%b" => before?.ToDate().ToString("yyyy-MM-dd") ?? "", "%b" => before?.ToDate().ToString("yyyy-MM-dd") ?? "",
"%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"), "%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"),
@@ -261,7 +261,7 @@ internal partial class HtmlMarkdownVisitor : MarkdownVisitor
else if (mention.Kind == MentionKind.Channel) else if (mention.Kind == MentionKind.Channel)
{ {
var channel = mention.TargetId?.Pipe(_context.TryGetChannel); var channel = mention.TargetId?.Pipe(_context.TryGetChannel);
var symbol = channel?.IsVoice == true ? "🔊" : "#"; var symbol = channel?.Kind.IsVoice() == true ? "🔊" : "#";
var name = channel?.Name ?? "deleted-channel"; var name = channel?.Name ?? "deleted-channel";
_buffer.Append( _buffer.Append(
@@ -1,4 +1,5 @@
using System.IO; using System.Collections.Generic;
using System.IO;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
@@ -48,6 +49,9 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex()); _writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
_writer.WriteBoolean("isBot", user.IsBot); _writer.WriteBoolean("isBot", user.IsBot);
_writer.WritePropertyName("roles");
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
_writer.WriteString( _writer.WriteString(
"avatarUrl", "avatarUrl",
await Context.ResolveAssetUrlAsync( await Context.ResolveAssetUrlAsync(
@@ -60,6 +64,28 @@ internal class JsonMessageWriter : MessageWriter
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
} }
private async ValueTask WriteRolesAsync(
IReadOnlyList<Role> roles,
CancellationToken cancellationToken = default)
{
_writer.WriteStartArray();
foreach (var role in roles)
{
_writer.WriteStartObject();
_writer.WriteString("id", role.Id.ToString());
_writer.WriteString("name", role.Name);
_writer.WriteString("color", role.Color?.ToHex());
_writer.WriteNumber("position", role.Position);
_writer.WriteEndObject();
}
_writer.WriteEndArray();
await _writer.FlushAsync(cancellationToken);
}
private async ValueTask WriteEmbedAuthorAsync( private async ValueTask WriteEmbedAuthorAsync(
EmbedAuthor embedAuthor, EmbedAuthor embedAuthor,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@@ -215,8 +241,8 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteStartObject("channel"); _writer.WriteStartObject("channel");
_writer.WriteString("id", Context.Request.Channel.Id.ToString()); _writer.WriteString("id", Context.Request.Channel.Id.ToString());
_writer.WriteString("type", Context.Request.Channel.Kind.ToString()); _writer.WriteString("type", Context.Request.Channel.Kind.ToString());
_writer.WriteString("categoryId", Context.Request.Channel.Category.Id.ToString()); _writer.WriteString("categoryId", Context.Request.Channel.Parent?.Id.ToString());
_writer.WriteString("category", Context.Request.Channel.Category.Name); _writer.WriteString("category", Context.Request.Channel.Category);
_writer.WriteString("name", Context.Request.Channel.Name); _writer.WriteString("name", Context.Request.Channel.Name);
_writer.WriteString("topic", Context.Request.Channel.Topic); _writer.WriteString("topic", Context.Request.Channel.Topic);
@@ -236,6 +262,9 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("before", Context.Request.Before?.ToDate()); _writer.WriteString("before", Context.Request.Before?.ToDate());
_writer.WriteEndObject(); _writer.WriteEndObject();
// Timestamp
_writer.WriteString("exportedAt", System.DateTimeOffset.UtcNow);
// Message array (start) // Message array (start)
_writer.WriteStartArray("messages"); _writer.WriteStartArray("messages");
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
@@ -321,7 +321,7 @@
foreach (var inviteCode in inviteCodes) foreach (var inviteCode in inviteCodes)
{ {
var invite = await Context.Discord.TryGetGuildInviteAsync(inviteCode, CancellationToken); var invite = await Context.Discord.TryGetInviteAsync(inviteCode, CancellationToken);
if (invite is null) if (invite is null)
{ {
continue; continue;
@@ -1,6 +1,7 @@
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown; using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing; using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@@ -71,7 +72,7 @@ internal partial class PlainTextMarkdownVisitor : MarkdownVisitor
_buffer.Append($"#{name}"); _buffer.Append($"#{name}");
// Voice channel marker // Voice channel marker
if (channel?.IsVoice == true) if (channel?.Kind.IsVoice() == true)
_buffer.Append(" [voice]"); _buffer.Append(" [voice]");
} }
else if (mention.Kind == MentionKind.Role) else if (mention.Kind == MentionKind.Role)
@@ -193,7 +193,7 @@ internal class PlainTextMessageWriter : MessageWriter
{ {
await _writer.WriteLineAsync(new string('=', 62)); await _writer.WriteLineAsync(new string('=', 62));
await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}"); await _writer.WriteLineAsync($"Guild: {Context.Request.Guild.Name}");
await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category.Name} / {Context.Request.Channel.Name}"); await _writer.WriteLineAsync($"Channel: {Context.Request.Channel.Category} / {Context.Request.Channel.Name}");
if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic)) if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
{ {
@@ -1004,7 +1004,7 @@
</div> </div>
<div class="preamble__entries-container"> <div class="preamble__entries-container">
<div class="preamble__entry">@Context.Request.Guild.Name</div> <div class="preamble__entry">@Context.Request.Guild.Name</div>
<div class="preamble__entry">@Context.Request.Channel.Category.Name / @Context.Request.Channel.Name</div> <div class="preamble__entry">@Context.Request.Channel.Category / @Context.Request.Channel.Name</div>
@if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic)) @if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
{ {
@@ -8,8 +8,8 @@ public static class BinaryExtensions
{ {
var buffer = new StringBuilder(2 * data.Length); var buffer = new StringBuilder(2 * data.Length);
foreach (var t in data) foreach (var b in data)
buffer.Append(t.ToString("X2")); buffer.Append(b.ToString("X2"));
return buffer.ToString(); return buffer.ToString();
} }
@@ -16,6 +16,21 @@ public static class StringExtensions
? str[..charCount] ? str[..charCount]
: str; : str;
public static string ToSpaceSeparatedWords(this string str)
{
var builder = new StringBuilder(str.Length * 2);
foreach (var c in str)
{
if (char.IsUpper(c) && builder.Length > 0)
builder.Append(' ');
builder.Append(c);
}
return builder.ToString();
}
public static IEnumerable<Rune> GetRunes(this string str) public static IEnumerable<Rune> GetRunes(this string str)
{ {
var lastIndex = 0; var lastIndex = 0;
+3 -3
View File
@@ -31,12 +31,12 @@ public class Bootstrapper : Bootstrapper<RootViewModel>
} }
#if !DEBUG #if !DEBUG
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs e) protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args)
{ {
base.OnUnhandledException(e); base.OnUnhandledException(args);
MessageBox.Show( MessageBox.Show(
e.Exception.ToString(), args.Exception.ToString(),
"Error occured", "Error occured",
MessageBoxButton.OK, MessageBoxButton.OK,
MessageBoxImage.Error MessageBoxImage.Error
@@ -13,7 +13,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Cogwheel" Version="2.0.2" /> <PackageReference Include="Cogwheel" Version="2.0.3" />
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" /> <PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" /> <PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
@@ -21,7 +21,7 @@
<PackageReference Include="MaterialDesignThemes" Version="4.9.0" /> <PackageReference Include="MaterialDesignThemes" Version="4.9.0" />
<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.9" /> <PackageReference Include="Onova" Version="2.6.10" />
<PackageReference Include="PropertyChanged.Fody" Version="4.1.0" PrivateAssets="all" /> <PackageReference Include="PropertyChanged.Fody" Version="4.1.0" PrivateAssets="all" />
<PackageReference Include="Stylet" Version="1.3.6" /> <PackageReference Include="Stylet" Version="1.3.6" />
</ItemGroup> </ItemGroup>
@@ -46,10 +46,13 @@ public partial class SettingsService : SettingsBase
public override void Save() public override void Save()
{ {
// Clear the token if it's not supposed to be persisted // Clear the token if it's not supposed to be persisted
var lastToken = LastToken;
if (!IsTokenPersisted) if (!IsTokenPersisted)
LastToken = null; LastToken = null;
base.Save(); base.Save();
LastToken = lastToken;
} }
} }
@@ -38,8 +38,6 @@ public class DashboardViewModel : PropertyChangedBase
public string? Token { get; set; } public string? Token { get; set; }
public bool IsTokenSet => !string.IsNullOrWhiteSpace(Token);
private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? ChannelsByGuild { get; set; } private IReadOnlyDictionary<Guild, IReadOnlyList<Channel>>? ChannelsByGuild { get; set; }
public IReadOnlyList<Guild>? AvailableGuilds => ChannelsByGuild?.Keys.ToArray(); public IReadOnlyList<Guild>? AvailableGuilds => ChannelsByGuild?.Keys.ToArray();
@@ -67,8 +65,15 @@ public class DashboardViewModel : PropertyChangedBase
_progressMuxer = Progress.CreateMuxer().WithAutoReset(); _progressMuxer = Progress.CreateMuxer().WithAutoReset();
this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)); this.Bind(
Progress.Bind(o => o.Current, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)); o => o.IsBusy,
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
);
Progress.Bind(
o => o.Current,
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
);
} }
public void OnViewLoaded() public void OnViewLoaded()
@@ -6,6 +6,7 @@
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors" xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase" xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components" xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
xmlns:controls="clr-namespace:DiscordChatExporter.Gui.Views.Controls"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters" xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:data="clr-namespace:DiscordChatExporter.Core.Discord.Data;assembly=DiscordChatExporter.Core" xmlns:data="clr-namespace:DiscordChatExporter.Core.Discord.Data;assembly=DiscordChatExporter.Core"
@@ -28,7 +29,7 @@
<!-- Sort guild channels by position --> <!-- Sort guild channels by position -->
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}"> <CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
<CollectionViewSource.GroupDescriptions> <CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription PropertyName="Category.Name" /> <PropertyGroupDescription PropertyName="Category" />
</CollectionViewSource.GroupDescriptions> </CollectionViewSource.GroupDescriptions>
<CollectionViewSource.SortDescriptions> <CollectionViewSource.SortDescriptions>
<componentModel:SortDescription Direction="Ascending" PropertyName="Position" /> <componentModel:SortDescription Direction="Ascending" PropertyName="Position" />
@@ -75,38 +76,29 @@
Kind="Key" /> Kind="Key" />
<!-- Token value --> <!-- Token value -->
<TextBox <controls:RevealablePasswordBox
x:Name="TokenValueTextBox" x:Name="TokenValueTextBox"
Grid.Column="1" Grid.Column="1"
Margin="0,6,6,8" Margin="0,6,6,8"
VerticalAlignment="Bottom" VerticalAlignment="Bottom"
materialDesign:HintAssist.Hint="Token" materialDesign:HintAssist.Hint="Token"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
materialDesign:TextFieldAssist.TextBoxViewMargin="0,0,2,0"
BorderThickness="0" BorderThickness="0"
FontFamily="Consolas"
FontSize="16" FontSize="16"
Text="{Binding Token, UpdateSourceTrigger=PropertyChanged}"> Password="{Binding Token, UpdateSourceTrigger=PropertyChanged}">
<TextBox.Style> <controls:RevealablePasswordBox.Style>
<Style BasedOn="{StaticResource {x:Type TextBox}}" TargetType="{x:Type TextBox}"> <Style TargetType="{x:Type controls:RevealablePasswordBox}">
<Style.Triggers> <Style.Triggers>
<!-- Blur the token when it's out of focus --> <DataTrigger Binding="{Binding IsMouseOver, RelativeSource={RelativeSource AncestorType=materialDesign:Card}}" Value="True">
<MultiDataTrigger> <Setter Property="IsRevealed" Value="True" />
<MultiDataTrigger.Conditions> </DataTrigger>
<Condition Binding="{Binding IsMouseOver, RelativeSource={RelativeSource AncestorType=materialDesign:Card}}" Value="False" /> <DataTrigger Binding="{Binding IsKeyboardFocusWithin, RelativeSource={RelativeSource AncestorType=materialDesign:Card}}" Value="True">
<Condition Binding="{Binding IsFocused, RelativeSource={RelativeSource Self}}" Value="False" /> <Setter Property="IsRevealed" Value="True" />
<!-- Don't blur if the token is not set, so the user can see the hint text --> </DataTrigger>
<Condition Binding="{Binding IsTokenSet}" Value="True" />
</MultiDataTrigger.Conditions>
<Setter Property="Effect">
<Setter.Value>
<BlurEffect Radius="12" />
</Setter.Value>
</Setter>
</MultiDataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
</TextBox.Style> </controls:RevealablePasswordBox.Style>
</TextBox> </controls:RevealablePasswordBox>
<!-- Pull data button --> <!-- Pull data button -->
<Button <Button
@@ -0,0 +1,24 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.Controls.RevealablePasswordBox"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
x:Name="Root"
mc:Ignorable="d">
<Grid>
<TextBox
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
BorderThickness="{Binding BorderThickness, ElementName=Root}"
Text="{Binding Password, ElementName=Root, UpdateSourceTrigger=PropertyChanged}"
Visibility="{Binding IsRevealed, ElementName=Root, Converter={x:Static s:BoolToVisibilityConverter.Instance}}" />
<PasswordBox
materialDesign:PasswordBoxAssist.Password="{Binding Password, ElementName=Root, UpdateSourceTrigger=PropertyChanged}"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
BorderThickness="{Binding BorderThickness, ElementName=Root}"
IsEnabled="False"
Visibility="{Binding IsRevealed, ElementName=Root, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}" />
</Grid>
</UserControl>
@@ -0,0 +1,37 @@
using System.Windows;
namespace DiscordChatExporter.Gui.Views.Controls;
public partial class RevealablePasswordBox
{
public static readonly DependencyProperty PasswordProperty = DependencyProperty.Register(
nameof(Password),
typeof(string),
typeof(RevealablePasswordBox),
new FrameworkPropertyMetadata(string.Empty, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault)
);
public static readonly DependencyProperty IsRevealedProperty = DependencyProperty.Register(
nameof(IsRevealed),
typeof(bool),
typeof(RevealablePasswordBox),
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.None)
);
public string Password
{
get => (string)GetValue(PasswordProperty);
set => SetValue(PasswordProperty, value);
}
public bool IsRevealed
{
get => (bool)GetValue(IsRevealedProperty);
set => SetValue(IsRevealedProperty, value);
}
public RevealablePasswordBox()
{
InitializeComponent();
}
}
@@ -60,7 +60,7 @@
FontWeight="Light" FontWeight="Light"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}"> Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<Run Text="{Binding Channels[0].Category.Name, Mode=OneWay}" ToolTip="{Binding Channels[0].Category.Name, Mode=OneWay}" /> <Run Text="{Binding Channels[0].Category, Mode=OneWay}" ToolTip="{Binding Channels[0].Category, Mode=OneWay}" />
<Run Text="/" /> <Run Text="/" />
<Run <Run
FontWeight="SemiBold" FontWeight="SemiBold"
@@ -19,7 +19,7 @@
<TextBlock <TextBlock
Grid.Row="0" Grid.Row="0"
Margin="16,16,16,8" Margin="16"
FontSize="19" FontSize="19"
FontWeight="Light" FontWeight="Light"
Text="Settings" /> Text="Settings" />
@@ -60,10 +60,10 @@
<ToggleButton <ToggleButton
x:Name="DarkModeToggleButton" x:Name="DarkModeToggleButton"
VerticalAlignment="Center" VerticalAlignment="Center"
Checked="DarkModeToggleButton_Checked" Checked="DarkModeToggleButton_OnChecked"
DockPanel.Dock="Right" DockPanel.Dock="Right"
IsChecked="{Binding IsDarkModeEnabled}" IsChecked="{Binding IsDarkModeEnabled}"
Unchecked="DarkModeToggleButton_Unchecked" /> Unchecked="DarkModeToggleButton_OnUnchecked" />
</DockPanel> </DockPanel>
<!-- Persist token --> <!-- Persist token -->
@@ -71,7 +71,7 @@
Margin="16,8" Margin="16,8"
Background="Transparent" Background="Transparent"
LastChildFill="False" LastChildFill="False"
ToolTip="Save last used token in a file so that it can be persisted between sessions"> ToolTip="Save the last used token to a file so that it can be persisted between sessions">
<TextBlock <TextBlock
VerticalAlignment="Center" VerticalAlignment="Center"
DockPanel.Dock="Left" DockPanel.Dock="Left"
@@ -132,13 +132,13 @@
</ScrollViewer> </ScrollViewer>
</Border> </Border>
<!-- Save button --> <!-- Close button -->
<Button <Button
Grid.Row="2" Grid.Row="2"
Margin="16" Margin="16"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Command="{s:Action Close}" Command="{s:Action Close}"
Content="SAVE" Content="CLOSE"
IsCancel="True" IsCancel="True"
IsDefault="True" IsDefault="True"
Style="{DynamicResource MaterialDesignOutlinedButton}" /> Style="{DynamicResource MaterialDesignOutlinedButton}" />
@@ -9,9 +9,9 @@ public partial class SettingsView
InitializeComponent(); InitializeComponent();
} }
private void DarkModeToggleButton_Checked(object sender, RoutedEventArgs e) => private void DarkModeToggleButton_OnChecked(object sender, RoutedEventArgs args) =>
App.SetDarkTheme(); App.SetDarkTheme();
private void DarkModeToggleButton_Unchecked(object sender, RoutedEventArgs e) => private void DarkModeToggleButton_OnUnchecked(object sender, RoutedEventArgs args) =>
App.SetLightTheme(); App.SetLightTheme();
} }
+6 -2
View File
@@ -1,5 +1,6 @@
# DiscordChatExporter # DiscordChatExporter
[![Status](https://img.shields.io/badge/status-maintenance-ffd700.svg)](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)
[![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://tyrrrz.me/ukraine) [![Made in Ukraine](https://img.shields.io/badge/made_in-ukraine-ffd700.svg?labelColor=0057b7)](https://tyrrrz.me/ukraine)
[![Build](https://img.shields.io/github/actions/workflow/status/Tyrrrz/DiscordChatExporter/main.yml?branch=master)](https://github.com/Tyrrrz/DiscordChatExporter/actions) [![Build](https://img.shields.io/github/actions/workflow/status/Tyrrrz/DiscordChatExporter/main.yml?branch=master)](https://github.com/Tyrrrz/DiscordChatExporter/actions)
[![Coverage](https://img.shields.io/codecov/c/github/Tyrrrz/DiscordChatExporter/master)](https://codecov.io/gh/Tyrrrz/DiscordChatExporter) [![Coverage](https://img.shields.io/codecov/c/github/Tyrrrz/DiscordChatExporter/master)](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
@@ -7,10 +8,13 @@
[![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases) [![Downloads](https://img.shields.io/github/downloads/Tyrrrz/DiscordChatExporter/total.svg)](https://github.com/Tyrrrz/DiscordChatExporter/releases)
[![Pulls](https://img.shields.io/docker/pulls/tyrrrz/discordchatexporter)](https://hub.docker.com/r/tyrrrz/discordchatexporter) [![Pulls](https://img.shields.io/docker/pulls/tyrrrz/discordchatexporter)](https://hub.docker.com/r/tyrrrz/discordchatexporter)
[![Discord](https://img.shields.io/discord/869237470565392384?label=discord)](https://discord.gg/2SUWKFnHSm) [![Discord](https://img.shields.io/discord/869237470565392384?label=discord)](https://discord.gg/2SUWKFnHSm)
[![Donate](https://img.shields.io/badge/donate-$$$-8a2be2.svg)](https://tyrrrz.me/donate)
[![Fuck Russia](https://img.shields.io/badge/fuck-russia-e4181c.svg?labelColor=000000)](https://twitter.com/tyrrrz/status/1495972128977571848) [![Fuck Russia](https://img.shields.io/badge/fuck-russia-e4181c.svg?labelColor=000000)](https://twitter.com/tyrrrz/status/1495972128977571848)
> 🟡 **Project status**: maintenance mode<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)</sup> <table>
<tr>
<td width="99999" align="center">Development of this project is entirely funded by the community. <b><a href="https://tyrrrz.me/donate">Consider donating to support!</a></b></td>
</tr>
</table>
<p align="center"> <p align="center">
<img src="favicon.png" alt="Icon" /> <img src="favicon.png" alt="Icon" />