Compare commits

..

31 Commits

Author SHA1 Message Date
Tyrrrz 3be5bc6a21 Remove dashes from OutputPathTooltip 2026-02-27 19:14:30 +02:00
Tyrrrz c9f152773a Clean up last PR 2026-02-27 19:12:27 +02:00
Copilot 5e39611395 Double-clicking a channel auto-opens export setup (#1493)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-02-27 19:10:19 +02:00
Tyrrrz 40491bdc0f Fix text misalignment on export setup 2026-02-27 19:05:16 +02:00
Tyrrrz 03dd272eaf Fix tests harder this time 2026-02-27 18:59:52 +02:00
Tyrrrz 98e294f7e4 Fix tests, clean up 2026-02-27 18:36:16 +02:00
Tyrrrz b22e786449 Fix the shrug emoji not rendering correctly 2026-02-27 18:35:53 +02:00
Tyrrrz 951c1e4613 Make Run's BaselineAlignment set globally 2026-02-27 18:31:40 +02:00
Tyrrrz 675d910ea3 Formatting 2026-02-27 17:56:29 +02:00
Tyrrrz 28f26e45fb Add tests for forwarded messages 2026-02-27 17:52:26 +02:00
Tyrrrz 4674c517e3 Refactor 2026-02-27 17:39:36 +02:00
Tyrrrz b660edfe78 Clean up after last PR 2026-02-27 17:30:06 +02:00
Tyrrrz 532470e3db Fix format 2026-02-27 15:42:47 +02:00
farley128 b533e08d74 Implement exporting for forwarded messages (#1451)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
2026-02-27 15:41:31 +02:00
Tyrrrz d490fd0a38 Improve, clean up encryption 2026-02-27 14:57:15 +02:00
Copilot 21c2398e2a Cleanup: fix filename typo, replace AsyncNonKeyedLocker, drop WithIndex(), use ProcessStartInfo ctor arg (#1492)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-02-27 14:42:07 +02:00
Tyrrrz 7ff2347df1 Clean up 2026-02-27 14:32:27 +02:00
Tyrrrz 3391e755fc Fix markdown not parsing for Persist Token tooltip 2026-02-27 14:19:03 +02:00
Tyrrrz f0e9d3be1b Reorganize files 2026-02-27 14:14:43 +02:00
Copilot eef0fc742d Encrypt Discord token at rest in settings file (machine-bound) (#1491)
* Initial plan

* Add token encryption when saving/loading settings

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Apply suggestion from @Tyrrrz

* Apply suggestion from @Tyrrrz

* Bind token encryption key to machine identity

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Switch to AES-GCM, hex encoding, and GetBytes/Fill improvements

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Address all review feedback: salt injection, code style, localization formatting

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Address latest review: ThisAssembly.Project, EnvironmentExtensions, inline Lazy, renames, localization wording

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Address latest review: layout comment, cipherSource, else block, MachineName fallback, csproj ordering

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Apply suggestion from @Tyrrrz

* Rename GetMachineId→TryGetMachineId, refactor Write to use single array with FillBytes

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Rename cipherSource→cipher in Read(), tokenBytes→tokenData in Write(), update layout comments

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Add cipherSource variable in Write(), update layout comment with size annotation

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Fix CSharpier formatting: inline multiline string assignments and reformat exception filter

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Quote EncryptionSalt argument to handle single quotes in secret value

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Revert double-quote fix on EncryptionSalt argument

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Apply suggestion from @Tyrrrz

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-27 14:01:25 +02:00
Tyrrrz 2e47c73388 Use range operator for substring extraction in StringSegment.ToString() 2026-02-27 11:30:09 +02:00
Tyrrrz 2d49146c74 Rename transformFallback parameter to fallbackTransform for clarity 2026-02-27 10:24:39 +02:00
Copilot c59374598a Migrate to Centralized NuGet Package Management (CPM) (#1489)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-02-26 21:29:46 +02:00
Tyrrrz 01d0203445 Clean up 2026-02-26 21:24:20 +02:00
Copilot c4bfb3424e Add option to reverse message order in exports (#1487)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-02-26 21:14:57 +02:00
Copilot 522caba420 Fix standard emoji not rendering as Discord-style images (#1488)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-02-26 17:20:04 +02:00
Copilot ad1170b42e Fix duplicate threads causing a crash when exporting in parallel (#1486)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-02-26 17:19:07 +02:00
Copilot d2c0e2169b Increase media asset URL hash length to eliminate collision risk with --reuse-media (#1485)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-02-26 16:35:18 +02:00
Tyrrrz e6562de1f5 Reformat XAML 2026-02-24 21:07:48 +02:00
Tyrrrz 5f8054e2f0 Fix alingment issues when dealing with inlines 2026-02-24 21:05:03 +02:00
Copilot 12d98e9ab0 Add localization (#1482)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-02-24 20:49:32 +02:00
57 changed files with 1364 additions and 404 deletions
+1
View File
@@ -127,6 +127,7 @@ jobs:
dotnet publish ${{ matrix.app }} dotnet publish ${{ matrix.app }}
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }} -p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
-p:CSharpier_Bypass=true -p:CSharpier_Bypass=true
-p:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }}
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }} -p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
--output ${{ matrix.app }}/bin/publish/ --output ${{ matrix.app }}/bin/publish/
--configuration Release --configuration Release
+43
View File
@@ -0,0 +1,43 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="AngleSharp" Version="1.4.0" />
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.5.0" />
<PackageVersion Include="AsyncKeyedLock" Version="8.0.1" />
<PackageVersion Include="Avalonia" Version="11.3.11" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.11" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.11" />
<PackageVersion Include="CliFx" Version="2.3.6" />
<PackageVersion Include="Cogwheel" Version="2.1.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.5" />
<PackageVersion Include="Deorcify" Version="1.1.0" />
<PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
<PackageVersion Include="DialogHost.Avalonia" Version="0.10.4" />
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.1" />
<PackageVersion Include="Gress" Version="2.1.1" />
<PackageVersion Include="JsonExtensions" Version="1.2.0" />
<PackageVersion Include="Markdig" Version="1.0.0" />
<PackageVersion Include="Material.Avalonia" Version="3.9.2" />
<PackageVersion Include="Material.Icons.Avalonia" Version="2.2.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="Onova" Version="2.6.13" />
<PackageVersion Include="Polly" Version="8.6.5" />
<PackageVersion Include="RazorBlade" Version="0.11.0" />
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
<PackageVersion Include="Superpower" Version="3.1.0" />
<PackageVersion Include="WebMarkupMin.Core" Version="2.20.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="YoutubeExplode" Version="6.5.6" />
</ItemGroup>
</Project>
@@ -10,21 +10,18 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.4.0" /> <PackageReference Include="AngleSharp" />
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" /> <PackageReference Include="coverlet.collector" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="8.8.0" /> <PackageReference Include="FluentAssertions" />
<PackageReference Include="GitHubActionsTestLogger" Version="3.0.1" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" /> <PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
Include="Microsoft.Extensions.Configuration.EnvironmentVariables" <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
Version="10.0.2" <PackageReference Include="Microsoft.NET.Test.Sdk" />
/> <PackageReference Include="xunit" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" /> <PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -16,6 +16,8 @@ public static class ChannelIds
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020"); public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
public static Snowflake ForwardTestCases { get; } = Snowflake.Parse("1455202357204877477");
public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521"); public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521");
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794"); public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
@@ -1,7 +1,12 @@
using System.Linq; using System.IO;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp.Dom; using AngleSharp.Dom;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions; using FluentAssertions;
using Xunit; using Xunit;
@@ -44,4 +49,41 @@ public class HtmlContentSpecs
"Yeet" "Yeet"
); );
} }
[Fact]
public async Task I_can_export_a_channel_in_the_HTML_format_in_the_reverse_order()
{
// Arrange
using var file = TempFile.Create();
// Act
await new ExportChannelsCommand
{
Token = Secrets.DiscordToken,
ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.HtmlDark,
OutputPath = file.Path,
Locale = "en-US",
IsUtcNormalizationEnabled = true,
IsReverseMessageOrder = true,
}.ExecuteAsync(new FakeConsole());
var document = Html.Parse(await File.ReadAllTextAsync(file.Path));
var messages = document.QuerySelectorAll("[data-message-id]").ToArray();
// Assert
messages
.Select(e => e.GetAttribute("data-message-id"))
.Should()
.Equal(
"885169254029213696",
"868505973294268457",
"868505969821364245",
"868505966528835604",
"868490009366396958",
"866732113319428096",
"866710679758045195",
"866674314627121232"
);
}
} }
@@ -0,0 +1,29 @@
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlForwardSpecs
{
[Fact]
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.ForwardTestCases,
Snowflake.Parse("1455202427115536514")
);
// Assert
message
.Text()
.ReplaceWhiteSpace()
.Should()
.ContainAll("Forwarded", @"¯\_(ツ)_/¯", "12/29/2025 2:14 PM");
}
}
@@ -1,7 +1,13 @@
using System.Linq; using System.IO;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions; using FluentAssertions;
using JsonExtensions;
using Xunit; using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs; namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -43,4 +49,43 @@ public class JsonContentSpecs
"Yeet" "Yeet"
); );
} }
[Fact]
public async Task I_can_export_a_channel_in_the_JSON_format_in_the_reverse_order()
{
// Arrange
using var file = TempFile.Create();
// Act
await new ExportChannelsCommand
{
Token = Secrets.DiscordToken,
ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
Locale = "en-US",
IsUtcNormalizationEnabled = true,
IsReverseMessageOrder = true,
}.ExecuteAsync(new FakeConsole());
var messages = Json.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages")
.EnumerateArray()
.ToArray();
// Assert
messages
.Select(j => j.GetProperty("id").GetString())
.Should()
.Equal(
"885169254029213696",
"868505973294268457",
"868505969821364245",
"868505966528835604",
"868490009366396958",
"866732113319428096",
"866710679758045195",
"866674314627121232"
);
}
} }
@@ -0,0 +1,33 @@
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonForwardSpecs
{
[Fact]
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.ForwardTestCases,
Snowflake.Parse("1455202427115536514")
);
// Assert
var reference = message.GetProperty("reference");
reference.GetProperty("type").GetString().Should().Be("Forward");
reference.GetProperty("guildId").GetString().Should().Be("869237470565392384");
var forwardedMessage = message.GetProperty("forwardedMessage");
forwardedMessage.GetProperty("content").GetString().Should().Contain(@"¯\_(ツ)_/¯");
forwardedMessage
.GetProperty("timestamp")
.GetString()
.Should()
.StartWith("2025-12-28T22:52:42.175+00:00");
}
}
@@ -0,0 +1,23 @@
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
public class PlainTextForwardSpecs
{
[Fact]
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
{
// Act
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.ForwardTestCases);
// Assert
document
.ReplaceWhiteSpace()
.Should()
.ContainAll("{Forwarded Message}", @"¯\_(ツ)_/¯", "12/28/2025 10:52 PM");
}
}
+1
View File
@@ -15,6 +15,7 @@ WORKDIR /tmp/app
COPY favicon.ico . COPY favicon.ico .
COPY NuGet.config . COPY NuGet.config .
COPY Directory.Build.props . COPY Directory.Build.props .
COPY Directory.Packages.props .
COPY DiscordChatExporter.Core DiscordChatExporter.Core COPY DiscordChatExporter.Core DiscordChatExporter.Core
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
@@ -83,6 +83,12 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public int ParallelLimit { get; init; } = 1; public int ParallelLimit { get; init; } = 1;
[CommandOption(
"reverse",
Description = "Export messages in reverse chronological order (newest first)."
)]
public bool IsReverseMessageOrder { get; init; }
[CommandOption( [CommandOption(
"markdown", "markdown",
Description = "Process markdown, mentions, and other special tokens." Description = "Process markdown, mentions, and other special tokens."
@@ -267,6 +273,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
Before, Before,
PartitionLimit, PartitionLimit,
MessageFilter, MessageFilter,
IsReverseMessageOrder,
ShouldFormatMarkdown, ShouldFormatMarkdown,
ShouldDownloadAssets, ShouldDownloadAssets,
ShouldReuseAssets, ShouldReuseAssets,
@@ -13,11 +13,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="2.3.6" /> <PackageReference Include="CliFx" />
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" /> <PackageReference Include="Deorcify" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" />
<PackageReference Include="Spectre.Console" Version="0.54.0" /> <PackageReference Include="Spectre.Console" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -8855,6 +8855,8 @@ internal static class EmojiIndex
["united_nations"] = "🇺🇳", ["united_nations"] = "🇺🇳",
}; };
public static IReadOnlyCollection<string> GetAllNames() => _toCodes.Keys;
public static string? TryGetCode(string name) => _toCodes.GetValueOrDefault(name); public static string? TryGetCode(string name) => _toCodes.GetValueOrDefault(name);
public static string? TryGetName(string code) => _fromCodes.GetValueOrDefault(code); public static string? TryGetName(string code) => _fromCodes.GetValueOrDefault(code);
@@ -27,9 +27,16 @@ public partial record Message(
IReadOnlyList<User> MentionedUsers, IReadOnlyList<User> MentionedUsers,
MessageReference? Reference, MessageReference? Reference,
Message? ReferencedMessage, Message? ReferencedMessage,
MessageSnapshot? ForwardedMessage,
Interaction? Interaction Interaction? Interaction
) : IHasId ) : IHasId
{ {
public bool IsEmpty { get; } =
string.IsNullOrWhiteSpace(Content)
&& !Attachments.Any()
&& !Embeds.Any()
&& !Stickers.Any();
public bool IsSystemNotification { get; } = public bool IsSystemNotification { get; } =
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated; Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
@@ -38,11 +45,7 @@ public partial record Message(
// App interactions are rendered as replies in the Discord client, but they are not actually replies // App interactions are rendered as replies in the Discord client, but they are not actually replies
public bool IsReplyLike => IsReply || Interaction is not null; public bool IsReplyLike => IsReply || Interaction is not null;
public bool IsEmpty { get; } = public bool IsForwarded { get; } = Reference?.Kind == MessageReferenceKind.Forward;
string.IsNullOrWhiteSpace(Content)
&& !Attachments.Any()
&& !Embeds.Any()
&& !Stickers.Any();
public IEnumerable<User> GetReferencedUsers() public IEnumerable<User> GetReferencedUsers()
{ {
@@ -171,7 +174,17 @@ public partial record Message
var messageReference = json.GetPropertyOrNull("message_reference") var messageReference = json.GetPropertyOrNull("message_reference")
?.Pipe(MessageReference.Parse); ?.Pipe(MessageReference.Parse);
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse); var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
// Currently Discord only supports 1 snapshot per forward
var forwardedMessage = json.GetPropertyOrNull("message_snapshots")
?.EnumerateArrayOrNull()
?.Select(j => j.GetPropertyOrNull("message"))
.WhereNotNull()
.Select(MessageSnapshot.Parse)
.FirstOrDefault();
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse); var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
return new Message( return new Message(
@@ -191,6 +204,7 @@ public partial record Message
mentionedUsers, mentionedUsers,
messageReference, messageReference,
referencedMessage, referencedMessage,
forwardedMessage,
interaction interaction
); );
} }
@@ -5,10 +5,19 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data; namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure // https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowflake? GuildId) public record MessageReference(
MessageReferenceKind Kind,
Snowflake? MessageId,
Snowflake? ChannelId,
Snowflake? GuildId
)
{ {
public static MessageReference Parse(JsonElement json) public static MessageReference Parse(JsonElement json)
{ {
var kind =
json.GetPropertyOrNull("type")?.GetInt32OrNull()?.Pipe(t => (MessageReferenceKind)t)
?? MessageReferenceKind.Default;
var messageId = json.GetPropertyOrNull("message_id") var messageId = json.GetPropertyOrNull("message_id")
?.GetNonWhiteSpaceStringOrNull() ?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse); ?.Pipe(Snowflake.Parse);
@@ -21,6 +30,6 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf
?.GetNonWhiteSpaceStringOrNull() ?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse); ?.Pipe(Snowflake.Parse);
return new MessageReference(messageId, channelId, guildId); return new MessageReference(kind, messageId, channelId, guildId);
} }
} }
@@ -0,0 +1,8 @@
namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/channel#message-reference-types
public enum MessageReferenceKind
{
Default = 0,
Forward = 1,
}
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data;
// https://docs.discord.com/developers/resources/message#message-snapshot-object
public record MessageSnapshot(
DateTimeOffset Timestamp,
DateTimeOffset? EditedTimestamp,
string Content,
IReadOnlyList<Attachment> Attachments,
IReadOnlyList<Embed> Embeds,
IReadOnlyList<Sticker> Stickers
)
{
public static MessageSnapshot Parse(JsonElement json)
{
var timestamp =
json.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull()
?? DateTimeOffset.MinValue;
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffsetOrNull();
var content = json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "";
var attachments =
json.GetPropertyOrNull("attachments")
?.EnumerateArrayOrNull()
?.Select(Attachment.Parse)
.ToArray()
?? [];
var embeds =
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
?? [];
var stickers =
json.GetPropertyOrNull("sticker_items")
?.EnumerateArrayOrNull()
?.Select(Sticker.Parse)
.ToArray()
?? [];
return new MessageSnapshot(
timestamp,
editedTimestamp,
content,
attachments,
embeds,
stickers
);
}
}
+124 -18
View File
@@ -194,6 +194,23 @@ public class DiscordClient(
return Application.Parse(response); return Application.Parse(response);
} }
private async ValueTask EnsureMessageContentIntentAsync(
CancellationToken cancellationToken = default
)
{
if (await ResolveTokenKindAsync(cancellationToken) != TokenKind.Bot)
return;
var application = await GetApplicationAsync(cancellationToken);
if (application.IsMessageContentIntentEnabled)
return;
throw new DiscordChatExporterException(
"Provided bot account is missing the MESSAGE_CONTENT privileged intent.",
true
);
}
public async ValueTask<User?> TryGetUserAsync( public async ValueTask<User?> TryGetUserAsync(
Snowflake userId, Snowflake userId,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
@@ -429,6 +446,11 @@ public class DiscordClient(
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value)) .Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
.ToArray(); .ToArray();
// Track yielded thread IDs to avoid duplicates that can occur when a thread transitions
// from active to archived between the two separate API calls used to fetch threads.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1433
var seenThreadIds = new HashSet<Snowflake>();
// User accounts can only fetch threads using the search endpoint // User accounts can only fetch threads using the search endpoint
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User) if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
{ {
@@ -472,7 +494,9 @@ public class DiscordClient(
break; break;
} }
yield return thread; if (seenThreadIds.Add(thread.Id))
yield return thread;
currentOffset++; currentOffset++;
} }
@@ -511,7 +535,12 @@ public class DiscordClient(
.Pipe(parentsById.GetValueOrDefault); .Pipe(parentsById.GetValueOrDefault);
if (filteredChannels.Contains(parent)) if (filteredChannels.Contains(parent))
yield return Channel.Parse(threadJson, parent); {
var thread = Channel.Parse(threadJson, parent);
if (seenThreadIds.Add(thread.Id))
yield return thread;
}
} }
} }
@@ -547,12 +576,14 @@ public class DiscordClient(
) )
{ {
var thread = Channel.Parse(threadJson, channel); var thread = Channel.Parse(threadJson, channel);
yield return thread;
currentBefore = threadJson currentBefore = threadJson
.GetProperty("thread_metadata") .GetProperty("thread_metadata")
.GetProperty("archive_timestamp") .GetProperty("archive_timestamp")
.GetString(); .GetString();
if (seenThreadIds.Add(thread.Id))
yield return thread;
} }
if (!response.Value.GetProperty("has_more").GetBoolean()) if (!response.Value.GetProperty("has_more").GetBoolean())
@@ -564,6 +595,24 @@ public class DiscordClient(
} }
} }
private async ValueTask<Message?> TryGetFirstMessageAsync(
Snowflake channelId,
Snowflake? after = null,
CancellationToken cancellationToken = default
)
{
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "1")
.SetQueryParameter("after", (after ?? Snowflake.Zero).ToString())
.Build();
var response = await GetJsonResponseAsync(url, cancellationToken);
var message = response.EnumerateArray().Select(Message.Parse).FirstOrDefault();
return message;
}
private async ValueTask<Message?> TryGetLastMessageAsync( private async ValueTask<Message?> TryGetLastMessageAsync(
Snowflake channelId, Snowflake channelId,
Snowflake? before = null, Snowflake? before = null,
@@ -622,22 +671,10 @@ public class DiscordClient(
yield break; yield break;
// If all messages are empty, make sure that it's not because the bot account doesn't // If all messages are empty, make sure that it's not because the bot account doesn't
// have the Message Content Intent enabled. // have the MESSAGE_CONTENT intent enabled.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959 // https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
if ( if (messages.All(m => m.IsEmpty))
messages.All(m => m.IsEmpty) await EnsureMessageContentIntentAsync(cancellationToken);
&& await ResolveTokenKindAsync(cancellationToken) == TokenKind.Bot
)
{
var application = await GetApplicationAsync(cancellationToken);
if (!application.IsMessageContentIntentEnabled)
{
throw new DiscordChatExporterException(
"Provided bot account does not have the Message Content Intent enabled.",
true
);
}
}
foreach (var message in messages) foreach (var message in messages)
{ {
@@ -670,6 +707,75 @@ public class DiscordClient(
} }
} }
public async IAsyncEnumerable<Message> GetMessagesInReverseAsync(
Snowflake channelId,
Snowflake? after = null,
Snowflake? before = null,
IProgress<Percentage>? progress = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
// Get the first message in the specified range, so we can later calculate the
// progress based on the difference between message timestamps.
// Snapshotting is not necessary here because new messages can't appear in the past.
var firstMessage = await TryGetFirstMessageAsync(channelId, after, cancellationToken);
if (firstMessage is null || firstMessage.Timestamp > before?.ToDate())
yield break;
// Keep track of the last message in range in order to calculate the progress
var lastMessage = default(Message);
var currentBefore = before;
while (true)
{
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "100")
.SetQueryParameter("before", currentBefore?.ToString())
.Build();
var response = await GetJsonResponseAsync(url, cancellationToken);
var messages = response.EnumerateArray().Select(Message.Parse).ToArray();
// Break if there are no messages (can happen if messages are deleted during execution)
if (!messages.Any())
yield break;
// If all messages are empty, make sure that it's not because the bot account doesn't
// have the MESSAGE_CONTENT intent enabled.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
if (messages.All(m => m.IsEmpty))
await EnsureMessageContentIntentAsync(cancellationToken);
foreach (var message in messages)
{
lastMessage ??= message;
// Report progress based on timestamps
if (progress is not null)
{
var exportedDuration = (lastMessage.Timestamp - message.Timestamp).Duration();
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
progress.Report(
Percentage.FromFraction(
// Avoid division by zero if all messages have the exact same timestamp
// (which happens when there's only one message in the channel)
totalDuration > TimeSpan.Zero
? exportedDuration / totalDuration
: 1
)
);
}
yield return message;
}
currentBefore = messages.Last().Id;
}
}
public async IAsyncEnumerable<User> GetMessageReactionsAsync( public async IAsyncEnumerable<User> GetMessageReactionsAsync(
Snowflake channelId, Snowflake channelId,
Snowflake messageId, Snowflake messageId,
@@ -1,14 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.4.0" /> <PackageReference Include="AngleSharp" />
<PackageReference Include="AsyncKeyedLock" Version="8.0.1" /> <PackageReference Include="AsyncKeyedLock" />
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" />
<PackageReference Include="Polly" Version="8.6.5" /> <PackageReference Include="Polly" />
<PackageReference Include="RazorBlade" Version="0.11.0" /> <PackageReference Include="RazorBlade" />
<PackageReference Include="Superpower" Version="3.1.0" /> <PackageReference Include="Superpower" />
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" /> <PackageReference Include="WebMarkupMin.Core" />
<PackageReference Include="YoutubeExplode" Version="6.5.6" /> <PackageReference Include="YoutubeExplode" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -64,15 +64,23 @@ public class ChannelExporter(DiscordClient discord)
); );
} }
await foreach ( var messages = !request.IsReverseMessageOrder
var message in discord.GetMessagesAsync( ? discord.GetMessagesAsync(
request.Channel.Id, request.Channel.Id,
request.After, request.After,
request.Before, request.Before,
progress, progress,
cancellationToken cancellationToken
) )
) : discord.GetMessagesInReverseAsync(
request.Channel.Id,
request.After,
request.Before,
progress,
cancellationToken
);
await foreach (var message in messages)
{ {
try try
{ {
@@ -37,6 +37,28 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
if (reuse && File.Exists(filePath)) if (reuse && File.Exists(filePath))
return _previousPathsByUrl[url] = filePath; return _previousPathsByUrl[url] = 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
if (reuse)
{
var legacyFilePath = Path.Combine(workingDirPath, GetLegacyFileNameFromUrl(url));
if (File.Exists(legacyFilePath))
{
// Overwrite in case the destination file was created concurrently between our
// earlier existence check and this move operation
try
{
File.Move(legacyFilePath, filePath, overwrite: true);
return _previousPathsByUrl[url] = filePath;
}
catch (IOException)
{
// The legacy file was moved or deleted concurrently or something else happened.
// Upgrading old files is not crucial, so we can just move on.
}
}
}
Directory.CreateDirectory(workingDirPath); Directory.CreateDirectory(workingDirPath);
await Http.ResiliencePipeline.ExecuteAsync( await Http.ResiliencePipeline.ExecuteAsync(
@@ -56,34 +78,23 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
internal partial class ExportAssetDownloader internal partial class ExportAssetDownloader
{ {
private static string GetUrlHash(string url) private static string NormalizeUrl(string url)
{ {
// Remove signature parameters from Discord CDN URLs to normalize them // Remove signature parameters from Discord CDN URLs to normalize them
static string NormalizeUrl(string url) var uri = new Uri(url);
{ if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
var uri = new Uri(url); return url;
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
return url;
var query = HttpUtility.ParseQueryString(uri.Query); var query = HttpUtility.ParseQueryString(uri.Query);
query.Remove("ex"); query.Remove("ex");
query.Remove("is"); query.Remove("is");
query.Remove("hm"); query.Remove("hm");
return uri.GetLeftPart(UriPartial.Path) + query; return uri.GetLeftPart(UriPartial.Path) + query;
}
return SHA256
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
.Pipe(Convert.ToHexStringLower)
// 5 chars ought to be enough for anybody
.Truncate(5);
} }
private static string GetFileNameFromUrl(string url) private static string GetFileNameFromUrl(string url, string urlHash)
{ {
var urlHash = GetUrlHash(url);
// Try to extract the file name from URL // Try to extract the file name from URL
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value; var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
@@ -107,4 +118,25 @@ internal partial class ExportAssetDownloader
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
); );
} }
private static string GetFileNameFromUrl(string url) =>
GetFileNameFromUrl(
url,
// 16 chars = 64 bits, reaches 1% collision probability at ~609 million files
SHA256
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
.Pipe(Convert.ToHexStringLower)
.Truncate(16)
);
// Legacy naming used a 5-char hash, kept for backwards compatibility with existing exports
private static string GetLegacyFileNameFromUrl(string url) =>
GetFileNameFromUrl(
url,
SHA256
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
.Pipe(Convert.ToHexStringLower)
// 5 chars = 20 bits, reaches 1% collision probability at ~145 files
.Truncate(5)
);
} }
@@ -33,6 +33,8 @@ public partial class ExportRequest
public MessageFilter MessageFilter { get; } public MessageFilter MessageFilter { get; }
public bool IsReverseMessageOrder { get; }
public bool ShouldFormatMarkdown { get; } public bool ShouldFormatMarkdown { get; }
public bool ShouldDownloadAssets { get; } public bool ShouldDownloadAssets { get; }
@@ -55,6 +57,7 @@ public partial class ExportRequest
Snowflake? before, Snowflake? before,
PartitionLimit partitionLimit, PartitionLimit partitionLimit,
MessageFilter messageFilter, MessageFilter messageFilter,
bool isReverseMessageOrder,
bool shouldFormatMarkdown, bool shouldFormatMarkdown,
bool shouldDownloadAssets, bool shouldDownloadAssets,
bool shouldReuseAssets, bool shouldReuseAssets,
@@ -69,6 +72,7 @@ public partial class ExportRequest
Before = before; Before = before;
PartitionLimit = partitionLimit; PartitionLimit = partitionLimit;
MessageFilter = messageFilter; MessageFilter = messageFilter;
IsReverseMessageOrder = isReverseMessageOrder;
ShouldFormatMarkdown = shouldFormatMarkdown; ShouldFormatMarkdown = shouldFormatMarkdown;
ShouldDownloadAssets = shouldDownloadAssets; ShouldDownloadAssets = shouldDownloadAssets;
ShouldReuseAssets = shouldReuseAssets; ShouldReuseAssets = shouldReuseAssets;
@@ -17,6 +17,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
private readonly HtmlMinifier _minifier = new(); private readonly HtmlMinifier _minifier = new();
private readonly List<Message> _messageGroup = []; private readonly List<Message> _messageGroup = [];
// Note: in reverse order, last message appears earlier than the first message
private bool CanJoinGroup(Message message) private bool CanJoinGroup(Message message)
{ {
// If the group is empty, any message can join it // If the group is empty, any message can join it
@@ -119,6 +119,24 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
} }
private async ValueTask WriteAttachmentAsync(
Attachment attachment,
CancellationToken cancellationToken = default
)
{
_writer.WriteStartObject();
_writer.WriteString("id", attachment.Id.ToString());
_writer.WriteString(
"url",
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
);
_writer.WriteString("fileName", attachment.FileName);
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
_writer.WriteEndObject();
}
private async ValueTask WriteEmbedAuthorAsync( private async ValueTask WriteEmbedAuthorAsync(
EmbedAuthor embedAuthor, EmbedAuthor embedAuthor,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
@@ -335,6 +353,24 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
} }
private async ValueTask WriteStickerAsync(
Sticker sticker,
CancellationToken cancellationToken = default
)
{
_writer.WriteStartObject();
_writer.WriteString("id", sticker.Id.ToString());
_writer.WriteString("name", sticker.Name);
_writer.WriteString("format", sticker.Format.ToString());
_writer.WriteString(
"sourceUrl",
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
);
_writer.WriteEndObject();
}
public override async ValueTask WritePreambleAsync( public override async ValueTask WritePreambleAsync(
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
@@ -437,19 +473,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);
_writer.WriteStartObject();
_writer.WriteString("id", attachment.Id.ToString());
_writer.WriteString(
"url",
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
);
_writer.WriteString("fileName", attachment.FileName);
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
_writer.WriteEndObject();
}
_writer.WriteEndArray(); _writer.WriteEndArray();
@@ -465,19 +489,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteStartArray("stickers"); _writer.WriteStartArray("stickers");
foreach (var sticker in message.Stickers) foreach (var sticker in message.Stickers)
{ await WriteStickerAsync(sticker, cancellationToken);
_writer.WriteStartObject();
_writer.WriteString("id", sticker.Id.ToString());
_writer.WriteString("name", sticker.Name);
_writer.WriteString("format", sticker.Format.ToString());
_writer.WriteString(
"sourceUrl",
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
);
_writer.WriteEndObject();
}
_writer.WriteEndArray(); _writer.WriteEndArray();
@@ -527,12 +539,58 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
if (message.Reference is not null) if (message.Reference is not null)
{ {
_writer.WriteStartObject("reference"); _writer.WriteStartObject("reference");
_writer.WriteString("type", message.Reference.Kind.ToString());
_writer.WriteString("messageId", message.Reference.MessageId?.ToString()); _writer.WriteString("messageId", message.Reference.MessageId?.ToString());
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString()); _writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
_writer.WriteString("guildId", message.Reference.GuildId?.ToString()); _writer.WriteString("guildId", message.Reference.GuildId?.ToString());
_writer.WriteEndObject(); _writer.WriteEndObject();
} }
// Forwarded message
if (message.ForwardedMessage is not null)
{
_writer.WriteStartObject("forwardedMessage");
_writer.WriteString(
"timestamp",
Context.NormalizeDate(message.ForwardedMessage.Timestamp)
);
_writer.WriteString(
"timestampEdited",
message.ForwardedMessage.EditedTimestamp?.Pipe(Context.NormalizeDate)
);
_writer.WriteString(
"content",
await FormatMarkdownAsync(message.ForwardedMessage.Content, cancellationToken)
);
// Forwarded attachments
_writer.WriteStartArray("attachments");
foreach (var attachment in message.ForwardedMessage.Attachments)
await WriteAttachmentAsync(attachment, cancellationToken);
_writer.WriteEndArray();
// Forwarded embeds
_writer.WriteStartArray("embeds");
foreach (var embed in message.ForwardedMessage.Embeds)
await WriteEmbedAsync(embed, cancellationToken);
_writer.WriteEndArray();
// Forwarded stickers
_writer.WriteStartArray("stickers");
foreach (var sticker in message.ForwardedMessage.Stickers)
await WriteStickerAsync(sticker, cancellationToken);
_writer.WriteEndArray();
_writer.WriteEndObject();
}
// Interaction // Interaction
if (message.Interaction is not null) if (message.Interaction is not null)
{ {
@@ -34,7 +34,7 @@
} }
<div class="chatlog__message-group"> <div class="chatlog__message-group">
@foreach (var (message, i) in Messages.WithIndex()) @foreach (var (i, message) in Messages.Index())
{ {
var isFirst = i == 0; var isFirst = i == 0;
@@ -262,6 +262,95 @@
</div> </div>
} }
@* Forwarded message *@
@if (message is { IsForwarded: true, ForwardedMessage: not null })
{
<div class="chatlog__forwarded">
<div class="chatlog__forwarded-header">
<svg class="chatlog__forwarded-icon">
<use href="#forward-icon"></use>
</svg>
<em>Forwarded</em>
</div>
@* Forwarded content *@
@if (!string.IsNullOrWhiteSpace(message.ForwardedMessage.Content))
{
<div class="chatlog__forwarded-content chatlog__markdown">
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.ForwardedMessage.Content))<!--/wmm:ignore--></span>
</div>
}
@* Forwarded attachments *@
@if (message.ForwardedMessage.Attachments.Any())
{
<div class="chatlog__forwarded-attachments">
@foreach (var attachment in message.ForwardedMessage.Attachments)
{
@if (attachment.IsImage)
{
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
<img class="chatlog__forwarded-attachment" src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
</a>
}
else if (attachment.IsVideo)
{
<video class="chatlog__forwarded-attachment" controls>
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
</video>
}
else if (attachment.IsAudio)
{
<audio class="chatlog__forwarded-attachment" controls>
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
</audio>
}
else
{
<div class="chatlog__attachment-generic">
<svg class="chatlog__attachment-generic-icon">
<use href="#attachment-icon"/>
</svg>
<div class="chatlog__attachment-generic-name">
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
@attachment.FileName
</a>
</div>
<div class="chatlog__attachment-generic-size">
@attachment.FileSize
</div>
</div>
}
}
</div>
}
@* Forwarded stickers *@
@foreach (var sticker in message.ForwardedMessage.Stickers)
{
<div class="chatlog__sticker" title="@sticker.Name">
@if (sticker.IsImage)
{
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
}
else if (sticker.Format == StickerFormat.Lottie)
{
<div class="chatlog__sticker--media" data-source="@await ResolveAssetUrlAsync(sticker.SourceUrl)"></div>
}
</div>
}
@* Forwarded timestamp *@
<div class="chatlog__forwarded-timestamp">
<span title="@FormatDate(message.ForwardedMessage.Timestamp, "f")">Originally sent: @FormatDate(message.ForwardedMessage.Timestamp)</span>
@if (message.ForwardedMessage.EditedTimestamp is not null)
{
<span title="@FormatDate(message.ForwardedMessage.EditedTimestamp.Value, "f")"> (edited)</span>
}
</div>
</div>
}
@* Attachments *@ @* Attachments *@
@foreach (var attachment in message.Attachments) @foreach (var attachment in message.Attachments)
{ {
@@ -23,13 +23,11 @@ internal static class PlainTextMessageExtensions
: "Removed a recipient.", : "Removed a recipient.",
MessageKind.Call => MessageKind.Call =>
$"Started a call that lasted { $"Started a call that lasted {message
message
.CallEndedTimestamp? .CallEndedTimestamp?
.Pipe(t => t - message.Timestamp) .Pipe(t => t - message.Timestamp)
.Pipe(t => t.TotalMinutes) .Pipe(t => t.TotalMinutes)
.ToString("n0", CultureInfo.InvariantCulture) ?? "0" .ToString("n0", CultureInfo.InvariantCulture) ?? "0"} minutes.",
} minutes.",
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content) MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
? $"Changed the channel name: {message.Content}" ? $"Changed the channel name: {message.Content}"
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
@@ -173,7 +174,7 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
await _writer.WriteLineAsync("{Reactions}"); await _writer.WriteLineAsync("{Reactions}");
foreach (var (reaction, i) in reactions.WithIndex()) foreach (var (i, reaction) in reactions.Index())
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@@ -224,6 +225,31 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
await _writer.WriteLineAsync(); await _writer.WriteLineAsync();
} }
private async ValueTask WriteForwardedMessageAsync(
MessageSnapshot forwardedMessage,
CancellationToken cancellationToken = default
)
{
await _writer.WriteLineAsync("{Forwarded Message}");
if (!string.IsNullOrWhiteSpace(forwardedMessage.Content))
{
await _writer.WriteLineAsync(
await FormatMarkdownAsync(forwardedMessage.Content, cancellationToken)
);
}
await _writer.WriteLineAsync(
$"Originally sent: {Context.FormatDate(forwardedMessage.Timestamp)}"
);
await WriteAttachmentsAsync(forwardedMessage.Attachments, cancellationToken);
await WriteEmbedsAsync(forwardedMessage.Embeds, cancellationToken);
await WriteStickersAsync(forwardedMessage.Stickers, cancellationToken);
await _writer.WriteLineAsync();
}
public override async ValueTask WriteMessageAsync( public override async ValueTask WriteMessageAsync(
Message message, Message message,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
@@ -248,6 +274,12 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
await _writer.WriteLineAsync(); await _writer.WriteLineAsync();
// Forwarded message content
if (message.ForwardedMessage is not null)
{
await WriteForwardedMessageAsync(message.ForwardedMessage, cancellationToken);
}
// Attachments, embeds, reactions, etc. // Attachments, embeds, reactions, etc.
await WriteAttachmentsAsync(message.Attachments, cancellationToken); await WriteAttachmentsAsync(message.Attachments, cancellationToken);
await WriteEmbedsAsync(message.Embeds, cancellationToken); await WriteEmbedsAsync(message.Embeds, cancellationToken);
@@ -304,6 +304,52 @@
unicode-bidi: bidi-override; unicode-bidi: bidi-override;
} }
.chatlog__forwarded {
display: flex;
flex-direction: column;
margin-bottom: 0.15rem;
padding: 0.5rem;
border-left: 4px solid @Themed("#4f545c", "#c7ccd1");
border-radius: 4px;
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
}
.chatlog__forwarded-header {
display: flex;
align-items: center;
margin-bottom: 0.25rem;
color: @Themed("#b5b6b8", "#5f5f60");
font-size: 0.75rem;
font-weight: 600;
}
.chatlog__forwarded-icon {
width: 16px;
height: 16px;
margin-right: 0.25rem;
}
.chatlog__forwarded-content {
color: @Themed("#dcddde", "#2e3338");
font-size: 0.95rem;
}
.chatlog__forwarded-attachments {
margin-top: 0.3rem;
}
.chatlog__forwarded-attachment {
max-width: 300px;
max-height: 200px;
border-radius: 3px;
}
.chatlog__forwarded-timestamp {
margin-top: 0.25rem;
color: @Themed("#a3a6aa", "#5e6772");
font-size: 0.75rem;
}
.chatlog__system-notification-icon { .chatlog__system-notification-icon {
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -999,6 +1045,9 @@
<path fill="#b9bbbe" d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.40991 9H3.55274C3.47819 9 3.42168 8.93274 3.43453 8.85931L3.74253 7.09931C3.75258 7.04189 3.80244 7 3.86074 7H7.75991L8.45234 3.09903C8.46251 3.04174 8.51231 3 8.57049 3H10.3267C10.4014 3 10.4579 3.06746 10.4449 3.14097L9.75991 7H15.7599L16.4523 3.09903C16.4625 3.04174 16.5123 3 16.5705 3H18.3267C18.4014 3 18.4579 3.06746 18.4449 3.14097L17.7599 7H21.6171C21.6916 7 21.7481 7.06725 21.7353 7.14069L21.4273 8.90069C21.4172 8.95811 21.3674 9 21.3091 9H17.4099L17.0495 11.04H15.05L15.4104 9H9.41035L8.35035 15H10.5599V17H7.99991L7.30749 20.901C7.29732 20.9583 7.24752 21 7.18934 21H5.43309Z" /> <path fill="#b9bbbe" d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.40991 9H3.55274C3.47819 9 3.42168 8.93274 3.43453 8.85931L3.74253 7.09931C3.75258 7.04189 3.80244 7 3.86074 7H7.75991L8.45234 3.09903C8.46251 3.04174 8.51231 3 8.57049 3H10.3267C10.4014 3 10.4579 3.06746 10.4449 3.14097L9.75991 7H15.7599L16.4523 3.09903C16.4625 3.04174 16.5123 3 16.5705 3H18.3267C18.4014 3 18.4579 3.06746 18.4449 3.14097L17.7599 7H21.6171C21.6916 7 21.7481 7.06725 21.7353 7.14069L21.4273 8.90069C21.4172 8.95811 21.3674 9 21.3091 9H17.4099L17.0495 11.04H15.05L15.4104 9H9.41035L8.35035 15H10.5599V17H7.99991L7.30749 20.901C7.29732 20.9583 7.24752 21 7.18934 21H5.43309Z" />
<path fill="#b9bbbe" d="M13.4399 12.96C12.9097 12.96 12.4799 13.3898 12.4799 13.92V20.2213C12.4799 20.7515 12.9097 21.1813 13.4399 21.1813H14.3999C14.5325 21.1813 14.6399 21.2887 14.6399 21.4213V23.4597C14.6399 23.6677 14.8865 23.7773 15.0408 23.6378L17.4858 21.4289C17.6622 21.2695 17.8916 21.1813 18.1294 21.1813H22.5599C23.0901 21.1813 23.5199 20.7515 23.5199 20.2213V13.92C23.5199 13.3898 23.0901 12.96 22.5599 12.96H13.4399Z" /> <path fill="#b9bbbe" d="M13.4399 12.96C12.9097 12.96 12.4799 13.3898 12.4799 13.92V20.2213C12.4799 20.7515 12.9097 21.1813 13.4399 21.1813H14.3999C14.5325 21.1813 14.6399 21.2887 14.6399 21.4213V23.4597C14.6399 23.6677 14.8865 23.7773 15.0408 23.6378L17.4858 21.4289C17.6622 21.2695 17.8916 21.1813 18.1294 21.1813H22.5599C23.0901 21.1813 23.5199 20.7515 23.5199 20.2213V13.92C23.5199 13.3898 23.0901 12.96 22.5599 12.96H13.4399Z" />
</symbol> </symbol>
<symbol id="forward-icon" viewBox="0 0 24 24">
<path fill="#b9bbbe" d="M13 4L21 12L13 20V15C8 15 4 17 1 22C2 16 6 10 13 9V4Z" />
</symbol>
</defs> </defs>
</svg> </svg>
</head> </head>
@@ -14,7 +14,7 @@ internal static class MatcherExtensions
this IMatcher<TContext, TValue> matcher, this IMatcher<TContext, TValue> matcher,
TContext context, TContext context,
StringSegment segment, StringSegment segment,
Func<TContext, StringSegment, TValue> transformFallback Func<TContext, StringSegment, TValue> fallbackTransform
) )
{ {
// Loop through segments divided by individual matches // Loop through segments divided by individual matches
@@ -40,7 +40,7 @@ internal static class MatcherExtensions
yield return new ParsedMatch<TValue>( yield return new ParsedMatch<TValue>(
fallbackSegment, fallbackSegment,
transformFallback(context, fallbackSegment) fallbackTransform(context, fallbackSegment)
); );
} }
@@ -57,7 +57,7 @@ internal static class MatcherExtensions
yield return new ParsedMatch<TValue>( yield return new ParsedMatch<TValue>(
fallbackSegment, fallbackSegment,
transformFallback(context, fallbackSegment) fallbackTransform(context, fallbackSegment)
); );
} }
} }
@@ -208,53 +208,18 @@ internal static partial class MarkdownParser
private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex( new Regex(
@"(" // Build a pattern from all known emoji, sorted longest-first so that compound
+ // emoji (e.g. sequences with ZWJ or skin-tone modifiers) are matched before
// Country flag emoji (two regional indicator surrogate pairs) // their individual components.
@"(?:\uD83C[\uDDE6-\uDDFF]){2}|" "("
+ + string.Join(
// Digit emoji (digit followed by enclosing mark) "|",
@"\d\p{Me}|" EmojiIndex
+ .GetAllNames()
// Surrogate pair .OrderByDescending(e => e.Length)
@"\p{Cs}{2}|" .Select(Regex.Escape)
+ )
// Miscellaneous characters + ")",
@"["
+ @"\u2600-\u2604"
+ @"\u260E\u2611"
+ @"\u2614-\u2615"
+ @"\u2618\u261D\u2620"
+ @"\u2622-\u2623"
+ @"\u2626\u262A"
+ @"\u262E-\u262F"
+ @"\u2638-\u263A"
+ @"\u2640\u2642"
+ @"\u2648-\u2653"
+ @"\u265F-\u2660"
+ @"\u2663"
+ @"\u2665-\u2666"
+ @"\u2668\u267B"
+ @"\u267E-\u267F"
+ @"\u2692-\u2697"
+ @"\u2699"
+ @"\u269B-\u269C"
+ @"\u26A0-\u26A1"
+ @"\u26A7"
+ @"\u26AA-\u26AB"
+ @"\u26B0-\u26B1"
+ @"\u26BD-\u26BE"
+ @"\u26C4-\u26C5"
+ @"\u26C8"
+ @"\u26CE-\u26CF"
+ @"\u26D1"
+ @"\u26D3-\u26D4"
+ @"\u26E9-\u26EA"
+ @"\u26F0-\u26F5"
+ @"\u26F7-\u26FA"
+ @"\u26FD"
+ @"]"
+ @")",
DefaultRegexOptions DefaultRegexOptions
), ),
(_, _, m) => new EmojiNode(m.Groups[1].Value) (_, _, m) => new EmojiNode(m.Groups[1].Value)
@@ -310,7 +275,7 @@ internal static partial class MarkdownParser
// Capture the shrug kaomoji. // Capture the shrug kaomoji.
// This escapes it from matching for formatting. // This escapes it from matching for formatting.
@"¯\_(ツ)_/¯", @"¯\_(ツ)_/¯",
(s, _) => new TextNode(s.ToString()) (_, s) => new TextNode(s.ToString())
); );
private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
@@ -14,5 +14,5 @@ internal readonly record struct StringSegment(string Source, int StartIndex, int
public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length); public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length);
public override string ToString() => Source.Substring(StartIndex, Length); public override string ToString() => Source[StartIndex..EndIndex];
} }
@@ -12,16 +12,6 @@ public static class CollectionExtensions
} }
} }
extension<T>(IEnumerable<T> source)
{
public IEnumerable<(T value, int index)> WithIndex()
{
var i = 0;
foreach (var o in source)
yield return (o, i++);
}
}
extension<T>(IEnumerable<T?> source) extension<T>(IEnumerable<T?> source)
where T : class where T : class
{ {
@@ -34,4 +24,17 @@ public static class CollectionExtensions
} }
} }
} }
extension<T>(IEnumerable<T?> source)
where T : struct
{
public IEnumerable<T> WhereNotNull()
{
foreach (var o in source)
{
if (o is not null)
yield return o.Value;
}
}
}
} }
+5
View File
@@ -116,6 +116,11 @@
</Style> </Style>
</Style> </Style>
<!-- Run -->
<Style Selector="Run">
<Setter Property="BaselineAlignment" Value="Center" />
</Style>
<!-- Text box --> <!-- Text box -->
<Style Selector="TextBox"> <Style Selector="TextBox">
<Setter Property="FontSize" Value="14" /> <Setter Property="FontSize" Value="14" />
@@ -4,6 +4,8 @@ using System.Linq;
using Avalonia.Controls.Documents; using Avalonia.Controls.Documents;
using Avalonia.Data.Converters; using Avalonia.Data.Converters;
using Avalonia.Media; using Avalonia.Media;
using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.Views.Controls;
using Markdig; using Markdig;
using Markdig.Syntax; using Markdig.Syntax;
using Markdig.Syntax.Inlines; using Markdig.Syntax.Inlines;
@@ -78,6 +80,12 @@ public class MarkdownToInlinesConverter : IValueConverter
break; break;
} }
case LinkInline link:
{
inlines.Add(new HyperLink { Text = link.GetInnerText(), Url = link.Url });
break;
}
case ContainerInline container: case ContainerInline container:
{ {
foreach (var child in container) foreach (var child in container)
@@ -96,55 +104,55 @@ public class MarkdownToInlinesConverter : IValueConverter
var isFirst = true; var isFirst = true;
void RenderParagraph(ParagraphBlock paragraph)
{
if (!isFirst)
{
// Insert a blank line between paragraphs
inlines.Add(new LineBreak());
inlines.Add(new LineBreak());
}
isFirst = false;
foreach (var markdownInline in paragraph.Inline!)
ProcessInline(inlines, markdownInline);
}
void RenderListBlock(ListBlock list)
{
var itemOrder = 1;
if (list.IsOrdered && int.TryParse(list.OrderedStart, out var startNum))
itemOrder = startNum;
foreach (var listItem in list.OfType<ListItemBlock>())
{
if (!isFirst)
inlines.Add(new LineBreak());
isFirst = false;
var prefix = list.IsOrdered ? $"{itemOrder++}. " : $"{list.BulletType} ";
inlines.Add(new Run(prefix));
foreach (var subBlock in listItem.OfType<ParagraphBlock>())
{
if (subBlock is { Inline: not null } p)
foreach (var markdownInline in p.Inline)
ProcessInline(inlines, markdownInline);
}
}
}
foreach (var block in Markdown.Parse(text, MarkdownPipeline)) foreach (var block in Markdown.Parse(text, MarkdownPipeline))
{ {
switch (block) switch (block)
{ {
case ParagraphBlock { Inline: not null } paragraph: case ParagraphBlock { Inline: not null } paragraph:
RenderParagraph(paragraph); {
if (!isFirst)
{
// Insert a blank line between paragraphs
inlines.Add(new LineBreak());
inlines.Add(new LineBreak());
}
isFirst = false;
foreach (var markdownInline in paragraph.Inline!)
ProcessInline(inlines, markdownInline);
break; break;
}
case ListBlock list: case ListBlock list:
RenderListBlock(list); {
var itemOrder = 1;
if (list.IsOrdered && int.TryParse(list.OrderedStart, out var startNum))
itemOrder = startNum;
foreach (var listItem in list.OfType<ListItemBlock>())
{
if (!isFirst)
inlines.Add(new LineBreak());
isFirst = false;
var prefix = list.IsOrdered ? $"{itemOrder++}. " : $"{list.BulletType} ";
inlines.Add(new Run(prefix));
foreach (var subBlock in listItem.OfType<ParagraphBlock>())
{
if (subBlock is { Inline: not null })
{
foreach (var markdownInline in subBlock.Inline)
ProcessInline(inlines, markdownInline);
}
}
}
break; break;
}
} }
} }
@@ -8,6 +8,15 @@
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>
<PropertyGroup>
<EncryptionSalt>HimalayanPinkSalt</EncryptionSalt>
</PropertyGroup>
<ItemGroup>
<!-- Expose this property in code -->
<ProjectProperty Include="EncryptionSalt" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<PublishMacOSBundle>false</PublishMacOSBundle> <PublishMacOSBundle>false</PublishMacOSBundle>
</PropertyGroup> </PropertyGroup>
@@ -23,25 +32,22 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.5.0" /> <PackageReference Include="AsyncImageLoader.Avalonia" />
<PackageReference Include="Avalonia" Version="11.3.11" /> <PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.11" /> <PackageReference Include="Avalonia.Desktop" />
<PackageReference <PackageReference Include="Avalonia.Diagnostics" Condition="'$(Configuration)' == 'Debug'" />
Include="Avalonia.Diagnostics" <PackageReference Include="Cogwheel" />
Version="11.3.11" <PackageReference Include="CommunityToolkit.Mvvm" />
Condition="'$(Configuration)' == 'Debug'" <PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
/> <PackageReference Include="Deorcify" PrivateAssets="all" />
<PackageReference Include="Cogwheel" Version="2.1.0" /> <PackageReference Include="DialogHost.Avalonia" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" /> <PackageReference Include="Gress" />
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" /> <PackageReference Include="Markdig" />
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" /> <PackageReference Include="Material.Avalonia" />
<PackageReference Include="DialogHost.Avalonia" Version="0.10.4" /> <PackageReference Include="Material.Icons.Avalonia" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Markdig" Version="1.0.0" /> <PackageReference Include="Onova" />
<PackageReference Include="Material.Avalonia" Version="3.9.2" /> <PackageReference Include="ThisAssembly.Project" PrivateAssets="all" />
<PackageReference Include="Material.Icons.Avalonia" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageReference Include="Onova" Version="2.6.13" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -2,8 +2,8 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AsyncKeyedLock;
using Avalonia; using Avalonia;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using DialogHostAvalonia; using DialogHostAvalonia;
@@ -13,11 +13,12 @@ namespace DiscordChatExporter.Gui.Framework;
public class DialogManager : IDisposable public class DialogManager : IDisposable
{ {
private readonly AsyncNonKeyedLocker _dialogLock = new(); private readonly SemaphoreSlim _dialogLock = new(1, 1);
public async Task<T?> ShowDialogAsync<T>(DialogViewModelBase<T> dialog) public async Task<T?> ShowDialogAsync<T>(DialogViewModelBase<T> dialog)
{ {
using (await _dialogLock.LockAsync()) await _dialogLock.WaitAsync();
try
{ {
await DialogHost.Show( await DialogHost.Show(
dialog, dialog,
@@ -39,11 +40,15 @@ public class DialogManager : IDisposable
); );
// Yield to allow DialogHost to fully reset its state before // Yield to allow DialogHost to fully reset its state before
// another dialog is shown (e.g. when dialogs are shown sequentially) // another dialog is shown (e.g. when dialogs are shown sequentially).
await Task.Yield(); await Task.Yield();
return dialog.DialogResult; return dialog.DialogResult;
} }
finally
{
_dialogLock.Release();
}
} }
public async Task<string?> PromptSaveFilePathAsync( public async Task<string?> PromptSaveFilePathAsync(
@@ -30,10 +30,10 @@ public partial class LocalizationManager
""", """,
// Token instructions (bot) // Token instructions (bot)
[nameof(TokenBotHeader)] = "To get the token for your bot:", [nameof(TokenBotHeader)] = "To get the token for your bot:",
[nameof(TokenBotIntro)] =
"The token is generated during bot creation. If you lost it, generate a new one:",
[nameof(TokenBotStep1)] = "1. Open Discord",
[nameof(TokenBotInstructions)] = """ [nameof(TokenBotInstructions)] = """
The token is generated during bot creation. If you lost it, generate a new one:
1. Open Discord [developer portal](https://discord.com/developers/applications)
2. Open your application's settings 2. Open your application's settings
3. Navigate to the **Bot** section on the left 3. Navigate to the **Bot** section on the left
4. Under **Token** click **Reset Token** 4. Under **Token** click **Reset Token**
@@ -41,9 +41,8 @@ public partial class LocalizationManager
* Integrations using the previous token will stop working until updated * Integrations using the previous token will stop working until updated
* Your bot needs to have the **Message Content Intent** enabled to read messages * Your bot needs to have the **Message Content Intent** enabled to read messages
""", """,
[nameof(TokenDeveloperPortalLinkText)] = "developer portal", [nameof(TokenHelpText)] =
[nameof(TokenDocumentationLinkText)] = "documentation", "If you have questions or issues, please refer to the [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
[nameof(TokenHelpText)] = "If you have questions or issues, please refer to the",
// Settings // Settings
[nameof(SettingsTitle)] = "Settings", [nameof(SettingsTitle)] = "Settings",
[nameof(ThemeLabel)] = "Theme", [nameof(ThemeLabel)] = "Theme",
@@ -53,8 +52,10 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Auto-update", [nameof(AutoUpdateLabel)] = "Auto-update",
[nameof(AutoUpdateTooltip)] = "Perform automatic updates on every launch", [nameof(AutoUpdateTooltip)] = "Perform automatic updates on every launch",
[nameof(PersistTokenLabel)] = "Persist token", [nameof(PersistTokenLabel)] = "Persist token",
[nameof(PersistTokenTooltip)] = [nameof(PersistTokenTooltip)] = """
"Save the last used token to a file so that it can be persisted between sessions", Save the last used token to a file so that it can be persisted between sessions.
**Warning**: although the token is stored with encryption, it may still be recovered by an attacker who has access to your system.
""",
[nameof(RateLimitPreferenceLabel)] = "Rate limit preference", [nameof(RateLimitPreferenceLabel)] = "Rate limit preference",
[nameof(RateLimitPreferenceTooltip)] = [nameof(RateLimitPreferenceTooltip)] =
"Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.", "Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.",
@@ -69,6 +70,26 @@ public partial class LocalizationManager
// Export Setup // Export Setup
[nameof(ChannelsSelectedText)] = "channels selected", [nameof(ChannelsSelectedText)] = "channels selected",
[nameof(OutputPathLabel)] = "Output path", [nameof(OutputPathLabel)] = "Output path",
[nameof(OutputPathTooltip)] = """
Output file or directory path.
If a directory is specified, file names will be generated automatically based on the channel names and export parameters.
Directory paths must end with a slash to avoid ambiguity.
Available template tokens:
**%g** server ID
**%G** server name
**%t** category ID
**%T** category name
**%c** channel ID
**%C** channel name
**%p** channel position
**%P** category position
**%a** after date
**%b** before date
**%d** current date
""",
[nameof(FormatLabel)] = "Format", [nameof(FormatLabel)] = "Format",
[nameof(FormatTooltip)] = "Export format", [nameof(FormatTooltip)] = "Export format",
[nameof(AfterDateLabel)] = "After (date)", [nameof(AfterDateLabel)] = "After (date)",
@@ -85,6 +106,9 @@ public partial class LocalizationManager
[nameof(MessageFilterLabel)] = "Message filter", [nameof(MessageFilterLabel)] = "Message filter",
[nameof(MessageFilterTooltip)] = [nameof(MessageFilterTooltip)] =
"Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image'). See the documentation for more info.", "Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image'). See the documentation for more info.",
[nameof(ReverseMessageOrderLabel)] = "Reverse messages",
[nameof(ReverseMessageOrderTooltip)] =
"Export messages in reverse chronological order (newest first)",
[nameof(FormatMarkdownLabel)] = "Format markdown", [nameof(FormatMarkdownLabel)] = "Format markdown",
[nameof(FormatMarkdownTooltip)] = [nameof(FormatMarkdownTooltip)] =
"Process markdown, mentions, and other special tokens", "Process markdown, mentions, and other special tokens",
@@ -124,7 +148,7 @@ public partial class LocalizationManager
"Update has been downloaded and will be installed when you exit", "Update has been downloaded and will be installed when you exit",
[nameof(UpdateInstallNowButton)] = "INSTALL NOW", [nameof(UpdateInstallNowButton)] = "INSTALL NOW",
[nameof(UpdateFailedMessage)] = "Failed to perform application update", [nameof(UpdateFailedMessage)] = "Failed to perform application update",
[nameof(ErrorPullingServersTitle)] = "Error pulling servers", [nameof(ErrorPullingGuildsTitle)] = "Error pulling servers",
[nameof(ErrorPullingChannelsTitle)] = "Error pulling channels", [nameof(ErrorPullingChannelsTitle)] = "Error pulling channels",
[nameof(ErrorExportingTitle)] = "Error exporting channel(s)", [nameof(ErrorExportingTitle)] = "Error exporting channel(s)",
[nameof(SuccessfulExportMessage)] = "Successfully exported {0} channel(s)", [nameof(SuccessfulExportMessage)] = "Successfully exported {0} channel(s)",
@@ -32,10 +32,10 @@ public partial class LocalizationManager
""", """,
// Token instructions (bot) // Token instructions (bot)
[nameof(TokenBotHeader)] = "Obtenir le token pour votre bot :", [nameof(TokenBotHeader)] = "Obtenir le token pour votre bot :",
[nameof(TokenBotIntro)] =
"Le token est généré lors de la création du bot. Si vous l'avez perdu, générez-en un nouveau :",
[nameof(TokenBotStep1)] = "1. Ouvrez Discord",
[nameof(TokenBotInstructions)] = """ [nameof(TokenBotInstructions)] = """
Le token est généré lors de la création du bot. Si vous l'avez perdu, générez-en un nouveau :
1. Ouvrez Discord [portail développeur](https://discord.com/developers/applications)
2. Ouvrez les paramètres de votre application 2. Ouvrez les paramètres de votre application
3. Naviguez vers la section **Bot** à gauche 3. Naviguez vers la section **Bot** à gauche
4. Sous **Token**, cliquez sur **Reset Token** 4. Sous **Token**, cliquez sur **Reset Token**
@@ -43,9 +43,8 @@ public partial class LocalizationManager
* Les intégrations utilisant l'ancien token cesseront de fonctionner jusqu'à leur mise à jour * Les intégrations utilisant l'ancien token cesseront de fonctionner jusqu'à leur mise à jour
* Votre bot doit avoir l'option **Message Content Intent** activée pour lire les messages * Votre bot doit avoir l'option **Message Content Intent** activée pour lire les messages
""", """,
[nameof(TokenDeveloperPortalLinkText)] = "portail développeur", [nameof(TokenHelpText)] =
[nameof(TokenDocumentationLinkText)] = "documentation", "Pour les questions ou problèmes, veuillez consulter la [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
[nameof(TokenHelpText)] = "Pour les questions ou problèmes, veuillez consulter la",
// Settings // Settings
[nameof(SettingsTitle)] = "Paramètres", [nameof(SettingsTitle)] = "Paramètres",
[nameof(ThemeLabel)] = "Thème", [nameof(ThemeLabel)] = "Thème",
@@ -55,8 +54,10 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Mise à jour automatique", [nameof(AutoUpdateLabel)] = "Mise à jour automatique",
[nameof(AutoUpdateTooltip)] = "Effectuer des mises à jour automatiques à chaque lancement", [nameof(AutoUpdateTooltip)] = "Effectuer des mises à jour automatiques à chaque lancement",
[nameof(PersistTokenLabel)] = "Conserver le token", [nameof(PersistTokenLabel)] = "Conserver le token",
[nameof(PersistTokenTooltip)] = [nameof(PersistTokenTooltip)] = """
"Enregistrer le dernier token utilisé dans un fichier pour le conserver entre les sessions", Enregistrer le dernier token utilisé dans un fichier pour le conserver entre les sessions.
**Avertissement** : bien que le token soit stocké avec chiffrement, il peut toujours être récupéré par un attaquant ayant accès à votre système.
""",
[nameof(RateLimitPreferenceLabel)] = "Préférence de limite de débit", [nameof(RateLimitPreferenceLabel)] = "Préférence de limite de débit",
[nameof(RateLimitPreferenceTooltip)] = [nameof(RateLimitPreferenceTooltip)] =
"Indique s'il faut respecter les limites de débit recommandées. Si désactivé, seules les limites strictes (réponses 429) seront respectées.", "Indique s'il faut respecter les limites de débit recommandées. Si désactivé, seules les limites strictes (réponses 429) seront respectées.",
@@ -71,6 +72,26 @@ public partial class LocalizationManager
// Export Setup // Export Setup
[nameof(ChannelsSelectedText)] = "canaux sélectionnés", [nameof(ChannelsSelectedText)] = "canaux sélectionnés",
[nameof(OutputPathLabel)] = "Chemin de sortie", [nameof(OutputPathLabel)] = "Chemin de sortie",
[nameof(OutputPathTooltip)] = """
Chemin du fichier ou répertoire de sortie.
Si un répertoire est spécifié, les noms de fichiers seront générés automatiquement en fonction des noms de canaux et des paramètres d'exportation.
Les chemins de répertoire doivent se terminer par un slash pour éviter toute ambiguïté.
Jetons de modèle disponibles :
**%g** ID du serveur
**%G** nom du serveur
**%t** ID de la catégorie
**%T** nom de la catégorie
**%c** ID du canal
**%C** nom du canal
**%p** position du canal
**%P** position de la catégorie
**%a** date après
**%b** date avant
**%d** date actuelle
""",
[nameof(FormatLabel)] = "Format", [nameof(FormatLabel)] = "Format",
[nameof(FormatTooltip)] = "Format d'exportation", [nameof(FormatTooltip)] = "Format d'exportation",
[nameof(AfterDateLabel)] = "Après (date)", [nameof(AfterDateLabel)] = "Après (date)",
@@ -87,6 +108,9 @@ public partial class LocalizationManager
[nameof(MessageFilterLabel)] = "Filtre de messages", [nameof(MessageFilterLabel)] = "Filtre de messages",
[nameof(MessageFilterTooltip)] = [nameof(MessageFilterTooltip)] =
"Inclure uniquement les messages satisfaisant ce filtre (ex. 'from:foo#1234' ou 'has:image'). Voir la documentation pour plus d'informations.", "Inclure uniquement les messages satisfaisant ce filtre (ex. 'from:foo#1234' ou 'has:image'). Voir la documentation pour plus d'informations.",
[nameof(ReverseMessageOrderLabel)] = "Inverser l'ordre des messages",
[nameof(ReverseMessageOrderTooltip)] =
"Exporter les messages en ordre chronologique inversé (les plus récents en premier)",
[nameof(FormatMarkdownLabel)] = "Formater le markdown", [nameof(FormatMarkdownLabel)] = "Formater le markdown",
[nameof(FormatMarkdownTooltip)] = [nameof(FormatMarkdownTooltip)] =
"Traiter le markdown, les mentions et autres tokens spéciaux", "Traiter le markdown, les mentions et autres tokens spéciaux",
@@ -126,7 +150,7 @@ public partial class LocalizationManager
"La mise à jour a été téléchargée et sera installée à la fermeture", "La mise à jour a été téléchargée et sera installée à la fermeture",
[nameof(UpdateInstallNowButton)] = "INSTALLER MAINTENANT", [nameof(UpdateInstallNowButton)] = "INSTALLER MAINTENANT",
[nameof(UpdateFailedMessage)] = "Échec de la mise à jour de l'application", [nameof(UpdateFailedMessage)] = "Échec de la mise à jour de l'application",
[nameof(ErrorPullingServersTitle)] = "Erreur lors du chargement des serveurs", [nameof(ErrorPullingGuildsTitle)] = "Erreur lors du chargement des serveurs",
[nameof(ErrorPullingChannelsTitle)] = "Erreur lors du chargement des canaux", [nameof(ErrorPullingChannelsTitle)] = "Erreur lors du chargement des canaux",
[nameof(ErrorExportingTitle)] = "Erreur lors de l'exportation des canaux", [nameof(ErrorExportingTitle)] = "Erreur lors de l'exportation des canaux",
[nameof(SuccessfulExportMessage)] = "{0} canal(-aux) exporté(s) avec succès", [nameof(SuccessfulExportMessage)] = "{0} canal(-aux) exporté(s) avec succès",
@@ -32,10 +32,10 @@ public partial class LocalizationManager
""", """,
// Token instructions (bot) // Token instructions (bot)
[nameof(TokenBotHeader)] = "Token für Ihren Bot abrufen:", [nameof(TokenBotHeader)] = "Token für Ihren Bot abrufen:",
[nameof(TokenBotIntro)] =
"Der Token wird bei der Bot-Erstellung generiert. Falls er verloren gegangen ist, generieren Sie einen neuen:",
[nameof(TokenBotStep1)] = "1. Öffnen Sie Discord",
[nameof(TokenBotInstructions)] = """ [nameof(TokenBotInstructions)] = """
Der Token wird bei der Bot-Erstellung generiert. Falls er verloren gegangen ist, generieren Sie einen neuen:
1. Öffnen Sie Discord [Entwicklerportal](https://discord.com/developers/applications)
2. Öffnen Sie die Einstellungen Ihrer Anwendung 2. Öffnen Sie die Einstellungen Ihrer Anwendung
3. Navigieren Sie zum Abschnitt **Bot** auf der linken Seite 3. Navigieren Sie zum Abschnitt **Bot** auf der linken Seite
4. Klicken Sie unter **Token** auf **Reset Token** 4. Klicken Sie unter **Token** auf **Reset Token**
@@ -43,9 +43,8 @@ public partial class LocalizationManager
* Integrationen, die den alten Token verwenden, hören auf zu funktionieren, bis sie aktualisiert werden * Integrationen, die den alten Token verwenden, hören auf zu funktionieren, bis sie aktualisiert werden
* Ihr Bot benötigt die aktivierte **Message Content Intent**, um Nachrichten zu lesen * Ihr Bot benötigt die aktivierte **Message Content Intent**, um Nachrichten zu lesen
""", """,
[nameof(TokenDeveloperPortalLinkText)] = "Entwicklerportal", [nameof(TokenHelpText)] =
[nameof(TokenDocumentationLinkText)] = "Dokumentation", "Bei Fragen oder Problemen lesen Sie die [Dokumentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
[nameof(TokenHelpText)] = "Bei Fragen oder Problemen lesen Sie die",
// Settings // Settings
[nameof(SettingsTitle)] = "Einstellungen", [nameof(SettingsTitle)] = "Einstellungen",
[nameof(ThemeLabel)] = "Design", [nameof(ThemeLabel)] = "Design",
@@ -55,8 +54,10 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Automatische Updates", [nameof(AutoUpdateLabel)] = "Automatische Updates",
[nameof(AutoUpdateTooltip)] = "Automatische Updates bei jedem Start durchführen", [nameof(AutoUpdateTooltip)] = "Automatische Updates bei jedem Start durchführen",
[nameof(PersistTokenLabel)] = "Token speichern", [nameof(PersistTokenLabel)] = "Token speichern",
[nameof(PersistTokenTooltip)] = [nameof(PersistTokenTooltip)] = """
"Den zuletzt verwendeten Token in einer Datei speichern, damit er zwischen Sitzungen erhalten bleibt", Den zuletzt verwendeten Token in einer Datei speichern, damit er zwischen Sitzungen erhalten bleibt.
**Warnung**: Der Token wird mit Verschlüsselung gespeichert, kann aber dennoch von einem Angreifer mit Zugriff auf Ihr System wiederhergestellt werden.
""",
[nameof(RateLimitPreferenceLabel)] = "Ratenlimit-Einstellung", [nameof(RateLimitPreferenceLabel)] = "Ratenlimit-Einstellung",
[nameof(RateLimitPreferenceTooltip)] = [nameof(RateLimitPreferenceTooltip)] =
"Ob empfohlene Ratenlimits eingehalten werden sollen. Wenn deaktiviert, werden nur harte Ratenlimits (d. h. 429-Antworten) eingehalten.", "Ob empfohlene Ratenlimits eingehalten werden sollen. Wenn deaktiviert, werden nur harte Ratenlimits (d. h. 429-Antworten) eingehalten.",
@@ -71,6 +72,26 @@ public partial class LocalizationManager
// Export Setup // Export Setup
[nameof(ChannelsSelectedText)] = "Kanäle ausgewählt", [nameof(ChannelsSelectedText)] = "Kanäle ausgewählt",
[nameof(OutputPathLabel)] = "Ausgabepfad", [nameof(OutputPathLabel)] = "Ausgabepfad",
[nameof(OutputPathTooltip)] = """
Ausgabedatei- oder Verzeichnispfad.
Wenn ein Verzeichnis angegeben wird, werden Dateinamen automatisch basierend auf den Kanalnamen und Exportparametern generiert.
Verzeichnispfade müssen mit einem Schrägstrich enden, um Mehrdeutigkeiten zu vermeiden.
Verfügbare Vorlagen-Token:
**%g** Server-ID
**%G** Servername
**%t** Kategorie-ID
**%T** Kategoriename
**%c** Kanal-ID
**%C** Kanalname
**%p** Kanalposition
**%P** Kategorieposition
**%a** Datum ab
**%b** Datum bis
**%d** aktuelles Datum
""",
[nameof(FormatLabel)] = "Format", [nameof(FormatLabel)] = "Format",
[nameof(FormatTooltip)] = "Exportformat", [nameof(FormatTooltip)] = "Exportformat",
[nameof(AfterDateLabel)] = "Nach (Datum)", [nameof(AfterDateLabel)] = "Nach (Datum)",
@@ -91,6 +112,9 @@ public partial class LocalizationManager
[nameof(MessageFilterLabel)] = "Nachrichtenfilter", [nameof(MessageFilterLabel)] = "Nachrichtenfilter",
[nameof(MessageFilterTooltip)] = [nameof(MessageFilterTooltip)] =
"Nur Nachrichten einschließen, die diesem Filter entsprechen (z. B. 'from:foo#1234' oder 'has:image'). Weitere Informationen finden Sie in der Dokumentation.", "Nur Nachrichten einschließen, die diesem Filter entsprechen (z. B. 'from:foo#1234' oder 'has:image'). Weitere Informationen finden Sie in der Dokumentation.",
[nameof(ReverseMessageOrderLabel)] = "Nachrichtenreihenfolge umkehren",
[nameof(ReverseMessageOrderTooltip)] =
"Nachrichten in umgekehrter chronologischer Reihenfolge exportieren (neueste zuerst)",
[nameof(FormatMarkdownLabel)] = "Markdown formatieren", [nameof(FormatMarkdownLabel)] = "Markdown formatieren",
[nameof(FormatMarkdownTooltip)] = [nameof(FormatMarkdownTooltip)] =
"Markdown, Erwähnungen und andere spezielle Token verarbeiten", "Markdown, Erwähnungen und andere spezielle Token verarbeiten",
@@ -130,7 +154,7 @@ public partial class LocalizationManager
"Update wurde heruntergeladen und wird beim Beenden installiert", "Update wurde heruntergeladen und wird beim Beenden installiert",
[nameof(UpdateInstallNowButton)] = "JETZT INSTALLIEREN", [nameof(UpdateInstallNowButton)] = "JETZT INSTALLIEREN",
[nameof(UpdateFailedMessage)] = "Anwendungsupdate konnte nicht durchgeführt werden", [nameof(UpdateFailedMessage)] = "Anwendungsupdate konnte nicht durchgeführt werden",
[nameof(ErrorPullingServersTitle)] = "Fehler beim Laden der Server", [nameof(ErrorPullingGuildsTitle)] = "Fehler beim Laden der Server",
[nameof(ErrorPullingChannelsTitle)] = "Fehler beim Laden der Kanäle", [nameof(ErrorPullingChannelsTitle)] = "Fehler beim Laden der Kanäle",
[nameof(ErrorExportingTitle)] = "Fehler beim Exportieren der Kanäle", [nameof(ErrorExportingTitle)] = "Fehler beim Exportieren der Kanäle",
[nameof(SuccessfulExportMessage)] = "{0} Kanal/-äle erfolgreich exportiert", [nameof(SuccessfulExportMessage)] = "{0} Kanal/-äle erfolgreich exportiert",
@@ -30,10 +30,10 @@ public partial class LocalizationManager
""", """,
// Token instructions (bot) // Token instructions (bot)
[nameof(TokenBotHeader)] = "Cómo obtener el token para tu bot:", [nameof(TokenBotHeader)] = "Cómo obtener el token para tu bot:",
[nameof(TokenBotIntro)] =
"El token se genera al crear el bot. Si lo perdiste, genera uno nuevo:",
[nameof(TokenBotStep1)] = "1. Abre Discord",
[nameof(TokenBotInstructions)] = """ [nameof(TokenBotInstructions)] = """
El token se genera al crear el bot. Si lo perdiste, genera uno nuevo:
1. Abre Discord [portal de desarrolladores](https://discord.com/developers/applications)
2. Abre la configuración de tu aplicación 2. Abre la configuración de tu aplicación
3. Navega a la sección **Bot** en el lado izquierdo 3. Navega a la sección **Bot** en el lado izquierdo
4. En **Token**, haz clic en **Reset Token** 4. En **Token**, haz clic en **Reset Token**
@@ -41,9 +41,8 @@ public partial class LocalizationManager
* Las integraciones que usen el token anterior dejarán de funcionar hasta que se actualicen * Las integraciones que usen el token anterior dejarán de funcionar hasta que se actualicen
* Tu bot necesita tener habilitado **Message Content Intent** para leer mensajes * Tu bot necesita tener habilitado **Message Content Intent** para leer mensajes
""", """,
[nameof(TokenDeveloperPortalLinkText)] = "portal de desarrolladores", [nameof(TokenHelpText)] =
[nameof(TokenDocumentationLinkText)] = "documentación", "Si tienes preguntas o problemas, consulta la [documentación](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
[nameof(TokenHelpText)] = "Si tienes preguntas o problemas, consulta la",
// Settings // Settings
[nameof(SettingsTitle)] = "Ajustes", [nameof(SettingsTitle)] = "Ajustes",
[nameof(ThemeLabel)] = "Tema", [nameof(ThemeLabel)] = "Tema",
@@ -53,8 +52,10 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Actualización automática", [nameof(AutoUpdateLabel)] = "Actualización automática",
[nameof(AutoUpdateTooltip)] = "Realizar actualizaciones automáticas en cada inicio", [nameof(AutoUpdateTooltip)] = "Realizar actualizaciones automáticas en cada inicio",
[nameof(PersistTokenLabel)] = "Guardar token", [nameof(PersistTokenLabel)] = "Guardar token",
[nameof(PersistTokenTooltip)] = [nameof(PersistTokenTooltip)] = """
"Guardar el último token utilizado en un archivo para conservarlo entre sesiones", Guardar el último token utilizado en un archivo para conservarlo entre sesiones.
**Advertencia**: aunque el token se almacena con cifrado, aún puede ser recuperado por un atacante con acceso a tu sistema.
""",
[nameof(RateLimitPreferenceLabel)] = "Preferencia de límite de velocidad", [nameof(RateLimitPreferenceLabel)] = "Preferencia de límite de velocidad",
[nameof(RateLimitPreferenceTooltip)] = [nameof(RateLimitPreferenceTooltip)] =
"Si se deben respetar los límites de velocidad recomendados. Si está desactivado, solo se respetarán los límites estrictos (respuestas 429).", "Si se deben respetar los límites de velocidad recomendados. Si está desactivado, solo se respetarán los límites estrictos (respuestas 429).",
@@ -69,6 +70,26 @@ public partial class LocalizationManager
// Export Setup // Export Setup
[nameof(ChannelsSelectedText)] = "canales seleccionados", [nameof(ChannelsSelectedText)] = "canales seleccionados",
[nameof(OutputPathLabel)] = "Ruta de salida", [nameof(OutputPathLabel)] = "Ruta de salida",
[nameof(OutputPathTooltip)] = """
Ruta del archivo o directorio de salida.
Si se especifica un directorio, los nombres de archivo se generarán automáticamente según los nombres de los canales y los parámetros de exportación.
Las rutas de directorio deben terminar con una barra diagonal para evitar ambigüedades.
Tokens de plantilla disponibles:
**%g** ID del servidor
**%G** nombre del servidor
**%t** ID de categoría
**%T** nombre de categoría
**%c** ID del canal
**%C** nombre del canal
**%p** posición del canal
**%P** posición de la categoría
**%a** fecha desde
**%b** fecha hasta
**%d** fecha actual
""",
[nameof(FormatLabel)] = "Formato", [nameof(FormatLabel)] = "Formato",
[nameof(FormatTooltip)] = "Formato de exportación", [nameof(FormatTooltip)] = "Formato de exportación",
[nameof(AfterDateLabel)] = "Después (fecha)", [nameof(AfterDateLabel)] = "Después (fecha)",
@@ -85,6 +106,9 @@ public partial class LocalizationManager
[nameof(MessageFilterLabel)] = "Filtro de mensajes", [nameof(MessageFilterLabel)] = "Filtro de mensajes",
[nameof(MessageFilterTooltip)] = [nameof(MessageFilterTooltip)] =
"Solo incluir mensajes que satisfagan este filtro (p. ej. 'from:foo#1234' o 'has:image'). Consulte la documentación para más información.", "Solo incluir mensajes que satisfagan este filtro (p. ej. 'from:foo#1234' o 'has:image'). Consulte la documentación para más información.",
[nameof(ReverseMessageOrderLabel)] = "Invertir orden de mensajes",
[nameof(ReverseMessageOrderTooltip)] =
"Exportar mensajes en orden cronológico inverso (los más recientes primero)",
[nameof(FormatMarkdownLabel)] = "Formatear markdown", [nameof(FormatMarkdownLabel)] = "Formatear markdown",
[nameof(FormatMarkdownTooltip)] = [nameof(FormatMarkdownTooltip)] =
"Procesar markdown, menciones y otros tokens especiales", "Procesar markdown, menciones y otros tokens especiales",
@@ -124,7 +148,7 @@ public partial class LocalizationManager
"La actualización se ha descargado y se instalará al salir", "La actualización se ha descargado y se instalará al salir",
[nameof(UpdateInstallNowButton)] = "INSTALAR AHORA", [nameof(UpdateInstallNowButton)] = "INSTALAR AHORA",
[nameof(UpdateFailedMessage)] = "Error al realizar la actualización de la aplicación", [nameof(UpdateFailedMessage)] = "Error al realizar la actualización de la aplicación",
[nameof(ErrorPullingServersTitle)] = "Error al cargar servidores", [nameof(ErrorPullingGuildsTitle)] = "Error al cargar servidores",
[nameof(ErrorPullingChannelsTitle)] = "Error al cargar canales", [nameof(ErrorPullingChannelsTitle)] = "Error al cargar canales",
[nameof(ErrorExportingTitle)] = "Error al exportar canal(es)", [nameof(ErrorExportingTitle)] = "Error al exportar canal(es)",
[nameof(SuccessfulExportMessage)] = "{0} canal(es) exportado(s) con éxito", [nameof(SuccessfulExportMessage)] = "{0} canal(es) exportado(s) con éxito",
@@ -30,10 +30,10 @@ public partial class LocalizationManager
""", """,
// Token instructions (bot) // Token instructions (bot)
[nameof(TokenBotHeader)] = "Як отримати токен для бота:", [nameof(TokenBotHeader)] = "Як отримати токен для бота:",
[nameof(TokenBotIntro)] =
"Токен генерується під час створення бота. Якщо ви його втратили, згенеруйте новий:",
[nameof(TokenBotStep1)] = "1. Відкрийте Discord",
[nameof(TokenBotInstructions)] = """ [nameof(TokenBotInstructions)] = """
Токен генерується під час створення бота. Якщо ви його втратили, згенеруйте новий:
1. Відкрийте Discord [портал розробника](https://discord.com/developers/applications)
2. Відкрийте налаштування вашого застосунку 2. Відкрийте налаштування вашого застосунку
3. Перейдіть до розділу **Bot** ліворуч 3. Перейдіть до розділу **Bot** ліворуч
4. В розділі **Token** натисніть **Reset Token** 4. В розділі **Token** натисніть **Reset Token**
@@ -41,9 +41,8 @@ public partial class LocalizationManager
* Інтеграції, що використовують попередній токен, перестануть працювати * Інтеграції, що використовують попередній токен, перестануть працювати
* Ваш бот повинен мати включений **Message Content Intent** для читання повідомлень * Ваш бот повинен мати включений **Message Content Intent** для читання повідомлень
""", """,
[nameof(TokenDeveloperPortalLinkText)] = "портал розробника", [nameof(TokenHelpText)] =
[nameof(TokenDocumentationLinkText)] = "документацію", "Якщо у вас є запитання або проблеми, зверніться до [документації](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
[nameof(TokenHelpText)] = "Якщо у вас є запитання або проблеми, зверніться до",
// Settings // Settings
[nameof(SettingsTitle)] = "Налаштування", [nameof(SettingsTitle)] = "Налаштування",
[nameof(ThemeLabel)] = "Тема", [nameof(ThemeLabel)] = "Тема",
@@ -53,8 +52,10 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Авто-оновлення", [nameof(AutoUpdateLabel)] = "Авто-оновлення",
[nameof(AutoUpdateTooltip)] = "Виконувати автоматичні оновлення при кожному запуску", [nameof(AutoUpdateTooltip)] = "Виконувати автоматичні оновлення при кожному запуску",
[nameof(PersistTokenLabel)] = "Зберігати токен", [nameof(PersistTokenLabel)] = "Зберігати токен",
[nameof(PersistTokenTooltip)] = [nameof(PersistTokenTooltip)] = """
"Зберігати останній використаний токен у файлі для збереження між сеансами", Зберігати останній використаний токен у файлі для збереження між сеансами.
**Увага**: хоча токен зберігається із шифруванням, він може бути відновлений зловмисником, який має доступ до вашої системи.
""",
[nameof(RateLimitPreferenceLabel)] = "Ліміт запитів", [nameof(RateLimitPreferenceLabel)] = "Ліміт запитів",
[nameof(RateLimitPreferenceTooltip)] = [nameof(RateLimitPreferenceTooltip)] =
"Чи дотримуватись рекомендованих лімітів запитів. Якщо вимкнено, будуть дотримуватись лише жорсткі ліміти (тобто відповіді 429).", "Чи дотримуватись рекомендованих лімітів запитів. Якщо вимкнено, будуть дотримуватись лише жорсткі ліміти (тобто відповіді 429).",
@@ -69,6 +70,26 @@ public partial class LocalizationManager
// Export Setup // Export Setup
[nameof(ChannelsSelectedText)] = "каналів вибрано", [nameof(ChannelsSelectedText)] = "каналів вибрано",
[nameof(OutputPathLabel)] = "Шлях збереження", [nameof(OutputPathLabel)] = "Шлях збереження",
[nameof(OutputPathTooltip)] = """
Шлях до файлу або директорії виводу.
Якщо вказано директорію, імена файлів генеруватимуться автоматично на основі назв каналів та параметрів експорту.
Шляхи до директорій повинні закінчуватись слешем для уникнення неоднозначності.
Доступні шаблонні токени:
**%g** ID сервера
**%G** назва сервера
**%t** ID категорії
**%T** назва категорії
**%c** ID каналу
**%C** назва каналу
**%p** позиція каналу
**%P** позиція категорії
**%a** дата після
**%b** дата до
**%d** поточна дата
""",
[nameof(FormatLabel)] = "Формат", [nameof(FormatLabel)] = "Формат",
[nameof(FormatTooltip)] = "Формат експорту", [nameof(FormatTooltip)] = "Формат експорту",
[nameof(AfterDateLabel)] = "Після (дата)", [nameof(AfterDateLabel)] = "Після (дата)",
@@ -85,6 +106,9 @@ public partial class LocalizationManager
[nameof(MessageFilterLabel)] = "Фільтр повідомлень", [nameof(MessageFilterLabel)] = "Фільтр повідомлень",
[nameof(MessageFilterTooltip)] = [nameof(MessageFilterTooltip)] =
"Включати лише повідомлення, що відповідають цьому фільтру (напр. 'from:foo#1234' або 'has:image'). Дивіться документацію для більш детальної інформації.", "Включати лише повідомлення, що відповідають цьому фільтру (напр. 'from:foo#1234' або 'has:image'). Дивіться документацію для більш детальної інформації.",
[nameof(ReverseMessageOrderLabel)] = "Зворотній порядок повідомлень",
[nameof(ReverseMessageOrderTooltip)] =
"Експортувати повідомлення у зворотному хронологічному порядку (найновіші спочатку)",
[nameof(FormatMarkdownLabel)] = "Форматувати markdown", [nameof(FormatMarkdownLabel)] = "Форматувати markdown",
[nameof(FormatMarkdownTooltip)] = [nameof(FormatMarkdownTooltip)] =
"Обробляти markdown, згадки та інші спеціальні токени", "Обробляти markdown, згадки та інші спеціальні токени",
@@ -123,7 +147,7 @@ public partial class LocalizationManager
[nameof(UpdateReadyMessage)] = "Оновлення завантажено та буде встановлено після виходу", [nameof(UpdateReadyMessage)] = "Оновлення завантажено та буде встановлено після виходу",
[nameof(UpdateInstallNowButton)] = "ВСТАНОВИТИ ЗАРАЗ", [nameof(UpdateInstallNowButton)] = "ВСТАНОВИТИ ЗАРАЗ",
[nameof(UpdateFailedMessage)] = "Не вдалося виконати оновлення програми", [nameof(UpdateFailedMessage)] = "Не вдалося виконати оновлення програми",
[nameof(ErrorPullingServersTitle)] = "Помилка завантаження серверів", [nameof(ErrorPullingGuildsTitle)] = "Помилка завантаження серверів",
[nameof(ErrorPullingChannelsTitle)] = "Помилка завантаження каналів", [nameof(ErrorPullingChannelsTitle)] = "Помилка завантаження каналів",
[nameof(ErrorExportingTitle)] = "Помилка експорту каналу(-ів)", [nameof(ErrorExportingTitle)] = "Помилка експорту каналу(-ів)",
[nameof(SuccessfulExportMessage)] = "Успішно експортовано {0} канал(-ів)", [nameof(SuccessfulExportMessage)] = "Успішно експортовано {0} канал(-ів)",
@@ -91,11 +91,7 @@ public partial class LocalizationManager
// Token instructions (bot) // Token instructions (bot)
public string TokenBotHeader => Get(); public string TokenBotHeader => Get();
public string TokenBotIntro => Get();
public string TokenBotStep1 => Get();
public string TokenBotInstructions => Get(); public string TokenBotInstructions => Get();
public string TokenDeveloperPortalLinkText => Get();
public string TokenDocumentationLinkText => Get();
public string TokenHelpText => Get(); public string TokenHelpText => Get();
// ---- Settings ---- // ---- Settings ----
@@ -124,6 +120,7 @@ public partial class LocalizationManager
public string ChannelsSelectedText => Get(); public string ChannelsSelectedText => Get();
public string OutputPathLabel => Get(); public string OutputPathLabel => Get();
public string OutputPathTooltip => Get();
public string FormatLabel => Get(); public string FormatLabel => Get();
public string FormatTooltip => Get(); public string FormatTooltip => Get();
public string AfterDateLabel => Get(); public string AfterDateLabel => Get();
@@ -138,6 +135,8 @@ public partial class LocalizationManager
public string PartitionLimitTooltip => Get(); public string PartitionLimitTooltip => Get();
public string MessageFilterLabel => Get(); public string MessageFilterLabel => Get();
public string MessageFilterTooltip => Get(); public string MessageFilterTooltip => Get();
public string ReverseMessageOrderLabel => Get();
public string ReverseMessageOrderTooltip => Get();
public string FormatMarkdownLabel => Get(); public string FormatMarkdownLabel => Get();
public string FormatMarkdownTooltip => Get(); public string FormatMarkdownTooltip => Get();
public string DownloadAssetsLabel => Get(); public string DownloadAssetsLabel => Get();
@@ -166,7 +165,7 @@ public partial class LocalizationManager
public string UpdateReadyMessage => Get(); public string UpdateReadyMessage => Get();
public string UpdateInstallNowButton => Get(); public string UpdateInstallNowButton => Get();
public string UpdateFailedMessage => Get(); public string UpdateFailedMessage => Get();
public string ErrorPullingServersTitle => Get(); public string ErrorPullingGuildsTitle => Get();
public string ErrorPullingChannelsTitle => Get(); public string ErrorPullingChannelsTitle => Get();
public string ErrorExportingTitle => Get(); public string ErrorExportingTitle => Get();
public string SuccessfulExportMessage => Get(); public string SuccessfulExportMessage => Get();
@@ -0,0 +1,102 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using DiscordChatExporter.Gui.Utils.Extensions;
namespace DiscordChatExporter.Gui.Services;
public partial class SettingsService
{
private class TokenEncryptionConverter : JsonConverter<string?>
{
private const string Prefix = "enc:";
private static readonly Lazy<byte[]> Key = new(() =>
Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(Environment.TryGetMachineId() ?? string.Empty),
Encoding.UTF8.GetBytes(ThisAssembly.Project.EncryptionSalt),
600_000,
HashAlgorithmName.SHA256,
16
)
);
public override string? Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
)
{
var value = reader.GetString();
// No prefix means the token is stored as plain text, which was the case for older
// versions of the application. Load it as is and encrypt it on next save.
if (
string.IsNullOrWhiteSpace(value)
|| !value.StartsWith(Prefix, StringComparison.Ordinal)
)
{
return value;
}
try
{
var encryptedData = Convert.FromHexString(value[Prefix.Length..]);
var tokenData = new byte[encryptedData.AsSpan(28).Length];
// Layout: nonce (12 bytes) | tag (16 bytes) | cipher
using var aes = new AesGcm(Key.Value, 16);
aes.Decrypt(
encryptedData.AsSpan(0, 12),
encryptedData.AsSpan(28),
encryptedData.AsSpan(12, 16),
tokenData
);
return Encoding.UTF8.GetString(tokenData);
}
catch (Exception ex)
when (ex
is FormatException
or CryptographicException
or ArgumentException
or IndexOutOfRangeException
)
{
return null;
}
}
public override void Write(
Utf8JsonWriter writer,
string? value,
JsonSerializerOptions options
)
{
if (string.IsNullOrWhiteSpace(value))
{
writer.WriteNullValue();
return;
}
var tokenData = Encoding.UTF8.GetBytes(value);
var encryptedData = new byte[28 + tokenData.Length];
// Nonce
RandomNumberGenerator.Fill(encryptedData.AsSpan(0, 12));
// Layout: nonce (12 bytes) | tag (16 bytes) | cipher
using var aes = new AesGcm(Key.Value, 16);
aes.Encrypt(
encryptedData.AsSpan(0, 12),
tokenData,
encryptedData.AsSpan(28),
encryptedData.AsSpan(12, 16)
);
writer.WriteStringValue(Prefix + Convert.ToHexStringLower(encryptedData));
}
}
}
@@ -50,6 +50,7 @@ public partial class SettingsService()
public partial int ParallelLimit { get; set; } = 1; public partial int ParallelLimit { get; set; } = 1;
[ObservableProperty] [ObservableProperty]
[JsonConverter(typeof(TokenEncryptionConverter))]
public partial string? LastToken { get; set; } public partial string? LastToken { get; set; }
[ObservableProperty] [ObservableProperty]
@@ -61,6 +62,9 @@ public partial class SettingsService()
[ObservableProperty] [ObservableProperty]
public partial string? LastMessageFilterValue { get; set; } public partial string? LastMessageFilterValue { get; set; }
[ObservableProperty]
public partial bool LastIsReverseMessageOrder { get; set; }
[ObservableProperty] [ObservableProperty]
public partial bool LastShouldFormatMarkdown { get; set; } = true; public partial bool LastShouldFormatMarkdown { get; set; } = true;
@@ -0,0 +1,54 @@
using System;
using System.IO;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class EnvironmentExtensions
{
extension(Environment)
{
public static string? TryGetMachineId()
{
// Windows: stable GUID written during OS installation
if (OperatingSystem.IsWindows())
{
try
{
using var regKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(
@"SOFTWARE\Microsoft\Cryptography"
);
if (
regKey?.GetValue("MachineGuid") is string guid
&& !string.IsNullOrWhiteSpace(guid)
)
return guid;
}
catch { }
}
else
{
// Unix: /etc/machine-id (set once by systemd at first boot)
foreach (var path in new[] { "/etc/machine-id", "/var/lib/dbus/machine-id" })
{
try
{
var id = File.ReadAllText(path).Trim();
if (!string.IsNullOrWhiteSpace(id))
return id;
}
catch { }
}
}
// Last-resort fallback
try
{
return Environment.MachineName;
}
catch
{
return null;
}
}
}
}
@@ -0,0 +1,18 @@
using System.Linq;
using Markdig.Syntax.Inlines;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class MarkdigExtensions
{
extension(Inline inline)
{
public string GetInnerText() =>
inline switch
{
LiteralInline literal => literal.Content.ToString(),
ContainerInline container => string.Concat(container.Select(c => c.GetInnerText())),
_ => string.Empty,
};
}
}
@@ -9,7 +9,7 @@ internal static class ProcessExtensions
public static void StartShellExecute(string path) public static void StartShellExecute(string path)
{ {
using var process = new Process(); using var process = new Process();
process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true }; process.StartInfo = new ProcessStartInfo(path) { UseShellExecute = true };
process.Start(); process.Start();
} }
@@ -107,9 +107,6 @@ public partial class DashboardViewModel : ViewModelBase
private async Task ShowSettingsAsync() => private async Task ShowSettingsAsync() =>
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel()); await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
[RelayCommand]
private void ShowHelp() => Process.StartShellExecute(Program.ProjectDocumentationUrl);
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token); private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
[RelayCommand(CanExecute = nameof(CanPullGuilds))] [RelayCommand(CanExecute = nameof(CanPullGuilds))]
@@ -146,7 +143,7 @@ public partial class DashboardViewModel : ViewModelBase
catch (Exception ex) catch (Exception ex)
{ {
var dialog = _viewModelManager.CreateMessageBoxViewModel( var dialog = _viewModelManager.CreateMessageBoxViewModel(
LocalizationManager.ErrorPullingServersTitle, LocalizationManager.ErrorPullingGuildsTitle,
ex.ToString() ex.ToString()
); );
@@ -278,6 +275,7 @@ public partial class DashboardViewModel : ViewModelBase
dialog.Before?.Pipe(Snowflake.FromDate), dialog.Before?.Pipe(Snowflake.FromDate),
dialog.PartitionLimit, dialog.PartitionLimit,
dialog.MessageFilter, dialog.MessageFilter,
dialog.IsReverseMessageOrder,
dialog.ShouldFormatMarkdown, dialog.ShouldFormatMarkdown,
dialog.ShouldDownloadAssets, dialog.ShouldDownloadAssets,
dialog.ShouldReuseAssets, dialog.ShouldReuseAssets,
@@ -330,13 +328,6 @@ public partial class DashboardViewModel : ViewModelBase
} }
} }
[RelayCommand]
private void OpenDiscord() => Process.StartShellExecute("https://discord.com/app");
[RelayCommand]
private void OpenDiscordDeveloperPortal() =>
Process.StartShellExecute("https://discord.com/developers/applications");
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
if (disposing) if (disposing)
@@ -62,6 +62,9 @@ public partial class ExportSetupViewModel(
[NotifyPropertyChangedFor(nameof(MessageFilter))] [NotifyPropertyChangedFor(nameof(MessageFilter))]
public partial string? MessageFilterValue { get; set; } public partial string? MessageFilterValue { get; set; }
[ObservableProperty]
public partial bool IsReverseMessageOrder { get; set; }
[ObservableProperty] [ObservableProperty]
public partial bool ShouldFormatMarkdown { get; set; } public partial bool ShouldFormatMarkdown { get; set; }
@@ -106,6 +109,7 @@ public partial class ExportSetupViewModel(
SelectedFormat = settingsService.LastExportFormat; SelectedFormat = settingsService.LastExportFormat;
PartitionLimitValue = settingsService.LastPartitionLimitValue; PartitionLimitValue = settingsService.LastPartitionLimitValue;
MessageFilterValue = settingsService.LastMessageFilterValue; MessageFilterValue = settingsService.LastMessageFilterValue;
IsReverseMessageOrder = settingsService.LastIsReverseMessageOrder;
ShouldFormatMarkdown = settingsService.LastShouldFormatMarkdown; ShouldFormatMarkdown = settingsService.LastShouldFormatMarkdown;
ShouldDownloadAssets = settingsService.LastShouldDownloadAssets; ShouldDownloadAssets = settingsService.LastShouldDownloadAssets;
ShouldReuseAssets = settingsService.LastShouldReuseAssets; ShouldReuseAssets = settingsService.LastShouldReuseAssets;
@@ -120,7 +124,8 @@ public partial class ExportSetupViewModel(
|| !string.IsNullOrWhiteSpace(MessageFilterValue) || !string.IsNullOrWhiteSpace(MessageFilterValue)
|| ShouldDownloadAssets || ShouldDownloadAssets
|| ShouldReuseAssets || ShouldReuseAssets
|| !string.IsNullOrWhiteSpace(AssetsDirPath); || !string.IsNullOrWhiteSpace(AssetsDirPath)
|| IsReverseMessageOrder;
} }
[RelayCommand] [RelayCommand]
@@ -184,6 +189,7 @@ public partial class ExportSetupViewModel(
settingsService.LastExportFormat = SelectedFormat; settingsService.LastExportFormat = SelectedFormat;
settingsService.LastPartitionLimitValue = PartitionLimitValue; settingsService.LastPartitionLimitValue = PartitionLimitValue;
settingsService.LastMessageFilterValue = MessageFilterValue; settingsService.LastMessageFilterValue = MessageFilterValue;
settingsService.LastIsReverseMessageOrder = IsReverseMessageOrder;
settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown; settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown;
settingsService.LastShouldDownloadAssets = ShouldDownloadAssets; settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
settingsService.LastShouldReuseAssets = ShouldReuseAssets; settingsService.LastShouldReuseAssets = ShouldReuseAssets;
@@ -4,7 +4,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components" xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
xmlns:controls="clr-namespace:DiscordChatExporter.Gui.Views.Controls"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters" xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles" xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
@@ -161,7 +160,8 @@
<Grid <Grid
Background="Transparent" Background="Transparent"
Classes.category="{Binding Channel.IsCategory}" Classes.category="{Binding Channel.IsCategory}"
ColumnDefinitions="Auto,*,Auto"> ColumnDefinitions="Auto,*,Auto"
DoubleTapped="ChannelGrid_OnDoubleTapped">
<Grid.Styles> <Grid.Styles>
<Style Selector="Grid"> <Style Selector="Grid">
<Style Selector="^:not(.category)"> <Style Selector="^:not(.category)">
@@ -220,35 +220,35 @@
<Panel IsVisible="{Binding !AvailableGuilds.Count}"> <Panel IsVisible="{Binding !AvailableGuilds.Count}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Margin="32,16" Spacing="0"> <StackPanel Margin="32,16" Spacing="0">
<StackPanel.Styles>
<Style Selector="TextBlock">
<Setter Property="FontSize" Value="14" />
<Setter Property="FontWeight" Value="Light" />
<Setter Property="LineHeight" Value="23" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
</StackPanel.Styles>
<!-- User token --> <!-- User token -->
<TextBlock> <TextBlock>
<InlineUIContainer> <InlineUIContainer>
<materialIcons:MaterialIcon <materialIcons:MaterialIcon
Width="18" Width="18"
Height="18" Height="18"
Margin="0,-2,0,0"
Foreground="{DynamicResource PrimaryHueMidBrush}" Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" /> Kind="Account" />
</InlineUIContainer> </InlineUIContainer>
<Run Text=" " /> <Run Text="" />
<Run <Run
FontSize="16" FontSize="16"
FontWeight="SemiBold" FontWeight="SemiBold"
Text="{Binding LocalizationManager.TokenPersonalHeader}" /> Text="{Binding LocalizationManager.TokenPersonalHeader}" />
</TextBlock> </TextBlock>
<TextBlock Inlines="{Binding LocalizationManager.TokenPersonalTosWarning, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" /> <TextBlock
FontSize="14"
FontWeight="Light"
Inlines="{Binding LocalizationManager.TokenPersonalTosWarning, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
LineHeight="23"
TextWrapping="Wrap" />
<TextBlock Inlines="{Binding LocalizationManager.TokenPersonalInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" /> <TextBlock
FontSize="14"
FontWeight="Light"
Inlines="{Binding LocalizationManager.TokenPersonalInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
LineHeight="23"
TextWrapping="Wrap" />
<!-- Bot token --> <!-- Bot token -->
<TextBlock Margin="0,12,0,0"> <TextBlock Margin="0,12,0,0">
@@ -256,30 +256,30 @@
<materialIcons:MaterialIcon <materialIcons:MaterialIcon
Width="18" Width="18"
Height="18" Height="18"
Margin="0,-2,0,0"
Foreground="{DynamicResource PrimaryHueMidBrush}" Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" /> Kind="Robot" />
</InlineUIContainer> </InlineUIContainer>
<Run Text=" " /> <Run Text="" />
<Run <Run
FontSize="16" FontSize="16"
FontWeight="SemiBold" FontWeight="SemiBold"
Text="{Binding LocalizationManager.TokenBotHeader}" /> Text="{Binding LocalizationManager.TokenBotHeader}" />
</TextBlock> </TextBlock>
<TextBlock Text="{Binding LocalizationManager.TokenBotIntro}" /> <TextBlock
FontSize="14"
FontWeight="Light"
Inlines="{Binding LocalizationManager.TokenBotInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
LineHeight="23"
TextWrapping="Wrap" />
<TextBlock> <TextBlock
<Run Text="{Binding LocalizationManager.TokenBotStep1}" /> Margin="0,12,0,0"
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="{Binding LocalizationManager.TokenDeveloperPortalLinkText}" /> FontSize="14"
</TextBlock> FontWeight="Light"
Inlines="{Binding LocalizationManager.TokenHelpText, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
<TextBlock Inlines="{Binding LocalizationManager.TokenBotInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" /> LineHeight="23"
TextWrapping="Wrap" />
<TextBlock Margin="0,12,0,0">
<Run Text="{Binding LocalizationManager.TokenHelpText}" />
<controls:HyperLink Command="{Binding ShowHelpCommand}" Text="{Binding LocalizationManager.TokenDocumentationLinkText}" />
</TextBlock>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
</Panel> </Panel>
@@ -1,5 +1,6 @@
using System.Linq; using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Gui.Framework; using DiscordChatExporter.Gui.Framework;
@@ -36,4 +37,12 @@ public partial class DashboardView : UserControl<DashboardViewModel>
container.IsSelected = false; container.IsSelected = false;
} }
} }
private void ChannelGrid_OnDoubleTapped(object? sender, TappedEventArgs args)
{
if (DataContext.SelectedChannels.Count != 1)
return;
DataContext.ExportCommand.Execute(null);
}
} }
@@ -17,4 +17,4 @@
</Style> </Style>
</TextBlock.Styles> </TextBlock.Styles>
</TextBlock> </TextBlock>
</UserControl> </UserControl>
@@ -1,7 +1,9 @@
using System.Windows.Input; using System.Diagnostics;
using System.Windows.Input;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using DiscordChatExporter.Gui.Utils.Extensions;
namespace DiscordChatExporter.Gui.Views.Controls; namespace DiscordChatExporter.Gui.Views.Controls;
@@ -16,6 +18,12 @@ public partial class HyperLink : UserControl
public static readonly StyledProperty<object?> CommandParameterProperty = public static readonly StyledProperty<object?> CommandParameterProperty =
Button.CommandParameterProperty.AddOwner<HyperLink>(); Button.CommandParameterProperty.AddOwner<HyperLink>();
// If Url is set and Command is not set, clicking will open this URL in the default browser.
public static readonly StyledProperty<string?> UrlProperty = AvaloniaProperty.Register<
HyperLink,
string?
>(nameof(Url));
public HyperLink() => InitializeComponent(); public HyperLink() => InitializeComponent();
public string? Text public string? Text
@@ -36,14 +44,22 @@ public partial class HyperLink : UserControl
set => SetValue(CommandParameterProperty, value); set => SetValue(CommandParameterProperty, value);
} }
public string? Url
{
get => GetValue(UrlProperty);
set => SetValue(UrlProperty, value);
}
private void TextBlock_OnPointerReleased(object? sender, PointerReleasedEventArgs args) private void TextBlock_OnPointerReleased(object? sender, PointerReleasedEventArgs args)
{ {
if (Command is null) if (Command is not null)
return; {
if (Command.CanExecute(CommandParameter))
if (!Command.CanExecute(CommandParameter)) Command.Execute(CommandParameter);
return; }
else if (!string.IsNullOrWhiteSpace(Url))
Command.Execute(CommandParameter); {
Process.StartShellExecute(Url);
}
} }
} }
@@ -51,11 +51,7 @@
IsVisible="{Binding IsSingleChannel}" IsVisible="{Binding IsSingleChannel}"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Channels[0], Converter={x:Static converters:ChannelToHierarchicalNameStringConverter.Instance}}"> ToolTip.Tip="{Binding Channels[0], Converter={x:Static converters:ChannelToHierarchicalNameStringConverter.Instance}}">
<TextBlock IsVisible="{Binding !!Channels[0].Parent}"> <Run Text="{Binding Channels[0].Parent.Name, StringFormat='{}{0} / ', FallbackValue='', TargetNullValue='', Mode=OneWay}" /><Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
<Run Text="{Binding Channels[0].Parent.Name, Mode=OneWay}" />
<Run Text="/" />
</TextBlock>
<Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
</TextBlock> </TextBlock>
</Grid> </Grid>
@@ -73,60 +69,10 @@
Text="{Binding OutputPath}" Text="{Binding OutputPath}"
Theme="{DynamicResource FilledTextBox}"> Theme="{DynamicResource FilledTextBox}">
<ToolTip.Tip> <ToolTip.Tip>
<TextBlock> <TextBlock
<Run Text="Output file or directory path." /> MaxWidth="400"
<LineBreak /> Inlines="{Binding LocalizationManager.OutputPathTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
<Run Text="If a directory is specified, file names will be generated automatically based on the channel names and export parameters." /> TextWrapping="Wrap" />
<LineBreak />
<Run Text="Directory paths must end with a slash to avoid ambiguity." />
<LineBreak />
<LineBreak />
<Run Text="Available template tokens:" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%g" />
<Run Text="— server ID" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%G" />
<Run Text="— server name" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%t" />
<Run Text="— category ID" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%T" />
<Run Text="— category name" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%c" />
<Run Text="— channel ID" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%C" />
<Run Text="— channel name" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%p" />
<Run Text="— channel position" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%P" />
<Run Text="— category position" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%a" />
<Run Text="— after date" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%b" />
<Run Text="— before date" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%d" />
<Run Text="— current date" />
</TextBlock>
</ToolTip.Tip> </ToolTip.Tip>
<TextBox.InnerRightContent> <TextBox.InnerRightContent>
<Button <Button
@@ -246,6 +192,15 @@
Theme="{DynamicResource FilledTextBox}" Theme="{DynamicResource FilledTextBox}"
ToolTip.Tip="{Binding LocalizationManager.MessageFilterTooltip}" /> ToolTip.Tip="{Binding LocalizationManager.MessageFilterTooltip}" />
<!-- Reverse message order -->
<DockPanel
Margin="16,8"
LastChildFill="False"
ToolTip.Tip="{Binding LocalizationManager.ReverseMessageOrderTooltip}">
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ReverseMessageOrderLabel}" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsReverseMessageOrder}" />
</DockPanel>
<!-- Markdown formatting --> <!-- Markdown formatting -->
<DockPanel <DockPanel
Margin="16,8" Margin="16,8"
@@ -59,10 +59,13 @@
</DockPanel> </DockPanel>
<!-- Persist token --> <!-- Persist token -->
<DockPanel <DockPanel Margin="16,8" LastChildFill="False">
Margin="16,8" <ToolTip.Tip>
LastChildFill="False" <TextBlock
ToolTip.Tip="{Binding LocalizationManager.PersistTokenTooltip}"> MaxWidth="400"
Inlines="{Binding LocalizationManager.PersistTokenTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
TextWrapping="Wrap" />
</ToolTip.Tip>
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.PersistTokenLabel}" /> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.PersistTokenLabel}" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" /> <ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
</DockPanel> </DockPanel>
+1
View File
@@ -8,6 +8,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{EA305DD5-1
License.txt = License.txt License.txt = License.txt
Readme.md = Readme.md Readme.md = Readme.md
Directory.Build.props = Directory.Build.props Directory.Build.props = Directory.Build.props
Directory.Packages.props = Directory.Packages.props
DiscordChatExporter.Cli.dockerfile = DiscordChatExporter.Cli.dockerfile DiscordChatExporter.Cli.dockerfile = DiscordChatExporter.Cli.dockerfile
global.json = global.json global.json = global.json
NuGet.config = NuGet.config NuGet.config = NuGet.config