diff --git a/DiscordChatExporter.Cli/Commands/Base/DiscordCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/DiscordCommandBase.cs index a042bb0a..049d573a 100644 --- a/DiscordChatExporter.Cli/Commands/Base/DiscordCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/DiscordCommandBase.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using CliFx; using CliFx.Attributes; @@ -34,9 +35,9 @@ public abstract class DiscordCommandBase : ICommand )] public bool ShouldRespectRateLimits { get; init; } = true; - private DiscordClient? _discordClient; + [field: AllowNull, MaybeNull] protected DiscordClient Discord => - _discordClient ??= new DiscordClient( + field ??= new DiscordClient( Token, ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll ); diff --git a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs index e386c7f4..2d64bd00 100644 --- a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -16,7 +17,6 @@ using DiscordChatExporter.Core.Exceptions; using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting.Filtering; using DiscordChatExporter.Core.Exporting.Partitioning; -using DiscordChatExporter.Core.Utils.Extensions; using Gress; using Spectre.Console; @@ -24,8 +24,6 @@ namespace DiscordChatExporter.Cli.Commands.Base; public abstract class ExportCommandBase : DiscordCommandBase { - private readonly string _outputPath = Directory.GetCurrentDirectory(); - [CommandOption( "output", 'o', @@ -36,11 +34,11 @@ public abstract class ExportCommandBase : DiscordCommandBase )] public string OutputPath { - get => _outputPath; + get; // Handle ~/ in paths on Unix systems // https://github.com/Tyrrrz/DiscordChatExporter/pull/903 - init => _outputPath = Path.GetFullPath(value); - } + init => field = Path.GetFullPath(value); + } = Directory.GetCurrentDirectory(); [CommandOption("format", 'f', Description = "Export format.")] public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark; @@ -103,8 +101,6 @@ public abstract class ExportCommandBase : DiscordCommandBase )] public bool ShouldReuseAssets { get; init; } = false; - private readonly string? _assetsDirPath; - [CommandOption( "media-dir", Description = "Download assets to this directory. " @@ -112,10 +108,10 @@ public abstract class ExportCommandBase : DiscordCommandBase )] public string? AssetsDirPath { - get => _assetsDirPath; + get; // Handle ~/ in paths on Unix systems // https://github.com/Tyrrrz/DiscordChatExporter/pull/903 - init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null; + init => field = value is not null ? Path.GetFullPath(value) : null; } [Obsolete("This option doesn't do anything. Kept for backwards compatibility.")] @@ -144,8 +140,8 @@ public abstract class ExportCommandBase : DiscordCommandBase )] public bool IsUkraineSupportMessageDisabled { get; init; } = false; - private ChannelExporter? _channelExporter; - protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord); + [field: AllowNull, MaybeNull] + protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord); protected async ValueTask ExportAsync(IConsole console, IReadOnlyList channels) { @@ -353,44 +349,6 @@ public abstract class ExportCommandBase : DiscordCommandBase throw new CommandException("Export failed."); } - protected async ValueTask ExportAsync(IConsole console, IReadOnlyList channelIds) - { - var cancellationToken = console.RegisterCancellationHandler(); - - await console.Output.WriteLineAsync("Resolving channel(s)..."); - - var channels = new List(); - var channelsByGuild = new Dictionary>(); - - foreach (var channelId in channelIds) - { - var channel = await Discord.GetChannelAsync(channelId, cancellationToken); - - // Unwrap categories - if (channel.IsCategory) - { - var guildChannels = - channelsByGuild.GetValueOrDefault(channel.GuildId) - ?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken); - - foreach (var guildChannel in guildChannels) - { - if (guildChannel.Parent?.Id == channel.Id) - channels.Add(guildChannel); - } - - // Cache the guild channels to avoid redundant work - channelsByGuild[channel.GuildId] = guildChannels; - } - else - { - channels.Add(channel); - } - } - - await ExportAsync(console, channels); - } - public override async ValueTask ExecuteAsync(IConsole console) { // Support Ukraine callout diff --git a/DiscordChatExporter.Cli/Commands/ExportAllCommand.cs b/DiscordChatExporter.Cli/Commands/ExportAllCommand.cs index 293fea7e..5d156e84 100644 --- a/DiscordChatExporter.Cli/Commands/ExportAllCommand.cs +++ b/DiscordChatExporter.Cli/Commands/ExportAllCommand.cs @@ -5,8 +5,6 @@ using System.Threading.Tasks; using CliFx.Attributes; using CliFx.Infrastructure; using DiscordChatExporter.Cli.Commands.Base; -using DiscordChatExporter.Cli.Commands.Converters; -using DiscordChatExporter.Cli.Commands.Shared; using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Dump; diff --git a/DiscordChatExporter.Cli/Commands/ExportChannelsCommand.cs b/DiscordChatExporter.Cli/Commands/ExportChannelsCommand.cs index 982e587e..a0e1e94c 100644 --- a/DiscordChatExporter.Cli/Commands/ExportChannelsCommand.cs +++ b/DiscordChatExporter.Cli/Commands/ExportChannelsCommand.cs @@ -1,16 +1,11 @@ using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using CliFx.Attributes; using CliFx.Infrastructure; using DiscordChatExporter.Cli.Commands.Base; -using DiscordChatExporter.Cli.Commands.Converters; -using DiscordChatExporter.Cli.Commands.Shared; -using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Utils.Extensions; -using Spectre.Console; namespace DiscordChatExporter.Cli.Commands; diff --git a/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs b/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs index 64f18395..90eb3d79 100644 --- a/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs +++ b/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs @@ -3,8 +3,6 @@ using System.Threading.Tasks; using CliFx.Attributes; using CliFx.Infrastructure; using DiscordChatExporter.Cli.Commands.Base; -using DiscordChatExporter.Cli.Commands.Converters; -using DiscordChatExporter.Cli.Commands.Shared; using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; @@ -28,7 +26,6 @@ public class ExportGuildCommand : ExportCommandBase var cancellationToken = console.RegisterCancellationHandler(); var channels = new List(); - // Regular channels await console.Output.WriteLineAsync("Fetching channels..."); var fetchedChannelsCount = 0; diff --git a/DiscordChatExporter.Core/Discord/DiscordClient.cs b/DiscordChatExporter.Core/Discord/DiscordClient.cs index 134ac01f..b9bbdac2 100644 --- a/DiscordChatExporter.Core/Discord/DiscordClient.cs +++ b/DiscordChatExporter.Core/Discord/DiscordClient.cs @@ -321,6 +321,73 @@ public class DiscordClient( } } + public async IAsyncEnumerable GetGuildRolesAsync( + Snowflake guildId, + [EnumeratorCancellation] CancellationToken cancellationToken = default + ) + { + if (guildId == Guild.DirectMessages.Id) + yield break; + + var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken); + foreach (var roleJson in response.EnumerateArray()) + yield return Role.Parse(roleJson); + } + + public async ValueTask TryGetGuildMemberAsync( + Snowflake guildId, + Snowflake memberId, + CancellationToken cancellationToken = default + ) + { + if (guildId == Guild.DirectMessages.Id) + return null; + + var response = await TryGetJsonResponseAsync( + $"guilds/{guildId}/members/{memberId}", + cancellationToken + ); + return response?.Pipe(j => Member.Parse(j, guildId)); + } + + public async ValueTask TryGetInviteAsync( + string code, + CancellationToken cancellationToken = default + ) + { + var response = await TryGetJsonResponseAsync($"invites/{code}", cancellationToken); + return response?.Pipe(Invite.Parse); + } + + public async ValueTask GetChannelAsync( + Snowflake channelId, + CancellationToken cancellationToken = default + ) + { + var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken); + + var parentId = response + .GetPropertyOrNull("parent_id") + ?.GetNonWhiteSpaceStringOrNull() + ?.Pipe(Snowflake.Parse); + + try + { + var parent = parentId is not null + ? await GetChannelAsync(parentId.Value, cancellationToken) + : null; + + 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); + } + } + public async IAsyncEnumerable GetChannelThreadsAsync( IEnumerable channels, bool includeArchived = false, @@ -329,7 +396,7 @@ public class DiscordClient( [EnumeratorCancellation] CancellationToken cancellationToken = default ) { - Channel[] filteredChannels = channels + var filteredChannels = channels // Categories cannot have threads .Where(c => !c.IsCategory) // Voice channels cannot have threads @@ -478,73 +545,6 @@ public class DiscordClient( } } - public async IAsyncEnumerable GetGuildRolesAsync( - Snowflake guildId, - [EnumeratorCancellation] CancellationToken cancellationToken = default - ) - { - if (guildId == Guild.DirectMessages.Id) - yield break; - - var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken); - foreach (var roleJson in response.EnumerateArray()) - yield return Role.Parse(roleJson); - } - - public async ValueTask TryGetGuildMemberAsync( - Snowflake guildId, - Snowflake memberId, - CancellationToken cancellationToken = default - ) - { - if (guildId == Guild.DirectMessages.Id) - return null; - - var response = await TryGetJsonResponseAsync( - $"guilds/{guildId}/members/{memberId}", - cancellationToken - ); - return response?.Pipe(j => Member.Parse(j, guildId)); - } - - public async ValueTask TryGetInviteAsync( - string code, - CancellationToken cancellationToken = default - ) - { - var response = await TryGetJsonResponseAsync($"invites/{code}", cancellationToken); - return response?.Pipe(Invite.Parse); - } - - public async ValueTask GetChannelAsync( - Snowflake channelId, - CancellationToken cancellationToken = default - ) - { - var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken); - - var parentId = response - .GetPropertyOrNull("parent_id") - ?.GetNonWhiteSpaceStringOrNull() - ?.Pipe(Snowflake.Parse); - - try - { - var parent = parentId is not null - ? await GetChannelAsync(parentId.Value, cancellationToken) - : null; - - 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 TryGetLastMessageAsync( Snowflake channelId, Snowflake? before = null,