From 7add81a47296cd6064d7b0d21197a0c42f3d7d01 Mon Sep 17 00:00:00 2001 From: Leonardo Mosquera Date: Tue, 1 Apr 2025 18:14:35 -0300 Subject: [PATCH] Don't consider it an error if there is nothing to export (#1349) --- .../Specs/DateRangeSpecs.cs | 29 ++++++++ .../Commands/Base/ExportCommandBase.cs | 35 +++++++-- .../Exceptions/ChannelEmptyException.cs | 8 ++ .../Exporting/ChannelExporter.cs | 73 ++++++++----------- .../Exporting/MessageExporter.cs | 7 +- .../Components/DashboardViewModel.cs | 7 ++ 6 files changed, 110 insertions(+), 49 deletions(-) create mode 100644 DiscordChatExporter.Core/Exceptions/ChannelEmptyException.cs diff --git a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs index 070951bc..ab8c0d40 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/DateRangeSpecs.cs @@ -146,4 +146,33 @@ public class DateRangeSpecs .WhenTypeIs() ); } + + [Fact] + public async Task Export_file_is_created_even_when_nothing_to_export() + { + var long_in_the_past = new DateTimeOffset(1921, 08, 01, 0, 0, 0, TimeSpan.Zero); + + // Arrange + var before = long_in_the_past; + using var file = TempFile.Create(); + + // Act + await new ExportChannelsCommand + { + Token = Secrets.DiscordToken, + ChannelIds = [ChannelIds.DateRangeTestCases], + ExportFormat = ExportFormat.Json, + OutputPath = file.Path, + Before = Snowflake.FromDate(before), + }.ExecuteAsync(new FakeConsole()); + + // Assert + var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path)) + .GetProperty("messages") + .EnumerateArray() + .Select(j => j.GetProperty("timestamp").GetDateTimeOffset()) + .ToArray(); + + timestamps.Should().BeEmpty(); + } } diff --git a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs index 67f99910..f2fd495c 100644 --- a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs @@ -179,6 +179,7 @@ public abstract class ExportCommandBase : DiscordCommandBase // Export var cancellationToken = console.RegisterCancellationHandler(); var errorsByChannel = new ConcurrentDictionary(); + var warningsByChannel = new ConcurrentDictionary(); await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)..."); await console @@ -236,6 +237,10 @@ public abstract class ExportCommandBase : DiscordCommandBase } ); } + catch (ChannelEmptyException ex) + { + warningsByChannel[channel] = ex.Message; + } catch (DiscordChatExporterException ex) when (!ex.IsFatal) { errorsByChannel[channel] = ex.Message; @@ -252,6 +257,28 @@ public abstract class ExportCommandBase : DiscordCommandBase ); } + // 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()) { @@ -259,16 +286,14 @@ public abstract class ExportCommandBase : DiscordCommandBase using (console.WithForegroundColor(ConsoleColor.Red)) { - await console.Error.WriteLineAsync( - $"Failed to export {errorsByChannel.Count} the following channel(s):" - ); + await console.Error.WriteLineAsync($"Failed to export the following channel(s):"); } - foreach (var (channel, error) in errorsByChannel) + foreach (var (channel, message) in errorsByChannel) { await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: "); using (console.WithForegroundColor(ConsoleColor.Red)) - await console.Error.WriteLineAsync(error); + await console.Error.WriteLineAsync(message); } await console.Error.WriteLineAsync(); diff --git a/DiscordChatExporter.Core/Exceptions/ChannelEmptyException.cs b/DiscordChatExporter.Core/Exceptions/ChannelEmptyException.cs new file mode 100644 index 00000000..b2373cbb --- /dev/null +++ b/DiscordChatExporter.Core/Exceptions/ChannelEmptyException.cs @@ -0,0 +1,8 @@ +using System; + +namespace DiscordChatExporter.Core.Exceptions; + +// Thrown when there is circumstancially no message to export with given parameters, +// though it should not be treated as a runtime error; simply warn instead +public class ChannelEmptyException(string message) + : DiscordChatExporterException(message, false, null) { } diff --git a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs index 805e6b56..58830e7c 100644 --- a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs +++ b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs @@ -27,45 +27,42 @@ public class ChannelExporter(DiscordClient discord) ); } - // Check if the channel is empty - if (request.Channel.IsEmpty) - { - throw new DiscordChatExporterException( - $"Channel '{request.Channel.Name}' " - + $"of guild '{request.Guild.Name}' " - + $"does not contain any messages." - ); - } - - // Check if the 'after' boundary is valid - if (request.After is not null && !request.Channel.MayHaveMessagesAfter(request.After.Value)) - { - throw new DiscordChatExporterException( - $"Channel '{request.Channel.Name}' " - + $"of guild '{request.Guild.Name}' " - + $"does not contain any messages within the specified period." - ); - } - - // Check if the 'before' boundary is valid - if ( - request.Before is not null - && !request.Channel.MayHaveMessagesBefore(request.Before.Value) - ) - { - throw new DiscordChatExporterException( - $"Channel '{request.Channel.Name}' " - + $"of guild '{request.Guild.Name}' " - + $"does not contain any messages within the specified period." - ); - } - // Build context var context = new ExportContext(discord, request); await context.PopulateChannelsAndRolesAsync(cancellationToken); // Export messages await using var messageExporter = new MessageExporter(context); + + // 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." + ); + } + + // Check if the 'before' and 'after' boundaries are valid + if ( + ( + request.Before is not null + && !request.Channel.MayHaveMessagesBefore(request.Before.Value) + ) + || ( + request.After is not null + && !request.Channel.MayHaveMessagesAfter(request.After.Value) + ) + ) + { + 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." + ); + } + await foreach ( var message in discord.GetMessagesAsync( request.Channel.Id, @@ -98,15 +95,5 @@ public class ChannelExporter(DiscordClient discord) ); } } - - // Throw if no messages were exported - if (messageExporter.MessagesExported <= 0) - { - throw new DiscordChatExporterException( - $"Channel '{request.Channel.Name}' (#{request.Channel.Id}) " - + $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) " - + $"does not contain any matching messages within the specified period." - ); - } } } diff --git a/DiscordChatExporter.Core/Exporting/MessageExporter.cs b/DiscordChatExporter.Core/Exporting/MessageExporter.cs index 05181b1c..95497962 100644 --- a/DiscordChatExporter.Core/Exporting/MessageExporter.cs +++ b/DiscordChatExporter.Core/Exporting/MessageExporter.cs @@ -70,7 +70,12 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable MessagesExported++; } - public async ValueTask DisposeAsync() => await ResetWriterAsync(); + public async ValueTask DisposeAsync() + { + // causes the file to be created whether there were messages written or not + await GetWriterAsync(); + await ResetWriterAsync(); + } } internal partial class MessageExporter diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs index 2f2a8c1b..4288723c 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -283,6 +283,13 @@ public partial class DashboardViewModel : ViewModelBase Interlocked.Increment(ref successfulExportCount); } + catch (ChannelEmptyException ex) + { + _snackbarManager.Notify(ex.Message.TrimEnd('.')); + + // FIXME: not exactly successful, but not a failure either. Not ideal to duplicate the line + Interlocked.Increment(ref successfulExportCount); + } catch (DiscordChatExporterException ex) when (!ex.IsFatal) { _snackbarManager.Notify(ex.Message.TrimEnd('.'));