From d1647e82864f46a847f87bac7d0ed20410979921 Mon Sep 17 00:00:00 2001 From: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> Date: Fri, 17 Feb 2023 21:30:10 +0200 Subject: [PATCH] Add support for selectable assets directory in GUI --- .../Commands/Base/ExportCommandBase.cs | 16 +-- .../Exporting/ExportContext.cs | 41 +++--- .../Exporting/ExportRequest.cs | 122 ++++++++++++------ .../Exporting/MessageExporter.cs | 4 +- .../Services/SettingsService.cs | 2 + .../Components/DashboardViewModel.cs | 2 +- .../Dialogs/ExportSetupViewModel.cs | 37 ++++-- .../ViewModels/Dialogs/MessageBoxViewModel.cs | 9 +- .../Views/Dialogs/ExportSetupView.xaml | 21 +++ 9 files changed, 163 insertions(+), 91 deletions(-) diff --git a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs index 0bd339f..a98e020 100644 --- a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs @@ -98,20 +98,20 @@ public abstract class ExportCommandBase : TokenCommandBase )] public bool ShouldReuseAssets { get; init; } - private readonly string? _assetsPath; + private readonly string? _assetsDirPath; [CommandOption( "media-dir", - Description = "Download assets to this directory." + Description = "Download assets to this directory. If not specified, the asset directory path will be derived from the output path." )] - public string? AssetsPath + public string? AssetsDirPath { - get => _assetsPath; + get => _assetsDirPath; // Handle ~/ in paths on Unix systems // https://github.com/Tyrrrz/DiscordChatExporter/pull/903 - init => _assetsPath = value is not null ? Path.GetFullPath(value) : null; + init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null; } - + [CommandOption( "dateformat", Description = "Format used when writing dates." @@ -139,7 +139,7 @@ public abstract class ExportCommandBase : TokenCommandBase } // Assets directory should only be specified when the download assets option is set - if (!string.IsNullOrWhiteSpace(AssetsPath) && !ShouldDownloadAssets) + if (!string.IsNullOrWhiteSpace(AssetsDirPath) && !ShouldDownloadAssets) { throw new CommandException( "Option --media-dir cannot be used without --media." @@ -194,7 +194,7 @@ public abstract class ExportCommandBase : TokenCommandBase guild, channel, OutputPath, - AssetsPath, + AssetsDirPath, ExportFormat, After, Before, diff --git a/DiscordChatExporter.Core/Exporting/ExportContext.cs b/DiscordChatExporter.Core/Exporting/ExportContext.cs index d88a134..9416627 100644 --- a/DiscordChatExporter.Core/Exporting/ExportContext.cs +++ b/DiscordChatExporter.Core/Exporting/ExportContext.cs @@ -29,7 +29,7 @@ internal class ExportContext Request = request; _assetDownloader = new ExportAssetDownloader( - request.OutputAssetsDirPath, + request.AssetsDirPath, request.ShouldReuseAssets ); } @@ -92,34 +92,25 @@ internal class ExportContext try { - var absoluteFilePath = await _assetDownloader.DownloadAsync(url, cancellationToken); - - // We want relative path so that the output files can be copied around without breaking. - // Base directory path may be null if the file is stored at the root or relative to working directory. - var relativeFilePath = !string.IsNullOrWhiteSpace(Request.OutputBaseDirPath) - ? Path.GetRelativePath(Request.OutputBaseDirPath, absoluteFilePath) - : absoluteFilePath; - - // If the assets path is outside of the export directory, fall back to absolute path - var filePath = relativeFilePath.StartsWith("..") - ? absoluteFilePath - : relativeFilePath; - - - // HACK: for HTML, we need to format the URL properly + var filePath = await _assetDownloader.DownloadAsync(url, cancellationToken); + var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath); + + // Prefer relative paths so that the output files can be copied around without breaking references. + // If the assets path is outside of the export directory, use the absolute path instead. + var optimalFilePath = + relativeFilePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || + relativeFilePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) + ? filePath + : relativeFilePath; + + // For HTML, the path needs to be properly formatted if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight) { - // Need to escape each path segment while keeping the directory separators intact - return string.Join( - Path.AltDirectorySeparatorChar, - filePath - .Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) - .Select(Uri.EscapeDataString) - .Select(x => x.Replace("%3A", ":")) - ); + // Create a 'file:///' URI and then strip the 'file:///' prefix to allow for relative paths + return new Uri(new Uri("file:///"), optimalFilePath).ToString()[8..]; } - return filePath; + return optimalFilePath; } // Try to catch only exceptions related to failed HTTP requests // https://github.com/Tyrrrz/DiscordChatExporter/issues/332 diff --git a/DiscordChatExporter.Core/Exporting/ExportRequest.cs b/DiscordChatExporter.Core/Exporting/ExportRequest.cs index 98b11cb..a917253 100644 --- a/DiscordChatExporter.Core/Exporting/ExportRequest.cs +++ b/DiscordChatExporter.Core/Exporting/ExportRequest.cs @@ -10,48 +10,87 @@ using DiscordChatExporter.Core.Utils; namespace DiscordChatExporter.Core.Exporting; -public partial record ExportRequest( - Guild Guild, - Channel Channel, - string OutputPath, - string? AssetsPath, - ExportFormat Format, - Snowflake? After, - Snowflake? Before, - PartitionLimit PartitionLimit, - MessageFilter MessageFilter, - bool ShouldFormatMarkdown, - bool ShouldDownloadAssets, - bool ShouldReuseAssets, - string DateFormat) +public partial class ExportRequest { - private string? _outputBaseFilePath; - public string OutputBaseFilePath => _outputBaseFilePath ??= GetOutputBaseFilePath( - Guild, - Channel, - OutputPath, - Format, - After, - Before - ); - - public string OutputBaseDirPath => Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath; - - private string? _outputAssetsDirPath; - public string OutputAssetsDirPath => _outputAssetsDirPath ??= ( - AssetsPath is not null - ? EvaluateTemplateTokens( - AssetsPath, - Guild, - Channel, - After, - Before - ) - : $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}" + public Guild Guild { get; } + + public Channel Channel { get; } + + public string OutputFilePath { get; } + + public string OutputDirPath { get; } + + public string AssetsDirPath { get; } + + public ExportFormat Format { get; } + + public Snowflake? After { get; } + + public Snowflake? Before { get; } + + public PartitionLimit PartitionLimit { get; } + + public MessageFilter MessageFilter { get; } + + public bool ShouldFormatMarkdown { get; } + + public bool ShouldDownloadAssets { get; } + + public bool ShouldReuseAssets { get; } + + public string DateFormat { get; } + + public ExportRequest( + Guild guild, + Channel channel, + string outputPath, + string? assetsDirPath, + ExportFormat format, + Snowflake? after, + Snowflake? before, + PartitionLimit partitionLimit, + MessageFilter messageFilter, + bool shouldFormatMarkdown, + bool shouldDownloadAssets, + bool shouldReuseAssets, + string dateFormat) + { + Guild = guild; + Channel = channel; + Format = format; + After = after; + Before = before; + PartitionLimit = partitionLimit; + MessageFilter = messageFilter; + ShouldFormatMarkdown = shouldFormatMarkdown; + ShouldDownloadAssets = shouldDownloadAssets; + ShouldReuseAssets = shouldReuseAssets; + DateFormat = dateFormat; + + OutputFilePath = GetOutputBaseFilePath( + Guild, + Channel, + outputPath, + Format, + After, + Before ); + + OutputDirPath = Path.GetDirectoryName(OutputFilePath)!; + + AssetsDirPath = !string.IsNullOrWhiteSpace(assetsDirPath) + ? FormatPath( + assetsDirPath, + Guild, + Channel, + After, + Before + ) + : $"{OutputFilePath}_Files{Path.DirectorySeparatorChar}"; + } } -public partial record ExportRequest +public partial class ExportRequest { public static string GetDefaultOutputFileName( Guild guild, @@ -95,7 +134,7 @@ public partial record ExportRequest return PathEx.EscapeFileName(buffer.ToString()); } - private static string EvaluateTemplateTokens( + private static string FormatPath( string path, Guild guild, Channel channel, @@ -120,7 +159,8 @@ public partial record ExportRequest "%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"), "%%" => "%", _ => m.Value - })); + }) + ); } private static string GetOutputBaseFilePath( @@ -131,7 +171,7 @@ public partial record ExportRequest Snowflake? after = null, Snowflake? before = null) { - var actualOutputPath = EvaluateTemplateTokens(outputPath, guild, channel, after, before); + var actualOutputPath = FormatPath(outputPath, guild, channel, after, before); // Output is a directory if (Directory.Exists(actualOutputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(actualOutputPath))) diff --git a/DiscordChatExporter.Core/Exporting/MessageExporter.cs b/DiscordChatExporter.Core/Exporting/MessageExporter.cs index 1cf2288..a5ad396 100644 --- a/DiscordChatExporter.Core/Exporting/MessageExporter.cs +++ b/DiscordChatExporter.Core/Exporting/MessageExporter.cs @@ -51,8 +51,8 @@ internal partial class MessageExporter : IAsyncDisposable if (_writer is not null) return _writer; - Directory.CreateDirectory(_context.Request.OutputBaseDirPath); - var filePath = GetPartitionFilePath(_context.Request.OutputBaseFilePath, _partitionIndex); + Directory.CreateDirectory(_context.Request.OutputDirPath); + var filePath = GetPartitionFilePath(_context.Request.OutputFilePath, _partitionIndex); var writer = CreateMessageWriter(filePath, _context.Request.Format, _context); await writer.WritePreambleAsync(cancellationToken); diff --git a/DiscordChatExporter.Gui/Services/SettingsService.cs b/DiscordChatExporter.Gui/Services/SettingsService.cs index 3cabcb3..9e8d3de 100644 --- a/DiscordChatExporter.Gui/Services/SettingsService.cs +++ b/DiscordChatExporter.Gui/Services/SettingsService.cs @@ -35,6 +35,8 @@ public partial class SettingsService : SettingsManager public bool LastShouldReuseAssets { get; set; } + public string? LastAssetsDirPath { get; set; } + public SettingsService() { Configuration.StorageSpace = StorageSpace.Instance; diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs index a262267..07eb062 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -186,7 +186,7 @@ public class DashboardViewModel : PropertyChangedBase dialog.Guild!, channel, dialog.OutputPath!, - null, + dialog.AssetsDirPath, dialog.SelectedFormat, dialog.After?.Pipe(Snowflake.FromDate), dialog.Before?.Pipe(Snowflake.FromDate), diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs index c5180a8..2fd54d7 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs @@ -65,6 +65,8 @@ public class ExportSetupViewModel : DialogScreen public bool ShouldReuseAssets { get; set; } + public string? AssetsDirPath { get; set; } + public bool IsAdvancedSectionDisplayed { get; set; } public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService) @@ -79,15 +81,18 @@ public class ExportSetupViewModel : DialogScreen ShouldFormatMarkdown = _settingsService.LastShouldFormatMarkdown; ShouldDownloadAssets = _settingsService.LastShouldDownloadAssets; ShouldReuseAssets = _settingsService.LastShouldReuseAssets; + AssetsDirPath = _settingsService.LastAssetsDirPath; // Show the "advanced options" section by default if any // of the advanced options are set to non-default values. IsAdvancedSectionDisplayed = - After != default || - Before != default || + After is not null || + Before is not null || !string.IsNullOrWhiteSpace(PartitionLimitValue) || !string.IsNullOrWhiteSpace(MessageFilterValue) || - ShouldDownloadAssets != default; + ShouldDownloadAssets || + ShouldReuseAssets || + !string.IsNullOrWhiteSpace(AssetsDirPath); } public void ToggleAdvancedSection() => IsAdvancedSectionDisplayed = !IsAdvancedSectionDisplayed; @@ -107,18 +112,25 @@ public class ExportSetupViewModel : DialogScreen var extension = SelectedFormat.GetFileExtension(); var filter = $"{extension.ToUpperInvariant()} files|*.{extension}"; - var outputPath = _dialogManager.PromptSaveFilePath(filter, defaultFileName); - if (!string.IsNullOrWhiteSpace(outputPath)) - OutputPath = outputPath; + var path = _dialogManager.PromptSaveFilePath(filter, defaultFileName); + if (!string.IsNullOrWhiteSpace(path)) + OutputPath = path; } else { - var outputPath = _dialogManager.PromptDirectoryPath(); - if (!string.IsNullOrWhiteSpace(outputPath)) - OutputPath = outputPath; + var path = _dialogManager.PromptDirectoryPath(); + if (!string.IsNullOrWhiteSpace(path)) + OutputPath = path; } } + public void ShowAssetsDirPathPrompt() + { + var path = _dialogManager.PromptDirectoryPath(); + if (!string.IsNullOrWhiteSpace(path)) + AssetsDirPath = path; + } + public void Confirm() { // Prompt the output path if it's not set yet @@ -138,6 +150,7 @@ public class ExportSetupViewModel : DialogScreen _settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown; _settingsService.LastShouldDownloadAssets = ShouldDownloadAssets; _settingsService.LastShouldReuseAssets = ShouldReuseAssets; + _settingsService.LastAssetsDirPath = AssetsDirPath; Close(true); } @@ -145,8 +158,10 @@ public class ExportSetupViewModel : DialogScreen public static class ExportSetupViewModelExtensions { - public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory, - Guild guild, IReadOnlyList channels) + public static ExportSetupViewModel CreateExportSetupViewModel( + this IViewModelFactory factory, + Guild guild, + IReadOnlyList channels) { var viewModel = factory.CreateExportSetupViewModel(); diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs index f8a238e..310305a 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs @@ -25,8 +25,10 @@ public static class MessageBoxViewModelExtensions { public static MessageBoxViewModel CreateMessageBoxViewModel( this IViewModelFactory factory, - string title, string message, - string? okButtonText, string? cancelButtonText) + string title, + string message, + string? okButtonText, + string? cancelButtonText) { var viewModel = factory.CreateMessageBoxViewModel(); viewModel.Title = title; @@ -42,6 +44,7 @@ public static class MessageBoxViewModelExtensions public static MessageBoxViewModel CreateMessageBoxViewModel( this IViewModelFactory factory, - string title, string message) => + string title, + string message) => factory.CreateMessageBoxViewModel(title, message, "CLOSE", null); } \ No newline at end of file diff --git a/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.xaml b/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.xaml index 2d9003d..ee5152a 100644 --- a/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.xaml +++ b/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.xaml @@ -285,6 +285,27 @@ VerticalAlignment="Center" IsChecked="{Binding ShouldReuseAssets}" /> + + + + + +