diff --git a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs index be427b7b..354d9e0e 100644 --- a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs @@ -118,7 +118,7 @@ public abstract class ExportCommandBase : DiscordCommandBase "prev-export", Description = "What the exporter should do if the channel had already been exported." )] - public FileExistsHandling FileExistsHandling { get; init; } = FileExistsHandling.Abort; + public ExportExistsHandling ExportExistsHandling { get; init; } = ExportExistsHandling.Abort; [Obsolete("This option doesn't do anything. Kept for backwards compatibility.")] [CommandOption( @@ -230,6 +230,7 @@ public abstract class ExportCommandBase : DiscordCommandBase // Export await console.Output.WriteLineAsync($"Exporting {unwrappedChannels.Count} channel(s)..."); + var outputDirFilesDict = new ConcurrentDictionary(); var (progressTicker, logger) = console.CreateProgressTicker(); await progressTicker .HideCompleted( @@ -270,7 +271,7 @@ public abstract class ExportCommandBase : DiscordCommandBase ExportFormat, After, Before, - FileExistsHandling, + ExportExistsHandling, PartitionLimit, MessageFilter, ShouldFormatMarkdown, @@ -284,6 +285,7 @@ public abstract class ExportCommandBase : DiscordCommandBase logger, ParallelLimit > 1, request, + outputDirFilesDict, progress.ToPercentageBased(), innerCancellationToken ); @@ -298,7 +300,7 @@ public abstract class ExportCommandBase : DiscordCommandBase ); }); - logger.PrintExportSummary(FileExistsHandling); + logger.PrintExportSummary(ExportExistsHandling); // Fail the command only if ALL channels failed to export. // If only some channels failed to export, it's okay. diff --git a/DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs b/DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs index 45e560ff..dd132e8a 100644 --- a/DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs +++ b/DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs @@ -128,7 +128,7 @@ public class ConsoleProgressLogger(IAnsiConsole console) : ProgressLogger /// 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) + public void PrintExportSummary(ExportExistsHandling updateType) { var exportSummary = GetExportSummary(updateType); exportSummary.TryGetValue(ExportResult.NewExportSuccess, out var newExportSuccessMessage); diff --git a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs index 9a8aa37c..2348b5c7 100644 --- a/DiscordChatExporter.Core/Exporting/ChannelExporter.cs +++ b/DiscordChatExporter.Core/Exporting/ChannelExporter.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Concurrent; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using DiscordChatExporter.Core.Discord; @@ -16,6 +18,7 @@ public class ChannelExporter(DiscordClient discord) ProgressLogger logger, bool logSuccess, ExportRequest request, + ConcurrentDictionary outputDirFilesDict, IProgress? progress = null, CancellationToken cancellationToken = default ) @@ -32,62 +35,27 @@ public class ChannelExporter(DiscordClient discord) 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: - logger.LogError(request, "Aborted export due to existing export files"); - return; - case FileExistsHandling.Overwrite: - logger.LogWarning(request, "Removing existing export files"); - MessageExporter.RemoveExistingFiles(request.OutputFilePath); - break; - case FileExistsHandling.Append: - var lastMessageSnowflake = MessageExporter.GetLastMessageSnowflake( - request.OutputFilePath, - request.Format - ); - if (lastMessageSnowflake != null) - { - if (!request.Channel.MayHaveMessagesAfter(lastMessageSnowflake.Value)) - { - logger.IncrementCounter(ExportResult.UpdateExportSkip); - logger.LogInfo(request, "Existing export already up to date"); - return; - } - request.LastPriorMessage = lastMessageSnowflake.Value; - logger.LogInfo( - request, - "Appending existing export starting at " - + lastMessageSnowflake.Value.ToDate() - ); - } - else - { - if (request.Channel.IsEmpty) - { - logger.IncrementCounter(ExportResult.UpdateExportSkip); - logger.LogInfo(request, "Existing empty export already up to date"); - return; - } - logger.LogInfo(request, "Appending existing empty export."); - } - currentPartitionIndex = MessageExporter.GetPartitionCount( - request.OutputFilePath - ); - break; - default: - throw new InvalidOperationException( - $"Unknown FileExistsHandling value '{request.FileExistsHandling}'." - ); - } - } + // TODO: Add a way for the user to choose the setting + var searchForExistingExport = true; + if ( + !DetectExistingExport( + request, + logger, + searchForExistingExport, + outputDirFilesDict, + out var existingExportFile + ) + ) + return; + if ( + !HandleExistingExport( + request, + logger, + existingExportFile, + out var currentPartitionIndex + ) + ) + return; // Build context var context = new ExportContext(discord, request); @@ -95,7 +63,10 @@ public class ChannelExporter(DiscordClient discord) // Initialize the exporter before further checks to ensure the file is created even if // an exception is thrown after this point. - await using var messageExporter = new MessageExporter(context, currentPartitionIndex); + await using var messageExporter = new MessageExporter( + context, + currentPartitionIndex!.Value + ); // Check if the channel is empty if (request.Channel.IsEmpty) @@ -160,13 +131,13 @@ public class ChannelExporter(DiscordClient discord) } } - if (!exportExists) + if (existingExportFile == null) { logger.IncrementCounter(ExportResult.NewExportSuccess); if (logSuccess) logger.LogSuccess(request, "Successfully exported the channel"); } - else if (request.FileExistsHandling == FileExistsHandling.Append) + else if (request.ExportExistsHandling == ExportExistsHandling.Append) { logger.IncrementCounter(ExportResult.UpdateExportSuccess); if (logSuccess) @@ -179,4 +150,171 @@ public class ChannelExporter(DiscordClient discord) logger.LogSuccess(request, "Successfully overwrote the channel export"); } } + + /// + /// Detects whether an existing export of the given request exists. + /// + /// The request specifying the current channel export. + /// The logger that's used to log progress updates about the export. + /// + /// If false, it will only be detected whether an existing export exists at the current target file path. + /// This means that an existing export won't be detected if the name of the channel, the channel parent or the + /// guild has changed or if the default file name formatting has changed. + /// If true, the entire directory will be searched for an existing export file of the given request (if there is + /// none at the current target file path). + /// + /// + /// A thread-safe dictionary mapping lists of filenames to the directory they're in. + /// If the directory files are needed, they will be collected lazily and stored for future use in other channel + /// exports. + /// + /// + /// The absolute base file path of the existing export of this request, if one has been detected. Null otherwise. + /// + /// + /// Whether the export should continue normally (true) or return (false). + /// This is true if no, or exactly one, existing export of this request has been detected, and false if + /// several ones have been detected. + /// + private static bool DetectExistingExport( + ExportRequest request, + ProgressLogger logger, + bool searchForExistingExport, + ConcurrentDictionary outputDirFilesDict, + out string? existingExportFile + ) + { + existingExportFile = null; + + if (File.Exists(request.OutputFilePath)) + { + existingExportFile = request.OutputFilePath; + return true; + } + if (!searchForExistingExport) + return true; + + // Look for an existing export under a different file name + var outputFileRegex = request.GetDefaultOutputFileNameRegex(); + var outputDirFiles = outputDirFilesDict.GetOrAdd( + request.OutputDirPath, + outputDirPath => + Directory + .GetFiles(outputDirPath) + .Select(Path.GetFileName) + .Select(fileName => fileName!) + .ToArray() + ); + var regexFiles = outputDirFiles + .Where(fileName => outputFileRegex.IsMatch(fileName)) + .ToArray(); + if (regexFiles.Length == 0) + return true; + if (regexFiles.Length > 1) + { + logger.LogError( + request, + "Found multiple existing channel exports under different file names: " + + string.Join(", ", regexFiles) + + "." + ); + return false; + } + + logger.LogInfo( + request, + "Found existing channel export under file name " + regexFiles[0] + "." + ); + existingExportFile = Path.Combine(request.OutputDirPath, regexFiles[0]); + return true; + } + + /// + /// Handles the existing export files of the current request according to the set file exists handling. + /// + /// The request specifying the current channel export. + /// The logger that's used to log progress updates about the export. + /// + /// The absolute base file path of the existing export of this request, if one has been detected. + /// If this is null, the function will immediately return. + /// + /// + /// The index of the current export partition the newly exported messages should be written to. + /// + /// + /// Whether the export should continue normally (true) or return (false). + /// This is false both if the export should be aborted and if the export is already up to date, and true otherwise. + /// + /// + private static bool HandleExistingExport( + ExportRequest request, + ProgressLogger logger, + string? existingExportFile, + out int? currentPartitionIndex + ) + { + currentPartitionIndex = null; + + if (existingExportFile == null) + { + currentPartitionIndex = 0; + return true; + } + + // TODO: Maybe add an "Ask" option in the future + switch (request.ExportExistsHandling) + { + case ExportExistsHandling.Abort: + logger.LogError(request, "Aborted export due to existing export files"); + return false; + case ExportExistsHandling.Overwrite: + logger.LogWarning(request, "Removing existing export files"); + MessageExporter.RemoveExistingExport(existingExportFile); + currentPartitionIndex = 0; + return true; + case ExportExistsHandling.Append: + if (existingExportFile != request.OutputFilePath) + { + logger.LogInfo(request, "Moving existing export files to the new file names"); + MessageExporter.MoveExistingExport(existingExportFile, request.OutputFilePath); + } + + var lastMessageSnowflake = MessageExporter.GetLastMessageSnowflake( + request.OutputFilePath, + request.Format + ); + if (lastMessageSnowflake != null) + { + if (!request.Channel.MayHaveMessagesAfter(lastMessageSnowflake.Value)) + { + logger.IncrementCounter(ExportResult.UpdateExportSkip); + logger.LogInfo(request, "Existing export already up to date"); + return false; + } + request.LastPriorMessage = lastMessageSnowflake.Value; + logger.LogInfo( + request, + "Appending existing export starting at " + + lastMessageSnowflake.Value.ToDate() + ); + } + else + { + if (request.Channel.IsEmpty) + { + logger.IncrementCounter(ExportResult.UpdateExportSkip); + logger.LogInfo(request, "Existing empty export already up to date"); + return false; + } + logger.LogInfo(request, "Appending existing empty export."); + } + + currentPartitionIndex = MessageExporter.GetPartitionCount(request.OutputFilePath); + return true; + default: + throw new InvalidOperationException( + $"Unknown FileExistsHandling value '{request.ExportExistsHandling}'." + ); + } + } } diff --git a/DiscordChatExporter.Core/Exporting/FileExistsHandling.cs b/DiscordChatExporter.Core/Exporting/ExportExistsHandling.cs similarity index 95% rename from DiscordChatExporter.Core/Exporting/FileExistsHandling.cs rename to DiscordChatExporter.Core/Exporting/ExportExistsHandling.cs index d6cfd41c..9c6bf911 100644 --- a/DiscordChatExporter.Core/Exporting/FileExistsHandling.cs +++ b/DiscordChatExporter.Core/Exporting/ExportExistsHandling.cs @@ -3,7 +3,7 @@ namespace DiscordChatExporter.Core.Exporting; /// /// Represents the setting on how to handle the export of a channel that has already been exported. /// -public enum FileExistsHandling +public enum ExportExistsHandling { /// /// If a channel had previously been exported, its export will be aborted. diff --git a/DiscordChatExporter.Core/Exporting/ExportRequest.cs b/DiscordChatExporter.Core/Exporting/ExportRequest.cs index d3dcba70..5abe25fb 100644 --- a/DiscordChatExporter.Core/Exporting/ExportRequest.cs +++ b/DiscordChatExporter.Core/Exporting/ExportRequest.cs @@ -30,7 +30,7 @@ public partial class ExportRequest public Snowflake? Before { get; } - public FileExistsHandling FileExistsHandling { get; } + public ExportExistsHandling ExportExistsHandling { get; } public Snowflake? LastPriorMessage { get; set; } @@ -58,7 +58,7 @@ public partial class ExportRequest ExportFormat format, Snowflake? after, Snowflake? before, - FileExistsHandling fileExistsHandling, + ExportExistsHandling exportExistsHandling, PartitionLimit partitionLimit, MessageFilter messageFilter, bool shouldFormatMarkdown, @@ -73,7 +73,7 @@ public partial class ExportRequest Format = format; After = after; Before = before; - FileExistsHandling = fileExistsHandling; + ExportExistsHandling = exportExistsHandling; PartitionLimit = partitionLimit; MessageFilter = messageFilter; ShouldFormatMarkdown = shouldFormatMarkdown; @@ -104,6 +104,7 @@ public partial class ExportRequest Snowflake? before = null ) { + // Do not change this without adding the new version to the corresponding regex below var buffer = new StringBuilder(); // Guild name @@ -154,6 +155,63 @@ public partial class ExportRequest return PathEx.EscapeFileName(buffer.ToString()); } + /// + /// Returns a regex that matches any default file name this channel export might have had in the past. + /// This can be used to detect existing exports of this channel with a different guild, parent and / or channel + /// name. + /// This only matches existing exports with the same date range as the current export. + /// + /// A regex that matches any default file name this channel might have had in the past. + public Regex GetDefaultOutputFileNameRegex() + { + // While this code looks similar to GetDefaultOutputFileName, the two functions are intentionally independent + // Even if the default output file name gets changed, the previous default file names should still be matched + // by this; the new version should just be added additionally to this regex + var buffer = new StringBuilder(); + + // Guild name + buffer.Append(".*?"); + + // Parent name + if (Channel.Parent is not null) + buffer.Append(" - ").Append(".*?"); + + // Channel name and ID + buffer + .Append(" - ") + .Append(".*?") + .Append(' ') + .Append("\\[") + .Append(Channel.Id) + .Append("\\]"); + + // Date range + if (After is not null || Before is not null) + { + buffer.Append(' ').Append("\\("); + if (After is not null && Before is not null) + { + buffer.Append( + $"{After.Value.ToDate():yyyy-MM-dd} to {Before.Value.ToDate():yyyy-MM-dd}" + ); + } + else if (After is not null) + { + buffer.Append($"after {After.Value.ToDate():yyyy-MM-dd}"); + } + else if (Before is not null) + { + buffer.Append($"before {Before.Value.ToDate():yyyy-MM-dd}"); + } + buffer.Append("\\)"); + } + + // File extension + buffer.Append("\\.").Append(Format.GetFileExtension()); + + return new Regex(buffer.ToString()); + } + private static string FormatPath( string path, Guild guild, diff --git a/DiscordChatExporter.Core/Exporting/Logging/ProgressLogger.cs b/DiscordChatExporter.Core/Exporting/Logging/ProgressLogger.cs index 3a35e6d8..c638c3d0 100644 --- a/DiscordChatExporter.Core/Exporting/Logging/ProgressLogger.cs +++ b/DiscordChatExporter.Core/Exporting/Logging/ProgressLogger.cs @@ -27,7 +27,7 @@ public abstract class ProgressLogger /// /// 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) + protected Dictionary GetExportSummary(ExportExistsHandling updateType) { _counters.TryGetValue(ExportResult.NewExportSuccess, out var newExportSuccessCount); _counters.TryGetValue( @@ -48,7 +48,7 @@ public abstract class ProgressLogger if (updateExportSuccessCount > 0) exportSummary[ExportResult.UpdateExportSuccess] = "Successfully " - + (updateType == FileExistsHandling.Append ? "appended" : "overrode") + + (updateType == ExportExistsHandling.Append ? "appended" : "overrode") + $" {updateExportSuccessCount} existing channel export(s)."; if (updateExportSkipCount > 0) exportSummary[ExportResult.UpdateExportSkip] = diff --git a/DiscordChatExporter.Core/Exporting/MessageExporter.cs b/DiscordChatExporter.Core/Exporting/MessageExporter.cs index fe23b7b8..fdc46c9e 100644 --- a/DiscordChatExporter.Core/Exporting/MessageExporter.cs +++ b/DiscordChatExporter.Core/Exporting/MessageExporter.cs @@ -109,7 +109,7 @@ internal partial class MessageExporter if (File.Exists(filePath)) { throw new InvalidOperationException( - "Error: An exported file already exists. This should never happen." + "Error: The target export file already exists. This should never happen." ); } @@ -184,10 +184,9 @@ internal partial class MessageExporter /// /// The path of the first partition of the Discord channel export that should be removed. /// - public static void RemoveExistingFiles(string baseFilePath) + public static void RemoveExistingExport(string baseFilePath) { - var currentPartition = 0; - while (true) + for (var currentPartition = 0; ; currentPartition++) { var currentFilePath = GetPartitionFilePath(baseFilePath, currentPartition); if (File.Exists(currentFilePath)) @@ -198,7 +197,33 @@ internal partial class MessageExporter { return; } - currentPartition++; + } + } + + /// + /// Moves all partitions of the previously exported Discord channel with the given old base file path to the given + /// new base file path. + /// + /// + /// The old path of the first partition of the Discord channel export that should be moved. + /// + /// + /// The new path to which the first partition of the Discord channel export should be moved. + /// + public static void MoveExistingExport(string oldBaseFilePath, string newBaseFilePath) + { + for (var currentPartition = 0; ; currentPartition++) + { + var currentOldFilePath = GetPartitionFilePath(oldBaseFilePath, currentPartition); + if (File.Exists(currentOldFilePath)) + { + var currentNewFilePath = GetPartitionFilePath(newBaseFilePath, currentPartition); + File.Move(currentOldFilePath, currentNewFilePath); + } + else + { + return; + } } } diff --git a/DiscordChatExporter.Gui/Framework/SnackbarManager.cs b/DiscordChatExporter.Gui/Framework/SnackbarManager.cs index 41edecb5..0e502c26 100644 --- a/DiscordChatExporter.Gui/Framework/SnackbarManager.cs +++ b/DiscordChatExporter.Gui/Framework/SnackbarManager.cs @@ -104,7 +104,7 @@ public class SnackbarProgressLogger(SnackbarManager snackbarManager) : ProgressL /// 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) + public void PrintExportSummary(ExportExistsHandling updateType) { var exportSummary = GetExportSummary(updateType); exportSummary.TryGetValue(ExportResult.NewExportSuccess, out var newExportSuccessMessage); diff --git a/DiscordChatExporter.Gui/Services/SettingsService.cs b/DiscordChatExporter.Gui/Services/SettingsService.cs index c1a618d0..a56da7e1 100644 --- a/DiscordChatExporter.Gui/Services/SettingsService.cs +++ b/DiscordChatExporter.Gui/Services/SettingsService.cs @@ -37,7 +37,7 @@ public partial class SettingsService() public partial ThreadInclusionMode ThreadInclusionMode { get; set; } [ObservableProperty] - public partial FileExistsHandling FileExistsHandling { get; set; } + public partial ExportExistsHandling ExportExistsHandling { get; set; } [ObservableProperty] public partial string? Locale { get; set; } diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs index 052007d5..2f9761b0 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; @@ -243,6 +244,7 @@ public partial class DashboardViewModel : ViewModelBase var exporter = new ChannelExporter(_discord); var logger = new SnackbarProgressLogger(_snackbarManager); + var outputDirFilesDict = new ConcurrentDictionary(); var channelProgressPairs = dialog .Channels!.Select(c => new { Channel = c, Progress = _progressMuxer.CreateInput() }) @@ -269,7 +271,7 @@ public partial class DashboardViewModel : ViewModelBase dialog.SelectedFormat, dialog.After?.Pipe(timestamp => Snowflake.FromDate(timestamp, true)), dialog.Before?.Pipe(timestamp => Snowflake.FromDate(timestamp)), - _settingsService.FileExistsHandling, + _settingsService.ExportExistsHandling, dialog.PartitionLimit, dialog.MessageFilter, dialog.ShouldFormatMarkdown, @@ -283,6 +285,7 @@ public partial class DashboardViewModel : ViewModelBase logger, true, request, + outputDirFilesDict, progress, cancellationToken ); @@ -298,7 +301,7 @@ public partial class DashboardViewModel : ViewModelBase } ); - logger.PrintExportSummary(_settingsService.FileExistsHandling); + logger.PrintExportSummary(_settingsService.ExportExistsHandling); } catch (Exception ex) { diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs index c9dd1e61..8d96f0d1 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/SettingsViewModel.cs @@ -62,13 +62,13 @@ public class SettingsViewModel : DialogViewModelBase set => _settingsService.ThreadInclusionMode = value; } - public IReadOnlyList AvailableFileExistHandlingOptions { get; } = - Enum.GetValues(); + public IReadOnlyList AvailableExportExistsHandlingOptions { get; } = + Enum.GetValues(); - public FileExistsHandling FileExistsHandling + public ExportExistsHandling ExportExistsHandling { - get => _settingsService.FileExistsHandling; - set => _settingsService.FileExistsHandling = value; + get => _settingsService.ExportExistsHandling; + set => _settingsService.ExportExistsHandling = value; } // These items have to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected diff --git a/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml b/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml index adce95e2..b1cc5abf 100644 --- a/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml +++ b/DiscordChatExporter.Gui/Views/Dialogs/SettingsView.axaml @@ -132,7 +132,7 @@ - + + ItemsSource="{Binding AvailableExportExistsHandlingOptions}" + SelectedItem="{Binding ExportExistsHandling}" />