Add support for selectable assets directory in GUI

This commit is contained in:
Tyrrrz 2023-02-17 21:30:10 +02:00
parent 95115f3e99
commit d1647e8286
9 changed files with 159 additions and 87 deletions

View file

@ -98,20 +98,20 @@ public abstract class ExportCommandBase : TokenCommandBase
)] )]
public bool ShouldReuseAssets { get; init; } public bool ShouldReuseAssets { get; init; }
private readonly string? _assetsPath; private readonly string? _assetsDirPath;
[CommandOption( [CommandOption(
"media-dir", "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 // Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903 // 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( [CommandOption(
"dateformat", "dateformat",
Description = "Format used when writing dates." 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 // 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( throw new CommandException(
"Option --media-dir cannot be used without --media." "Option --media-dir cannot be used without --media."
@ -194,7 +194,7 @@ public abstract class ExportCommandBase : TokenCommandBase
guild, guild,
channel, channel,
OutputPath, OutputPath,
AssetsPath, AssetsDirPath,
ExportFormat, ExportFormat,
After, After,
Before, Before,

View file

@ -29,7 +29,7 @@ internal class ExportContext
Request = request; Request = request;
_assetDownloader = new ExportAssetDownloader( _assetDownloader = new ExportAssetDownloader(
request.OutputAssetsDirPath, request.AssetsDirPath,
request.ShouldReuseAssets request.ShouldReuseAssets
); );
} }
@ -92,34 +92,25 @@ internal class ExportContext
try try
{ {
var absoluteFilePath = await _assetDownloader.DownloadAsync(url, cancellationToken); var filePath = await _assetDownloader.DownloadAsync(url, cancellationToken);
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
// We want relative path so that the output files can be copied around without breaking. // Prefer relative paths so that the output files can be copied around without breaking references.
// Base directory path may be null if the file is stored at the root or relative to working directory. // If the assets path is outside of the export directory, use the absolute path instead.
var relativeFilePath = !string.IsNullOrWhiteSpace(Request.OutputBaseDirPath) var optimalFilePath =
? Path.GetRelativePath(Request.OutputBaseDirPath, absoluteFilePath) relativeFilePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
: absoluteFilePath; relativeFilePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal)
? filePath
: relativeFilePath;
// If the assets path is outside of the export directory, fall back to absolute path // For HTML, the path needs to be properly formatted
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) if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
{ {
// Need to escape each path segment while keeping the directory separators intact // Create a 'file:///' URI and then strip the 'file:///' prefix to allow for relative paths
return string.Join( return new Uri(new Uri("file:///"), optimalFilePath).ToString()[8..];
Path.AltDirectorySeparatorChar,
filePath
.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.Select(Uri.EscapeDataString)
.Select(x => x.Replace("%3A", ":"))
);
} }
return filePath; return optimalFilePath;
} }
// Try to catch only exceptions related to failed HTTP requests // Try to catch only exceptions related to failed HTTP requests
// https://github.com/Tyrrrz/DiscordChatExporter/issues/332 // https://github.com/Tyrrrz/DiscordChatExporter/issues/332

View file

@ -10,48 +10,87 @@ using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Core.Exporting; namespace DiscordChatExporter.Core.Exporting;
public partial record ExportRequest( public partial class 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)
{ {
private string? _outputBaseFilePath; public Guild Guild { get; }
public string OutputBaseFilePath => _outputBaseFilePath ??= GetOutputBaseFilePath(
Guild,
Channel,
OutputPath,
Format,
After,
Before
);
public string OutputBaseDirPath => Path.GetDirectoryName(OutputBaseFilePath) ?? OutputPath; public Channel Channel { get; }
private string? _outputAssetsDirPath; public string OutputFilePath { get; }
public string OutputAssetsDirPath => _outputAssetsDirPath ??= (
AssetsPath is not null public string OutputDirPath { get; }
? EvaluateTemplateTokens(
AssetsPath, public string AssetsDirPath { get; }
Guild,
Channel, public ExportFormat Format { get; }
After,
Before public Snowflake? After { get; }
)
: $"{OutputBaseFilePath}_Files{Path.DirectorySeparatorChar}" 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( public static string GetDefaultOutputFileName(
Guild guild, Guild guild,
@ -95,7 +134,7 @@ public partial record ExportRequest
return PathEx.EscapeFileName(buffer.ToString()); return PathEx.EscapeFileName(buffer.ToString());
} }
private static string EvaluateTemplateTokens( private static string FormatPath(
string path, string path,
Guild guild, Guild guild,
Channel channel, Channel channel,
@ -120,7 +159,8 @@ public partial record ExportRequest
"%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"), "%d" => DateTimeOffset.Now.ToString("yyyy-MM-dd"),
"%%" => "%", "%%" => "%",
_ => m.Value _ => m.Value
})); })
);
} }
private static string GetOutputBaseFilePath( private static string GetOutputBaseFilePath(
@ -131,7 +171,7 @@ public partial record ExportRequest
Snowflake? after = null, Snowflake? after = null,
Snowflake? before = null) Snowflake? before = null)
{ {
var actualOutputPath = EvaluateTemplateTokens(outputPath, guild, channel, after, before); var actualOutputPath = FormatPath(outputPath, guild, channel, after, before);
// Output is a directory // Output is a directory
if (Directory.Exists(actualOutputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(actualOutputPath))) if (Directory.Exists(actualOutputPath) || string.IsNullOrWhiteSpace(Path.GetExtension(actualOutputPath)))

View file

@ -51,8 +51,8 @@ internal partial class MessageExporter : IAsyncDisposable
if (_writer is not null) if (_writer is not null)
return _writer; return _writer;
Directory.CreateDirectory(_context.Request.OutputBaseDirPath); Directory.CreateDirectory(_context.Request.OutputDirPath);
var filePath = GetPartitionFilePath(_context.Request.OutputBaseFilePath, _partitionIndex); var filePath = GetPartitionFilePath(_context.Request.OutputFilePath, _partitionIndex);
var writer = CreateMessageWriter(filePath, _context.Request.Format, _context); var writer = CreateMessageWriter(filePath, _context.Request.Format, _context);
await writer.WritePreambleAsync(cancellationToken); await writer.WritePreambleAsync(cancellationToken);

View file

@ -35,6 +35,8 @@ public partial class SettingsService : SettingsManager
public bool LastShouldReuseAssets { get; set; } public bool LastShouldReuseAssets { get; set; }
public string? LastAssetsDirPath { get; set; }
public SettingsService() public SettingsService()
{ {
Configuration.StorageSpace = StorageSpace.Instance; Configuration.StorageSpace = StorageSpace.Instance;

View file

@ -186,7 +186,7 @@ public class DashboardViewModel : PropertyChangedBase
dialog.Guild!, dialog.Guild!,
channel, channel,
dialog.OutputPath!, dialog.OutputPath!,
null, dialog.AssetsDirPath,
dialog.SelectedFormat, dialog.SelectedFormat,
dialog.After?.Pipe(Snowflake.FromDate), dialog.After?.Pipe(Snowflake.FromDate),
dialog.Before?.Pipe(Snowflake.FromDate), dialog.Before?.Pipe(Snowflake.FromDate),

View file

@ -65,6 +65,8 @@ public class ExportSetupViewModel : DialogScreen
public bool ShouldReuseAssets { get; set; } public bool ShouldReuseAssets { get; set; }
public string? AssetsDirPath { get; set; }
public bool IsAdvancedSectionDisplayed { get; set; } public bool IsAdvancedSectionDisplayed { get; set; }
public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService) public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService)
@ -79,15 +81,18 @@ public class ExportSetupViewModel : DialogScreen
ShouldFormatMarkdown = _settingsService.LastShouldFormatMarkdown; ShouldFormatMarkdown = _settingsService.LastShouldFormatMarkdown;
ShouldDownloadAssets = _settingsService.LastShouldDownloadAssets; ShouldDownloadAssets = _settingsService.LastShouldDownloadAssets;
ShouldReuseAssets = _settingsService.LastShouldReuseAssets; ShouldReuseAssets = _settingsService.LastShouldReuseAssets;
AssetsDirPath = _settingsService.LastAssetsDirPath;
// Show the "advanced options" section by default if any // Show the "advanced options" section by default if any
// of the advanced options are set to non-default values. // of the advanced options are set to non-default values.
IsAdvancedSectionDisplayed = IsAdvancedSectionDisplayed =
After != default || After is not null ||
Before != default || Before is not null ||
!string.IsNullOrWhiteSpace(PartitionLimitValue) || !string.IsNullOrWhiteSpace(PartitionLimitValue) ||
!string.IsNullOrWhiteSpace(MessageFilterValue) || !string.IsNullOrWhiteSpace(MessageFilterValue) ||
ShouldDownloadAssets != default; ShouldDownloadAssets ||
ShouldReuseAssets ||
!string.IsNullOrWhiteSpace(AssetsDirPath);
} }
public void ToggleAdvancedSection() => IsAdvancedSectionDisplayed = !IsAdvancedSectionDisplayed; public void ToggleAdvancedSection() => IsAdvancedSectionDisplayed = !IsAdvancedSectionDisplayed;
@ -107,18 +112,25 @@ public class ExportSetupViewModel : DialogScreen
var extension = SelectedFormat.GetFileExtension(); var extension = SelectedFormat.GetFileExtension();
var filter = $"{extension.ToUpperInvariant()} files|*.{extension}"; var filter = $"{extension.ToUpperInvariant()} files|*.{extension}";
var outputPath = _dialogManager.PromptSaveFilePath(filter, defaultFileName); var path = _dialogManager.PromptSaveFilePath(filter, defaultFileName);
if (!string.IsNullOrWhiteSpace(outputPath)) if (!string.IsNullOrWhiteSpace(path))
OutputPath = outputPath; OutputPath = path;
} }
else else
{ {
var outputPath = _dialogManager.PromptDirectoryPath(); var path = _dialogManager.PromptDirectoryPath();
if (!string.IsNullOrWhiteSpace(outputPath)) if (!string.IsNullOrWhiteSpace(path))
OutputPath = outputPath; OutputPath = path;
} }
} }
public void ShowAssetsDirPathPrompt()
{
var path = _dialogManager.PromptDirectoryPath();
if (!string.IsNullOrWhiteSpace(path))
AssetsDirPath = path;
}
public void Confirm() public void Confirm()
{ {
// Prompt the output path if it's not set yet // Prompt the output path if it's not set yet
@ -138,6 +150,7 @@ public class ExportSetupViewModel : DialogScreen
_settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown; _settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown;
_settingsService.LastShouldDownloadAssets = ShouldDownloadAssets; _settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
_settingsService.LastShouldReuseAssets = ShouldReuseAssets; _settingsService.LastShouldReuseAssets = ShouldReuseAssets;
_settingsService.LastAssetsDirPath = AssetsDirPath;
Close(true); Close(true);
} }
@ -145,8 +158,10 @@ public class ExportSetupViewModel : DialogScreen
public static class ExportSetupViewModelExtensions public static class ExportSetupViewModelExtensions
{ {
public static ExportSetupViewModel CreateExportSetupViewModel(this IViewModelFactory factory, public static ExportSetupViewModel CreateExportSetupViewModel(
Guild guild, IReadOnlyList<Channel> channels) this IViewModelFactory factory,
Guild guild,
IReadOnlyList<Channel> channels)
{ {
var viewModel = factory.CreateExportSetupViewModel(); var viewModel = factory.CreateExportSetupViewModel();

View file

@ -25,8 +25,10 @@ public static class MessageBoxViewModelExtensions
{ {
public static MessageBoxViewModel CreateMessageBoxViewModel( public static MessageBoxViewModel CreateMessageBoxViewModel(
this IViewModelFactory factory, this IViewModelFactory factory,
string title, string message, string title,
string? okButtonText, string? cancelButtonText) string message,
string? okButtonText,
string? cancelButtonText)
{ {
var viewModel = factory.CreateMessageBoxViewModel(); var viewModel = factory.CreateMessageBoxViewModel();
viewModel.Title = title; viewModel.Title = title;
@ -42,6 +44,7 @@ public static class MessageBoxViewModelExtensions
public static MessageBoxViewModel CreateMessageBoxViewModel( public static MessageBoxViewModel CreateMessageBoxViewModel(
this IViewModelFactory factory, this IViewModelFactory factory,
string title, string message) => string title,
string message) =>
factory.CreateMessageBoxViewModel(title, message, "CLOSE", null); factory.CreateMessageBoxViewModel(title, message, "CLOSE", null);
} }

View file

@ -285,6 +285,27 @@
VerticalAlignment="Center" VerticalAlignment="Center"
IsChecked="{Binding ShouldReuseAssets}" /> IsChecked="{Binding ShouldReuseAssets}" />
</Grid> </Grid>
<!-- Assets path -->
<Grid Margin="16,8">
<TextBox
Padding="16,16,42,16"
materialDesign:HintAssist.Hint="Assets directory path"
materialDesign:HintAssist.IsFloating="True"
Style="{DynamicResource MaterialDesignOutlinedTextBox}"
Text="{Binding AssetsDirPath}"
ToolTip="Download assets to this directory. If not specified, the asset directory path will be derived from the output path." />
<Button
Width="24"
Height="24"
Margin="0,0,12,0"
Padding="0"
HorizontalAlignment="Right"
Command="{s:Action ShowAssetsDirPathPrompt}"
Style="{DynamicResource MaterialDesignToolForegroundButton}">
<materialDesign:PackIcon Kind="FolderOpen" />
</Button>
</Grid>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>