diff --git a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs index 8573f4b6..be427b7b 100644 --- a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs @@ -229,16 +229,15 @@ public abstract class ExportCommandBase : DiscordCommandBase } // Export - var errorsByChannel = new ConcurrentDictionary(); - var warningsByChannel = new ConcurrentDictionary(); - await console.Output.WriteLineAsync($"Exporting {unwrappedChannels.Count} channel(s)..."); - await console - .CreateProgressTicker() + var (progressTicker, logger) = console.CreateProgressTicker(); + await progressTicker .HideCompleted( // When exporting multiple channels in parallel, hide the completed tasks // because it gets hard to visually parse them as they complete out of order. // https://github.com/Tyrrrz/DiscordChatExporter/issues/1124 + // They are logged above the active tasks instead, so that the user can still + // see the entire progress. ParallelLimit > 1 ) .StartAsync(async ctx => @@ -282,6 +281,8 @@ public abstract class ExportCommandBase : DiscordCommandBase ); await Exporter.ExportChannelAsync( + logger, + ParallelLimit > 1, request, progress.ToPercentageBased(), innerCancellationToken @@ -289,71 +290,19 @@ public abstract class ExportCommandBase : DiscordCommandBase } ); } - catch (ChannelEmptyException ex) - { - warningsByChannel[channel] = ex.Message; - } catch (DiscordChatExporterException ex) when (!ex.IsFatal) { - errorsByChannel[channel] = ex.Message; + logger.LogError(null, ex.Message); } } ); }); - // Print the result - using (console.WithForegroundColor(ConsoleColor.White)) - { - await console.Output.WriteLineAsync( - $"Successfully exported {unwrappedChannels.Count - errorsByChannel.Count} channel(s)." - ); - } - - // Print warnings - if (warningsByChannel.Any()) - { - await console.Output.WriteLineAsync(); - - using (console.WithForegroundColor(ConsoleColor.Yellow)) - { - await console.Error.WriteLineAsync( - "Warnings reported for the following channel(s):" - ); - } - - foreach (var (channel, message) in warningsByChannel) - { - await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: "); - using (console.WithForegroundColor(ConsoleColor.Yellow)) - await console.Error.WriteLineAsync(message); - } - - await console.Error.WriteLineAsync(); - } - - // Print errors - if (errorsByChannel.Any()) - { - await console.Output.WriteLineAsync(); - - using (console.WithForegroundColor(ConsoleColor.Red)) - { - await console.Error.WriteLineAsync("Failed to export the following channel(s):"); - } - - foreach (var (channel, message) in errorsByChannel) - { - await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: "); - using (console.WithForegroundColor(ConsoleColor.Red)) - await console.Error.WriteLineAsync(message); - } - - await console.Error.WriteLineAsync(); - } + logger.PrintExportSummary(FileExistsHandling); // Fail the command only if ALL channels failed to export. // If only some channels failed to export, it's okay. - if (errorsByChannel.Count >= unwrappedChannels.Count) + if (logger.AllFailed()) throw new CommandException("Export failed."); } diff --git a/DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs b/DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs index 4509026b..45e560ff 100644 --- a/DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs +++ b/DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs @@ -1,6 +1,8 @@ using System; using System.Threading.Tasks; using CliFx.Infrastructure; +using DiscordChatExporter.Core.Exporting; +using DiscordChatExporter.Core.Exporting.Logging; using Spectre.Console; namespace DiscordChatExporter.Cli.Utils.Extensions; @@ -20,9 +22,11 @@ internal static class ConsoleExtensions public static Status CreateStatusTicker(this IConsole console) => console.CreateAnsiConsole().Status().AutoRefresh(true); - public static Progress CreateProgressTicker(this IConsole console) => - console - .CreateAnsiConsole() + public static (Progress, ConsoleProgressLogger) CreateProgressTicker(this IConsole console) + { + var ansiConsole = console.CreateAnsiConsole(); + var logger = new ConsoleProgressLogger(ansiConsole); + var progress = ansiConsole .Progress() .AutoClear(false) .AutoRefresh(true) @@ -32,6 +36,8 @@ internal static class ConsoleExtensions new ProgressBarColumn(), new PercentageColumn() ); + return (progress, logger); + } public static async ValueTask StartTaskAsync( this ProgressContext context, @@ -59,3 +65,93 @@ internal static class ConsoleExtensions } } } + +/// +/// The ConsoleProgressLogger is a subclass that logs the status updates of the exported +/// channels with colors on the console. +/// It also provides a way to print the generated export summary on the console. +/// +/// The console that the progress information and summary is logged on. +public class ConsoleProgressLogger(IAnsiConsole console) : ProgressLogger +{ + /// + /// The ConsoleProgressLogger logs the success message to the console. + public override void LogSuccess(ExportRequest request, string message) + { + LogMessage("SUCCESS", "[green]", request, message); + } + + /// + /// The ConsoleProgressLogger logs the informational message to the console. + public override void LogInfo(ExportRequest request, string message) + { + LogMessage("INFO", "[default]", request, message); + } + + /// + /// The ConsoleProgressLogger logs the warning message to the console. + public override void LogWarning(ExportRequest request, string message) + { + LogMessage("WARNING", "[yellow]", request, message); + } + + /// + /// The ConsoleProgressLogger logs the error message to the console. + public override void LogError(ExportRequest? request, string message) + { + IncrementCounter(ExportResult.ExportError); + LogMessage("ERROR", "[red]", request, message); + } + + /// + /// Logs the given message of the given category about the current channel export with the given color to the + /// console. + /// + /// The category of the message that should be logged. + /// The color in which the message should be logged. + /// The request specifying the current channel export. + /// The message about the current channel export that should be logged. + private void LogMessage(string category, string color, ExportRequest? request, string message) + { + var paddedCategory = (category + ":").PadRight(10); + + var channelInfo = ""; + if (request != null) + channelInfo = + request.Guild.Name + " / " + request.Channel.GetHierarchicalName() + " | "; + + var logMessage = $"{color}{paddedCategory}{channelInfo}{message}[/]"; + console.MarkupLine(logMessage); + } + + /// + /// Prints a summary on all previously logged exports and their respective results to the console. + /// + /// The file exists handling of the export whose summary should be printed. + public void PrintExportSummary(FileExistsHandling updateType) + { + var exportSummary = GetExportSummary(updateType); + exportSummary.TryGetValue(ExportResult.NewExportSuccess, out var newExportSuccessMessage); + exportSummary.TryGetValue( + ExportResult.NewExportSuccessEmpty, + out var newExportSuccessEmptyMessage + ); + exportSummary.TryGetValue( + ExportResult.UpdateExportSuccess, + out var updateExportSuccessMessage + ); + exportSummary.TryGetValue(ExportResult.UpdateExportSkip, out var updateExportSkipMessage); + exportSummary.TryGetValue(ExportResult.ExportError, out var exportErrorMessage); + + if (newExportSuccessMessage != null) + console.MarkupLine($"[green]{newExportSuccessMessage}[/]"); + if (newExportSuccessEmptyMessage != null) + console.MarkupLine($"[default]{newExportSuccessEmptyMessage}[/]"); + if (updateExportSuccessMessage != null) + console.MarkupLine($"[green]{updateExportSuccessMessage}[/]"); + if (updateExportSkipMessage != null) + console.MarkupLine($"[default]{updateExportSkipMessage}[/]"); + if (exportErrorMessage != null) + console.MarkupLine($"[red]{exportErrorMessage}[/]"); + } +} diff --git a/DiscordChatExporter.Core/Exceptions/ChannelEmptyException.cs b/DiscordChatExporter.Core/Exceptions/ChannelEmptyException.cs deleted file mode 100644 index 42e6c787..00000000 --- a/DiscordChatExporter.Core/Exceptions/ChannelEmptyException.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace DiscordChatExporter.Core.Exceptions; - -public class ChannelEmptyException(string message) : DiscordChatExporterException(message); diff --git a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs index 6a307970..4a434a97 100644 --- a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs +++ b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Exceptions; +using DiscordChatExporter.Core.Exporting.Logging; using Gress; namespace DiscordChatExporter.Core.Exporting; @@ -12,6 +13,8 @@ namespace DiscordChatExporter.Core.Exporting; public class ChannelExporter(DiscordClient discord) { public async ValueTask ExportChannelAsync( + ProgressLogger logger, + bool logSuccess, ExportRequest request, IProgress? progress = null, CancellationToken cancellationToken = default @@ -20,26 +23,29 @@ public class ChannelExporter(DiscordClient discord) // Forum channels don't have messages, they are just a list of threads if (request.Channel.Kind == ChannelKind.GuildForum) { - throw new DiscordChatExporterException( - $"Channel '{request.Channel.Name}' " - + $"of guild '{request.Guild.Name}' " - + $"is a forum and cannot be exported directly. " - + "You need to pull its threads and export them individually." + // TODO: The GUI apparently has no thread inclusion setting + logger.LogError( + request, + "This channel is a forum and cannot be exported. " + + "Did you forget to turn on thread inclusion?" ); + return; } var currentPartitionIndex = 0; + var exportExists = false; // TODO: Maybe add a way to search for old files after a username change if (File.Exists(request.OutputFilePath)) { + exportExists = true; // TODO: Maybe add an "Ask" option in the future switch (request.FileExistsHandling) { case FileExistsHandling.Abort: - Console.WriteLine("Channel aborted"); + logger.LogError(request, "Aborted export due to existing export files"); return; case FileExistsHandling.Overwrite: - Console.WriteLine("Removing old files"); + logger.LogWarning(request, "Removing existing export files"); MessageExporter.RemoveExistingFiles(request.OutputFilePath); break; case FileExistsHandling.Append: @@ -53,12 +59,15 @@ public class ChannelExporter(DiscordClient discord) if (!request.Channel.MayHaveMessagesAfter(request.LastPriorMessage.Value)) { - Console.WriteLine("Download already up to date"); + logger.IncrementCounter(ExportResult.UpdateExportSkip); + logger.LogInfo(request, "Existing export already up to date"); return; } - Console.WriteLine( - "Downloading data after " + lastMessageSnowflake.Value.ToDate() + logger.LogInfo( + request, + "Appending existing export starting at " + + lastMessageSnowflake.Value.ToDate() ); currentPartitionIndex = MessageExporter.GetPartitionCount( request.OutputFilePath @@ -83,11 +92,10 @@ public class ChannelExporter(DiscordClient discord) // Check if the channel is empty if (request.Channel.IsEmpty) { - throw new ChannelEmptyException( - $"Channel '{request.Channel.Name}' " - + $"of guild '{request.Guild.Name}' " - + $"does not contain any messages; an empty file will be created." - ); + logger.IncrementCounter(ExportResult.NewExportSuccess); + logger.IncrementCounter(ExportResult.NewExportSuccessEmpty); + logger.LogInfo(request, "The channel does not contain any messages"); + return; } // Check if the 'before' and 'after' boundaries are valid @@ -102,11 +110,13 @@ public class ChannelExporter(DiscordClient discord) ) ) { - throw new ChannelEmptyException( - $"Channel '{request.Channel.Name}' " - + $"of guild '{request.Guild.Name}' " - + $"does not contain any messages within the specified period; an empty file will be created." + logger.IncrementCounter(ExportResult.NewExportSuccess); + logger.IncrementCounter(ExportResult.NewExportSuccessEmpty); + logger.LogWarning( + request, + "The channel does not contain any messages within the specified period" ); + return; } await foreach ( @@ -141,5 +151,24 @@ public class ChannelExporter(DiscordClient discord) ); } } + + if (!exportExists) + { + logger.IncrementCounter(ExportResult.NewExportSuccess); + if (logSuccess) + logger.LogSuccess(request, "Successfully exported the channel"); + } + else if (request.FileExistsHandling == FileExistsHandling.Append) + { + logger.IncrementCounter(ExportResult.UpdateExportSuccess); + if (logSuccess) + logger.LogSuccess(request, "Successfully appended the channel export"); + } + else + { + logger.IncrementCounter(ExportResult.UpdateExportSuccess); + if (logSuccess) + logger.LogSuccess(request, "Successfully overwrote the channel export"); + } } } diff --git a/DiscordChatExporter.Core/Exporting/Logging/ExportResult.cs b/DiscordChatExporter.Core/Exporting/Logging/ExportResult.cs new file mode 100644 index 00000000..053e9449 --- /dev/null +++ b/DiscordChatExporter.Core/Exporting/Logging/ExportResult.cs @@ -0,0 +1,32 @@ +namespace DiscordChatExporter.Core.Exporting.Logging; + +/// +/// Represents a possible result of the export of a single channel +/// +public enum ExportResult +{ + /// + /// The channel is a new channel that has been exported successfully. + /// + NewExportSuccess, + + /// + /// The channel is a new empty channel that has been exported successfully. + /// + NewExportSuccessEmpty, + + /// + /// The channel is a channel that had already been exported and has been appended or overwritten successfully. + /// + UpdateExportSuccess, + + /// + /// The channel is a channel that had already been exported and hasn't been appended as there are no new messages. + /// + UpdateExportSkip, + + /// + /// The channel couldn't be exported successfully. + /// + ExportError, +} diff --git a/DiscordChatExporter.Core/Exporting/Logging/ProgressLogger.cs b/DiscordChatExporter.Core/Exporting/Logging/ProgressLogger.cs new file mode 100644 index 00000000..3a35e6d8 --- /dev/null +++ b/DiscordChatExporter.Core/Exporting/Logging/ProgressLogger.cs @@ -0,0 +1,105 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace DiscordChatExporter.Core.Exporting.Logging; + +/// +/// The ProgressLogger provides a consistent way to log the entire progress of an export, meaning all status updates of +/// each of the exported channels. +/// It also allows to count the different results of the individual channel exports and can provide a summary of them. +/// +public abstract class ProgressLogger +{ + private readonly ConcurrentDictionary _counters = []; + + /// + /// Increments the internal counter of the given export result in a thread-safe way. + /// + /// The export result whose counter should be incremented. + public void IncrementCounter(ExportResult exportResult) + { + _counters.AddOrUpdate(exportResult, 1, (_, currentCount) => currentCount + 1); + } + + /// + /// Generates and returns a summary on all previously logged exports and their respective results. + /// The summary is returned as one string for each export result that occurred at least once. + /// + /// The file exists handling of the export whose summary should be returned. + /// A summary on all previously logged exports and their respective results. + protected Dictionary GetExportSummary(FileExistsHandling updateType) + { + _counters.TryGetValue(ExportResult.NewExportSuccess, out var newExportSuccessCount); + _counters.TryGetValue( + ExportResult.NewExportSuccessEmpty, + out var newExportSuccessEmptyCount + ); + _counters.TryGetValue(ExportResult.UpdateExportSuccess, out var updateExportSuccessCount); + _counters.TryGetValue(ExportResult.UpdateExportSkip, out var updateExportSkipCount); + _counters.TryGetValue(ExportResult.ExportError, out var exportErrorCount); + + Dictionary exportSummary = []; + if (newExportSuccessCount > 0) + exportSummary[ExportResult.NewExportSuccess] = + $"Successfully exported {newExportSuccessCount} new channel(s)."; + if (newExportSuccessEmptyCount > 0) + exportSummary[ExportResult.NewExportSuccessEmpty] = + $"{newExportSuccessEmptyCount} of those channel(s) has / have been empty."; + if (updateExportSuccessCount > 0) + exportSummary[ExportResult.UpdateExportSuccess] = + "Successfully " + + (updateType == FileExistsHandling.Append ? "appended" : "overrode") + + $" {updateExportSuccessCount} existing channel export(s)."; + if (updateExportSkipCount > 0) + exportSummary[ExportResult.UpdateExportSkip] = + $"Skipped {updateExportSkipCount} existing up-to-date channel export(s)"; + if (exportErrorCount > 0) + exportSummary[ExportResult.ExportError] = + $"Failed to export {exportErrorCount} channel(s) (see prior error message(s))."; + + return exportSummary; + } + + /// + /// Returns whether all exports have failed. + /// If exports have been skipped as there are no new messages to append, this still returns true if no export + /// finished successfully and at least one failed. + /// + /// Whether all exports have failed. + public bool AllFailed() + { + _counters.TryGetValue(ExportResult.NewExportSuccess, out var newExportSuccessCount); + _counters.TryGetValue(ExportResult.UpdateExportSuccess, out var updateExportSuccessCount); + _counters.TryGetValue(ExportResult.ExportError, out var exportErrorCount); + return newExportSuccessCount + updateExportSuccessCount == 0 && exportErrorCount > 0; + } + + /// + /// Logs the success of the current channel export. + /// + /// The request specifying the current channel export + /// The success message about the current channel export that should be logged. + public abstract void LogSuccess(ExportRequest request, string message); + + /// + /// Logs an informational message about the current channel export. + /// + /// The request specifying the current channel export. + /// The informational message about the current channel export that should be logged. + public abstract void LogInfo(ExportRequest request, string message); + + /// + /// Logs a warning message about the current channel export. + /// + /// The request specifying the current channel export. + /// The warning message about the current channel export that should be logged. + public abstract void LogWarning(ExportRequest request, string message); + + /// + /// Logs an error message about the current channel export. + /// If this is called, the count of channels that failed is automatically increased. + /// + /// The request specifying the current channel export. + /// The error message about the current channel export that should be logged. + public abstract void LogError(ExportRequest request, string message); +} diff --git a/DiscordChatExporter.Gui/Framework/SnackbarManager.cs b/DiscordChatExporter.Gui/Framework/SnackbarManager.cs index a4af9f5d..41edecb5 100644 --- a/DiscordChatExporter.Gui/Framework/SnackbarManager.cs +++ b/DiscordChatExporter.Gui/Framework/SnackbarManager.cs @@ -1,5 +1,7 @@ using System; using Avalonia.Threading; +using DiscordChatExporter.Core.Exporting; +using DiscordChatExporter.Core.Exporting.Logging; using Material.Styles.Controls; using Material.Styles.Models; @@ -32,3 +34,103 @@ public class SnackbarManager DispatcherPriority.Normal ); } + +/// +/// The SnackbarProgressLogger is a subclass that logs the status updates of the exported +/// channels in the GUI snackbar. +/// +/// +/// The snackbar manager that's used to control the snackbar and add log messages to it. +/// +public class SnackbarProgressLogger(SnackbarManager snackbarManager) : ProgressLogger +{ + /// + /// The SnackbarProgressLogger logs the success message in the GUI snackbar. + public override void LogSuccess(ExportRequest request, string message) + { + LogMessage("SUCCESS", request, message); + } + + /// + /// The SnackbarProgressLogger logs the informational message in the GUI snackbar. + public override void LogInfo(ExportRequest request, string message) + { + LogMessage("INFO", request, message); + } + + /// + /// The SnackbarProgressLogger logs the warning message in the GUI snackbar. + public override void LogWarning(ExportRequest request, string message) + { + LogMessage("WARNING", request, message); + } + + /// + /// The SnackbarProgressLogger logs the error message in the GUI snackbar. + public override void LogError(ExportRequest? request, string message) + { + IncrementCounter(ExportResult.ExportError); + // TODO: Maybe highlight error messages with a red background + LogMessage("ERROR", request, message, TimeSpan.FromSeconds(10)); + } + + /// + /// Logs the given message of the given category about the current channel export in the GUI snackbar. + /// + /// The category of the message that should be logged. + /// The request specifying the current channel export. + /// The message about the current channel export that should be logged. + /// + /// The duration the message should be displayed in the snackbar. + /// If the given value is null, the default duration is used. + /// + private void LogMessage( + string category, + ExportRequest? request, + string message, + TimeSpan? duration = null + ) + { + var channelInfo = ""; + if (request != null) + channelInfo = + request.Guild.Name + " / " + request.Channel.GetHierarchicalName() + " | "; + + var logMessage = $"{category}: {channelInfo}{message}"; + snackbarManager.Notify(logMessage, duration); + } + + /// + /// Prints a summary on all previously logged exports and their respective results in the GUI snackbar. + /// + /// The file exists handling of the export whose summary should be printed. + public void PrintExportSummary(FileExistsHandling updateType) + { + var exportSummary = GetExportSummary(updateType); + exportSummary.TryGetValue(ExportResult.NewExportSuccess, out var newExportSuccessMessage); + exportSummary.TryGetValue( + ExportResult.NewExportSuccessEmpty, + out var newExportSuccessEmptyMessage + ); + exportSummary.TryGetValue( + ExportResult.UpdateExportSuccess, + out var updateExportSuccessMessage + ); + exportSummary.TryGetValue(ExportResult.UpdateExportSkip, out var updateExportSkipMessage); + exportSummary.TryGetValue(ExportResult.ExportError, out var exportErrorMessage); + + var summaryString = ""; + if (newExportSuccessMessage != null) + summaryString += newExportSuccessMessage + "\n"; + if (newExportSuccessEmptyMessage != null) + summaryString += newExportSuccessEmptyMessage + "\n"; + if (updateExportSuccessMessage != null) + summaryString += updateExportSuccessMessage + "\n"; + if (updateExportSkipMessage != null) + summaryString += updateExportSkipMessage + "\n"; + if (exportErrorMessage != null) + summaryString += exportErrorMessage + "\n"; + + snackbarManager.Notify(summaryString.TrimEnd(), TimeSpan.FromSeconds(15)); + } +} diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs index f5ead982..052007d5 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -242,13 +242,12 @@ public partial class DashboardViewModel : ViewModelBase return; var exporter = new ChannelExporter(_discord); + var logger = new SnackbarProgressLogger(_snackbarManager); var channelProgressPairs = dialog .Channels!.Select(c => new { Channel = c, Progress = _progressMuxer.CreateInput() }) .ToArray(); - var successfulExportCount = 0; - await Parallel.ForEachAsync( channelProgressPairs, new ParallelOptions @@ -280,17 +279,17 @@ public partial class DashboardViewModel : ViewModelBase _settingsService.IsUtcNormalizationEnabled ); - await exporter.ExportChannelAsync(request, progress, cancellationToken); - - Interlocked.Increment(ref successfulExportCount); - } - catch (ChannelEmptyException ex) - { - _snackbarManager.Notify(ex.Message.TrimEnd('.')); + await exporter.ExportChannelAsync( + logger, + true, + request, + progress, + cancellationToken + ); } catch (DiscordChatExporterException ex) when (!ex.IsFatal) { - _snackbarManager.Notify(ex.Message.TrimEnd('.')); + logger.LogError(null, ex.Message.TrimEnd('.')); } finally { @@ -299,13 +298,7 @@ public partial class DashboardViewModel : ViewModelBase } ); - // Notify of the overall completion - if (successfulExportCount > 0) - { - _snackbarManager.Notify( - $"Successfully exported {successfulExportCount} channel(s)" - ); - } + logger.PrintExportSummary(_settingsService.FileExistsHandling); } catch (Exception ex) {