From 017ed5ae6dc668fb26c77fb2729d03356feaf3eb Mon Sep 17 00:00:00 2001 From: Tyrrrz Date: Tue, 23 Mar 2021 22:38:44 +0200 Subject: [PATCH] [CLI] Update CliFx and use Spectre.Console for progress reporting --- .../Commands/Base/ExportCommandBase.cs | 39 +++----- .../Base/ExportMultipleCommandBase.cs | 94 +++++++++---------- .../Commands/Base/TokenCommandBase.cs | 5 +- .../Commands/ExportAllCommand.cs | 8 +- .../Commands/ExportChannelCommand.cs | 17 +++- .../Commands/ExportDirectMessagesCommand.cs | 8 +- .../Commands/ExportGuildCommand.cs | 8 +- .../Commands/GetChannelsCommand.cs | 21 +++-- .../GetDirectMessageChannelsCommand.cs | 20 +++- .../Commands/GetGuildsCommand.cs | 20 +++- .../Commands/GuideCommand.cs | 36 +++---- .../DiscordChatExporter.Cli.csproj | 4 +- .../Utils/Extensions/ConsoleExtensions.cs | 30 ++++++ 13 files changed, 191 insertions(+), 119 deletions(-) create mode 100644 DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs diff --git a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs index 4527add..6fecab3 100644 --- a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs @@ -1,12 +1,12 @@ using System.IO; using System.Threading.Tasks; -using CliFx; using CliFx.Attributes; using CliFx.Exceptions; -using CliFx.Utilities; +using CliFx.Infrastructure; using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Exporting; +using Spectre.Console; namespace DiscordChatExporter.Cli.Commands.Base { @@ -39,14 +39,8 @@ namespace DiscordChatExporter.Cli.Commands.Base private ChannelExporter? _channelExporter; protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord); - protected async ValueTask ExportAsync(IConsole console, Guild guild, Channel channel) + protected async ValueTask ExportChannelAsync(Guild guild, Channel channel, ProgressContext progressContext) { - await console.Output.WriteAsync( - $"Exporting channel '{channel.Category} / {channel.Name}'... " - ); - - var progress = console.CreateProgressTicker(); - var request = new ExportRequest( guild, channel, @@ -60,22 +54,19 @@ namespace DiscordChatExporter.Cli.Commands.Base DateFormat ); - await Exporter.ExportChannelAsync(request, progress); - - await console.Output.WriteLineAsync(); - await console.Output.WriteLineAsync("Done."); - } - - protected async ValueTask ExportAsync(IConsole console, Channel channel) - { - var guild = await Discord.GetGuildAsync(channel.GuildId); - await ExportAsync(console, guild, channel); - } + var progress = progressContext.AddTask( + $"{channel.Category} / {channel.Name}", + new ProgressTaskSettings {MaxValue = 1} + ); - protected async ValueTask ExportAsync(IConsole console, Snowflake channelId) - { - var channel = await Discord.GetChannelAsync(channelId); - await ExportAsync(console, channel); + try + { + await Exporter.ExportChannelAsync(request, progress); + } + finally + { + progress.StopTask(); + } } public override ValueTask ExecuteAsync(IConsole console) diff --git a/DiscordChatExporter.Cli/Commands/Base/ExportMultipleCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/ExportMultipleCommandBase.cs index cbd46d0..ce46897 100644 --- a/DiscordChatExporter.Cli/Commands/Base/ExportMultipleCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/ExportMultipleCommandBase.cs @@ -1,16 +1,15 @@ -using System.Collections.Concurrent; +using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Threading; using System.Threading.Tasks; -using CliFx; using CliFx.Attributes; -using CliFx.Utilities; +using CliFx.Exceptions; +using CliFx.Infrastructure; +using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Exceptions; -using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Utils.Extensions; -using Gress; using Tyrrrz.Extensions; namespace DiscordChatExporter.Cli.Commands.Base @@ -20,63 +19,60 @@ namespace DiscordChatExporter.Cli.Commands.Base [CommandOption("parallel", Description = "Limits how many channels can be exported in parallel.")] public int ParallelLimit { get; init; } = 1; - protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList channels) + protected async ValueTask ExportChannelsAsync(IConsole console, IReadOnlyList channels) { - // This uses a different route from ExportCommandBase.ExportAsync() because it runs - // in parallel and needs another way to report progress to console. + await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)..."); - await console.Output.WriteAsync( - $"Exporting {channels.Count} channels... " - ); + var errors = new ConcurrentDictionary(); - var progress = console.CreateProgressTicker(); + await console.CreateProgressTicker().StartAsync(async progressContext => + { + await channels.ParallelForEachAsync(async channel => + { + try + { + var guild = await Discord.GetGuildAsync(channel.GuildId); + await ExportChannelAsync(guild, channel, progressContext); + } + catch (DiscordChatExporterException ex) when (!ex.IsCritical) + { + errors[channel] = ex.Message; + } + }, ParallelLimit.ClampMin(1)); - var operations = progress.Wrap().CreateOperations(channels.Count); + await console.Output.WriteLineAsync(); + }); - var successfulExportCount = 0; - var errors = new ConcurrentBag<(Channel, string)>(); + // Print result + await console.Output.WriteLineAsync( + $"Successfully exported {channels.Count - errors.Count} channel(s)." + ); - await channels.Zip(operations).ParallelForEachAsync(async tuple => + // Print errors + if (errors.Any()) { - var (channel, operation) = tuple; + using (console.WithForegroundColor(ConsoleColor.Red)) + await console.Output.WriteLineAsync($"Failed to export {errors.Count} channel(s):"); - try + foreach (var (channel, error) in errors) { - var guild = await Discord.GetGuildAsync(channel.GuildId); - - var request = new ExportRequest( - guild, - channel, - OutputPath, - ExportFormat, - After, - Before, - PartitionLimit, - ShouldDownloadMedia, - ShouldReuseMedia, - DateFormat - ); - - await Exporter.ExportChannelAsync(request, operation); + await console.Output.WriteAsync($"{channel.Category} / {channel.Name}: "); - Interlocked.Increment(ref successfulExportCount); + using (console.WithForegroundColor(ConsoleColor.Red)) + await console.Output.WriteLineAsync(error); } - catch (DiscordChatExporterException ex) when (!ex.IsCritical) - { - errors.Add((channel, ex.Message)); - } - finally - { - operation.Dispose(); - } - }, ParallelLimit.ClampMin(1)); - await console.Output.WriteLineAsync(); + await console.Output.WriteLineAsync(); + } - foreach (var (channel, error) in errors) - await console.Error.WriteLineAsync($"Channel '{channel}': {error}"); + // Fail the command if ALL channels failed to export. + // Having some of the channels fail to export is fine and expected. + if (errors.Count >= channels.Count) + { + throw new CommandException("Export failed."); + } - await console.Output.WriteLineAsync($"Successfully exported {successfulExportCount} channel(s)."); + await console.Output.WriteLineAsync("Done."); } } } \ No newline at end of file diff --git a/DiscordChatExporter.Cli/Commands/Base/TokenCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/TokenCommandBase.cs index 615136d..51c87ea 100644 --- a/DiscordChatExporter.Cli/Commands/Base/TokenCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/TokenCommandBase.cs @@ -1,16 +1,17 @@ using System.Threading.Tasks; using CliFx; using CliFx.Attributes; +using CliFx.Infrastructure; using DiscordChatExporter.Core.Discord; namespace DiscordChatExporter.Cli.Commands.Base { public abstract class TokenCommandBase : ICommand { - [CommandOption("token", 't', IsRequired = true, EnvironmentVariableName = "DISCORD_TOKEN", Description = "Authentication token.")] + [CommandOption("token", 't', IsRequired = true, EnvironmentVariable = "DISCORD_TOKEN", Description = "Authentication token.")] public string TokenValue { get; init; } = ""; - [CommandOption("bot", 'b', EnvironmentVariableName = "DISCORD_TOKEN_BOT", Description = "Authenticate as a bot.")] + [CommandOption("bot", 'b', EnvironmentVariable = "DISCORD_TOKEN_BOT", Description = "Authenticate as a bot.")] public bool IsBotToken { get; init; } private AuthToken GetAuthToken() => new( diff --git a/DiscordChatExporter.Cli/Commands/ExportAllCommand.cs b/DiscordChatExporter.Cli/Commands/ExportAllCommand.cs index 9628f1a..714e240 100644 --- a/DiscordChatExporter.Cli/Commands/ExportAllCommand.cs +++ b/DiscordChatExporter.Cli/Commands/ExportAllCommand.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; -using CliFx; using CliFx.Attributes; +using CliFx.Infrastructure; using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Core.Discord.Data; @@ -17,6 +17,9 @@ namespace DiscordChatExporter.Cli.Commands { await base.ExecuteAsync(console); + // Get channel metadata + await console.Output.WriteLineAsync("Fetching channels..."); + var channels = new List(); // Aggregate channels from all guilds @@ -32,7 +35,8 @@ namespace DiscordChatExporter.Cli.Commands } } - await ExportMultipleAsync(console, channels); + // Export + await ExportChannelsAsync(console, channels); } } } \ No newline at end of file diff --git a/DiscordChatExporter.Cli/Commands/ExportChannelCommand.cs b/DiscordChatExporter.Cli/Commands/ExportChannelCommand.cs index c14ea87..68f78c8 100644 --- a/DiscordChatExporter.Cli/Commands/ExportChannelCommand.cs +++ b/DiscordChatExporter.Cli/Commands/ExportChannelCommand.cs @@ -1,7 +1,8 @@ using System.Threading.Tasks; -using CliFx; using CliFx.Attributes; +using CliFx.Infrastructure; using DiscordChatExporter.Cli.Commands.Base; +using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Core.Discord; namespace DiscordChatExporter.Cli.Commands @@ -15,7 +16,19 @@ namespace DiscordChatExporter.Cli.Commands public override async ValueTask ExecuteAsync(IConsole console) { await base.ExecuteAsync(console); - await ExportAsync(console, ChannelId); + + // Get channel metadata + await console.Output.WriteLineAsync("Fetching channel..."); + var channel = await Discord.GetChannelAsync(ChannelId); + var guild = await Discord.GetGuildAsync(channel.GuildId); + + // Export + await console.Output.WriteLineAsync("Exporting..."); + await console.CreateProgressTicker().StartAsync(async progressContext => + { + await ExportChannelAsync(guild, channel, progressContext); + }); + await console.Output.WriteLineAsync("Done."); } } } \ No newline at end of file diff --git a/DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs b/DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs index c03c8e3..785c377 100644 --- a/DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs +++ b/DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -using CliFx; using CliFx.Attributes; +using CliFx.Infrastructure; using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Utils.Extensions; @@ -14,8 +14,12 @@ namespace DiscordChatExporter.Cli.Commands { await base.ExecuteAsync(console); + // Get channel metadata + await console.Output.WriteLineAsync("Fetching channels..."); var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id); - await ExportMultipleAsync(console, channels); + + // Export + await ExportChannelsAsync(console, channels); } } } \ No newline at end of file diff --git a/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs b/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs index dbac7bc..e18188b 100644 --- a/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs +++ b/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs @@ -1,6 +1,6 @@ using System.Threading.Tasks; -using CliFx; using CliFx.Attributes; +using CliFx.Infrastructure; using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Utils.Extensions; @@ -17,8 +17,12 @@ namespace DiscordChatExporter.Cli.Commands { await base.ExecuteAsync(console); + // Get channel metadata + await console.Output.WriteLineAsync("Fetching channels..."); var channels = await Discord.GetGuildChannelsAsync(GuildId); - await ExportMultipleAsync(console, channels); + + // Export + await ExportChannelsAsync(console, channels); } } } \ No newline at end of file diff --git a/DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs b/DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs index cdd7244..c0db564 100644 --- a/DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs +++ b/DiscordChatExporter.Cli/Commands/GetChannelsCommand.cs @@ -1,10 +1,10 @@ -using System.Linq; +using System; +using System.Linq; using System.Threading.Tasks; -using CliFx; using CliFx.Attributes; +using CliFx.Infrastructure; using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Core.Discord; -using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Cli.Commands @@ -21,9 +21,18 @@ namespace DiscordChatExporter.Cli.Commands foreach (var channel in channels.OrderBy(c => c.Category.Position).ThenBy(c => c.Name)) { - await console.Output.WriteLineAsync( - $"{channel.Id} | {channel.Category} / {channel.Name}" - ); + // Channel ID + await console.Output.WriteAsync(channel.Id.ToString()); + + // Separator + using (console.WithForegroundColor(ConsoleColor.DarkGray)) + await console.Output.WriteAsync(" | "); + + // Channel category / name + using (console.WithForegroundColor(ConsoleColor.White)) + await console.Output.WriteAsync($"{channel.Category} / {channel.Name}"); + + await console.Output.WriteLineAsync(); } } } diff --git a/DiscordChatExporter.Cli/Commands/GetDirectMessageChannelsCommand.cs b/DiscordChatExporter.Cli/Commands/GetDirectMessageChannelsCommand.cs index 0840227..e76dfac 100644 --- a/DiscordChatExporter.Cli/Commands/GetDirectMessageChannelsCommand.cs +++ b/DiscordChatExporter.Cli/Commands/GetDirectMessageChannelsCommand.cs @@ -1,7 +1,8 @@ -using System.Linq; +using System; +using System.Linq; using System.Threading.Tasks; -using CliFx; using CliFx.Attributes; +using CliFx.Infrastructure; using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Utils.Extensions; @@ -17,9 +18,18 @@ namespace DiscordChatExporter.Cli.Commands foreach (var channel in channels.OrderBy(c => c.Name)) { - await console.Output.WriteLineAsync( - $"{channel.Id} | {channel.Category} / {channel.Name}" - ); + // Channel ID + await console.Output.WriteAsync(channel.Id.ToString()); + + // Separator + using (console.WithForegroundColor(ConsoleColor.DarkGray)) + await console.Output.WriteAsync(" | "); + + // Channel category / name + using (console.WithForegroundColor(ConsoleColor.White)) + await console.Output.WriteAsync($"{channel.Category} / {channel.Name}"); + + await console.Output.WriteLineAsync(); } } } diff --git a/DiscordChatExporter.Cli/Commands/GetGuildsCommand.cs b/DiscordChatExporter.Cli/Commands/GetGuildsCommand.cs index 6a3abae..76f93cf 100644 --- a/DiscordChatExporter.Cli/Commands/GetGuildsCommand.cs +++ b/DiscordChatExporter.Cli/Commands/GetGuildsCommand.cs @@ -1,7 +1,8 @@ -using System.Linq; +using System; +using System.Linq; using System.Threading.Tasks; -using CliFx; using CliFx.Attributes; +using CliFx.Infrastructure; using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Core.Utils.Extensions; @@ -16,9 +17,18 @@ namespace DiscordChatExporter.Cli.Commands foreach (var guild in guilds.OrderBy(g => g.Name)) { - await console.Output.WriteLineAsync( - $"{guild.Id} | {guild.Name}" - ); + // Guild ID + await console.Output.WriteAsync(guild.Id.ToString()); + + // Separator + using (console.WithForegroundColor(ConsoleColor.DarkGray)) + await console.Output.WriteAsync(" | "); + + // Guild name + using (console.WithForegroundColor(ConsoleColor.White)) + await console.Output.WriteAsync(guild.Name); + + await console.Output.WriteLineAsync(); } } } diff --git a/DiscordChatExporter.Cli/Commands/GuideCommand.cs b/DiscordChatExporter.Cli/Commands/GuideCommand.cs index 2996b69..6ea4459 100644 --- a/DiscordChatExporter.Cli/Commands/GuideCommand.cs +++ b/DiscordChatExporter.Cli/Commands/GuideCommand.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using CliFx; using CliFx.Attributes; +using CliFx.Infrastructure; namespace DiscordChatExporter.Cli.Commands { @@ -10,9 +11,9 @@ namespace DiscordChatExporter.Cli.Commands { public ValueTask ExecuteAsync(IConsole console) { - console.WithForegroundColor(ConsoleColor.White, () => - console.Output.WriteLine("To get user token:") - ); + using (console.WithForegroundColor(ConsoleColor.White)) + console.Output.WriteLine("To get user token:"); + console.Output.WriteLine(" 1. Open Discord"); console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools"); console.Output.WriteLine(" 3. Navigate to the Application tab"); @@ -22,18 +23,18 @@ namespace DiscordChatExporter.Cli.Commands console.Output.WriteLine(" * Automating user accounts is technically against TOS, use at your own risk."); console.Output.WriteLine(); - console.WithForegroundColor(ConsoleColor.White, () => - console.Output.WriteLine("To get bot token:") - ); + using (console.WithForegroundColor(ConsoleColor.White)) + console.Output.WriteLine("To get bot token:"); + console.Output.WriteLine(" 1. Go to Discord developer portal"); console.Output.WriteLine(" 2. Open your application's settings"); console.Output.WriteLine(" 3. Navigate to the Bot section on the left"); console.Output.WriteLine(" 4. Under Token click Copy"); console.Output.WriteLine(); - console.WithForegroundColor(ConsoleColor.White, () => - console.Output.WriteLine("To get guild ID or guild channel ID:") - ); + using (console.WithForegroundColor(ConsoleColor.White)) + console.Output.WriteLine("To get guild ID or guild channel ID:"); + console.Output.WriteLine(" 1. Open Discord"); console.Output.WriteLine(" 2. Open Settings"); console.Output.WriteLine(" 3. Go to Appearance section"); @@ -41,9 +42,9 @@ namespace DiscordChatExporter.Cli.Commands console.Output.WriteLine(" 5. Right click on the desired guild or channel and click Copy ID"); console.Output.WriteLine(); - console.WithForegroundColor(ConsoleColor.White, () => - console.Output.WriteLine("To get direct message channel ID:") - ); + using (console.WithForegroundColor(ConsoleColor.White)) + console.Output.WriteLine("To get direct message channel ID:"); + console.Output.WriteLine(" 1. Open Discord"); console.Output.WriteLine(" 2. Open the desired direct message channel"); console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools"); @@ -52,12 +53,11 @@ namespace DiscordChatExporter.Cli.Commands console.Output.WriteLine(" 6. Copy the first long sequence of numbers inside the URL"); console.Output.WriteLine(); - console.WithForegroundColor(ConsoleColor.White, - () => console.Output.WriteLine("For more information, check out the wiki:") - ); - console.WithForegroundColor(ConsoleColor.Blue, - () => console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki") - ); + using (console.WithForegroundColor(ConsoleColor.White)) + console.Output.WriteLine("For more information, check out the wiki:"); + + using (console.WithForegroundColor(ConsoleColor.DarkCyan)) + console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki"); return default; } diff --git a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj index 23be1bb..8562cd5 100644 --- a/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj +++ b/DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj @@ -6,8 +6,8 @@ - - + + diff --git a/DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs b/DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs new file mode 100644 index 0000000..b4b9516 --- /dev/null +++ b/DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs @@ -0,0 +1,30 @@ +using CliFx.Infrastructure; +using Spectre.Console; + +namespace DiscordChatExporter.Cli.Utils.Extensions +{ + internal static class ConsoleExtensions + { + public static IAnsiConsole CreateAnsiConsole(this IConsole console) => AnsiConsole.Create( + new AnsiConsoleSettings + { + Ansi = AnsiSupport.Detect, + ColorSystem = ColorSystemSupport.Detect, + Out = console.Output + } + ); + + public static Progress CreateProgressTicker(this IConsole console) => console + .CreateAnsiConsole() + .Progress() + .AutoClear(false) + .AutoRefresh(true) + .HideCompleted(false) + .Columns(new ProgressColumn[] + { + new TaskDescriptionColumn {Alignment = Justify.Left}, + new ProgressBarColumn(), + new PercentageColumn() + }); + } +} \ No newline at end of file