mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-08-13 14:03:01 -06:00
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:
parent
0358045c94
commit
9ee1599b9d
|
|
@ -17,30 +17,43 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||||
{
|
{
|
||||||
private static readonly AsyncKeyedLocker<string> Locker = new();
|
private static readonly AsyncKeyedLocker<string> Locker = new();
|
||||||
|
|
||||||
// File paths of the previously downloaded assets
|
// File paths of the previously downloaded assets. The same URL can intentionally be stored
|
||||||
private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal);
|
// 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(
|
public async ValueTask<string> DownloadAsync(
|
||||||
string url,
|
string url,
|
||||||
|
string? relativeDirPath = null,
|
||||||
|
string? preferredFileName = null,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var fileName = GetFileNameFromUrl(url);
|
var actualWorkingDirPath = !string.IsNullOrWhiteSpace(relativeDirPath)
|
||||||
var filePath = Path.Combine(workingDirPath, fileName);
|
? 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);
|
using var _ = await Locker.LockAsync(filePath, cancellationToken);
|
||||||
|
|
||||||
if (_previousPathsByUrl.TryGetValue(url, out var cachedFilePath))
|
if (_previousPathsByRequest.TryGetValue(requestKey, out var cachedFilePath))
|
||||||
return cachedFilePath;
|
return cachedFilePath;
|
||||||
|
|
||||||
// Reuse existing files if we're allowed to
|
// Reuse existing files if we're allowed to
|
||||||
if (reuse && File.Exists(filePath))
|
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
|
// 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.
|
// 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.
|
// 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);
|
var legacyFileNames = GetLegacyFileNamesFromUrl(url);
|
||||||
foreach (var legacyFileName in legacyFileNames)
|
foreach (var legacyFileName in legacyFileNames)
|
||||||
|
|
@ -53,7 +66,7 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.Move(legacyFilePath, filePath, true);
|
File.Move(legacyFilePath, filePath, true);
|
||||||
return _previousPathsByUrl[url] = filePath;
|
return _previousPathsByRequest[requestKey] = filePath;
|
||||||
}
|
}
|
||||||
catch (IOException)
|
catch (IOException)
|
||||||
{
|
{
|
||||||
|
|
@ -64,7 +77,7 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(workingDirPath);
|
Directory.CreateDirectory(actualWorkingDirPath);
|
||||||
|
|
||||||
await Http.ResiliencePipeline.ExecuteAsync(
|
await Http.ResiliencePipeline.ExecuteAsync(
|
||||||
async innerCancellationToken =>
|
async innerCancellationToken =>
|
||||||
|
|
@ -84,7 +97,7 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
return _previousPathsByUrl[url] = filePath;
|
return _previousPathsByRequest[requestKey] = filePath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,17 +120,69 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||||
public Color? TryGetUserColor(Snowflake id) =>
|
public Color? TryGetUserColor(Snowflake id) =>
|
||||||
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
|
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
|
||||||
|
|
||||||
public async ValueTask<string> ResolveAssetUrlAsync(
|
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 url,
|
||||||
CancellationToken cancellationToken = default
|
string? relativeDirPath,
|
||||||
|
string? preferredFileName,
|
||||||
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (!Request.ShouldDownloadAssets)
|
var filePath = await _assetDownloader.DownloadAsync(
|
||||||
return url;
|
url,
|
||||||
|
relativeDirPath,
|
||||||
|
preferredFileName,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var filePath = await _assetDownloader.DownloadAsync(url, cancellationToken);
|
|
||||||
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
|
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
|
||||||
|
|
||||||
// Prefer the relative path so that the export package can be copied around without breaking references.
|
// Prefer the relative path so that the export package can be copied around without breaking references.
|
||||||
|
|
@ -153,6 +205,28 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||||
|
|
||||||
return optimalFilePath;
|
return optimalFilePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async ValueTask<string> ResolveAssetUrlAsync(
|
||||||
|
string url,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (!Request.ShouldDownloadAssets)
|
||||||
|
return url;
|
||||||
|
|
||||||
|
var relativeDirPath = Request.IsForumExport ? GetForumCommonAssetDirPath() : null;
|
||||||
|
if (Request.IsForumExport && relativeDirPath is null)
|
||||||
|
return url;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await ResolveDownloadedAssetUrlAsync(
|
||||||
|
url,
|
||||||
|
relativeDirPath,
|
||||||
|
null,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
}
|
||||||
// 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
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/372
|
||||||
|
|
@ -163,4 +237,36 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||||
return url;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,16 @@ public partial class ExportRequest
|
||||||
|
|
||||||
public bool ShouldReuseAssets { get; }
|
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 string? Locale { get; }
|
||||||
|
|
||||||
public CultureInfo? CultureInfo { get; }
|
public CultureInfo? CultureInfo { get; }
|
||||||
|
|
@ -62,7 +72,13 @@ public partial class ExportRequest
|
||||||
bool shouldDownloadAssets,
|
bool shouldDownloadAssets,
|
||||||
bool shouldReuseAssets,
|
bool shouldReuseAssets,
|
||||||
string? locale,
|
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;
|
Guild = guild;
|
||||||
|
|
@ -76,6 +92,11 @@ public partial class ExportRequest
|
||||||
ShouldFormatMarkdown = shouldFormatMarkdown;
|
ShouldFormatMarkdown = shouldFormatMarkdown;
|
||||||
ShouldDownloadAssets = shouldDownloadAssets;
|
ShouldDownloadAssets = shouldDownloadAssets;
|
||||||
ShouldReuseAssets = shouldReuseAssets;
|
ShouldReuseAssets = shouldReuseAssets;
|
||||||
|
IsForumExport = isForumExport;
|
||||||
|
ShouldDownloadForumAvatars = shouldDownloadForumAvatars;
|
||||||
|
ForumCommonAssetMode = forumCommonAssetMode;
|
||||||
|
ForumAttachmentFolderMode = forumAttachmentFolderMode;
|
||||||
|
ForumAttachmentNamingMode = forumAttachmentNamingMode;
|
||||||
Locale = locale;
|
Locale = locale;
|
||||||
IsUtcNormalizationEnabled = isUtcNormalizationEnabled;
|
IsUtcNormalizationEnabled = isUtcNormalizationEnabled;
|
||||||
|
|
||||||
|
|
@ -83,8 +104,10 @@ public partial class ExportRequest
|
||||||
|
|
||||||
OutputDirPath = Path.GetDirectoryName(OutputFilePath)!;
|
OutputDirPath = Path.GetDirectoryName(OutputFilePath)!;
|
||||||
|
|
||||||
AssetsDirPath = !string.IsNullOrWhiteSpace(assetsDirPath)
|
AssetsDirPath =
|
||||||
|
!string.IsNullOrWhiteSpace(assetsDirPath)
|
||||||
? FormatPath(assetsDirPath, Guild, Channel, After, Before)
|
? FormatPath(assetsDirPath, Guild, Channel, After, Before)
|
||||||
|
: IsForumExport ? Path.Combine(OutputDirPath, "_forum_assets")
|
||||||
: $"{OutputFilePath}_Files{Path.DirectorySeparatorChar}";
|
: $"{OutputFilePath}_Files{Path.DirectorySeparatorChar}";
|
||||||
|
|
||||||
CultureInfo = Locale?.Pipe(CultureInfo.GetCultureInfo);
|
CultureInfo = Locale?.Pipe(CultureInfo.GetCultureInfo);
|
||||||
|
|
|
||||||
22
DiscordChatExporter.Core/Exporting/ForumAssetOptions.cs
Normal file
22
DiscordChatExporter.Core/Exporting/ForumAssetOptions.cs
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ using System.Text.Encodings.Web;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||||
|
|
@ -66,7 +67,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||||
|
|
||||||
_writer.WriteString(
|
_writer.WriteString(
|
||||||
"avatarUrl",
|
"avatarUrl",
|
||||||
await Context.ResolveAssetUrlAsync(
|
await Context.ResolveAvatarUrlAsync(
|
||||||
Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl,
|
Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
|
|
@ -121,6 +122,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||||
|
|
||||||
private async ValueTask WriteAttachmentAsync(
|
private async ValueTask WriteAttachmentAsync(
|
||||||
Attachment attachment,
|
Attachment attachment,
|
||||||
|
Snowflake messageId,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
|
@ -129,7 +131,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||||
_writer.WriteString("id", attachment.Id.ToString());
|
_writer.WriteString("id", attachment.Id.ToString());
|
||||||
_writer.WriteString(
|
_writer.WriteString(
|
||||||
"url",
|
"url",
|
||||||
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
|
await Context.ResolveAttachmentUrlAsync(attachment, messageId, cancellationToken)
|
||||||
);
|
);
|
||||||
_writer.WriteString("fileName", attachment.FileName);
|
_writer.WriteString("fileName", attachment.FileName);
|
||||||
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||||
|
|
@ -473,7 +475,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||||
_writer.WriteStartArray("attachments");
|
_writer.WriteStartArray("attachments");
|
||||||
|
|
||||||
foreach (var attachment in message.Attachments)
|
foreach (var attachment in message.Attachments)
|
||||||
await WriteAttachmentAsync(attachment, cancellationToken);
|
await WriteAttachmentAsync(attachment, message.Id, cancellationToken);
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
|
@ -570,7 +572,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||||
_writer.WriteStartArray("attachments");
|
_writer.WriteStartArray("attachments");
|
||||||
|
|
||||||
foreach (var attachment in message.ForwardedMessage.Attachments)
|
foreach (var attachment in message.ForwardedMessage.Attachments)
|
||||||
await WriteAttachmentAsync(attachment, cancellationToken);
|
await WriteAttachmentAsync(attachment, message.Id, cancellationToken);
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Gui.Localization;
|
using DiscordChatExporter.Gui.Localization;
|
||||||
using DiscordChatExporter.Gui.ViewModels;
|
using DiscordChatExporter.Gui.ViewModels;
|
||||||
|
|
@ -18,13 +19,15 @@ public class ViewModelManager(IServiceProvider services, LocalizationManager loc
|
||||||
|
|
||||||
public ExportSetupViewModel GetExportSetupViewModel(
|
public ExportSetupViewModel GetExportSetupViewModel(
|
||||||
Guild guild,
|
Guild guild,
|
||||||
IReadOnlyList<Channel> channels
|
IReadOnlyList<Channel> channels,
|
||||||
|
IReadOnlySet<Snowflake> forumChannelIds
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var viewModel = services.GetRequiredService<ExportSetupViewModel>();
|
var viewModel = services.GetRequiredService<ExportSetupViewModel>();
|
||||||
|
|
||||||
viewModel.Guild = guild;
|
viewModel.Guild = guild;
|
||||||
viewModel.Channels = channels;
|
viewModel.Channels = channels;
|
||||||
|
viewModel.ForumChannelIds = forumChannelIds;
|
||||||
|
|
||||||
return viewModel;
|
return viewModel;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,10 @@ public partial class SettingsService()
|
||||||
public partial Language Language { get; set; }
|
public partial Language Language { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool IsAutoUpdateEnabled { get; set; } = true;
|
public partial bool IsAutoUpdateEnabled { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool IsTokenPersisted { get; set; } = true;
|
public partial bool IsTokenPersisted { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial RateLimitPreference RateLimitPreference { get; set; } =
|
public partial RateLimitPreference RateLimitPreference { get; set; } =
|
||||||
|
|
@ -49,7 +49,7 @@ public partial class SettingsService()
|
||||||
public partial string? LastToken { get; set; }
|
public partial string? LastToken { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
public partial ExportFormat LastExportFormat { get; set; } = ExportFormat.Json;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string? LastPartitionLimitValue { get; set; }
|
public partial string? LastPartitionLimitValue { get; set; }
|
||||||
|
|
@ -64,14 +64,41 @@ public partial class SettingsService()
|
||||||
public partial bool LastShouldFormatMarkdown { get; set; } = true;
|
public partial bool LastShouldFormatMarkdown { get; set; } = true;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool LastShouldDownloadAssets { get; set; }
|
public partial bool LastShouldDownloadAssets { get; set; } = true;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool LastShouldReuseAssets { get; set; }
|
public partial bool LastShouldReuseAssets { get; set; } = true;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string? LastAssetsDirPath { get; set; }
|
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()
|
public override void Save()
|
||||||
{
|
{
|
||||||
// Clear the token if it's not supposed to be persisted
|
// Clear the token if it's not supposed to be persisted
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
|
@ -91,6 +92,25 @@ public partial class DashboardViewModel : ViewModelBase
|
||||||
|
|
||||||
public ObservableCollection<ChannelConnection> SelectedChannels { get; } = [];
|
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()
|
public override Task InitializeAsync()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(_settingsService.LastToken))
|
if (!string.IsNullOrWhiteSpace(_settingsService.LastToken))
|
||||||
|
|
@ -222,6 +242,47 @@ public partial class DashboardViewModel : ViewModelBase
|
||||||
private bool CanExport() =>
|
private bool CanExport() =>
|
||||||
!IsBusy && _discord is not null && SelectedGuild is not null && SelectedChannels.Any();
|
!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))]
|
[RelayCommand(CanExecute = nameof(CanExport))]
|
||||||
private async Task ExportAsync()
|
private async Task ExportAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -232,9 +293,31 @@ public partial class DashboardViewModel : ViewModelBase
|
||||||
if (_discord is null || SelectedGuild is null || !SelectedChannels.Any())
|
if (_discord is null || SelectedGuild is null || !SelectedChannels.Any())
|
||||||
return;
|
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(
|
var dialog = _viewModelManager.GetExportSetupViewModel(
|
||||||
SelectedGuild,
|
SelectedGuild,
|
||||||
SelectedChannels.Select(c => c.Channel).ToArray()
|
channelsToExport,
|
||||||
|
forumChannelIds
|
||||||
);
|
);
|
||||||
|
|
||||||
if (await _dialogManager.ShowDialogAsync(dialog) != true)
|
if (await _dialogManager.ShowDialogAsync(dialog) != true)
|
||||||
|
|
@ -252,7 +335,12 @@ public partial class DashboardViewModel : ViewModelBase
|
||||||
channelProgressPairs,
|
channelProgressPairs,
|
||||||
new ParallelOptions
|
new ParallelOptions
|
||||||
{
|
{
|
||||||
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit),
|
MaxDegreeOfParallelism = Math.Max(
|
||||||
|
1,
|
||||||
|
dialog.HasForumChannels
|
||||||
|
? dialog.SelectedForumParallelLimit
|
||||||
|
: _settingsService.ParallelLimit
|
||||||
|
),
|
||||||
},
|
},
|
||||||
async (pair, cancellationToken) =>
|
async (pair, cancellationToken) =>
|
||||||
{
|
{
|
||||||
|
|
@ -261,22 +349,33 @@ public partial class DashboardViewModel : ViewModelBase
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var isForumChannel = dialog.IsForumChannel(channel);
|
||||||
|
|
||||||
var request = new ExportRequest(
|
var request = new ExportRequest(
|
||||||
dialog.Guild!,
|
dialog.Guild!,
|
||||||
channel,
|
channel,
|
||||||
dialog.OutputPath!,
|
dialog.OutputPath!,
|
||||||
dialog.AssetsDirPath,
|
isForumChannel ? dialog.ForumAssetsDirPath : dialog.AssetsDirPath,
|
||||||
dialog.SelectedFormat,
|
isForumChannel ? ExportFormat.Json : dialog.SelectedFormat,
|
||||||
dialog.After?.Pipe(Snowflake.FromDate),
|
dialog.After?.Pipe(Snowflake.FromDate),
|
||||||
dialog.Before?.Pipe(Snowflake.FromDate),
|
dialog.Before?.Pipe(Snowflake.FromDate),
|
||||||
dialog.PartitionLimit,
|
dialog.PartitionLimit,
|
||||||
dialog.MessageFilter,
|
dialog.MessageFilter,
|
||||||
dialog.IsReverseMessageOrder,
|
dialog.IsReverseMessageOrder,
|
||||||
dialog.ShouldFormatMarkdown,
|
dialog.ShouldFormatMarkdown,
|
||||||
dialog.ShouldDownloadAssets,
|
isForumChannel
|
||||||
dialog.ShouldReuseAssets,
|
? dialog.ForumShouldDownloadAssets
|
||||||
|
: dialog.ShouldDownloadAssets,
|
||||||
|
isForumChannel
|
||||||
|
? dialog.ForumShouldReuseAssets
|
||||||
|
: dialog.ShouldReuseAssets,
|
||||||
_settingsService.Locale,
|
_settingsService.Locale,
|
||||||
_settingsService.IsUtcNormalizationEnabled
|
_settingsService.IsUtcNormalizationEnabled,
|
||||||
|
isForumChannel,
|
||||||
|
dialog.ForumShouldDownloadAvatars,
|
||||||
|
dialog.SelectedForumCommonAssetMode,
|
||||||
|
dialog.SelectedForumAttachmentFolderMode,
|
||||||
|
dialog.SelectedForumAttachmentNamingMode
|
||||||
);
|
);
|
||||||
|
|
||||||
await exporter.ExportChannelAsync(request, progress, cancellationToken);
|
await exporter.ExportChannelAsync(request, progress, cancellationToken);
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,20 @@ public partial class ExportSetupViewModel(
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsSingleChannel))]
|
[NotifyPropertyChangedFor(nameof(IsSingleChannel))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasRegularChannels))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasMixedChannelTypes))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasOnlyForumChannels))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasOnlyRegularChannels))]
|
||||||
public partial IReadOnlyList<Channel>? Channels { get; set; }
|
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]
|
[ObservableProperty]
|
||||||
public partial string? OutputPath { get; set; }
|
public partial string? OutputPath { get; set; }
|
||||||
|
|
||||||
|
|
@ -77,13 +89,61 @@ public partial class ExportSetupViewModel(
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial string? AssetsDirPath { get; set; }
|
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]
|
[ObservableProperty]
|
||||||
public partial bool IsAdvancedSectionDisplayed { get; set; }
|
public partial bool IsAdvancedSectionDisplayed { get; set; }
|
||||||
|
|
||||||
public bool IsSingleChannel => Channels?.Count == 1;
|
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<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 bool IsAfterDateSet => AfterDate is not null;
|
||||||
|
|
||||||
public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero);
|
public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero);
|
||||||
|
|
@ -113,6 +173,14 @@ public partial class ExportSetupViewModel(
|
||||||
ShouldDownloadAssets = settingsService.LastShouldDownloadAssets;
|
ShouldDownloadAssets = settingsService.LastShouldDownloadAssets;
|
||||||
ShouldReuseAssets = settingsService.LastShouldReuseAssets;
|
ShouldReuseAssets = settingsService.LastShouldReuseAssets;
|
||||||
AssetsDirPath = settingsService.LastAssetsDirPath;
|
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
|
// 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.
|
||||||
|
|
@ -173,6 +241,14 @@ public partial class ExportSetupViewModel(
|
||||||
AssetsDirPath = path;
|
AssetsDirPath = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ShowForumAssetsDirPathPromptAsync()
|
||||||
|
{
|
||||||
|
var path = await dialogManager.PromptDirectoryPathAsync();
|
||||||
|
if (!string.IsNullOrWhiteSpace(path))
|
||||||
|
ForumAssetsDirPath = path;
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task ConfirmAsync()
|
private async Task ConfirmAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -195,6 +271,14 @@ public partial class ExportSetupViewModel(
|
||||||
settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
|
settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
|
||||||
settingsService.LastShouldReuseAssets = ShouldReuseAssets;
|
settingsService.LastShouldReuseAssets = ShouldReuseAssets;
|
||||||
settingsService.LastAssetsDirPath = AssetsDirPath;
|
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);
|
Close(true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@
|
||||||
ItemsSource="{Binding AvailableChannels}"
|
ItemsSource="{Binding AvailableChannels}"
|
||||||
SelectedItems="{Binding SelectedChannels}"
|
SelectedItems="{Binding SelectedChannels}"
|
||||||
SelectionChanged="AvailableChannelsTreeView_OnSelectionChanged"
|
SelectionChanged="AvailableChannelsTreeView_OnSelectionChanged"
|
||||||
SelectionMode="Multiple"
|
SelectionMode="{Binding ChannelSelectionMode}"
|
||||||
TextSearch.Text="Name"
|
TextSearch.Text="Name"
|
||||||
>
|
>
|
||||||
<TreeView.Styles>
|
<TreeView.Styles>
|
||||||
|
|
@ -302,22 +302,39 @@
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<!-- Export button -->
|
<!-- Selection mode and export buttons -->
|
||||||
|
<StackPanel
|
||||||
|
Margin="32,24"
|
||||||
|
HorizontalAlignment="Right"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
IsVisible="{Binding $self.IsEffectivelyEnabled}"
|
||||||
|
Orientation="Horizontal"
|
||||||
|
Spacing="12"
|
||||||
|
>
|
||||||
|
<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
|
<Button
|
||||||
Width="56"
|
Width="56"
|
||||||
Height="56"
|
Height="56"
|
||||||
Margin="32,24"
|
|
||||||
Padding="0"
|
Padding="0"
|
||||||
HorizontalAlignment="Right"
|
|
||||||
VerticalAlignment="Bottom"
|
|
||||||
Background="{DynamicResource MaterialSecondaryMidBrush}"
|
Background="{DynamicResource MaterialSecondaryMidBrush}"
|
||||||
Command="{Binding ExportCommand}"
|
Command="{Binding ExportCommand}"
|
||||||
Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}"
|
Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}"
|
||||||
IsVisible="{Binding $self.IsEffectivelyEnabled}"
|
|
||||||
Theme="{DynamicResource MaterialIconButton}"
|
Theme="{DynamicResource MaterialIconButton}"
|
||||||
|
ToolTip.Tip="Export selected channels"
|
||||||
>
|
>
|
||||||
<materialIcons:MaterialIcon Width="32" Height="32" Kind="Download" />
|
<materialIcons:MaterialIcon Width="32" Height="32" Kind="Download" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</StackPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,150 @@
|
||||||
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||||
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
|
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
|
||||||
x:Name="UserControl"
|
x:Name="UserControl"
|
||||||
Width="380"
|
Width="520"
|
||||||
x:DataType="dialogs:ExportSetupViewModel"
|
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">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
<!-- Guild/channel info -->
|
<!-- Guild/channel info -->
|
||||||
<Grid Grid.Row="0" Margin="16" ColumnDefinitions="Auto,*">
|
<Grid Grid.Row="0" Margin="16" ColumnDefinitions="Auto,*">
|
||||||
|
|
@ -89,6 +230,7 @@
|
||||||
|
|
||||||
<!-- Format -->
|
<!-- Format -->
|
||||||
<ComboBox
|
<ComboBox
|
||||||
|
IsVisible="{Binding HasRegularChannels}"
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
materialAssists:ComboBoxAssist.Label="{Binding LocalizationManager.FormatLabel}"
|
materialAssists:ComboBoxAssist.Label="{Binding LocalizationManager.FormatLabel}"
|
||||||
ItemsSource="{Binding AvailableFormats}"
|
ItemsSource="{Binding AvailableFormats}"
|
||||||
|
|
@ -103,6 +245,28 @@
|
||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</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 -->
|
<!-- Advanced section -->
|
||||||
<StackPanel
|
<StackPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
|
|
@ -220,51 +384,11 @@
|
||||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldFormatMarkdown}" />
|
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldFormatMarkdown}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Download assets -->
|
<ContentControl
|
||||||
<DockPanel
|
Content="{Binding}"
|
||||||
LastChildFill="False"
|
ContentTemplate="{StaticResource RegularAssetSettingsTemplate}"
|
||||||
ToolTip.Tip="{Binding LocalizationManager.DownloadAssetsTooltip}"
|
IsVisible="{Binding HasOnlyRegularChannels}"
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue