mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2025-05-12 22:25:38 -04:00
Add support for selectable assets directory in GUI
This commit is contained in:
parent
95115f3e99
commit
d1647e8286
9 changed files with 159 additions and 87 deletions
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)))
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
|
@ -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>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue