mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-23 22:13:56 +02:00
Compare commits
31 Commits
32f93a670e
..
2.47
| Author | SHA1 | Date | |
|---|---|---|---|
| 3be5bc6a21 | |||
| c9f152773a | |||
| 5e39611395 | |||
| 40491bdc0f | |||
| 03dd272eaf | |||
| 98e294f7e4 | |||
| b22e786449 | |||
| 951c1e4613 | |||
| 675d910ea3 | |||
| 28f26e45fb | |||
| 4674c517e3 | |||
| b660edfe78 | |||
| 532470e3db | |||
| b533e08d74 | |||
| d490fd0a38 | |||
| 21c2398e2a | |||
| 7ff2347df1 | |||
| 3391e755fc | |||
| f0e9d3be1b | |||
| eef0fc742d | |||
| 2e47c73388 | |||
| 2d49146c74 | |||
| c59374598a | |||
| 01d0203445 | |||
| c4bfb3424e | |||
| 522caba420 | |||
| ad1170b42e | |||
| d2c0e2169b | |||
| e6562de1f5 | |||
| 5f8054e2f0 | |||
| 12d98e9ab0 |
@@ -127,6 +127,7 @@ jobs:
|
||||
dotnet publish ${{ matrix.app }}
|
||||
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||
-p:CSharpier_Bypass=true
|
||||
-p:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }}
|
||||
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
|
||||
--output ${{ matrix.app }}/bin/publish/
|
||||
--configuration Release
|
||||
|
||||
@@ -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>
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="3.0.1" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
|
||||
<PackageReference
|
||||
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
||||
Version="10.0.2"
|
||||
/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
|
||||
<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" />
|
||||
<PackageReference Include="AngleSharp" />
|
||||
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -16,6 +16,8 @@ public static class ChannelIds
|
||||
|
||||
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 MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
@@ -44,4 +49,41 @@ public class HtmlContentSpecs
|
||||
"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 CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using JsonExtensions;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
@@ -43,4 +49,43 @@ public class JsonContentSpecs
|
||||
"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");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ WORKDIR /tmp/app
|
||||
COPY favicon.ico .
|
||||
COPY NuGet.config .
|
||||
COPY Directory.Build.props .
|
||||
COPY Directory.Packages.props .
|
||||
COPY DiscordChatExporter.Core DiscordChatExporter.Core
|
||||
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
||||
|
||||
|
||||
@@ -83,6 +83,12 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public int ParallelLimit { get; init; } = 1;
|
||||
|
||||
[CommandOption(
|
||||
"reverse",
|
||||
Description = "Export messages in reverse chronological order (newest first)."
|
||||
)]
|
||||
public bool IsReverseMessageOrder { get; init; }
|
||||
|
||||
[CommandOption(
|
||||
"markdown",
|
||||
Description = "Process markdown, mentions, and other special tokens."
|
||||
@@ -267,6 +273,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
Before,
|
||||
PartitionLimit,
|
||||
MessageFilter,
|
||||
IsReverseMessageOrder,
|
||||
ShouldFormatMarkdown,
|
||||
ShouldDownloadAssets,
|
||||
ShouldReuseAssets,
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.3.6" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.54.0" />
|
||||
<PackageReference Include="CliFx" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" />
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -8855,6 +8855,8 @@ internal static class EmojiIndex
|
||||
["united_nations"] = "🇺🇳",
|
||||
};
|
||||
|
||||
public static IReadOnlyCollection<string> GetAllNames() => _toCodes.Keys;
|
||||
|
||||
public static string? TryGetCode(string name) => _toCodes.GetValueOrDefault(name);
|
||||
|
||||
public static string? TryGetName(string code) => _fromCodes.GetValueOrDefault(code);
|
||||
|
||||
@@ -27,9 +27,16 @@ public partial record Message(
|
||||
IReadOnlyList<User> MentionedUsers,
|
||||
MessageReference? Reference,
|
||||
Message? ReferencedMessage,
|
||||
MessageSnapshot? ForwardedMessage,
|
||||
Interaction? Interaction
|
||||
) : IHasId
|
||||
{
|
||||
public bool IsEmpty { get; } =
|
||||
string.IsNullOrWhiteSpace(Content)
|
||||
&& !Attachments.Any()
|
||||
&& !Embeds.Any()
|
||||
&& !Stickers.Any();
|
||||
|
||||
public bool IsSystemNotification { get; } =
|
||||
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
|
||||
public bool IsReplyLike => IsReply || Interaction is not null;
|
||||
|
||||
public bool IsEmpty { get; } =
|
||||
string.IsNullOrWhiteSpace(Content)
|
||||
&& !Attachments.Any()
|
||||
&& !Embeds.Any()
|
||||
&& !Stickers.Any();
|
||||
public bool IsForwarded { get; } = Reference?.Kind == MessageReferenceKind.Forward;
|
||||
|
||||
public IEnumerable<User> GetReferencedUsers()
|
||||
{
|
||||
@@ -171,7 +174,17 @@ public partial record Message
|
||||
|
||||
var messageReference = json.GetPropertyOrNull("message_reference")
|
||||
?.Pipe(MessageReference.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);
|
||||
|
||||
return new Message(
|
||||
@@ -191,6 +204,7 @@ public partial record Message
|
||||
mentionedUsers,
|
||||
messageReference,
|
||||
referencedMessage,
|
||||
forwardedMessage,
|
||||
interaction
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,19 @@ using JsonExtensions.Reading;
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
// 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)
|
||||
{
|
||||
var kind =
|
||||
json.GetPropertyOrNull("type")?.GetInt32OrNull()?.Pipe(t => (MessageReferenceKind)t)
|
||||
?? MessageReferenceKind.Default;
|
||||
|
||||
var messageId = json.GetPropertyOrNull("message_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
@@ -21,6 +30,6 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,23 @@ public class DiscordClient(
|
||||
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(
|
||||
Snowflake userId,
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -429,6 +446,11 @@ public class DiscordClient(
|
||||
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
||||
.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
|
||||
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
||||
{
|
||||
@@ -472,7 +494,9 @@ public class DiscordClient(
|
||||
break;
|
||||
}
|
||||
|
||||
yield return thread;
|
||||
if (seenThreadIds.Add(thread.Id))
|
||||
yield return thread;
|
||||
|
||||
currentOffset++;
|
||||
}
|
||||
|
||||
@@ -511,7 +535,12 @@ public class DiscordClient(
|
||||
.Pipe(parentsById.GetValueOrDefault);
|
||||
|
||||
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);
|
||||
yield return thread;
|
||||
|
||||
currentBefore = threadJson
|
||||
.GetProperty("thread_metadata")
|
||||
.GetProperty("archive_timestamp")
|
||||
.GetString();
|
||||
|
||||
if (seenThreadIds.Add(thread.Id))
|
||||
yield return thread;
|
||||
}
|
||||
|
||||
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(
|
||||
Snowflake channelId,
|
||||
Snowflake? before = null,
|
||||
@@ -622,22 +671,10 @@ public class DiscordClient(
|
||||
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.
|
||||
// have the MESSAGE_CONTENT intent enabled.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||
if (
|
||||
messages.All(m => m.IsEmpty)
|
||||
&& 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
|
||||
);
|
||||
}
|
||||
}
|
||||
if (messages.All(m => m.IsEmpty))
|
||||
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||
|
||||
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(
|
||||
Snowflake channelId,
|
||||
Snowflake messageId,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageReference Include="AsyncKeyedLock" Version="8.0.1" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Polly" Version="8.6.5" />
|
||||
<PackageReference Include="RazorBlade" Version="0.11.0" />
|
||||
<PackageReference Include="Superpower" Version="3.1.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.5.6" />
|
||||
<PackageReference Include="AngleSharp" />
|
||||
<PackageReference Include="AsyncKeyedLock" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" />
|
||||
<PackageReference Include="JsonExtensions" />
|
||||
<PackageReference Include="Polly" />
|
||||
<PackageReference Include="RazorBlade" />
|
||||
<PackageReference Include="Superpower" />
|
||||
<PackageReference Include="WebMarkupMin.Core" />
|
||||
<PackageReference Include="YoutubeExplode" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -64,15 +64,23 @@ public class ChannelExporter(DiscordClient discord)
|
||||
);
|
||||
}
|
||||
|
||||
await foreach (
|
||||
var message in discord.GetMessagesAsync(
|
||||
var messages = !request.IsReverseMessageOrder
|
||||
? discord.GetMessagesAsync(
|
||||
request.Channel.Id,
|
||||
request.After,
|
||||
request.Before,
|
||||
progress,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
: discord.GetMessagesInReverseAsync(
|
||||
request.Channel.Id,
|
||||
request.After,
|
||||
request.Before,
|
||||
progress,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await foreach (var message in messages)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -37,6 +37,28 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
if (reuse && File.Exists(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);
|
||||
|
||||
await Http.ResiliencePipeline.ExecuteAsync(
|
||||
@@ -56,34 +78,23 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
|
||||
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
|
||||
static string NormalizeUrl(string url)
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
||||
return url;
|
||||
var uri = new Uri(url);
|
||||
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
||||
return url;
|
||||
|
||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||
query.Remove("ex");
|
||||
query.Remove("is");
|
||||
query.Remove("hm");
|
||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||
query.Remove("ex");
|
||||
query.Remove("is");
|
||||
query.Remove("hm");
|
||||
|
||||
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);
|
||||
return uri.GetLeftPart(UriPartial.Path) + query;
|
||||
}
|
||||
|
||||
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
|
||||
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
|
||||
@@ -107,4 +118,25 @@ internal partial class ExportAssetDownloader
|
||||
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 bool IsReverseMessageOrder { get; }
|
||||
|
||||
public bool ShouldFormatMarkdown { get; }
|
||||
|
||||
public bool ShouldDownloadAssets { get; }
|
||||
@@ -55,6 +57,7 @@ public partial class ExportRequest
|
||||
Snowflake? before,
|
||||
PartitionLimit partitionLimit,
|
||||
MessageFilter messageFilter,
|
||||
bool isReverseMessageOrder,
|
||||
bool shouldFormatMarkdown,
|
||||
bool shouldDownloadAssets,
|
||||
bool shouldReuseAssets,
|
||||
@@ -69,6 +72,7 @@ public partial class ExportRequest
|
||||
Before = before;
|
||||
PartitionLimit = partitionLimit;
|
||||
MessageFilter = messageFilter;
|
||||
IsReverseMessageOrder = isReverseMessageOrder;
|
||||
ShouldFormatMarkdown = shouldFormatMarkdown;
|
||||
ShouldDownloadAssets = shouldDownloadAssets;
|
||||
ShouldReuseAssets = shouldReuseAssets;
|
||||
|
||||
@@ -17,6 +17,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
||||
private readonly HtmlMinifier _minifier = new();
|
||||
private readonly List<Message> _messageGroup = [];
|
||||
|
||||
// Note: in reverse order, last message appears earlier than the first message
|
||||
private bool CanJoinGroup(Message message)
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
|
||||
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(
|
||||
EmbedAuthor embedAuthor,
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -335,6 +353,24 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
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(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
@@ -437,19 +473,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
_writer.WriteStartArray("attachments");
|
||||
|
||||
foreach (var attachment in message.Attachments)
|
||||
{
|
||||
_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();
|
||||
}
|
||||
await WriteAttachmentAsync(attachment, cancellationToken);
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
@@ -465,19 +489,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
_writer.WriteStartArray("stickers");
|
||||
|
||||
foreach (var sticker in message.Stickers)
|
||||
{
|
||||
_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();
|
||||
}
|
||||
await WriteStickerAsync(sticker, cancellationToken);
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
@@ -527,12 +539,58 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
if (message.Reference is not null)
|
||||
{
|
||||
_writer.WriteStartObject("reference");
|
||||
_writer.WriteString("type", message.Reference.Kind.ToString());
|
||||
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
|
||||
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
|
||||
_writer.WriteString("guildId", message.Reference.GuildId?.ToString());
|
||||
_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
|
||||
if (message.Interaction is not null)
|
||||
{
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
<div class="chatlog__message-group">
|
||||
@foreach (var (message, i) in Messages.WithIndex())
|
||||
@foreach (var (i, message) in Messages.Index())
|
||||
{
|
||||
var isFirst = i == 0;
|
||||
|
||||
@@ -262,6 +262,95 @@
|
||||
</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 *@
|
||||
@foreach (var attachment in message.Attachments)
|
||||
{
|
||||
|
||||
@@ -23,13 +23,11 @@ internal static class PlainTextMessageExtensions
|
||||
: "Removed a recipient.",
|
||||
|
||||
MessageKind.Call =>
|
||||
$"Started a call that lasted {
|
||||
message
|
||||
$"Started a call that lasted {message
|
||||
.CallEndedTimestamp?
|
||||
.Pipe(t => t - message.Timestamp)
|
||||
.Pipe(t => t.TotalMinutes)
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||
} minutes.",
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"} minutes.",
|
||||
|
||||
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(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.Linq;
|
||||
using System.Threading;
|
||||
@@ -173,7 +174,7 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
await _writer.WriteLineAsync("{Reactions}");
|
||||
|
||||
foreach (var (reaction, i) in reactions.WithIndex())
|
||||
foreach (var (i, reaction) in reactions.Index())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -224,6 +225,31 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
||||
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(
|
||||
Message message,
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -248,6 +274,12 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
await _writer.WriteLineAsync();
|
||||
|
||||
// Forwarded message content
|
||||
if (message.ForwardedMessage is not null)
|
||||
{
|
||||
await WriteForwardedMessageAsync(message.ForwardedMessage, cancellationToken);
|
||||
}
|
||||
|
||||
// Attachments, embeds, reactions, etc.
|
||||
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
||||
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
||||
|
||||
@@ -304,6 +304,52 @@
|
||||
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 {
|
||||
width: 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="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 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>
|
||||
</svg>
|
||||
</head>
|
||||
|
||||
@@ -14,7 +14,7 @@ internal static class MatcherExtensions
|
||||
this IMatcher<TContext, TValue> matcher,
|
||||
TContext context,
|
||||
StringSegment segment,
|
||||
Func<TContext, StringSegment, TValue> transformFallback
|
||||
Func<TContext, StringSegment, TValue> fallbackTransform
|
||||
)
|
||||
{
|
||||
// Loop through segments divided by individual matches
|
||||
@@ -40,7 +40,7 @@ internal static class MatcherExtensions
|
||||
|
||||
yield return new ParsedMatch<TValue>(
|
||||
fallbackSegment,
|
||||
transformFallback(context, fallbackSegment)
|
||||
fallbackTransform(context, fallbackSegment)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ internal static class MatcherExtensions
|
||||
|
||||
yield return new ParsedMatch<TValue>(
|
||||
fallbackSegment,
|
||||
transformFallback(context, fallbackSegment)
|
||||
fallbackTransform(context, fallbackSegment)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,53 +208,18 @@ internal static partial class MarkdownParser
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
new Regex(
|
||||
@"("
|
||||
+
|
||||
// Country flag emoji (two regional indicator surrogate pairs)
|
||||
@"(?:\uD83C[\uDDE6-\uDDFF]){2}|"
|
||||
+
|
||||
// Digit emoji (digit followed by enclosing mark)
|
||||
@"\d\p{Me}|"
|
||||
+
|
||||
// Surrogate pair
|
||||
@"\p{Cs}{2}|"
|
||||
+
|
||||
// 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"
|
||||
+ @"]"
|
||||
+ @")",
|
||||
// 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
|
||||
// their individual components.
|
||||
"("
|
||||
+ string.Join(
|
||||
"|",
|
||||
EmojiIndex
|
||||
.GetAllNames()
|
||||
.OrderByDescending(e => e.Length)
|
||||
.Select(Regex.Escape)
|
||||
)
|
||||
+ ")",
|
||||
DefaultRegexOptions
|
||||
),
|
||||
(_, _, m) => new EmojiNode(m.Groups[1].Value)
|
||||
@@ -310,7 +275,7 @@ internal static partial class MarkdownParser
|
||||
// Capture the shrug kaomoji.
|
||||
// This escapes it from matching for formatting.
|
||||
@"¯\_(ツ)_/¯",
|
||||
(s, _) => new TextNode(s.ToString())
|
||||
(_, s) => new TextNode(s.ToString())
|
||||
);
|
||||
|
||||
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 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)
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,11 @@
|
||||
</Style>
|
||||
</Style>
|
||||
|
||||
<!-- Run -->
|
||||
<Style Selector="Run">
|
||||
<Setter Property="BaselineAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!-- Text box -->
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||
using DiscordChatExporter.Gui.Views.Controls;
|
||||
using Markdig;
|
||||
using Markdig.Syntax;
|
||||
@@ -20,14 +21,6 @@ public class MarkdownToInlinesConverter : IValueConverter
|
||||
.UseEmphasisExtras()
|
||||
.Build();
|
||||
|
||||
private static string GetPlainText(MarkdownInline inline) =>
|
||||
inline switch
|
||||
{
|
||||
LiteralInline literal => literal.Content.ToString(),
|
||||
ContainerInline container => string.Concat(container.Select(GetPlainText)),
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
private static void ProcessInline(
|
||||
InlineCollection inlines,
|
||||
MarkdownInline markdownInline,
|
||||
@@ -89,12 +82,7 @@ public class MarkdownToInlinesConverter : IValueConverter
|
||||
|
||||
case LinkInline link:
|
||||
{
|
||||
inlines.Add(
|
||||
new InlineUIContainer(
|
||||
new HyperLink { Text = GetPlainText(link), Url = link.Url }
|
||||
)
|
||||
);
|
||||
|
||||
inlines.Add(new HyperLink { Text = link.GetInnerText(), Url = link.Url });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -150,13 +138,16 @@ public class MarkdownToInlinesConverter : IValueConverter
|
||||
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)
|
||||
if (subBlock is { Inline: not null })
|
||||
{
|
||||
foreach (var markdownInline in subBlock.Inline)
|
||||
ProcessInline(inlines, markdownInline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<EncryptionSalt>HimalayanPinkSalt</EncryptionSalt>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Expose this property in code -->
|
||||
<ProjectProperty Include="EncryptionSalt" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<PublishMacOSBundle>false</PublishMacOSBundle>
|
||||
</PropertyGroup>
|
||||
@@ -23,25 +32,22 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.5.0" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.11" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.11" />
|
||||
<PackageReference
|
||||
Include="Avalonia.Diagnostics"
|
||||
Version="11.3.11"
|
||||
Condition="'$(Configuration)' == 'Debug'"
|
||||
/>
|
||||
<PackageReference Include="Cogwheel" Version="2.1.0" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||
<PackageReference Include="DialogHost.Avalonia" Version="0.10.4" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="Markdig" Version="1.0.0" />
|
||||
<PackageReference Include="Material.Avalonia" Version="3.9.2" />
|
||||
<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" />
|
||||
<PackageReference Include="AsyncImageLoader.Avalonia" />
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Cogwheel" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||
<PackageReference Include="DialogHost.Avalonia" />
|
||||
<PackageReference Include="Gress" />
|
||||
<PackageReference Include="Markdig" />
|
||||
<PackageReference Include="Material.Avalonia" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Onova" />
|
||||
<PackageReference Include="ThisAssembly.Project" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using Avalonia;
|
||||
using Avalonia.Platform.Storage;
|
||||
using DialogHostAvalonia;
|
||||
@@ -13,11 +13,12 @@ namespace DiscordChatExporter.Gui.Framework;
|
||||
|
||||
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)
|
||||
{
|
||||
using (await _dialogLock.LockAsync())
|
||||
await _dialogLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await DialogHost.Show(
|
||||
dialog,
|
||||
@@ -39,11 +40,15 @@ public class DialogManager : IDisposable
|
||||
);
|
||||
|
||||
// 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();
|
||||
|
||||
return dialog.DialogResult;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dialogLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> PromptSaveFilePathAsync(
|
||||
|
||||
@@ -52,8 +52,10 @@ public partial class LocalizationManager
|
||||
[nameof(AutoUpdateLabel)] = "Auto-update",
|
||||
[nameof(AutoUpdateTooltip)] = "Perform automatic updates on every launch",
|
||||
[nameof(PersistTokenLabel)] = "Persist token",
|
||||
[nameof(PersistTokenTooltip)] =
|
||||
"Save the last used token to a file so that it can be persisted between sessions",
|
||||
[nameof(PersistTokenTooltip)] = """
|
||||
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(RateLimitPreferenceTooltip)] =
|
||||
"Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.",
|
||||
@@ -76,17 +78,17 @@ public partial class LocalizationManager
|
||||
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
|
||||
**%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(FormatTooltip)] = "Export format",
|
||||
@@ -104,6 +106,9 @@ public partial class LocalizationManager
|
||||
[nameof(MessageFilterLabel)] = "Message filter",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"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(FormatMarkdownTooltip)] =
|
||||
"Process markdown, mentions, and other special tokens",
|
||||
|
||||
@@ -54,8 +54,10 @@ public partial class LocalizationManager
|
||||
[nameof(AutoUpdateLabel)] = "Mise à jour automatique",
|
||||
[nameof(AutoUpdateTooltip)] = "Effectuer des mises à jour automatiques à chaque lancement",
|
||||
[nameof(PersistTokenLabel)] = "Conserver le token",
|
||||
[nameof(PersistTokenTooltip)] =
|
||||
"Enregistrer le dernier token utilisé dans un fichier pour le conserver entre les sessions",
|
||||
[nameof(PersistTokenTooltip)] = """
|
||||
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(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.",
|
||||
@@ -78,17 +80,17 @@ public partial class LocalizationManager
|
||||
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
|
||||
**%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(FormatTooltip)] = "Format d'exportation",
|
||||
@@ -106,6 +108,9 @@ public partial class LocalizationManager
|
||||
[nameof(MessageFilterLabel)] = "Filtre de messages",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"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(FormatMarkdownTooltip)] =
|
||||
"Traiter le markdown, les mentions et autres tokens spéciaux",
|
||||
|
||||
@@ -54,8 +54,10 @@ public partial class LocalizationManager
|
||||
[nameof(AutoUpdateLabel)] = "Automatische Updates",
|
||||
[nameof(AutoUpdateTooltip)] = "Automatische Updates bei jedem Start durchführen",
|
||||
[nameof(PersistTokenLabel)] = "Token speichern",
|
||||
[nameof(PersistTokenTooltip)] =
|
||||
"Den zuletzt verwendeten Token in einer Datei speichern, damit er zwischen Sitzungen erhalten bleibt",
|
||||
[nameof(PersistTokenTooltip)] = """
|
||||
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(RateLimitPreferenceTooltip)] =
|
||||
"Ob empfohlene Ratenlimits eingehalten werden sollen. Wenn deaktiviert, werden nur harte Ratenlimits (d. h. 429-Antworten) eingehalten.",
|
||||
@@ -78,17 +80,17 @@ public partial class LocalizationManager
|
||||
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
|
||||
**%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(FormatTooltip)] = "Exportformat",
|
||||
@@ -110,6 +112,9 @@ public partial class LocalizationManager
|
||||
[nameof(MessageFilterLabel)] = "Nachrichtenfilter",
|
||||
[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.",
|
||||
[nameof(ReverseMessageOrderLabel)] = "Nachrichtenreihenfolge umkehren",
|
||||
[nameof(ReverseMessageOrderTooltip)] =
|
||||
"Nachrichten in umgekehrter chronologischer Reihenfolge exportieren (neueste zuerst)",
|
||||
[nameof(FormatMarkdownLabel)] = "Markdown formatieren",
|
||||
[nameof(FormatMarkdownTooltip)] =
|
||||
"Markdown, Erwähnungen und andere spezielle Token verarbeiten",
|
||||
|
||||
@@ -52,8 +52,10 @@ public partial class LocalizationManager
|
||||
[nameof(AutoUpdateLabel)] = "Actualización automática",
|
||||
[nameof(AutoUpdateTooltip)] = "Realizar actualizaciones automáticas en cada inicio",
|
||||
[nameof(PersistTokenLabel)] = "Guardar token",
|
||||
[nameof(PersistTokenTooltip)] =
|
||||
"Guardar el último token utilizado en un archivo para conservarlo entre sesiones",
|
||||
[nameof(PersistTokenTooltip)] = """
|
||||
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(RateLimitPreferenceTooltip)] =
|
||||
"Si se deben respetar los límites de velocidad recomendados. Si está desactivado, solo se respetarán los límites estrictos (respuestas 429).",
|
||||
@@ -76,17 +78,17 @@ public partial class LocalizationManager
|
||||
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
|
||||
**%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(FormatTooltip)] = "Formato de exportación",
|
||||
@@ -104,6 +106,9 @@ public partial class LocalizationManager
|
||||
[nameof(MessageFilterLabel)] = "Filtro de mensajes",
|
||||
[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.",
|
||||
[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(FormatMarkdownTooltip)] =
|
||||
"Procesar markdown, menciones y otros tokens especiales",
|
||||
|
||||
@@ -52,8 +52,10 @@ public partial class LocalizationManager
|
||||
[nameof(AutoUpdateLabel)] = "Авто-оновлення",
|
||||
[nameof(AutoUpdateTooltip)] = "Виконувати автоматичні оновлення при кожному запуску",
|
||||
[nameof(PersistTokenLabel)] = "Зберігати токен",
|
||||
[nameof(PersistTokenTooltip)] =
|
||||
"Зберігати останній використаний токен у файлі для збереження між сеансами",
|
||||
[nameof(PersistTokenTooltip)] = """
|
||||
Зберігати останній використаний токен у файлі для збереження між сеансами.
|
||||
**Увага**: хоча токен зберігається із шифруванням, він може бути відновлений зловмисником, який має доступ до вашої системи.
|
||||
""",
|
||||
[nameof(RateLimitPreferenceLabel)] = "Ліміт запитів",
|
||||
[nameof(RateLimitPreferenceTooltip)] =
|
||||
"Чи дотримуватись рекомендованих лімітів запитів. Якщо вимкнено, будуть дотримуватись лише жорсткі ліміти (тобто відповіді 429).",
|
||||
@@ -76,17 +78,17 @@ public partial class LocalizationManager
|
||||
Шляхи до директорій повинні закінчуватись слешем для уникнення неоднозначності.
|
||||
|
||||
Доступні шаблонні токени:
|
||||
- **%g** — ID сервера
|
||||
- **%G** — назва сервера
|
||||
- **%t** — ID категорії
|
||||
- **%T** — назва категорії
|
||||
- **%c** — ID каналу
|
||||
- **%C** — назва каналу
|
||||
- **%p** — позиція каналу
|
||||
- **%P** — позиція категорії
|
||||
- **%a** — дата після
|
||||
- **%b** — дата до
|
||||
- **%d** — поточна дата
|
||||
**%g** — ID сервера
|
||||
**%G** — назва сервера
|
||||
**%t** — ID категорії
|
||||
**%T** — назва категорії
|
||||
**%c** — ID каналу
|
||||
**%C** — назва каналу
|
||||
**%p** — позиція каналу
|
||||
**%P** — позиція категорії
|
||||
**%a** — дата після
|
||||
**%b** — дата до
|
||||
**%d** — поточна дата
|
||||
""",
|
||||
[nameof(FormatLabel)] = "Формат",
|
||||
[nameof(FormatTooltip)] = "Формат експорту",
|
||||
@@ -104,6 +106,9 @@ public partial class LocalizationManager
|
||||
[nameof(MessageFilterLabel)] = "Фільтр повідомлень",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"Включати лише повідомлення, що відповідають цьому фільтру (напр. 'from:foo#1234' або 'has:image'). Дивіться документацію для більш детальної інформації.",
|
||||
[nameof(ReverseMessageOrderLabel)] = "Зворотній порядок повідомлень",
|
||||
[nameof(ReverseMessageOrderTooltip)] =
|
||||
"Експортувати повідомлення у зворотному хронологічному порядку (найновіші спочатку)",
|
||||
[nameof(FormatMarkdownLabel)] = "Форматувати markdown",
|
||||
[nameof(FormatMarkdownTooltip)] =
|
||||
"Обробляти markdown, згадки та інші спеціальні токени",
|
||||
|
||||
@@ -135,6 +135,8 @@ public partial class LocalizationManager
|
||||
public string PartitionLimitTooltip => Get();
|
||||
public string MessageFilterLabel => Get();
|
||||
public string MessageFilterTooltip => Get();
|
||||
public string ReverseMessageOrderLabel => Get();
|
||||
public string ReverseMessageOrderTooltip => Get();
|
||||
public string FormatMarkdownLabel => Get();
|
||||
public string FormatMarkdownTooltip => Get();
|
||||
public string DownloadAssetsLabel => 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;
|
||||
|
||||
[ObservableProperty]
|
||||
[JsonConverter(typeof(TokenEncryptionConverter))]
|
||||
public partial string? LastToken { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -61,6 +62,9 @@ public partial class SettingsService()
|
||||
[ObservableProperty]
|
||||
public partial string? LastMessageFilterValue { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool LastIsReverseMessageOrder { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
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)
|
||||
{
|
||||
using var process = new Process();
|
||||
process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true };
|
||||
process.StartInfo = new ProcessStartInfo(path) { UseShellExecute = true };
|
||||
|
||||
process.Start();
|
||||
}
|
||||
|
||||
@@ -275,6 +275,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
dialog.Before?.Pipe(Snowflake.FromDate),
|
||||
dialog.PartitionLimit,
|
||||
dialog.MessageFilter,
|
||||
dialog.IsReverseMessageOrder,
|
||||
dialog.ShouldFormatMarkdown,
|
||||
dialog.ShouldDownloadAssets,
|
||||
dialog.ShouldReuseAssets,
|
||||
|
||||
@@ -62,6 +62,9 @@ public partial class ExportSetupViewModel(
|
||||
[NotifyPropertyChangedFor(nameof(MessageFilter))]
|
||||
public partial string? MessageFilterValue { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsReverseMessageOrder { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool ShouldFormatMarkdown { get; set; }
|
||||
|
||||
@@ -106,6 +109,7 @@ public partial class ExportSetupViewModel(
|
||||
SelectedFormat = settingsService.LastExportFormat;
|
||||
PartitionLimitValue = settingsService.LastPartitionLimitValue;
|
||||
MessageFilterValue = settingsService.LastMessageFilterValue;
|
||||
IsReverseMessageOrder = settingsService.LastIsReverseMessageOrder;
|
||||
ShouldFormatMarkdown = settingsService.LastShouldFormatMarkdown;
|
||||
ShouldDownloadAssets = settingsService.LastShouldDownloadAssets;
|
||||
ShouldReuseAssets = settingsService.LastShouldReuseAssets;
|
||||
@@ -120,7 +124,8 @@ public partial class ExportSetupViewModel(
|
||||
|| !string.IsNullOrWhiteSpace(MessageFilterValue)
|
||||
|| ShouldDownloadAssets
|
||||
|| ShouldReuseAssets
|
||||
|| !string.IsNullOrWhiteSpace(AssetsDirPath);
|
||||
|| !string.IsNullOrWhiteSpace(AssetsDirPath)
|
||||
|| IsReverseMessageOrder;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -184,6 +189,7 @@ public partial class ExportSetupViewModel(
|
||||
settingsService.LastExportFormat = SelectedFormat;
|
||||
settingsService.LastPartitionLimitValue = PartitionLimitValue;
|
||||
settingsService.LastMessageFilterValue = MessageFilterValue;
|
||||
settingsService.LastIsReverseMessageOrder = IsReverseMessageOrder;
|
||||
settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown;
|
||||
settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
|
||||
settingsService.LastShouldReuseAssets = ShouldReuseAssets;
|
||||
|
||||
@@ -160,7 +160,8 @@
|
||||
<Grid
|
||||
Background="Transparent"
|
||||
Classes.category="{Binding Channel.IsCategory}"
|
||||
ColumnDefinitions="Auto,*,Auto">
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
DoubleTapped="ChannelGrid_OnDoubleTapped">
|
||||
<Grid.Styles>
|
||||
<Style Selector="Grid">
|
||||
<Style Selector="^:not(.category)">
|
||||
@@ -219,35 +220,35 @@
|
||||
<Panel IsVisible="{Binding !AvailableGuilds.Count}">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="32,16" Spacing="0">
|
||||
|
||||
<!-- User token -->
|
||||
<TextBlock>
|
||||
<InlineUIContainer>
|
||||
<materialIcons:MaterialIcon
|
||||
Width="18"
|
||||
Height="18"
|
||||
Margin="0,-2,0,0"
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Account" />
|
||||
</InlineUIContainer>
|
||||
<Run Text=" " />
|
||||
<Run Text="" />
|
||||
<Run
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding LocalizationManager.TokenPersonalHeader}" />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Inlines="{Binding LocalizationManager.TokenPersonalTosWarning, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap" />
|
||||
<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}}"
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenPersonalInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<!-- Bot token -->
|
||||
<TextBlock Margin="0,12,0,0">
|
||||
@@ -255,29 +256,30 @@
|
||||
<materialIcons:MaterialIcon
|
||||
Width="18"
|
||||
Height="18"
|
||||
Margin="0,-2,0,0"
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Robot" />
|
||||
</InlineUIContainer>
|
||||
<Run Text=" " />
|
||||
<Run Text="" />
|
||||
<Run
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding LocalizationManager.TokenBotHeader}" />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Inlines="{Binding LocalizationManager.TokenBotInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenBotInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Margin="0,12,0,0"
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap"
|
||||
Inlines="{Binding LocalizationManager.TokenHelpText, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" />
|
||||
<TextBlock
|
||||
Margin="0,12,0,0"
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenHelpText, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Panel>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Gui.Framework;
|
||||
@@ -36,4 +37,12 @@ public partial class DashboardView : UserControl<DashboardViewModel>
|
||||
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>
|
||||
</TextBlock.Styles>
|
||||
</TextBlock>
|
||||
</UserControl>
|
||||
</UserControl>
|
||||
@@ -51,11 +51,7 @@
|
||||
IsVisible="{Binding IsSingleChannel}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Channels[0], Converter={x:Static converters:ChannelToHierarchicalNameStringConverter.Instance}}">
|
||||
<TextBlock IsVisible="{Binding !!Channels[0].Parent}">
|
||||
<Run Text="{Binding Channels[0].Parent.Name, Mode=OneWay}" />
|
||||
<Run Text="/" />
|
||||
</TextBlock>
|
||||
<Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
|
||||
<Run Text="{Binding Channels[0].Parent.Name, StringFormat='{}{0} / ', FallbackValue='', TargetNullValue='', Mode=OneWay}" /><Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
|
||||
@@ -75,8 +71,8 @@
|
||||
<ToolTip.Tip>
|
||||
<TextBlock
|
||||
MaxWidth="400"
|
||||
TextWrapping="Wrap"
|
||||
Inlines="{Binding LocalizationManager.OutputPathTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" />
|
||||
Inlines="{Binding LocalizationManager.OutputPathTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
TextWrapping="Wrap" />
|
||||
</ToolTip.Tip>
|
||||
<TextBox.InnerRightContent>
|
||||
<Button
|
||||
@@ -196,6 +192,15 @@
|
||||
Theme="{DynamicResource FilledTextBox}"
|
||||
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 -->
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
|
||||
@@ -59,10 +59,13 @@
|
||||
</DockPanel>
|
||||
|
||||
<!-- Persist token -->
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.PersistTokenTooltip}">
|
||||
<DockPanel Margin="16,8" LastChildFill="False">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock
|
||||
MaxWidth="400"
|
||||
Inlines="{Binding LocalizationManager.PersistTokenTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
TextWrapping="Wrap" />
|
||||
</ToolTip.Tip>
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.PersistTokenLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
|
||||
</DockPanel>
|
||||
|
||||
@@ -8,6 +8,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{EA305DD5-1
|
||||
License.txt = License.txt
|
||||
Readme.md = Readme.md
|
||||
Directory.Build.props = Directory.Build.props
|
||||
Directory.Packages.props = Directory.Packages.props
|
||||
DiscordChatExporter.Cli.dockerfile = DiscordChatExporter.Cli.dockerfile
|
||||
global.json = global.json
|
||||
NuGet.config = NuGet.config
|
||||
|
||||
Reference in New Issue
Block a user