Better support for exporting threads (#1046)

pull/1107/head
William Unsworth 1 year ago committed by GitHub
parent c69211797f
commit 8776a6955b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -11,6 +11,7 @@ using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Exceptions; using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering; using DiscordChatExporter.Core.Exporting.Filtering;
@ -136,7 +137,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
private ChannelExporter? _channelExporter; private ChannelExporter? _channelExporter;
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord); protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord);
protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> channels) protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<IChannel> channels)
{ {
// Asset reuse can only be enabled if the download assets option is set // Asset reuse can only be enabled if the download assets option is set
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425 // https://github.com/Tyrrrz/DiscordChatExporter/issues/425
@ -175,9 +176,9 @@ public abstract class ExportCommandBase : DiscordCommandBase
); );
} }
// Export // Export channels
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
var errors = new ConcurrentDictionary<Channel, string>(); var channelErrors = new ConcurrentDictionary<IChannel, string>();
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)..."); await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
await console.CreateProgressTicker().StartAsync(async progressContext => await console.CreateProgressTicker().StartAsync(async progressContext =>
@ -194,7 +195,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
try try
{ {
await progressContext.StartTaskAsync( await progressContext.StartTaskAsync(
$"{channel.Category.Name} / {channel.Name}", $"{channel.ParentName} / {channel.Name}",
async progress => async progress =>
{ {
var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken); var guild = await Discord.GetGuildAsync(channel.GuildId, innerCancellationToken);
@ -225,7 +226,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
} }
catch (DiscordChatExporterException ex) when (!ex.IsFatal) catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{ {
errors[channel] = ex.Message; channelErrors[channel] = ex.Message;
} }
} }
); );
@ -235,25 +236,25 @@ public abstract class ExportCommandBase : DiscordCommandBase
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
{ {
await console.Output.WriteLineAsync( await console.Output.WriteLineAsync(
$"Successfully exported {channels.Count - errors.Count} channel(s)." $"Successfully exported {channels.Count - channelErrors.Count} channel(s)."
); );
} }
// Print errors // Print errors
if (errors.Any()) if (channelErrors.Any())
{ {
await console.Output.WriteLineAsync(); await console.Output.WriteLineAsync();
using (console.WithForegroundColor(ConsoleColor.Red)) using (console.WithForegroundColor(ConsoleColor.Red))
{ {
await console.Error.WriteLineAsync( await console.Error.WriteLineAsync(
$"Failed to export {errors.Count} channel(s):" $"Failed to export {channelErrors.Count} channel(s):"
); );
} }
foreach (var (channel, error) in errors) foreach (var (channel, error) in channelErrors)
{ {
await console.Error.WriteAsync($"{channel.Category.Name} / {channel.Name}: "); await console.Error.WriteAsync($"{channel.ParentName} / {channel.Name}: ");
using (console.WithForegroundColor(ConsoleColor.Red)) using (console.WithForegroundColor(ConsoleColor.Red))
await console.Error.WriteLineAsync(error); await console.Error.WriteLineAsync(error);
@ -264,7 +265,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
// Fail the command only if ALL channels failed to export. // Fail the command only if ALL channels failed to export.
// If only some channels failed to export, it's okay. // If only some channels failed to export, it's okay.
if (errors.Count >= channels.Count) if (channelErrors.Count >= channels.Count)
throw new CommandException("Export failed."); throw new CommandException("Export failed.");
} }

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO.Compression; using System.IO.Compression;
using System.Text.Json; using System.Text.Json;

@ -1,4 +1,5 @@
using System.Threading.Tasks; using System;
using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;

@ -1,4 +1,5 @@
using System.Linq; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
@ -18,13 +19,14 @@ public class ExportGuildCommand : ExportCommandBase
Description = "Guild ID." Description = "Guild ID."
)] )]
public required Snowflake GuildId { get; init; } public required Snowflake GuildId { get; init; }
[CommandOption( [CommandOption(
"include-vc", "include-vc",
Description = "Include voice channels." Description = "Include voice channels."
)] )]
public bool IncludeVoiceChannels { get; init; } = true; 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);

@ -11,12 +11,15 @@ public partial record Channel(
Snowflake Id, Snowflake Id,
ChannelKind Kind, ChannelKind Kind,
Snowflake GuildId, Snowflake GuildId,
Snowflake ParentId,
string? ParentName,
int? ParentPosition,
ChannelCategory Category, ChannelCategory Category,
string Name, string Name,
int? Position, int? Position,
string? IconUrl, string? IconUrl,
string? Topic, string? Topic,
Snowflake? LastMessageId) : IHasId Snowflake? LastMessageId) : IChannel
{ {
// Only needed for WPF data binding. Don't use anywhere else. // Only needed for WPF data binding. Don't use anywhere else.
public bool IsVoice => Kind.IsVoice(); public bool IsVoice => Kind.IsVoice();
@ -24,7 +27,7 @@ public partial record Channel(
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, ChannelCategory? categoryHint = null, int? positionHint = null, string? parentName = null, int? parentPosition = 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();
@ -33,6 +36,8 @@ 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 parentId = json.GetProperty("parent_id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var category = categoryHint ?? ChannelCategory.CreateDefault(kind); var category = categoryHint ?? ChannelCategory.CreateDefault(kind);
var name = var name =
@ -70,6 +75,9 @@ public partial record Channel
id, id,
kind, kind,
guildId, guildId,
parentId,
parentName,
parentPosition,
category, category,
name, name,
position, position,

@ -11,11 +11,17 @@ public record ChannelThread(
ChannelKind Kind, ChannelKind Kind,
Snowflake GuildId, Snowflake GuildId,
Snowflake ParentId, Snowflake ParentId,
string? ParentName,
string Name, string Name,
bool IsActive, bool IsActive,
Snowflake? LastMessageId) : IHasId Snowflake? LastMessageId) : IChannel
{ {
public static ChannelThread Parse(JsonElement json) public int? ParentPosition => null;
public int? Position => null;
public string? IconUrl => null;
public string? Topic => null;
public static ChannelThread Parse(JsonElement json, string parentName)
{ {
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();
@ -23,6 +29,7 @@ public record ChannelThread(
var parentId = json.GetProperty("parent_id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var parentId = json.GetProperty("parent_id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var name = json.GetProperty("name").GetNonWhiteSpaceString(); var name = json.GetProperty("name").GetNonWhiteSpaceString();
var isActive = !json var isActive = !json
.GetPropertyOrNull("thread_metadata")? .GetPropertyOrNull("thread_metadata")?
.GetPropertyOrNull("archived")? .GetPropertyOrNull("archived")?
@ -38,6 +45,7 @@ public record ChannelThread(
kind, kind,
guildId, guildId,
parentId, parentId,
parentName,
name, name,
isActive, isActive,
lastMessageId lastMessageId

@ -0,0 +1,15 @@
namespace DiscordChatExporter.Core.Discord.Data.Common;
public interface IChannel : IHasId
{
ChannelKind Kind { get; }
Snowflake GuildId { get; }
Snowflake ParentId { get; }
string? ParentName { get; }
int? ParentPosition { get; }
string Name { get; }
int? Position { get; }
string? IconUrl { get; }
string? Topic { get; }
Snowflake? LastMessageId { get; }
}

@ -238,7 +238,7 @@ public class DiscordClient
? categories.GetValueOrDefault(parentId) ? categories.GetValueOrDefault(parentId)
: null; : null;
yield return Channel.Parse(channelJson, category, position); yield return Channel.Parse(channelJson, category, position, category?.Name, category?.Position);
position++; position++;
} }
} }
@ -270,7 +270,7 @@ public class DiscordClient
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 ChannelThread.Parse(threadJson, channel.Name);
currentOffset++; currentOffset++;
} }
@ -286,7 +286,11 @@ public class DiscordClient
{ {
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 parentId = threadJson.GetProperty("parent_id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
var parentChannel = channels.First(t => t.Id == parentId);
yield return ChannelThread.Parse(threadJson, parentChannel.Name);
}
} }
foreach (var channel in channels) foreach (var channel in channels)
@ -299,7 +303,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 ChannelThread.Parse(threadJson, channel.Name);
} }
// Private archived threads // Private archived threads
@ -310,7 +314,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 ChannelThread.Parse(threadJson, channel.Name);
} }
} }
} }
@ -380,7 +384,7 @@ public class DiscordClient
? await GetChannelCategoryAsync(parentId.Value, cancellationToken) ? await GetChannelCategoryAsync(parentId.Value, cancellationToken)
: null; : null;
return Channel.Parse(response, category); return Channel.Parse(response, category, parentName: category?.Name, parentPosition: category?.Position);
} }
private async ValueTask<Message?> TryGetLastMessageAsync( private async ValueTask<Message?> TryGetLastMessageAsync(

@ -4,6 +4,7 @@ using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Exporting.Filtering; using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning; using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils; using DiscordChatExporter.Core.Utils;
@ -14,7 +15,7 @@ public partial class ExportRequest
{ {
public Guild Guild { get; } public Guild Guild { get; }
public Channel Channel { get; } public IChannel Channel { get; }
public string OutputFilePath { get; } public string OutputFilePath { get; }
@ -42,7 +43,7 @@ public partial class ExportRequest
public ExportRequest( public ExportRequest(
Guild guild, Guild guild,
Channel channel, IChannel channel,
string outputPath, string outputPath,
string? assetsDirPath, string? assetsDirPath,
ExportFormat format, ExportFormat format,
@ -94,7 +95,7 @@ public partial class ExportRequest
{ {
public static string GetDefaultOutputFileName( public static string GetDefaultOutputFileName(
Guild guild, Guild guild,
Channel channel, IChannel channel,
ExportFormat format, ExportFormat format,
Snowflake? after = null, Snowflake? after = null,
Snowflake? before = null) Snowflake? before = null)
@ -102,7 +103,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.ParentName} - {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)
@ -137,7 +138,7 @@ public partial class ExportRequest
private static string FormatPath( private static string FormatPath(
string path, string path,
Guild guild, Guild guild,
Channel channel, IChannel channel,
Snowflake? after, Snowflake? after,
Snowflake? before) Snowflake? before)
{ {
@ -148,12 +149,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.ParentId.ToString(),
"%T" => channel.Category.Name, "%T" => channel.ParentName ?? "",
"%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.ParentPosition?.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"),
@ -165,7 +166,7 @@ public partial class ExportRequest
private static string GetOutputBaseFilePath( private static string GetOutputBaseFilePath(
Guild guild, Guild guild,
Channel channel, IChannel channel,
string outputPath, string outputPath,
ExportFormat format, ExportFormat format,
Snowflake? after = null, Snowflake? after = null,

@ -241,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.ParentId.ToString());
_writer.WriteString("category", Context.Request.Channel.Category.Name); _writer.WriteString("category", Context.Request.Channel.ParentName);
_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);

@ -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.ParentName} / {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.ParentName / @Context.Request.Channel.Name</div>
@if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic)) @if (!string.IsNullOrWhiteSpace(Context.Request.Channel.Topic))
{ {

Loading…
Cancel
Save