diff --git a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs index 11992a6..0bd339f 100644 --- a/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs +++ b/DiscordChatExporter.Cli/Commands/Base/ExportCommandBase.cs @@ -98,6 +98,20 @@ public abstract class ExportCommandBase : TokenCommandBase )] public bool ShouldReuseAssets { get; init; } + private readonly string? _assetsPath; + + [CommandOption( + "media-dir", + Description = "Download assets to this directory." + )] + public string? AssetsPath + { + get => _assetsPath; + // Handle ~/ in paths on Unix systems + // https://github.com/Tyrrrz/DiscordChatExporter/pull/903 + init => _assetsPath = value is not null ? Path.GetFullPath(value) : null; + } + [CommandOption( "dateformat", Description = "Format used when writing dates." @@ -124,6 +138,14 @@ public abstract class ExportCommandBase : TokenCommandBase ); } + // Assets directory should only be specified when the download assets option is set + if (!string.IsNullOrWhiteSpace(AssetsPath) && !ShouldDownloadAssets) + { + throw new CommandException( + "Option --media-dir cannot be used without --media." + ); + } + // Make sure the user does not try to export all channels into a single file. // Output path must either be a directory, or contain template tokens. // https://github.com/Tyrrrz/DiscordChatExporter/issues/799 @@ -172,6 +194,7 @@ public abstract class ExportCommandBase : TokenCommandBase guild, channel, OutputPath, + AssetsPath, ExportFormat, After, Before, diff --git a/DiscordChatExporter.Core/Exporting/ExportContext.cs b/DiscordChatExporter.Core/Exporting/ExportContext.cs index 39ed360..d88a134 100644 --- a/DiscordChatExporter.Core/Exporting/ExportContext.cs +++ b/DiscordChatExporter.Core/Exporting/ExportContext.cs @@ -92,13 +92,19 @@ internal class ExportContext try { - var filePath = await _assetDownloader.DownloadAsync(url, cancellationToken); + 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, filePath) - : filePath; + ? 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 if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight) @@ -106,13 +112,14 @@ internal class ExportContext // Need to escape each path segment while keeping the directory separators intact return string.Join( Path.AltDirectorySeparatorChar, - relativeFilePath + filePath .Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) .Select(Uri.EscapeDataString) + .Select(x => x.Replace("%3A", ":")) ); } - return relativeFilePath; + return filePath; } // 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 6d0a421..98b11cb 100644 --- a/DiscordChatExporter.Core/Exporting/ExportRequest.cs +++ b/DiscordChatExporter.Core/Exporting/ExportRequest.cs @@ -14,6 +14,7 @@ public partial record ExportRequest( Guild Guild, Channel Channel, string OutputPath, + string? AssetsPath, ExportFormat Format, Snowflake? After, Snowflake? Before, @@ -36,7 +37,18 @@ public partial record ExportRequest( public string OutputBaseDirPath => Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath; - public string OutputAssetsDirPath => $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}"; + private string? _outputAssetsDirPath; + public string OutputAssetsDirPath => _outputAssetsDirPath ??= ( + AssetsPath is not null + ? EvaluateTemplateTokens( + AssetsPath, + Guild, + Channel, + After, + Before + ) + : $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}" + ); } public partial record ExportRequest @@ -83,17 +95,15 @@ public partial record ExportRequest return PathEx.EscapeFileName(buffer.ToString()); } - private static string GetOutputBaseFilePath( + private static string EvaluateTemplateTokens( + string path, Guild guild, Channel channel, - string outputPath, - ExportFormat format, - Snowflake? after = null, - Snowflake? before = null) + Snowflake? after, + Snowflake? before) { - // Format path - var actualOutputPath = Regex.Replace( - outputPath, + return Regex.Replace( + path, "%.", m => PathEx.EscapeFileName(m.Value switch { @@ -110,8 +120,18 @@ public partial record ExportRequest "%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"), "%%" => "%", _ => m.Value - }) - ); + })); + } + + private static string GetOutputBaseFilePath( + Guild guild, + Channel channel, + string outputPath, + ExportFormat format, + Snowflake? after = null, + Snowflake? before = null) + { + var actualOutputPath = EvaluateTemplateTokens(outputPath, guild, channel, after, before); // Output is a directory if (Directory.Exists(actualOutputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(actualOutputPath))) diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs index e7ec4d7..a262267 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -186,6 +186,7 @@ public class DashboardViewModel : PropertyChangedBase dialog.Guild!, channel, dialog.OutputPath!, + null, dialog.SelectedFormat, dialog.After?.Pipe(Snowflake.FromDate), dialog.Before?.Pipe(Snowflake.FromDate),