Add bulk forum export with configurable asset handling

- add bulk export of active and archived forum threads
- add opt-in multi-channel selection mode
- separate forum and regular channel export settings
- save forum exports as JSON
- add configurable asset folder structure and file naming
- allow shared, per-thread, or skipped common resources
- allow attachments to be grouped by media type or message
- make avatar downloads optional and disabled by default
- add configurable parallel forum export workers
- disable token persistence and auto-updates for the custom build
This commit is contained in:
Dantes 2026-07-21 16:23:00 +03:00
parent 0358045c94
commit 9ee1599b9d
12 changed files with 671 additions and 110 deletions

View file

@ -17,30 +17,43 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
{
private static readonly AsyncKeyedLocker<string> Locker = new();
// File paths of the previously downloaded assets
private readonly Dictionary<string, string> _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<string, string> _previousPathsByRequest = new(
StringComparer.Ordinal
);
public async ValueTask<string> 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;
}
}

View file

@ -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<string> 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<string> 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<string> ResolveAvatarUrlAsync(
string url,
CancellationToken cancellationToken = default
) =>
Request.IsForumExport && !Request.ShouldDownloadForumAvatars
? ValueTask.FromResult(url)
: ResolveAssetUrlAsync(url, cancellationToken);
public async ValueTask<string> 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;
}
}
}

View file

@ -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);

View file

@ -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,
}

View file

@ -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();

View file

@ -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();
}

View file

@ -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<Channel> channels
IReadOnlyList<Channel> channels,
IReadOnlySet<Snowflake> forumChannelIds
)
{
var viewModel = services.GetRequiredService<ExportSetupViewModel>();
viewModel.Guild = guild;
viewModel.Channels = channels;
viewModel.ForumChannelIds = forumChannelIds;
return viewModel;
}

View file

@ -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

View file

@ -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<ChannelConnection> 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<IReadOnlyList<Channel>> ExpandForumChannelsAsync(
IReadOnlyList<Channel> channels,
CancellationToken cancellationToken = default
)
{
if (_discord is null)
return channels;
var expandedChannels = new List<Channel>();
var seenChannelIds = new HashSet<Snowflake>();
// 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);

View file

@ -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<Channel>? Channels { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasForumChannels))]
[NotifyPropertyChangedFor(nameof(HasRegularChannels))]
[NotifyPropertyChangedFor(nameof(HasMixedChannelTypes))]
[NotifyPropertyChangedFor(nameof(HasOnlyForumChannels))]
[NotifyPropertyChangedFor(nameof(HasOnlyRegularChannels))]
public partial IReadOnlySet<Snowflake>? 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<ExportFormat> AvailableFormats { get; } = Enum.GetValues<ExportFormat>();
public IReadOnlyList<ForumCommonAssetMode> AvailableForumCommonAssetModes { get; } =
Enum.GetValues<ForumCommonAssetMode>();
public IReadOnlyList<ForumAttachmentFolderMode> AvailableForumAttachmentFolderModes { get; } =
Enum.GetValues<ForumAttachmentFolderMode>();
public IReadOnlyList<ForumAttachmentNamingMode> AvailableForumAttachmentNamingModes { get; } =
Enum.GetValues<ForumAttachmentNamingMode>();
public IReadOnlyList<int> 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);
}

View file

@ -147,7 +147,7 @@
ItemsSource="{Binding AvailableChannels}"
SelectedItems="{Binding SelectedChannels}"
SelectionChanged="AvailableChannelsTreeView_OnSelectionChanged"
SelectionMode="Multiple"
SelectionMode="{Binding ChannelSelectionMode}"
TextSearch.Text="Name"
>
<TreeView.Styles>
@ -302,22 +302,39 @@
</ScrollViewer>
</Panel>
<!-- Export button -->
<Button
Width="56"
Height="56"
<!-- Selection mode and export buttons -->
<StackPanel
Margin="32,24"
Padding="0"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Background="{DynamicResource MaterialSecondaryMidBrush}"
Command="{Binding ExportCommand}"
Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}"
IsVisible="{Binding $self.IsEffectivelyEnabled}"
Theme="{DynamicResource MaterialIconButton}"
Orientation="Horizontal"
Spacing="12"
>
<materialIcons:MaterialIcon Width="32" Height="32" Kind="Download" />
</Button>
<ToggleButton
Width="56"
Height="56"
Padding="0"
IsChecked="{Binding IsMultiSelectionEnabled}"
Theme="{DynamicResource MaterialIconButton}"
ToolTip.Tip="Enable multi-select mode"
>
<materialIcons:MaterialIcon Width="28" Height="28" Kind="CheckboxMultipleMarked" />
</ToggleButton>
<Button
Width="56"
Height="56"
Padding="0"
Background="{DynamicResource MaterialSecondaryMidBrush}"
Command="{Binding ExportCommand}"
Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}"
Theme="{DynamicResource MaterialIconButton}"
ToolTip.Tip="Export selected channels"
>
<materialIcons:MaterialIcon Width="32" Height="32" Kind="Download" />
</Button>
</StackPanel>
</Panel>
</DockPanel>
</UserControl>

View file

@ -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"
>
<UserControl.Resources>
<DataTemplate x:Key="ForumAssetSettingsTemplate" x:DataType="dialogs:ExportSetupViewModel">
<StackPanel Margin="8" Orientation="Vertical" Spacing="14">
<TextBlock FontWeight="SemiBold" Text="Forum export (JSON)" />
<TextBlock
Opacity="0.75"
Text="These options apply only to posts expanded from selected forum channels."
TextWrapping="Wrap"
/>
<DockPanel LastChildFill="False">
<TextBlock DockPanel.Dock="Left" Text="Download post files and resources" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ForumShouldDownloadAssets}" />
</DockPanel>
<DockPanel IsEnabled="{Binding ForumShouldDownloadAssets}" LastChildFill="False">
<TextBlock DockPanel.Dock="Left" Text="Reuse files already downloaded" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ForumShouldReuseAssets}" />
</DockPanel>
<DockPanel IsEnabled="{Binding ForumShouldDownloadAssets}" LastChildFill="False">
<TextBlock DockPanel.Dock="Left" Text="Download user avatars" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ForumShouldDownloadAvatars}" />
</DockPanel>
<ComboBox
IsEnabled="{Binding ForumShouldDownloadAssets}"
ItemsSource="{Binding AvailableForumCommonAssetModes}"
materialAssists:ComboBoxAssist.Label="Common resources (emoji, icons, previews)"
SelectedItem="{Binding SelectedForumCommonAssetMode}"
Theme="{DynamicResource MaterialFilledComboBox}"
>
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={x:Static converters:ForumAssetOptionToStringConverter.Instance}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox
IsEnabled="{Binding ForumShouldDownloadAssets}"
ItemsSource="{Binding AvailableForumAttachmentFolderModes}"
materialAssists:ComboBoxAssist.Label="Post attachment folders"
SelectedItem="{Binding SelectedForumAttachmentFolderMode}"
Theme="{DynamicResource MaterialFilledComboBox}"
>
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={x:Static converters:ForumAssetOptionToStringConverter.Instance}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox
IsEnabled="{Binding ForumShouldDownloadAssets}"
ItemsSource="{Binding AvailableForumAttachmentNamingModes}"
materialAssists:ComboBoxAssist.Label="Post attachment names"
SelectedItem="{Binding SelectedForumAttachmentNamingMode}"
Theme="{DynamicResource MaterialFilledComboBox}"
>
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={x:Static converters:ForumAssetOptionToStringConverter.Instance}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox
ItemsSource="{Binding AvailableForumParallelLimits}"
materialAssists:ComboBoxAssist.Label="Parallel forum export workers"
SelectedItem="{Binding SelectedForumParallelLimit}"
Theme="{DynamicResource MaterialFilledComboBox}"
ToolTip.Tip="4 is the recommended balance between speed and Discord rate limits."
/>
<TextBox
IsEnabled="{Binding ForumShouldDownloadAssets}"
materialAssists:TextFieldAssist.Label="Forum assets root (optional)"
Text="{Binding ForumAssetsDirPath}"
Theme="{DynamicResource FilledTextBox}"
>
<TextBox.InnerRightContent>
<Button
Margin="8,8,8,6"
Padding="8"
Command="{Binding ShowForumAssetsDirPathPromptCommand}"
Theme="{DynamicResource MaterialFlatButton}"
>
<materialIcons:MaterialIcon Width="20" Height="20" Kind="FolderOpen" />
</Button>
</TextBox.InnerRightContent>
</TextBox>
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="RegularAssetSettingsTemplate" x:DataType="dialogs:ExportSetupViewModel">
<StackPanel Margin="8" Orientation="Vertical" Spacing="14">
<TextBlock FontWeight="SemiBold" Text="Regular channel assets" />
<DockPanel
LastChildFill="False"
ToolTip.Tip="{Binding LocalizationManager.DownloadAssetsTooltip}"
>
<TextBlock
DockPanel.Dock="Left"
Text="{Binding LocalizationManager.DownloadAssetsLabel}"
/>
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldDownloadAssets}" />
</DockPanel>
<DockPanel
IsEnabled="{Binding ShouldDownloadAssets}"
LastChildFill="False"
ToolTip.Tip="{Binding LocalizationManager.ReuseAssetsTooltip}"
>
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ReuseAssetsLabel}" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldReuseAssets}" />
</DockPanel>
<TextBox
IsEnabled="{Binding ShouldDownloadAssets}"
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AssetsDirPathLabel}"
Text="{Binding AssetsDirPath}"
Theme="{DynamicResource FilledTextBox}"
ToolTip.Tip="{Binding LocalizationManager.AssetsDirPathTooltip}"
>
<TextBox.InnerRightContent>
<Button
Margin="8,8,8,6"
Padding="8"
Command="{Binding ShowAssetsDirPathPromptCommand}"
Theme="{DynamicResource MaterialFlatButton}"
>
<materialIcons:MaterialIcon Width="20" Height="20" Kind="FolderOpen" />
</Button>
</TextBox.InnerRightContent>
</TextBox>
</StackPanel>
</DataTemplate>
</UserControl.Resources>
<Grid RowDefinitions="Auto,*,Auto">
<!-- Guild/channel info -->
<Grid Grid.Row="0" Margin="16" ColumnDefinitions="Auto,*">
@ -89,6 +230,7 @@
<!-- Format -->
<ComboBox
IsVisible="{Binding HasRegularChannels}"
Margin="16,8"
materialAssists:ComboBoxAssist.Label="{Binding LocalizationManager.FormatLabel}"
ItemsSource="{Binding AvailableFormats}"
@ -103,6 +245,28 @@
</ComboBox.ItemTemplate>
</ComboBox>
<ContentControl
Margin="16,8"
Content="{Binding}"
ContentTemplate="{StaticResource ForumAssetSettingsTemplate}"
IsVisible="{Binding HasOnlyForumChannels}"
/>
<TabControl Margin="16,8" IsVisible="{Binding HasMixedChannelTypes}">
<TabItem Header="FORUM POSTS">
<ContentControl
Content="{Binding}"
ContentTemplate="{StaticResource ForumAssetSettingsTemplate}"
/>
</TabItem>
<TabItem Header="REGULAR CHANNELS">
<ContentControl
Content="{Binding}"
ContentTemplate="{StaticResource RegularAssetSettingsTemplate}"
/>
</TabItem>
</TabControl>
<!-- Advanced section -->
<StackPanel
Margin="16,8"
@ -220,51 +384,11 @@
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldFormatMarkdown}" />
</DockPanel>
<!-- Download assets -->
<DockPanel
LastChildFill="False"
ToolTip.Tip="{Binding LocalizationManager.DownloadAssetsTooltip}"
>
<TextBlock
DockPanel.Dock="Left"
Text="{Binding LocalizationManager.DownloadAssetsLabel}"
/>
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldDownloadAssets}" />
</DockPanel>
<!-- Reuse assets -->
<DockPanel
IsEnabled="{Binding ShouldDownloadAssets}"
LastChildFill="False"
ToolTip.Tip="{Binding LocalizationManager.ReuseAssetsTooltip}"
>
<TextBlock
DockPanel.Dock="Left"
Text="{Binding LocalizationManager.ReuseAssetsLabel}"
/>
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldReuseAssets}" />
</DockPanel>
<!-- Assets path -->
<TextBox
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AssetsDirPathLabel}"
IsEnabled="{Binding ShouldDownloadAssets}"
Text="{Binding AssetsDirPath}"
Theme="{DynamicResource FilledTextBox}"
ToolTip.Tip="{Binding LocalizationManager.AssetsDirPathTooltip}"
>
<TextBox.InnerRightContent>
<Button
Margin="8,8,8,6"
Padding="8"
VerticalAlignment="Center"
Command="{Binding ShowAssetsDirPathPromptCommand}"
Theme="{DynamicResource MaterialFlatButton}"
>
<materialIcons:MaterialIcon Width="20" Height="20" Kind="FolderOpen" />
</Button>
</TextBox.InnerRightContent>
</TextBox>
<ContentControl
Content="{Binding}"
ContentTemplate="{StaticResource RegularAssetSettingsTemplate}"
IsVisible="{Binding HasOnlyRegularChannels}"
/>
</StackPanel>
</StackPanel>
</ScrollViewer>