This commit is contained in:
Tyrrrz 2023-07-16 22:55:36 +03:00
parent 0ee1107638
commit e175c93038
19 changed files with 106 additions and 206 deletions

View file

@ -11,7 +11,6 @@ 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;
@ -137,7 +136,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<IChannel> channels) protected async ValueTask ExecuteAsync(IConsole console, IReadOnlyList<Channel> 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
@ -176,9 +175,9 @@ public abstract class ExportCommandBase : DiscordCommandBase
); );
} }
// Export channels // Export
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
var channelErrors = new ConcurrentDictionary<IChannel, string>(); var errors = new ConcurrentDictionary<Channel, 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 =>
@ -195,7 +194,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
try try
{ {
await progressContext.StartTaskAsync( await progressContext.StartTaskAsync(
$"{channel.ParentName} / {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);
@ -226,7 +225,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
} }
catch (DiscordChatExporterException ex) when (!ex.IsFatal) catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{ {
channelErrors[channel] = ex.Message; errors[channel] = ex.Message;
} }
} }
); );
@ -236,25 +235,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 - channelErrors.Count} channel(s)." $"Successfully exported {channels.Count - errors.Count} channel(s)."
); );
} }
// Print errors // Print errors
if (channelErrors.Any()) if (errors.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 {channelErrors.Count} channel(s):" $"Failed to export {errors.Count} channel(s):"
); );
} }
foreach (var (channel, error) in channelErrors) foreach (var (channel, error) in errors)
{ {
await console.Error.WriteAsync($"{channel.ParentName} / {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);
@ -265,7 +264,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 (channelErrors.Count >= channels.Count) if (errors.Count >= channels.Count)
throw new CommandException("Export failed."); throw new CommandException("Export failed.");
} }
@ -291,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);
} }

View file

@ -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;

View file

@ -1,4 +1,3 @@
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;

View file

@ -1,5 +1,4 @@
using System; using System.Threading.Tasks;
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;

View file

@ -1,5 +1,4 @@
using System; using System.Linq;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
@ -19,7 +18,7 @@ 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."

View file

@ -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");
} }
} }
} }

View file

@ -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}");
} }
} }
} }

View file

@ -11,23 +11,37 @@ public partial record Channel(
Snowflake Id, Snowflake Id,
ChannelKind Kind, ChannelKind Kind,
Snowflake GuildId, Snowflake GuildId,
Snowflake? ParentId, Channel? Parent,
string? ParentName,
int? ParentPosition,
ChannelCategory Category,
string Name, string Name,
int? Position, int? Position,
string? IconUrl, string? IconUrl,
string? Topic, string? Topic,
Snowflake? LastMessageId) : IChannel bool IsActive,
Snowflake? LastMessageId)
{ {
// Used for visual backwards-compatibility with old exports, where
// channels without a parent (i.e. mostly DM channels) 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. // Only needed for WPF data binding. Don't use anywhere else.
public bool IsVoice => Kind.IsVoice(); 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, string? parentName = null, int? parentPosition = 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();
@ -36,10 +50,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 parentId = json.GetPropertyOrNull("parent_id")?.GetNonWhiteSpaceStringOrNull()?.Pipe(Snowflake.Parse);
var category = categoryHint ?? ChannelCategory.CreateDefault(kind);
var name = var name =
// Guild channel // Guild channel
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ??
@ -66,6 +76,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()?
@ -75,14 +90,12 @@ public partial record Channel
id, id,
kind, kind,
guildId, guildId,
parentId, parent,
parentName,
parentPosition,
category,
name, name,
position, position,
iconUrl, iconUrl,
topic, topic,
isActive,
lastMessageId lastMessageId
); );
} }

View file

@ -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);
}
}

View file

@ -1,53 +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? ParentName,
string Name,
bool IsActive,
Snowflake? LastMessageId) : IChannel
{
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 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,
parentName,
name,
isActive,
lastMessageId
);
}
}

View file

@ -1,15 +0,0 @@
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; }
}

View file

@ -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)
{ {

View file

@ -215,14 +215,14 @@ public class DiscordClient
var channelsJson = response var channelsJson = response
.EnumerateArray() .EnumerateArray()
.OrderBy(j => j.GetProperty("position").GetInt32()) .OrderBy(c => c.GetProperty("position").GetInt32())
.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 +230,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, category?.Name, 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 +262,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, channel.Name); yield return Channel.Parse(threadJson, channel);
currentOffset++; currentOffset++;
} }
@ -284,12 +283,18 @@ 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())
{ {
var parentId = threadJson.GetProperty("parent_id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var parent = threadJson
var parentChannel = channels.First(t => t.Id == parentId); .GetPropertyOrNull("parent_id")?
yield return ChannelThread.Parse(threadJson, parentChannel.Name); .GetNonWhiteSpaceStringOrNull()?
.Pipe(Snowflake.Parse)
.Pipe(parentsById.GetValueOrDefault);
yield return Channel.Parse(threadJson, parent);
} }
} }
@ -303,7 +308,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, channel.Name); yield return Channel.Parse(threadJson, channel);
} }
// Private archived threads // Private archived threads
@ -314,7 +319,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, channel.Name); yield return Channel.Parse(threadJson, channel);
} }
} }
} }
@ -352,41 +357,33 @@ public class DiscordClient
return response?.Pipe(Invite.Parse); return response?.Pipe(Invite.Parse);
} }
public async ValueTask<ChannelCategory> GetChannelCategoryAsync( public async ValueTask<Channel?> TryGetChannelAsync(
Snowflake channelId, Snowflake channelId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
try var response = await TryGetJsonResponseAsync($"channels/{channelId}", cancellationToken);
{ if (response is null)
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken); return null;
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(
Snowflake channelId,
CancellationToken cancellationToken = default)
{
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
var parentId = response var parentId = response
.Value
.GetPropertyOrNull("parent_id")? .GetPropertyOrNull("parent_id")?
.GetNonWhiteSpaceStringOrNull()? .GetNonWhiteSpaceStringOrNull()?
.Pipe(Snowflake.Parse); .Pipe(Snowflake.Parse);
var category = parentId is not null var parent = parentId is not null
? await GetChannelCategoryAsync(parentId.Value, cancellationToken) ? await TryGetChannelAsync(parentId.Value, cancellationToken)
: null; : null;
return Channel.Parse(response, category, parentName: category?.Name, parentPosition: category?.Position); return Channel.Parse(response.Value, parent);
} }
public async ValueTask<Channel> GetChannelAsync(
Snowflake channelId,
CancellationToken cancellationToken = default) =>
await TryGetChannelAsync(channelId, cancellationToken) ??
throw new InvalidOperationException($"Channel {channelId} not found.");
private async ValueTask<Message?> TryGetLastMessageAsync( private async ValueTask<Message?> TryGetLastMessageAsync(
Snowflake channelId, Snowflake channelId,
Snowflake? before = null, Snowflake? before = null,

View file

@ -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

View file

@ -4,7 +4,6 @@ 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;
@ -15,7 +14,7 @@ public partial class ExportRequest
{ {
public Guild Guild { get; } public Guild Guild { get; }
public IChannel Channel { get; } public Channel Channel { get; }
public string OutputFilePath { get; } public string OutputFilePath { get; }
@ -43,7 +42,7 @@ public partial class ExportRequest
public ExportRequest( public ExportRequest(
Guild guild, Guild guild,
IChannel channel, Channel channel,
string outputPath, string outputPath,
string? assetsDirPath, string? assetsDirPath,
ExportFormat format, ExportFormat format,
@ -95,7 +94,7 @@ public partial class ExportRequest
{ {
public static string GetDefaultOutputFileName( public static string GetDefaultOutputFileName(
Guild guild, Guild guild,
IChannel channel, Channel channel,
ExportFormat format, ExportFormat format,
Snowflake? after = null, Snowflake? after = null,
Snowflake? before = null) Snowflake? before = null)
@ -103,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.ParentName} - {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)
@ -138,7 +137,7 @@ public partial class ExportRequest
private static string FormatPath( private static string FormatPath(
string path, string path,
Guild guild, Guild guild,
IChannel channel, Channel channel,
Snowflake? after, Snowflake? after,
Snowflake? before) Snowflake? before)
{ {
@ -149,12 +148,12 @@ public partial class ExportRequest
{ {
"%g" => guild.Id.ToString(), "%g" => guild.Id.ToString(),
"%G" => guild.Name, "%G" => guild.Name,
"%t" => channel.ParentId.ToString() ?? "", "%t" => channel.Parent?.Id.ToString() ?? "",
"%T" => channel.ParentName ?? "", "%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.ParentPosition?.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"),
@ -166,7 +165,7 @@ public partial class ExportRequest
private static string GetOutputBaseFilePath( private static string GetOutputBaseFilePath(
Guild guild, Guild guild,
IChannel channel, Channel channel,
string outputPath, string outputPath,
ExportFormat format, ExportFormat format,
Snowflake? after = null, Snowflake? after = null,

View file

@ -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.ParentId.ToString()); _writer.WriteString("categoryId", Context.Request.Channel.Parent?.Id.ToString());
_writer.WriteString("category", Context.Request.Channel.ParentName); _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);

View file

@ -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.ParentName} / {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))
{ {

View file

@ -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.ParentName / @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))
{ {

View file

@ -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"