diff --git a/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs b/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs index ced9a07f..4d092843 100644 --- a/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs +++ b/DiscordChatExporter.Core/Exporting/ExportAssetDownloader.cs @@ -17,30 +17,43 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse) { private static readonly AsyncKeyedLocker Locker = new(); - // File paths of the previously downloaded assets - private readonly Dictionary _previousPathsByUrl = new(StringComparer.Ordinal); + // File paths of the previously downloaded assets. The same URL can intentionally be stored + // in different forum folders, so the destination path is part of the cache key. + private readonly Dictionary _previousPathsByRequest = new( + StringComparer.Ordinal + ); public async ValueTask DownloadAsync( string url, + string? relativeDirPath = null, + string? preferredFileName = null, CancellationToken cancellationToken = default ) { - var fileName = GetFileNameFromUrl(url); - var filePath = Path.Combine(workingDirPath, fileName); + var actualWorkingDirPath = !string.IsNullOrWhiteSpace(relativeDirPath) + ? Path.Combine(workingDirPath, relativeDirPath) + : workingDirPath; + + var fileName = !string.IsNullOrWhiteSpace(preferredFileName) + ? Path.EscapeFileName(preferredFileName) + : GetFileNameFromUrl(url); + + var filePath = Path.Combine(actualWorkingDirPath, fileName); + var requestKey = url + '\n' + filePath; using var _ = await Locker.LockAsync(filePath, cancellationToken); - if (_previousPathsByUrl.TryGetValue(url, out var cachedFilePath)) + if (_previousPathsByRequest.TryGetValue(requestKey, out var cachedFilePath)) return cachedFilePath; // Reuse existing files if we're allowed to if (reuse && File.Exists(filePath)) - return _previousPathsByUrl[url] = filePath; + return _previousPathsByRequest[requestKey] = filePath; // Check for a file cached by the legacy naming scheme (5-char hash) and rename it // to the new naming scheme to preserve backwards compatibility with existing exports. // This will catch both the 5-char lowercase hash and the 5-char uppercase hash variants. - if (reuse) + if (reuse && string.IsNullOrWhiteSpace(preferredFileName)) { var legacyFileNames = GetLegacyFileNamesFromUrl(url); foreach (var legacyFileName in legacyFileNames) @@ -53,7 +66,7 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse) try { File.Move(legacyFilePath, filePath, true); - return _previousPathsByUrl[url] = filePath; + return _previousPathsByRequest[requestKey] = filePath; } catch (IOException) { @@ -64,7 +77,7 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse) } } - Directory.CreateDirectory(workingDirPath); + Directory.CreateDirectory(actualWorkingDirPath); await Http.ResiliencePipeline.ExecuteAsync( async innerCancellationToken => @@ -84,7 +97,7 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse) cancellationToken ); - return _previousPathsByUrl[url] = filePath; + return _previousPathsByRequest[requestKey] = filePath; } } diff --git a/DiscordChatExporter.Core/Exporting/ExportContext.cs b/DiscordChatExporter.Core/Exporting/ExportContext.cs index 3c7f5785..764800db 100644 --- a/DiscordChatExporter.Core/Exporting/ExportContext.cs +++ b/DiscordChatExporter.Core/Exporting/ExportContext.cs @@ -120,6 +120,92 @@ internal class ExportContext(DiscordClient discord, ExportRequest request) public Color? TryGetUserColor(Snowflake id) => GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault(); + private string GetForumThreadDirPath() + { + var channelName = Path.EscapeFileName(Request.Channel.Name).Truncate(60); + return Path.Combine("threads", $"{Request.Channel.Id}-{channelName}"); + } + + private string? GetForumCommonAssetDirPath() => + Request.ForumCommonAssetMode switch + { + ForumCommonAssetMode.SharedFolder => "common", + ForumCommonAssetMode.PerThreadFolder => Path.Combine(GetForumThreadDirPath(), "common"), + ForumCommonAssetMode.Skip => null, + _ => throw new ArgumentOutOfRangeException(), + }; + + private string GetForumAttachmentDirPath(Attachment attachment, Snowflake messageId) + { + var baseDirPath = Path.Combine(GetForumThreadDirPath(), "attachments"); + + return Request.ForumAttachmentFolderMode switch + { + ForumAttachmentFolderMode.PerThread => baseDirPath, + ForumAttachmentFolderMode.ByMediaType => Path.Combine( + baseDirPath, + attachment.IsImage ? "images" + : attachment.IsVideo ? "videos" + : attachment.IsAudio ? "audio" + : "files" + ), + ForumAttachmentFolderMode.ByMessage => Path.Combine( + baseDirPath, + "messages", + messageId.ToString() + ), + _ => throw new ArgumentOutOfRangeException(), + }; + } + + private string? GetForumAttachmentFileName(Attachment attachment, Snowflake messageId) => + Request.ForumAttachmentNamingMode switch + { + ForumAttachmentNamingMode.OriginalWithHash => null, + ForumAttachmentNamingMode.AttachmentIdAndOriginal => + $"{attachment.Id}_{attachment.FileName}", + ForumAttachmentNamingMode.MessageAndAttachmentIdsAndOriginal => + $"{messageId}_{attachment.Id}_{attachment.FileName}", + _ => throw new ArgumentOutOfRangeException(), + }; + + private async ValueTask ResolveDownloadedAssetUrlAsync( + string url, + string? relativeDirPath, + string? preferredFileName, + CancellationToken cancellationToken + ) + { + var filePath = await _assetDownloader.DownloadAsync( + url, + relativeDirPath, + preferredFileName, + cancellationToken + ); + + var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath); + + // Prefer the relative path so that the export package can be copied around without breaking references. + // However, if the assets directory lies outside the export directory, use the absolute path instead. + var shouldUseAbsoluteFilePath = + relativeFilePath.StartsWith( + ".." + Path.DirectorySeparatorChar, + StringComparison.Ordinal + ) + || relativeFilePath.StartsWith( + ".." + Path.AltDirectorySeparatorChar, + StringComparison.Ordinal + ); + + var optimalFilePath = shouldUseAbsoluteFilePath ? filePath : relativeFilePath; + + // For HTML, the path needs to be properly formatted + if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight) + return Url.EncodeFilePath(optimalFilePath); + + return optimalFilePath; + } + public async ValueTask ResolveAssetUrlAsync( string url, CancellationToken cancellationToken = default @@ -128,30 +214,18 @@ internal class ExportContext(DiscordClient discord, ExportRequest request) if (!Request.ShouldDownloadAssets) return url; + var relativeDirPath = Request.IsForumExport ? GetForumCommonAssetDirPath() : null; + if (Request.IsForumExport && relativeDirPath is null) + return url; + try { - var filePath = await _assetDownloader.DownloadAsync(url, cancellationToken); - var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath); - - // Prefer the relative path so that the export package can be copied around without breaking references. - // However, if the assets directory lies outside the export directory, use the absolute path instead. - var shouldUseAbsoluteFilePath = - relativeFilePath.StartsWith( - ".." + Path.DirectorySeparatorChar, - StringComparison.Ordinal - ) - || relativeFilePath.StartsWith( - ".." + Path.AltDirectorySeparatorChar, - StringComparison.Ordinal - ); - - var optimalFilePath = shouldUseAbsoluteFilePath ? filePath : relativeFilePath; - - // For HTML, the path needs to be properly formatted - if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight) - return Url.EncodeFilePath(optimalFilePath); - - return optimalFilePath; + return await ResolveDownloadedAssetUrlAsync( + url, + relativeDirPath, + null, + cancellationToken + ); } // Try to catch only exceptions related to failed HTTP requests // https://github.com/Tyrrrz/DiscordChatExporter/issues/332 @@ -163,4 +237,36 @@ internal class ExportContext(DiscordClient discord, ExportRequest request) return url; } } + + public ValueTask ResolveAvatarUrlAsync( + string url, + CancellationToken cancellationToken = default + ) => + Request.IsForumExport && !Request.ShouldDownloadForumAvatars + ? ValueTask.FromResult(url) + : ResolveAssetUrlAsync(url, cancellationToken); + + public async ValueTask ResolveAttachmentUrlAsync( + Attachment attachment, + Snowflake messageId, + CancellationToken cancellationToken = default + ) + { + if (!Request.ShouldDownloadAssets || !Request.IsForumExport) + return await ResolveAssetUrlAsync(attachment.Url, cancellationToken); + + try + { + return await ResolveDownloadedAssetUrlAsync( + attachment.Url, + GetForumAttachmentDirPath(attachment, messageId), + GetForumAttachmentFileName(attachment, messageId), + cancellationToken + ); + } + catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException) + { + return attachment.Url; + } + } } diff --git a/DiscordChatExporter.Core/Exporting/ExportRequest.cs b/DiscordChatExporter.Core/Exporting/ExportRequest.cs index 1285b246..d7e6d1b8 100644 --- a/DiscordChatExporter.Core/Exporting/ExportRequest.cs +++ b/DiscordChatExporter.Core/Exporting/ExportRequest.cs @@ -41,6 +41,16 @@ public partial class ExportRequest public bool ShouldReuseAssets { get; } + public bool IsForumExport { get; } + + public bool ShouldDownloadForumAvatars { get; } + + public ForumCommonAssetMode ForumCommonAssetMode { get; } + + public ForumAttachmentFolderMode ForumAttachmentFolderMode { get; } + + public ForumAttachmentNamingMode ForumAttachmentNamingMode { get; } + public string? Locale { get; } public CultureInfo? CultureInfo { get; } @@ -62,7 +72,13 @@ public partial class ExportRequest bool shouldDownloadAssets, bool shouldReuseAssets, string? locale, - bool isUtcNormalizationEnabled + bool isUtcNormalizationEnabled, + bool isForumExport = false, + bool shouldDownloadForumAvatars = false, + ForumCommonAssetMode forumCommonAssetMode = ForumCommonAssetMode.SharedFolder, + ForumAttachmentFolderMode forumAttachmentFolderMode = ForumAttachmentFolderMode.PerThread, + ForumAttachmentNamingMode forumAttachmentNamingMode = + ForumAttachmentNamingMode.AttachmentIdAndOriginal ) { Guild = guild; @@ -76,6 +92,11 @@ public partial class ExportRequest ShouldFormatMarkdown = shouldFormatMarkdown; ShouldDownloadAssets = shouldDownloadAssets; ShouldReuseAssets = shouldReuseAssets; + IsForumExport = isForumExport; + ShouldDownloadForumAvatars = shouldDownloadForumAvatars; + ForumCommonAssetMode = forumCommonAssetMode; + ForumAttachmentFolderMode = forumAttachmentFolderMode; + ForumAttachmentNamingMode = forumAttachmentNamingMode; Locale = locale; IsUtcNormalizationEnabled = isUtcNormalizationEnabled; @@ -83,8 +104,10 @@ public partial class ExportRequest OutputDirPath = Path.GetDirectoryName(OutputFilePath)!; - AssetsDirPath = !string.IsNullOrWhiteSpace(assetsDirPath) - ? FormatPath(assetsDirPath, Guild, Channel, After, Before) + AssetsDirPath = + !string.IsNullOrWhiteSpace(assetsDirPath) + ? FormatPath(assetsDirPath, Guild, Channel, After, Before) + : IsForumExport ? Path.Combine(OutputDirPath, "_forum_assets") : $"{OutputFilePath}_Files{Path.DirectorySeparatorChar}"; CultureInfo = Locale?.Pipe(CultureInfo.GetCultureInfo); diff --git a/DiscordChatExporter.Core/Exporting/ForumAssetOptions.cs b/DiscordChatExporter.Core/Exporting/ForumAssetOptions.cs new file mode 100644 index 00000000..0ce59051 --- /dev/null +++ b/DiscordChatExporter.Core/Exporting/ForumAssetOptions.cs @@ -0,0 +1,22 @@ +namespace DiscordChatExporter.Core.Exporting; + +public enum ForumCommonAssetMode +{ + SharedFolder, + PerThreadFolder, + Skip, +} + +public enum ForumAttachmentFolderMode +{ + PerThread, + ByMediaType, + ByMessage, +} + +public enum ForumAttachmentNamingMode +{ + OriginalWithHash, + AttachmentIdAndOriginal, + MessageAndAttachmentIdsAndOriginal, +} diff --git a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs index 4d2e19cd..8a718cea 100644 --- a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs @@ -6,6 +6,7 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data.Embeds; using DiscordChatExporter.Core.Markdown.Parsing; @@ -66,7 +67,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) _writer.WriteString( "avatarUrl", - await Context.ResolveAssetUrlAsync( + await Context.ResolveAvatarUrlAsync( Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl, cancellationToken ) @@ -121,6 +122,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) private async ValueTask WriteAttachmentAsync( Attachment attachment, + Snowflake messageId, CancellationToken cancellationToken = default ) { @@ -129,7 +131,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) _writer.WriteString("id", attachment.Id.ToString()); _writer.WriteString( "url", - await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken) + await Context.ResolveAttachmentUrlAsync(attachment, messageId, cancellationToken) ); _writer.WriteString("fileName", attachment.FileName); _writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes); @@ -473,7 +475,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) _writer.WriteStartArray("attachments"); foreach (var attachment in message.Attachments) - await WriteAttachmentAsync(attachment, cancellationToken); + await WriteAttachmentAsync(attachment, message.Id, cancellationToken); _writer.WriteEndArray(); @@ -570,7 +572,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) _writer.WriteStartArray("attachments"); foreach (var attachment in message.ForwardedMessage.Attachments) - await WriteAttachmentAsync(attachment, cancellationToken); + await WriteAttachmentAsync(attachment, message.Id, cancellationToken); _writer.WriteEndArray(); diff --git a/DiscordChatExporter.Gui/Converters/ForumAssetOptionToStringConverter.cs b/DiscordChatExporter.Gui/Converters/ForumAssetOptionToStringConverter.cs new file mode 100644 index 00000000..e5578cc1 --- /dev/null +++ b/DiscordChatExporter.Gui/Converters/ForumAssetOptionToStringConverter.cs @@ -0,0 +1,41 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using DiscordChatExporter.Core.Exporting; + +namespace DiscordChatExporter.Gui.Converters; + +public class ForumAssetOptionToStringConverter : IValueConverter +{ + public static ForumAssetOptionToStringConverter Instance { get; } = new(); + + public object? Convert( + object? value, + Type targetType, + object? parameter, + CultureInfo culture + ) => + value switch + { + ForumCommonAssetMode.SharedFolder => "One shared folder for the whole export", + ForumCommonAssetMode.PerThreadFolder => "Separate common folder inside every post", + ForumCommonAssetMode.Skip => "Do not download common resources", + + ForumAttachmentFolderMode.PerThread => "One attachments folder per post", + ForumAttachmentFolderMode.ByMediaType => "Split into images, videos, audio and files", + ForumAttachmentFolderMode.ByMessage => "Separate folder for every message", + + ForumAttachmentNamingMode.OriginalWithHash => "Original name + safety hash", + ForumAttachmentNamingMode.AttachmentIdAndOriginal => "Attachment ID + original name", + ForumAttachmentNamingMode.MessageAndAttachmentIdsAndOriginal => + "Message ID + attachment ID + original name", + _ => value?.ToString(), + }; + + public object ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture + ) => throw new NotSupportedException(); +} diff --git a/DiscordChatExporter.Gui/Framework/ViewModelManager.cs b/DiscordChatExporter.Gui/Framework/ViewModelManager.cs index 0e25c2e7..3e9e51a3 100644 --- a/DiscordChatExporter.Gui/Framework/ViewModelManager.cs +++ b/DiscordChatExporter.Gui/Framework/ViewModelManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Gui.Localization; using DiscordChatExporter.Gui.ViewModels; @@ -18,13 +19,15 @@ public class ViewModelManager(IServiceProvider services, LocalizationManager loc public ExportSetupViewModel GetExportSetupViewModel( Guild guild, - IReadOnlyList channels + IReadOnlyList channels, + IReadOnlySet forumChannelIds ) { var viewModel = services.GetRequiredService(); viewModel.Guild = guild; viewModel.Channels = channels; + viewModel.ForumChannelIds = forumChannelIds; return viewModel; } diff --git a/DiscordChatExporter.Gui/Services/SettingsService.cs b/DiscordChatExporter.Gui/Services/SettingsService.cs index 61240b54..07ce2db6 100644 --- a/DiscordChatExporter.Gui/Services/SettingsService.cs +++ b/DiscordChatExporter.Gui/Services/SettingsService.cs @@ -23,10 +23,10 @@ public partial class SettingsService() public partial Language Language { get; set; } [ObservableProperty] - public partial bool IsAutoUpdateEnabled { get; set; } = true; + public partial bool IsAutoUpdateEnabled { get; set; } [ObservableProperty] - public partial bool IsTokenPersisted { get; set; } = true; + public partial bool IsTokenPersisted { get; set; } [ObservableProperty] public partial RateLimitPreference RateLimitPreference { get; set; } = @@ -49,7 +49,7 @@ public partial class SettingsService() public partial string? LastToken { get; set; } [ObservableProperty] - public partial ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark; + public partial ExportFormat LastExportFormat { get; set; } = ExportFormat.Json; [ObservableProperty] public partial string? LastPartitionLimitValue { get; set; } @@ -64,14 +64,41 @@ public partial class SettingsService() public partial bool LastShouldFormatMarkdown { get; set; } = true; [ObservableProperty] - public partial bool LastShouldDownloadAssets { get; set; } + public partial bool LastShouldDownloadAssets { get; set; } = true; [ObservableProperty] - public partial bool LastShouldReuseAssets { get; set; } + public partial bool LastShouldReuseAssets { get; set; } = true; [ObservableProperty] public partial string? LastAssetsDirPath { get; set; } + [ObservableProperty] + public partial bool LastForumShouldDownloadAssets { get; set; } = true; + + [ObservableProperty] + public partial bool LastForumShouldReuseAssets { get; set; } = true; + + [ObservableProperty] + public partial string? LastForumAssetsDirPath { get; set; } + + [ObservableProperty] + public partial bool LastForumShouldDownloadAvatars { get; set; } + + [ObservableProperty] + public partial ForumCommonAssetMode LastForumCommonAssetMode { get; set; } = + ForumCommonAssetMode.SharedFolder; + + [ObservableProperty] + public partial ForumAttachmentFolderMode LastForumAttachmentFolderMode { get; set; } = + ForumAttachmentFolderMode.ByMediaType; + + [ObservableProperty] + public partial ForumAttachmentNamingMode LastForumAttachmentNamingMode { get; set; } = + ForumAttachmentNamingMode.AttachmentIdAndOriginal; + + [ObservableProperty] + public partial int LastForumParallelLimit { get; set; } = 4; + public override void Save() { // Clear the token if it's not supposed to be persisted diff --git a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs index 542c9526..0bc9768f 100644 --- a/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Components/DashboardViewModel.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Avalonia.Controls; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DiscordChatExporter.Core.Discord; @@ -91,6 +92,25 @@ public partial class DashboardViewModel : ViewModelBase public ObservableCollection SelectedChannels { get; } = []; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ChannelSelectionMode))] + public partial bool IsMultiSelectionEnabled { get; set; } + + public SelectionMode ChannelSelectionMode => + IsMultiSelectionEnabled + ? SelectionMode.Multiple | SelectionMode.Toggle + : SelectionMode.Single; + + partial void OnIsMultiSelectionEnabledChanged(bool value) + { + if (value || SelectedChannels.Count <= 1) + return; + + var lastSelectedChannel = SelectedChannels[^1]; + SelectedChannels.Clear(); + SelectedChannels.Add(lastSelectedChannel); + } + public override Task InitializeAsync() { if (!string.IsNullOrWhiteSpace(_settingsService.LastToken)) @@ -222,6 +242,47 @@ public partial class DashboardViewModel : ViewModelBase private bool CanExport() => !IsBusy && _discord is not null && SelectedGuild is not null && SelectedChannels.Any(); + private async Task> ExpandForumChannelsAsync( + IReadOnlyList channels, + CancellationToken cancellationToken = default + ) + { + if (_discord is null) + return channels; + + var expandedChannels = new List(); + var seenChannelIds = new HashSet(); + + // Keep regular channels and explicitly selected threads as-is. Forum channels are + // containers without their own message history, so they are replaced with all of + // their accessible active and archived threads below. + foreach (var channel in channels.Where(c => c.Kind != ChannelKind.GuildForum)) + { + if (seenChannelIds.Add(channel.Id)) + expandedChannels.Add(channel); + } + + var forums = channels.Where(c => c.Kind == ChannelKind.GuildForum).ToArray(); + if (forums.Length <= 0) + return expandedChannels; + + await foreach ( + var thread in _discord.GetChannelThreadsAsync( + forums, + includeArchived: true, + cancellationToken: cancellationToken + ) + ) + { + // A thread may already be selected explicitly or may transition from active to + // archived while it is being discovered. Export every thread only once. + if (seenChannelIds.Add(thread.Id)) + expandedChannels.Add(thread); + } + + return expandedChannels; + } + [RelayCommand(CanExecute = nameof(CanExport))] private async Task ExportAsync() { @@ -232,9 +293,31 @@ public partial class DashboardViewModel : ViewModelBase if (_discord is null || SelectedGuild is null || !SelectedChannels.Any()) return; + var selectedChannels = SelectedChannels.Select(c => c.Channel).ToArray(); + var channelsToExport = await ExpandForumChannelsAsync(selectedChannels); + + if (channelsToExport.Count <= 0) + { + _snackbarManager.Notify( + "No accessible active or archived threads were found in the selected forum." + ); + return; + } + + var selectedForumIds = selectedChannels + .Where(c => c.Kind == ChannelKind.GuildForum) + .Select(c => c.Id) + .ToHashSet(); + + var forumChannelIds = channelsToExport + .Where(c => c.Parent is not null && selectedForumIds.Contains(c.Parent.Id)) + .Select(c => c.Id) + .ToHashSet(); + var dialog = _viewModelManager.GetExportSetupViewModel( SelectedGuild, - SelectedChannels.Select(c => c.Channel).ToArray() + channelsToExport, + forumChannelIds ); if (await _dialogManager.ShowDialogAsync(dialog) != true) @@ -252,7 +335,12 @@ public partial class DashboardViewModel : ViewModelBase channelProgressPairs, new ParallelOptions { - MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit), + MaxDegreeOfParallelism = Math.Max( + 1, + dialog.HasForumChannels + ? dialog.SelectedForumParallelLimit + : _settingsService.ParallelLimit + ), }, async (pair, cancellationToken) => { @@ -261,22 +349,33 @@ public partial class DashboardViewModel : ViewModelBase try { + var isForumChannel = dialog.IsForumChannel(channel); + var request = new ExportRequest( dialog.Guild!, channel, dialog.OutputPath!, - dialog.AssetsDirPath, - dialog.SelectedFormat, + isForumChannel ? dialog.ForumAssetsDirPath : dialog.AssetsDirPath, + isForumChannel ? ExportFormat.Json : dialog.SelectedFormat, dialog.After?.Pipe(Snowflake.FromDate), dialog.Before?.Pipe(Snowflake.FromDate), dialog.PartitionLimit, dialog.MessageFilter, dialog.IsReverseMessageOrder, dialog.ShouldFormatMarkdown, - dialog.ShouldDownloadAssets, - dialog.ShouldReuseAssets, + isForumChannel + ? dialog.ForumShouldDownloadAssets + : dialog.ShouldDownloadAssets, + isForumChannel + ? dialog.ForumShouldReuseAssets + : dialog.ShouldReuseAssets, _settingsService.Locale, - _settingsService.IsUtcNormalizationEnabled + _settingsService.IsUtcNormalizationEnabled, + isForumChannel, + dialog.ForumShouldDownloadAvatars, + dialog.SelectedForumCommonAssetMode, + dialog.SelectedForumAttachmentFolderMode, + dialog.SelectedForumAttachmentNamingMode ); await exporter.ExportChannelAsync(request, progress, cancellationToken); diff --git a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs index 9f1aed31..28a86afe 100644 --- a/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs +++ b/DiscordChatExporter.Gui/ViewModels/Dialogs/ExportSetupViewModel.cs @@ -30,8 +30,20 @@ public partial class ExportSetupViewModel( [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsSingleChannel))] + [NotifyPropertyChangedFor(nameof(HasRegularChannels))] + [NotifyPropertyChangedFor(nameof(HasMixedChannelTypes))] + [NotifyPropertyChangedFor(nameof(HasOnlyForumChannels))] + [NotifyPropertyChangedFor(nameof(HasOnlyRegularChannels))] public partial IReadOnlyList? Channels { get; set; } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasForumChannels))] + [NotifyPropertyChangedFor(nameof(HasRegularChannels))] + [NotifyPropertyChangedFor(nameof(HasMixedChannelTypes))] + [NotifyPropertyChangedFor(nameof(HasOnlyForumChannels))] + [NotifyPropertyChangedFor(nameof(HasOnlyRegularChannels))] + public partial IReadOnlySet? ForumChannelIds { get; set; } + [ObservableProperty] public partial string? OutputPath { get; set; } @@ -77,13 +89,61 @@ public partial class ExportSetupViewModel( [ObservableProperty] public partial string? AssetsDirPath { get; set; } + [ObservableProperty] + public partial bool ForumShouldDownloadAssets { get; set; } + + [ObservableProperty] + public partial bool ForumShouldReuseAssets { get; set; } + + [ObservableProperty] + public partial string? ForumAssetsDirPath { get; set; } + + [ObservableProperty] + public partial bool ForumShouldDownloadAvatars { get; set; } + + [ObservableProperty] + public partial ForumCommonAssetMode SelectedForumCommonAssetMode { get; set; } + + [ObservableProperty] + public partial ForumAttachmentFolderMode SelectedForumAttachmentFolderMode { get; set; } + + [ObservableProperty] + public partial ForumAttachmentNamingMode SelectedForumAttachmentNamingMode { get; set; } + + [ObservableProperty] + public partial int SelectedForumParallelLimit { get; set; } + [ObservableProperty] public partial bool IsAdvancedSectionDisplayed { get; set; } public bool IsSingleChannel => Channels?.Count == 1; + public bool HasForumChannels => ForumChannelIds?.Count > 0; + + public bool HasRegularChannels => + Channels?.Any(c => ForumChannelIds?.Contains(c.Id) != true) == true; + + public bool HasMixedChannelTypes => HasForumChannels && HasRegularChannels; + + public bool HasOnlyForumChannels => HasForumChannels && !HasRegularChannels; + + public bool HasOnlyRegularChannels => HasRegularChannels && !HasForumChannels; + + public bool IsForumChannel(Channel channel) => ForumChannelIds?.Contains(channel.Id) == true; + public IReadOnlyList AvailableFormats { get; } = Enum.GetValues(); + public IReadOnlyList AvailableForumCommonAssetModes { get; } = + Enum.GetValues(); + + public IReadOnlyList AvailableForumAttachmentFolderModes { get; } = + Enum.GetValues(); + + public IReadOnlyList AvailableForumAttachmentNamingModes { get; } = + Enum.GetValues(); + + public IReadOnlyList AvailableForumParallelLimits { get; } = [1, 2, 4, 8]; + public bool IsAfterDateSet => AfterDate is not null; public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero); @@ -113,6 +173,14 @@ public partial class ExportSetupViewModel( ShouldDownloadAssets = settingsService.LastShouldDownloadAssets; ShouldReuseAssets = settingsService.LastShouldReuseAssets; AssetsDirPath = settingsService.LastAssetsDirPath; + ForumShouldDownloadAssets = settingsService.LastForumShouldDownloadAssets; + ForumShouldReuseAssets = settingsService.LastForumShouldReuseAssets; + ForumAssetsDirPath = settingsService.LastForumAssetsDirPath; + ForumShouldDownloadAvatars = settingsService.LastForumShouldDownloadAvatars; + SelectedForumCommonAssetMode = settingsService.LastForumCommonAssetMode; + SelectedForumAttachmentFolderMode = settingsService.LastForumAttachmentFolderMode; + SelectedForumAttachmentNamingMode = settingsService.LastForumAttachmentNamingMode; + SelectedForumParallelLimit = settingsService.LastForumParallelLimit; // Show the "advanced options" section by default if any // of the advanced options are set to non-default values. @@ -173,6 +241,14 @@ public partial class ExportSetupViewModel( AssetsDirPath = path; } + [RelayCommand] + private async Task ShowForumAssetsDirPathPromptAsync() + { + var path = await dialogManager.PromptDirectoryPathAsync(); + if (!string.IsNullOrWhiteSpace(path)) + ForumAssetsDirPath = path; + } + [RelayCommand] private async Task ConfirmAsync() { @@ -195,6 +271,14 @@ public partial class ExportSetupViewModel( settingsService.LastShouldDownloadAssets = ShouldDownloadAssets; settingsService.LastShouldReuseAssets = ShouldReuseAssets; settingsService.LastAssetsDirPath = AssetsDirPath; + settingsService.LastForumShouldDownloadAssets = ForumShouldDownloadAssets; + settingsService.LastForumShouldReuseAssets = ForumShouldReuseAssets; + settingsService.LastForumAssetsDirPath = ForumAssetsDirPath; + settingsService.LastForumShouldDownloadAvatars = ForumShouldDownloadAvatars; + settingsService.LastForumCommonAssetMode = SelectedForumCommonAssetMode; + settingsService.LastForumAttachmentFolderMode = SelectedForumAttachmentFolderMode; + settingsService.LastForumAttachmentNamingMode = SelectedForumAttachmentNamingMode; + settingsService.LastForumParallelLimit = SelectedForumParallelLimit; Close(true); } diff --git a/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml index 8ed1479b..57268228 100644 --- a/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml +++ b/DiscordChatExporter.Gui/Views/Components/DashboardView.axaml @@ -147,7 +147,7 @@ ItemsSource="{Binding AvailableChannels}" SelectedItems="{Binding SelectedChannels}" SelectionChanged="AvailableChannelsTreeView_OnSelectionChanged" - SelectionMode="Multiple" + SelectionMode="{Binding ChannelSelectionMode}" TextSearch.Text="Name" > @@ -302,22 +302,39 @@ - - + + + + + + diff --git a/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml b/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml index 12c1f79c..f961d2f3 100644 --- a/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml +++ b/DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml @@ -9,9 +9,150 @@ xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils" x:Name="UserControl" - Width="380" + Width="520" x:DataType="dialogs:ExportSetupViewModel" > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -89,6 +230,7 @@ + + + + + + + + + + + - - - - - - - - - - - - - - - - - - +