[CLI] Update CliFx and use Spectre.Console for progress reporting

This commit is contained in:
Tyrrrz 2021-03-23 22:38:44 +02:00
parent 6f90c367b9
commit 017ed5ae6d
13 changed files with 193 additions and 121 deletions

View file

@ -1,12 +1,12 @@
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Utilities; using CliFx.Infrastructure;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
using Spectre.Console;
namespace DiscordChatExporter.Cli.Commands.Base namespace DiscordChatExporter.Cli.Commands.Base
{ {
@ -39,14 +39,8 @@ namespace DiscordChatExporter.Cli.Commands.Base
private ChannelExporter? _channelExporter; private ChannelExporter? _channelExporter;
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord); 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( var request = new ExportRequest(
guild, guild,
channel, channel,
@ -60,22 +54,19 @@ namespace DiscordChatExporter.Cli.Commands.Base
DateFormat DateFormat
); );
await Exporter.ExportChannelAsync(request, progress); var progress = progressContext.AddTask(
$"{channel.Category} / {channel.Name}",
new ProgressTaskSettings {MaxValue = 1}
);
await console.Output.WriteLineAsync(); try
await console.Output.WriteLineAsync("Done."); {
} await Exporter.ExportChannelAsync(request, progress);
}
protected async ValueTask ExportAsync(IConsole console, Channel channel) finally
{ {
var guild = await Discord.GetGuildAsync(channel.GuildId); progress.StopTask();
await ExportAsync(console, guild, channel); }
}
protected async ValueTask ExportAsync(IConsole console, Snowflake channelId)
{
var channel = await Discord.GetChannelAsync(channelId);
await ExportAsync(console, channel);
} }
public override ValueTask ExecuteAsync(IConsole console) public override ValueTask ExecuteAsync(IConsole console)

View file

@ -1,16 +1,15 @@
using System.Collections.Concurrent; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes; 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.Discord.Data;
using DiscordChatExporter.Core.Exceptions; using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using Gress;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;
namespace DiscordChatExporter.Cli.Commands.Base 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.")] [CommandOption("parallel", Description = "Limits how many channels can be exported in parallel.")]
public int ParallelLimit { get; init; } = 1; public int ParallelLimit { get; init; } = 1;
protected async ValueTask ExportMultipleAsync(IConsole console, IReadOnlyList<Channel> channels) protected async ValueTask ExportChannelsAsync(IConsole console, IReadOnlyList<Channel> channels)
{ {
// This uses a different route from ExportCommandBase.ExportAsync() because it runs await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
// in parallel and needs another way to report progress to console.
await console.Output.WriteAsync( var errors = new ConcurrentDictionary<Channel, string>();
$"Exporting {channels.Count} channels... "
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));
await console.Output.WriteLineAsync();
});
// Print result
await console.Output.WriteLineAsync(
$"Successfully exported {channels.Count - errors.Count} channel(s)."
); );
var progress = console.CreateProgressTicker(); // Print errors
if (errors.Any())
var operations = progress.Wrap().CreateOperations(channels.Count);
var successfulExportCount = 0;
var errors = new ConcurrentBag<(Channel, string)>();
await channels.Zip(operations).ParallelForEachAsync(async tuple =>
{ {
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); await console.Output.WriteAsync($"{channel.Category} / {channel.Name}: ");
var request = new ExportRequest( using (console.WithForegroundColor(ConsoleColor.Red))
guild, await console.Output.WriteLineAsync(error);
channel,
OutputPath,
ExportFormat,
After,
Before,
PartitionLimit,
ShouldDownloadMedia,
ShouldReuseMedia,
DateFormat
);
await Exporter.ExportChannelAsync(request, operation);
Interlocked.Increment(ref successfulExportCount);
} }
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) // Fail the command if ALL channels failed to export.
await console.Error.WriteLineAsync($"Channel '{channel}': {error}"); // 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.");
} }
} }
} }

View file

@ -1,16 +1,17 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Cli.Commands.Base namespace DiscordChatExporter.Cli.Commands.Base
{ {
public abstract class TokenCommandBase : ICommand 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; } = ""; 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; } public bool IsBotToken { get; init; }
private AuthToken GetAuthToken() => new( private AuthToken GetAuthToken() => new(

View file

@ -1,7 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
@ -17,6 +17,9 @@ namespace DiscordChatExporter.Cli.Commands
{ {
await base.ExecuteAsync(console); await base.ExecuteAsync(console);
// Get channel metadata
await console.Output.WriteLineAsync("Fetching channels...");
var channels = new List<Channel>(); var channels = new List<Channel>();
// Aggregate channels from all guilds // Aggregate channels from all guilds
@ -32,7 +35,8 @@ namespace DiscordChatExporter.Cli.Commands
} }
} }
await ExportMultipleAsync(console, channels); // Export
await ExportChannelsAsync(console, channels);
} }
} }
} }

View file

@ -1,7 +1,8 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Cli.Commands namespace DiscordChatExporter.Cli.Commands
@ -15,7 +16,19 @@ namespace DiscordChatExporter.Cli.Commands
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
await base.ExecuteAsync(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.");
} }
} }
} }

View file

@ -1,6 +1,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@ -14,8 +14,12 @@ namespace DiscordChatExporter.Cli.Commands
{ {
await base.ExecuteAsync(console); await base.ExecuteAsync(console);
// Get channel metadata
await console.Output.WriteLineAsync("Fetching channels...");
var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id); var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id);
await ExportMultipleAsync(console, channels);
// Export
await ExportChannelsAsync(console, channels);
} }
} }
} }

View file

@ -1,6 +1,6 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@ -17,8 +17,12 @@ namespace DiscordChatExporter.Cli.Commands
{ {
await base.ExecuteAsync(console); await base.ExecuteAsync(console);
// Get channel metadata
await console.Output.WriteLineAsync("Fetching channels...");
var channels = await Discord.GetGuildChannelsAsync(GuildId); var channels = await Discord.GetGuildChannelsAsync(GuildId);
await ExportMultipleAsync(console, channels);
// Export
await ExportChannelsAsync(console, channels);
} }
} }
} }

View file

@ -1,10 +1,10 @@
using System.Linq; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands 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)) foreach (var channel in channels.OrderBy(c => c.Category.Position).ThenBy(c => c.Name))
{ {
await console.Output.WriteLineAsync( // Channel ID
$"{channel.Id} | {channel.Category} / {channel.Name}" 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();
} }
} }
} }

View file

@ -1,7 +1,8 @@
using System.Linq; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@ -17,9 +18,18 @@ namespace DiscordChatExporter.Cli.Commands
foreach (var channel in channels.OrderBy(c => c.Name)) foreach (var channel in channels.OrderBy(c => c.Name))
{ {
await console.Output.WriteLineAsync( // Channel ID
$"{channel.Id} | {channel.Category} / {channel.Name}" 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();
} }
} }
} }

View file

@ -1,7 +1,8 @@
using System.Linq; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@ -16,9 +17,18 @@ namespace DiscordChatExporter.Cli.Commands
foreach (var guild in guilds.OrderBy(g => g.Name)) foreach (var guild in guilds.OrderBy(g => g.Name))
{ {
await console.Output.WriteLineAsync( // Guild ID
$"{guild.Id} | {guild.Name}" 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();
} }
} }
} }

View file

@ -2,6 +2,7 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure;
namespace DiscordChatExporter.Cli.Commands namespace DiscordChatExporter.Cli.Commands
{ {
@ -10,9 +11,9 @@ namespace DiscordChatExporter.Cli.Commands
{ {
public ValueTask ExecuteAsync(IConsole console) public ValueTask ExecuteAsync(IConsole console)
{ {
console.WithForegroundColor(ConsoleColor.White, () => using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get user token:") console.Output.WriteLine("To get user token:");
);
console.Output.WriteLine(" 1. Open Discord"); console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools"); console.Output.WriteLine(" 2. Press Ctrl+Shift+I to show developer tools");
console.Output.WriteLine(" 3. Navigate to the Application tab"); 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(" * Automating user accounts is technically against TOS, use at your own risk.");
console.Output.WriteLine(); console.Output.WriteLine();
console.WithForegroundColor(ConsoleColor.White, () => using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get bot token:") console.Output.WriteLine("To get bot token:");
);
console.Output.WriteLine(" 1. Go to Discord developer portal"); console.Output.WriteLine(" 1. Go to Discord developer portal");
console.Output.WriteLine(" 2. Open your application's settings"); console.Output.WriteLine(" 2. Open your application's settings");
console.Output.WriteLine(" 3. Navigate to the Bot section on the left"); console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
console.Output.WriteLine(" 4. Under Token click Copy"); console.Output.WriteLine(" 4. Under Token click Copy");
console.Output.WriteLine(); console.Output.WriteLine();
console.WithForegroundColor(ConsoleColor.White, () => using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get guild ID or guild channel ID:") console.Output.WriteLine("To get guild ID or guild channel ID:");
);
console.Output.WriteLine(" 1. Open Discord"); console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Open Settings"); console.Output.WriteLine(" 2. Open Settings");
console.Output.WriteLine(" 3. Go to Appearance section"); 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(" 5. Right click on the desired guild or channel and click Copy ID");
console.Output.WriteLine(); console.Output.WriteLine();
console.WithForegroundColor(ConsoleColor.White, () => using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get direct message channel ID:") console.Output.WriteLine("To get direct message channel ID:");
);
console.Output.WriteLine(" 1. Open Discord"); console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Open the desired direct message channel"); console.Output.WriteLine(" 2. Open the desired direct message channel");
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools"); 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(" 6. Copy the first long sequence of numbers inside the URL");
console.Output.WriteLine(); console.Output.WriteLine();
console.WithForegroundColor(ConsoleColor.White, using (console.WithForegroundColor(ConsoleColor.White))
() => console.Output.WriteLine("For more information, check out the wiki:") console.Output.WriteLine("For more information, check out the wiki:");
);
console.WithForegroundColor(ConsoleColor.Blue, using (console.WithForegroundColor(ConsoleColor.DarkCyan))
() => console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki") console.Output.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/wiki");
);
return default; return default;
} }

View file

@ -6,8 +6,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="1.6.0" /> <PackageReference Include="CliFx" Version="2.0.0" />
<PackageReference Include="Gress" Version="1.2.0" /> <PackageReference Include="Spectre.Console" Version="0.38.0" />
<PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" /> <PackageReference Include="Tyrrrz.Extensions" Version="1.6.5" />
</ItemGroup> </ItemGroup>

View file

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