Don't consider it an error if there is nothing to export ()

This commit is contained in:
Leonardo Mosquera 2025-04-01 18:14:35 -03:00 committed by GitHub
parent cf7580014c
commit 7add81a472
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 110 additions and 49 deletions
DiscordChatExporter.Cli.Tests/Specs
DiscordChatExporter.Cli/Commands/Base
DiscordChatExporter.Core
DiscordChatExporter.Gui/ViewModels/Components

View file

@ -146,4 +146,33 @@ public class DateRangeSpecs
.WhenTypeIs<DateTimeOffset>() .WhenTypeIs<DateTimeOffset>()
); );
} }
[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();
}
} }

View file

@ -179,6 +179,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
// Export // Export
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
var errorsByChannel = new ConcurrentDictionary<Channel, string>(); var errorsByChannel = new ConcurrentDictionary<Channel, string>();
var warningsByChannel = new ConcurrentDictionary<Channel, string>();
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)..."); await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
await console await console
@ -236,6 +237,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
} }
); );
} }
catch (ChannelEmptyException ex)
{
warningsByChannel[channel] = ex.Message;
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal) catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{ {
errorsByChannel[channel] = ex.Message; 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 // Print errors
if (errorsByChannel.Any()) if (errorsByChannel.Any())
{ {
@ -259,16 +286,14 @@ public abstract class ExportCommandBase : DiscordCommandBase
using (console.WithForegroundColor(ConsoleColor.Red)) using (console.WithForegroundColor(ConsoleColor.Red))
{ {
await console.Error.WriteLineAsync( await console.Error.WriteLineAsync($"Failed to export the following channel(s):");
$"Failed to export {errorsByChannel.Count} the following channel(s):"
);
} }
foreach (var (channel, error) in errorsByChannel) foreach (var (channel, message) in errorsByChannel)
{ {
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: "); await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
using (console.WithForegroundColor(ConsoleColor.Red)) using (console.WithForegroundColor(ConsoleColor.Red))
await console.Error.WriteLineAsync(error); await console.Error.WriteLineAsync(message);
} }
await console.Error.WriteLineAsync(); await console.Error.WriteLineAsync();

View file

@ -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) { }

View file

@ -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 // Build context
var context = new ExportContext(discord, request); var context = new ExportContext(discord, request);
await context.PopulateChannelsAndRolesAsync(cancellationToken); await context.PopulateChannelsAndRolesAsync(cancellationToken);
// Export messages // Export messages
await using var messageExporter = new MessageExporter(context); 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 ( await foreach (
var message in discord.GetMessagesAsync( var message in discord.GetMessagesAsync(
request.Channel.Id, 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."
);
}
} }
} }

View file

@ -70,7 +70,12 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
MessagesExported++; 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 internal partial class MessageExporter

View file

@ -283,6 +283,13 @@ public partial class DashboardViewModel : ViewModelBase
Interlocked.Increment(ref successfulExportCount); 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) catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{ {
_snackbarManager.Notify(ex.Message.TrimEnd('.')); _snackbarManager.Notify(ex.Message.TrimEnd('.'));