diff --git a/DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs b/DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs index 9bccbabd..8bc9bdfa 100644 --- a/DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs +++ b/DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs @@ -3,6 +3,7 @@ using System.Net; using System.Threading.Tasks; using CliFx.Attributes; using CliFx.Services; +using DiscordChatExporter.Core.Models.Exceptions; using DiscordChatExporter.Core.Services; using DiscordChatExporter.Core.Services.Exceptions; @@ -39,6 +40,10 @@ namespace DiscordChatExporter.Cli.Commands { console.Error.WriteLine("This channel doesn't exist."); } + catch (DomainException ex) + { + console.Error.WriteLine(ex.Message); + } } } } diff --git a/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs b/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs index eb79f880..586266cb 100644 --- a/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs +++ b/DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using CliFx.Attributes; using CliFx.Services; using DiscordChatExporter.Core.Models; +using DiscordChatExporter.Core.Models.Exceptions; using DiscordChatExporter.Core.Services; using DiscordChatExporter.Core.Services.Exceptions; @@ -43,6 +44,10 @@ namespace DiscordChatExporter.Cli.Commands { console.Error.WriteLine("This channel doesn't exist."); } + catch (DomainException ex) + { + console.Error.WriteLine(ex.Message); + } } } } diff --git a/DiscordChatExporter.Core.Models/Exceptions/DomainException.cs b/DiscordChatExporter.Core.Models/Exceptions/DomainException.cs new file mode 100644 index 00000000..d81d1667 --- /dev/null +++ b/DiscordChatExporter.Core.Models/Exceptions/DomainException.cs @@ -0,0 +1,12 @@ +using System; + +namespace DiscordChatExporter.Core.Models.Exceptions +{ + public class DomainException : Exception + { + public DomainException(string message) + : base(message) + { + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/CsvMessageRenderer.cs b/DiscordChatExporter.Core.Rendering/CsvMessageRenderer.cs index 3bf5d758..c8370061 100644 --- a/DiscordChatExporter.Core.Rendering/CsvMessageRenderer.cs +++ b/DiscordChatExporter.Core.Rendering/CsvMessageRenderer.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.IO; +using System.Threading.Tasks; using DiscordChatExporter.Core.Models; using DiscordChatExporter.Core.Rendering.Logic; @@ -8,8 +9,8 @@ namespace DiscordChatExporter.Core.Rendering { private bool _isHeaderRendered; - public CsvMessageRenderer(string filePath, RenderContext context) - : base(filePath, context) + public CsvMessageRenderer(TextWriter writer, RenderContext context) + : base(writer, context) { } diff --git a/DiscordChatExporter.Core.Rendering/FacadeMessageRenderer.cs b/DiscordChatExporter.Core.Rendering/FacadeMessageRenderer.cs new file mode 100644 index 00000000..00ad9e1e --- /dev/null +++ b/DiscordChatExporter.Core.Rendering/FacadeMessageRenderer.cs @@ -0,0 +1,112 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using DiscordChatExporter.Core.Models; + +namespace DiscordChatExporter.Core.Rendering +{ + public partial class FacadeMessageRenderer : IMessageRenderer + { + private readonly string _baseFilePath; + private readonly ExportFormat _format; + private readonly RenderContext _context; + + private int _partitionIndex; + private TextWriter _writer; + private IMessageRenderer _innerRenderer; + + public FacadeMessageRenderer(string baseFilePath, ExportFormat format, RenderContext context) + { + _baseFilePath = baseFilePath; + _format = format; + _context = context; + } + + private void EnsureInnerRendererInitialized() + { + if (_writer != null && _innerRenderer != null) + return; + + // Get partition file path + var filePath = GetPartitionFilePath(_baseFilePath, _partitionIndex); + + // Create output directory + var dirPath = Path.GetDirectoryName(_baseFilePath); + if (!string.IsNullOrWhiteSpace(dirPath)) + Directory.CreateDirectory(dirPath); + + // Create writer (will be disposed by renderer) + _writer = File.CreateText(filePath); + + // Create inner renderer + if (_format == ExportFormat.PlainText) + { + _innerRenderer = new PlainTextMessageRenderer(_writer, _context); + } + else if (_format == ExportFormat.Csv) + { + _innerRenderer = new CsvMessageRenderer(_writer, _context); + } + else if (_format == ExportFormat.HtmlDark) + { + _innerRenderer = new HtmlMessageRenderer(_writer, _context, "Dark"); + } + else if (_format == ExportFormat.HtmlLight) + { + _innerRenderer = new HtmlMessageRenderer(_writer, _context, "Light"); + } + else + { + throw new InvalidOperationException($"Unknown export format [{_format}]."); + } + } + + public async Task NextPartitionAsync() + { + // Dispose writer and inner renderer + await DisposeAsync(); + _writer = null; + _innerRenderer = null; + + // Increment partition index + _partitionIndex++; + } + + public async Task RenderMessageAsync(Message message) + { + EnsureInnerRendererInitialized(); + await _innerRenderer.RenderMessageAsync(message); + } + + public async ValueTask DisposeAsync() + { + if (_innerRenderer != null) + await _innerRenderer.DisposeAsync(); + + if (_writer != null) + await _writer.DisposeAsync(); + } + } + + public partial class FacadeMessageRenderer + { + private static string GetPartitionFilePath(string baseFilePath, int partitionIndex) + { + // First partition - no changes + if (partitionIndex <= 0) + return baseFilePath; + + // Inject partition index into file name + var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath); + var fileExt = Path.GetExtension(baseFilePath); + var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}"; + + // Generate new path + var dirPath = Path.GetDirectoryName(baseFilePath); + if (!string.IsNullOrWhiteSpace(dirPath)) + return Path.Combine(dirPath, fileName); + + return fileName; + } + } +} \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/HtmlMessageRenderer.cs b/DiscordChatExporter.Core.Rendering/HtmlMessageRenderer.cs index 25ff3b1b..c3836c74 100644 --- a/DiscordChatExporter.Core.Rendering/HtmlMessageRenderer.cs +++ b/DiscordChatExporter.Core.Rendering/HtmlMessageRenderer.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; @@ -22,8 +23,8 @@ namespace DiscordChatExporter.Core.Rendering private bool _isLeadingBlockRendered; - public HtmlMessageRenderer(string filePath, RenderContext context, string themeName) - : base(filePath, context) + public HtmlMessageRenderer(TextWriter writer, RenderContext context, string themeName) + : base(writer, context) { _themeName = themeName; diff --git a/DiscordChatExporter.Core.Rendering/MessageRendererBase.cs b/DiscordChatExporter.Core.Rendering/MessageRendererBase.cs index ba5ebb31..4e9ab9b4 100644 --- a/DiscordChatExporter.Core.Rendering/MessageRendererBase.cs +++ b/DiscordChatExporter.Core.Rendering/MessageRendererBase.cs @@ -10,14 +10,14 @@ namespace DiscordChatExporter.Core.Rendering protected RenderContext Context { get; } - protected MessageRendererBase(string filePath, RenderContext context) + protected MessageRendererBase(TextWriter writer, RenderContext context) { - Writer = File.CreateText(filePath); + Writer = writer; Context = context; } public abstract Task RenderMessageAsync(Message message); - public virtual ValueTask DisposeAsync() => Writer.DisposeAsync(); + public virtual ValueTask DisposeAsync() => default; } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Rendering/PlainTextMessageRenderer.cs b/DiscordChatExporter.Core.Rendering/PlainTextMessageRenderer.cs index f0df5a71..59fa4f8d 100644 --- a/DiscordChatExporter.Core.Rendering/PlainTextMessageRenderer.cs +++ b/DiscordChatExporter.Core.Rendering/PlainTextMessageRenderer.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.IO; +using System.Threading.Tasks; using DiscordChatExporter.Core.Models; using DiscordChatExporter.Core.Rendering.Logic; @@ -8,8 +9,8 @@ namespace DiscordChatExporter.Core.Rendering { private bool _isPreambleRendered; - public PlainTextMessageRenderer(string filePath, RenderContext context) - : base(filePath, context) + public PlainTextMessageRenderer(TextWriter writer, RenderContext context) + : base(writer, context) { } diff --git a/DiscordChatExporter.Core.Services/ExportService.cs b/DiscordChatExporter.Core.Services/ExportService.cs index a3b94b37..7e716239 100644 --- a/DiscordChatExporter.Core.Services/ExportService.cs +++ b/DiscordChatExporter.Core.Services/ExportService.cs @@ -3,13 +3,14 @@ using System.Collections.Generic; using System.IO; using System.Threading.Tasks; using DiscordChatExporter.Core.Models; +using DiscordChatExporter.Core.Models.Exceptions; using DiscordChatExporter.Core.Rendering; using DiscordChatExporter.Core.Services.Logic; using Tyrrrz.Extensions; namespace DiscordChatExporter.Core.Services { - public class ExportService + public partial class ExportService { private readonly SettingsService _settingsService; private readonly DataService _dataService; @@ -20,47 +21,6 @@ namespace DiscordChatExporter.Core.Services _dataService = dataService; } - private string GetFilePathFromOutputPath(string outputPath, ExportFormat format, RenderContext context) - { - // Output is a directory - if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath))) - { - var fileName = ExportLogic.GetDefaultExportFileName(format, context.Guild, context.Channel, context.After, context.Before); - return Path.Combine(outputPath, fileName); - } - - // Output is a file - return outputPath; - } - - private IMessageRenderer CreateRenderer(string outputPath, int partitionIndex, ExportFormat format, RenderContext context) - { - var filePath = ExportLogic.GetExportPartitionFilePath( - GetFilePathFromOutputPath(outputPath, format, context), - partitionIndex); - - // Create output directory - var dirPath = Path.GetDirectoryName(filePath); - if (!string.IsNullOrWhiteSpace(dirPath)) - Directory.CreateDirectory(dirPath); - - // Create renderer - - if (format == ExportFormat.PlainText) - return new PlainTextMessageRenderer(filePath, context); - - if (format == ExportFormat.Csv) - return new CsvMessageRenderer(filePath, context); - - if (format == ExportFormat.HtmlDark) - return new HtmlMessageRenderer(filePath, context, "Dark"); - - if (format == ExportFormat.HtmlLight) - return new HtmlMessageRenderer(filePath, context, "Light"); - - throw new InvalidOperationException($"Unknown export format [{format}]."); - } - public async Task ExportChatLogAsync(AuthToken token, Guild guild, Channel channel, string outputPath, ExportFormat format, int? partitionLimit, DateTimeOffset? after = null, DateTimeOffset? before = null, IProgress? progress = null) @@ -76,35 +36,50 @@ namespace DiscordChatExporter.Core.Services mentionableUsers, mentionableChannels, mentionableRoles ); - // Render messages - var partitionIndex = 0; - var partitionMessageCount = 0; - var renderer = CreateRenderer(outputPath, partitionIndex, format, context); + // Create renderer + var baseFilePath = GetFilePathFromOutputPath(outputPath, format, context); + await using var renderer = new FacadeMessageRenderer(baseFilePath, format, context); + // Render messages + var messageCount = 0L; await foreach (var message in _dataService.GetMessagesAsync(token, channel.Id, after, before, progress)) { // Add encountered users to the list of mentionable users mentionableUsers.Add(message.Author); mentionableUsers.AddRange(message.MentionedUsers); - // If new partition is required, reset renderer - if (partitionLimit != null && partitionLimit > 0 && partitionMessageCount >= partitionLimit) - { - partitionIndex++; - partitionMessageCount = 0; - - // Flush old renderer and create a new one - await renderer.DisposeAsync(); - renderer = CreateRenderer(outputPath, partitionIndex, format, context); - } - // Render message await renderer.RenderMessageAsync(message); - partitionMessageCount++; + messageCount++; + + // Trigger next partition when needed + if (partitionLimit != null && + partitionLimit != 0 && + messageCount % partitionLimit.Value == 0) + { + await renderer.NextPartitionAsync(); + } } - // Flush last renderer - await renderer.DisposeAsync(); + // Throw if no messages were rendered + if (messageCount == 0) + throw new DomainException($"Channel [{channel.Name}] contains no messages for specified period"); + } + } + + public partial class ExportService + { + private static string GetFilePathFromOutputPath(string outputPath, ExportFormat format, RenderContext context) + { + // Output is a directory + if (Directory.Exists(outputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(outputPath))) + { + var fileName = ExportLogic.GetDefaultExportFileName(format, context.Guild, context.Channel, context.After, context.Before); + return Path.Combine(outputPath, fileName); + } + + // Output is a file + return outputPath; } } } \ No newline at end of file diff --git a/DiscordChatExporter.Core.Services/Logic/ExportLogic.cs b/DiscordChatExporter.Core.Services/Logic/ExportLogic.cs index aa95d09c..e96b13c9 100644 --- a/DiscordChatExporter.Core.Services/Logic/ExportLogic.cs +++ b/DiscordChatExporter.Core.Services/Logic/ExportLogic.cs @@ -49,24 +49,5 @@ namespace DiscordChatExporter.Core.Services.Logic return buffer.ToString(); } - - public static string GetExportPartitionFilePath(string baseFilePath, int partitionIndex) - { - // First partition - no changes - if (partitionIndex <= 0) - return baseFilePath; - - // Inject partition index into file name - var fileNameWithoutExt = Path.GetFileNameWithoutExtension(baseFilePath); - var fileExt = Path.GetExtension(baseFilePath); - var fileName = $"{fileNameWithoutExt} [part {partitionIndex + 1}]{fileExt}"; - - // Generate new path - var dirPath = Path.GetDirectoryName(baseFilePath); - if (!string.IsNullOrWhiteSpace(dirPath)) - return Path.Combine(dirPath, fileName); - - return fileName; - } } } \ No newline at end of file diff --git a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs b/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs index d46608c7..6c8ad644 100644 --- a/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/RootViewModel.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Net; using System.Threading.Tasks; using DiscordChatExporter.Core.Models; +using DiscordChatExporter.Core.Models.Exceptions; using DiscordChatExporter.Core.Services; using DiscordChatExporter.Core.Services.Exceptions; using DiscordChatExporter.Gui.Services; @@ -230,6 +231,10 @@ namespace DiscordChatExporter.Gui.ViewModels { Notifications.Enqueue("Forbidden – account may be locked by 2FA"); } + catch (DomainException ex) + { + Notifications.Enqueue(ex.Message); + } finally { operation.Dispose(); @@ -276,6 +281,10 @@ namespace DiscordChatExporter.Gui.ViewModels { Notifications.Enqueue($"Channel [{channel.Model.Name}] doesn't exist"); } + catch (DomainException ex) + { + Notifications.Enqueue(ex.Message); + } finally { operation.Dispose(); @@ -283,7 +292,8 @@ namespace DiscordChatExporter.Gui.ViewModels } // Notify of overall completion - Notifications.Enqueue($"Successfully exported {successfulExportCount} channel(s)"); + if (successfulExportCount > 0) + Notifications.Enqueue($"Successfully exported {successfulExportCount} channel(s)"); } } } \ No newline at end of file