From fcf58f5b8e5fc64694fd8851184d66f5848cd013 Mon Sep 17 00:00:00 2001 From: Solareon <769465+solareon@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:57:43 +0100 Subject: [PATCH 1/6] feat: add action row support this adds support for exporting the action rows sometimes found in embeds. Only was able to test with single button action rows. I used copilot to draft up a proof of concept and tweaked to get it working 100% and fixed the colors to match discord styling Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Specs/ComponentParsingSpecs.cs | 83 +++++++++++++++++++ .../Specs/JsonContentSpecs.cs | 2 +- .../Data/Components/ActionRowComponent.cs | 32 +++++++ .../Data/Components/ButtonComponent.cs | 48 +++++++++++ .../Discord/Data/Components/ButtonStyle.cs | 12 +++ .../Discord/Data/Message.cs | 14 +++- .../Exporting/JsonMessageWriter.cs | 55 +++++++++++- .../Exporting/MessageGroupTemplate.cshtml | 56 +++++++++++++ .../Exporting/PreambleTemplate.cshtml | 66 +++++++++++++++ 9 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 DiscordChatExporter.Cli.Tests/Specs/ComponentParsingSpecs.cs create mode 100644 DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs create mode 100644 DiscordChatExporter.Core/Discord/Data/Components/ButtonComponent.cs create mode 100644 DiscordChatExporter.Core/Discord/Data/Components/ButtonStyle.cs diff --git a/DiscordChatExporter.Cli.Tests/Specs/ComponentParsingSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/ComponentParsingSpecs.cs new file mode 100644 index 00000000..f723a21c --- /dev/null +++ b/DiscordChatExporter.Cli.Tests/Specs/ComponentParsingSpecs.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using DiscordChatExporter.Core.Discord.Data; +using FluentAssertions; +using Xunit; + +namespace DiscordChatExporter.Cli.Tests.Specs; + +public class ComponentParsingSpecs +{ + [Fact] + public void I_can_parse_a_link_button_component_from_a_message_payload() + { + // Arrange + using var document = JsonDocument.Parse( + """ + { + "id": "123456789012345678", + "type": 0, + "author": { + "id": "987654321098765432", + "username": "Tester", + "discriminator": "0", + "avatar": null + }, + "timestamp": "2026-02-25T00:00:00.000000+00:00", + "content": "", + "attachments": [], + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Direct Link", + "url": "https://www.example.com", + "custom_id": null, + "sku_id": null, + "disabled": false, + "emoji": { + "id": null, + "name": "📎", + "animated": false + } + } + ] + } + ], + "embeds": [], + "sticker_items": [], + "reactions": [], + "mentions": [] + } + """ + ); + + // Act + var message = Message.Parse(document.RootElement); + + // Assert + message.Components.Should().HaveCount(1); + message.IsEmpty.Should().BeFalse(); + + var actionRow = message.Components[0]; + actionRow.Components.Should().HaveCount(1); + + var button = actionRow.Components[0]; + button.Style.Should().Be(DiscordChatExporter.Core.Discord.Data.Components.ButtonStyle.Link); + button.Label.Should().Be("Direct Link"); + button + .Url.Should() + .Be( + "https://www.example.com" + ); + button.IsUrlButton.Should().BeTrue(); + button.IsDisabled.Should().BeFalse(); + + button.Emoji.Should().NotBeNull(); + button.Emoji!.Id.Should().BeNull(); + button.Emoji.Name.Should().Be("📎"); + button.Emoji.Code.Should().Be("paperclip"); + } +} diff --git a/DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs index ec1e7f1f..0f9701cf 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs @@ -88,4 +88,4 @@ public class JsonContentSpecs "866674314627121232" ); } -} +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs b/DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs new file mode 100644 index 00000000..6b597dd8 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using JsonExtensions.Reading; + +namespace DiscordChatExporter.Core.Discord.Data.Components; + +// https://discord.com/developers/docs/components/reference#action-row +public partial record ActionRowComponent(IReadOnlyList Components) +{ + public bool HasButtons => Components.Any(); +} + +public partial record ActionRowComponent +{ + public static ActionRowComponent? Parse(JsonElement json) + { + var type = json.GetPropertyOrNull("type")?.GetInt32OrNull(); + if (type != 1) + return null; + + var components = + json.GetPropertyOrNull("components") + ?.EnumerateArrayOrNull() + ?.Where(c => c.GetPropertyOrNull("type")?.GetInt32OrNull() == 2) + ?.Select(ButtonComponent.Parse) + .ToArray() + ?? []; + + return new ActionRowComponent(components); + } +} diff --git a/DiscordChatExporter.Core/Discord/Data/Components/ButtonComponent.cs b/DiscordChatExporter.Core/Discord/Data/Components/ButtonComponent.cs new file mode 100644 index 00000000..b719f210 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Components/ButtonComponent.cs @@ -0,0 +1,48 @@ +using System; +using System.Text.Json; +using DiscordChatExporter.Core.Discord.Data.Common; +using DiscordChatExporter.Core.Utils.Extensions; +using JsonExtensions.Reading; + +namespace DiscordChatExporter.Core.Discord.Data.Components; + +// https://discord.com/developers/docs/components/reference#button +public partial record ButtonComponent( + ButtonStyle Style, + string? Label, + Emoji? Emoji, + string? Url, + string? CustomId, + Snowflake? SkuId, + bool IsDisabled +) +{ + public bool IsUrlButton => !string.IsNullOrWhiteSpace(Url); +} + +public partial record ButtonComponent +{ + public static ButtonComponent Parse(JsonElement json) + { + var style = + json.GetPropertyOrNull("style") + ?.GetInt32OrNull() + ?.Pipe(s => + Enum.IsDefined(typeof(ButtonStyle), s) ? (ButtonStyle)s : (ButtonStyle?)null + ) + ?? ButtonStyle.Secondary; + + var label = json.GetPropertyOrNull("label")?.GetStringOrNull(); + var emoji = json.GetPropertyOrNull("emoji")?.Pipe(Emoji.Parse); + + var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); + var customId = json.GetPropertyOrNull("custom_id")?.GetNonWhiteSpaceStringOrNull(); + var skuId = json.GetPropertyOrNull("sku_id") + ?.GetNonWhiteSpaceStringOrNull() + ?.Pipe(Snowflake.Parse); + + var isDisabled = json.GetPropertyOrNull("disabled")?.GetBooleanOrNull() ?? false; + + return new ButtonComponent(style, label, emoji, url, customId, skuId, isDisabled); + } +} diff --git a/DiscordChatExporter.Core/Discord/Data/Components/ButtonStyle.cs b/DiscordChatExporter.Core/Discord/Data/Components/ButtonStyle.cs new file mode 100644 index 00000000..a00de9e8 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Components/ButtonStyle.cs @@ -0,0 +1,12 @@ +namespace DiscordChatExporter.Core.Discord.Data.Components; + +// https://discord.com/developers/docs/components/reference#button-button-styles +public enum ButtonStyle +{ + Primary = 1, + Secondary = 2, + Success = 3, + Danger = 4, + Link = 5, + Premium = 6, +} diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index 9b1c4f92..60932828 100644 --- a/DiscordChatExporter.Core/Discord/Data/Message.cs +++ b/DiscordChatExporter.Core/Discord/Data/Message.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; using DiscordChatExporter.Core.Discord.Data.Common; +using DiscordChatExporter.Core.Discord.Data.Components; using DiscordChatExporter.Core.Discord.Data.Embeds; using DiscordChatExporter.Core.Utils.Extensions; using JsonExtensions.Reading; @@ -21,6 +22,7 @@ public partial record Message( bool IsPinned, string Content, IReadOnlyList Attachments, + IReadOnlyList Components, IReadOnlyList Embeds, IReadOnlyList Stickers, IReadOnlyList Reactions, @@ -34,6 +36,7 @@ public partial record Message( public bool IsEmpty { get; } = string.IsNullOrWhiteSpace(Content) && !Attachments.Any() + && !Components.Any() && !Embeds.Any() && !Stickers.Any(); @@ -149,6 +152,14 @@ public partial record Message .ToArray() ?? []; + var components = + json.GetPropertyOrNull("components") + ?.EnumerateArrayOrNull() + ?.Select(ActionRowComponent.Parse) + .WhereNotNull() + .ToArray() + ?? []; + var embeds = NormalizeEmbeds( json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ?? [] @@ -198,6 +209,7 @@ public partial record Message isPinned, content, attachments, + components, embeds, stickers, reactions, @@ -208,4 +220,4 @@ public partial record Message interaction ); } -} +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs index 6bb8cc11..c3c38739 100644 --- a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using DiscordChatExporter.Core.Discord.Data; +using DiscordChatExporter.Core.Discord.Data.Components; using DiscordChatExporter.Core.Discord.Data.Embeds; using DiscordChatExporter.Core.Markdown.Parsing; using DiscordChatExporter.Core.Utils.Extensions; @@ -352,7 +353,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) _writer.WriteEndObject(); await _writer.FlushAsync(cancellationToken); } - + private async ValueTask WriteStickerAsync( Sticker sticker, CancellationToken cancellationToken = default @@ -369,6 +370,48 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) ); _writer.WriteEndObject(); + await _writer.FlushAsync(cancellationToken); + } + + private async ValueTask WriteButtonComponentAsync( + ButtonComponent button, + { + _writer.WriteStartObject(); + + _writer.WriteString("type", "Button"); + _writer.WriteString("style", button.Style.ToString()); + _writer.WriteString("label", button.Label); + _writer.WriteString("url", button.Url); + _writer.WriteString("customId", button.CustomId); + _writer.WriteString("skuId", button.SkuId?.ToString()); + _writer.WriteBoolean("isDisabled", button.IsDisabled); + + if (button.Emoji is not null) + { + _writer.WritePropertyName("emoji"); + await WriteEmojiAsync(button.Emoji, cancellationToken); + } + + _writer.WriteEndObject(); + await _writer.FlushAsync(cancellationToken); + } + + private async ValueTask WriteActionRowComponentAsync( + ActionRowComponent actionRow, + CancellationToken cancellationToken = default + ) + { + _writer.WriteStartObject(); + + _writer.WriteString("type", "ActionRow"); + + _writer.WriteStartArray("components"); + foreach (var button in actionRow.Components) + await WriteButtonComponentAsync(button, cancellationToken); + _writer.WriteEndArray(); + + _writer.WriteEndObject(); + await _writer.FlushAsync(cancellationToken); } public override async ValueTask WritePreambleAsync( @@ -477,6 +520,14 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) _writer.WriteEndArray(); + // Components + _writer.WriteStartArray("components"); + + foreach (var component in message.Components) + await WriteActionRowComponentAsync(component, cancellationToken); + + _writer.WriteEndArray(); + // Embeds _writer.WriteStartArray("embeds"); @@ -645,4 +696,4 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) await _writer.DisposeAsync(); await base.DisposeAsync(); } -} +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index cefb8bfb..b14e4122 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -3,6 +3,7 @@ @using System.Linq @using System.Threading.Tasks @using DiscordChatExporter.Core.Discord.Data +@using DiscordChatExporter.Core.Discord.Data.Components @using DiscordChatExporter.Core.Discord.Data.Embeds @using DiscordChatExporter.Core.Markdown.Parsing @using DiscordChatExporter.Core.Utils.Extensions @@ -731,6 +732,61 @@ } } + @* Components *@ + @if (message.Components.Any(c => c.HasButtons)) + { +
+ @foreach (var actionRow in message.Components.Where(c => c.HasButtons)) + { +
+ @foreach (var button in actionRow.Components) + { + var styleClass = button.Style switch { + ButtonStyle.Primary => "chatlog__component-button--primary", + ButtonStyle.Secondary => "chatlog__component-button--secondary", + ButtonStyle.Success => "chatlog__component-button--success", + ButtonStyle.Danger => "chatlog__component-button--danger", + ButtonStyle.Link => "chatlog__component-button--link", + ButtonStyle.Premium => "chatlog__component-button--premium", + _ => "chatlog__component-button--secondary" + }; + + var isUrlButton = button.IsUrlButton; + + if (isUrlButton) + { + + @if (button.Emoji is not null) + { + @button.Emoji.Name + } + + @if (!string.IsNullOrWhiteSpace(button.Label)) + { + @button.Label + } + + } + else + { + + } + } +
+ } +
+ } + @* Stickers *@ @foreach (var sticker in message.Stickers) { diff --git a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml index 3b9cad68..a43a4db3 100644 --- a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml @@ -430,6 +430,72 @@ font-weight: 500; } + .chatlog__components { + margin-top: 0.3rem; + } + + .chatlog__action-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.3rem; + } + + .chatlog__component-button { + display: inline-flex; + gap: 0.35rem; + align-items: center; + justify-content: center; + min-height: 2rem; + min-width: 3.75rem; + padding: 0 1rem; + border: 0; + border-radius: 3px; + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.2; + text-decoration: none; + white-space: nowrap; + cursor: default; + box-sizing: border-box; + } + + a.chatlog__component-button:hover { + text-decoration: none; + filter: brightness(0.95); + } + + .chatlog__component-button--primary { + background-color: #5865f2; + color: #ffffff; + } + + .chatlog__component-button--secondary { + background-color: @Themed("#4e5058", "#e3e5e8"); + color: @Themed("#ffffff", "#313338"); + } + + .chatlog__component-button--success { + background-color: #248046; + color: #ffffff; + } + + .chatlog__component-button--danger { + background-color: #da373c; + color: #ffffff; + } + + .chatlog__component-button--link { + background-color: #4e5058; + color: #ffffff; + } + + .chatlog__component-button--premium { + background-color: #5865f2; + color: #ffffff; + } + .chatlog__attachment { position: relative; width: fit-content; From 231857e9257b4d7c541c6741b5b27e9ca0db3190 Mon Sep 17 00:00:00 2001 From: Solareon <769465+solareon@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:40:46 +0100 Subject: [PATCH 2/6] refactor: remove excessive test --- .../Specs/ComponentParsingSpecs.cs | 83 ------------------- 1 file changed, 83 deletions(-) delete mode 100644 DiscordChatExporter.Cli.Tests/Specs/ComponentParsingSpecs.cs diff --git a/DiscordChatExporter.Cli.Tests/Specs/ComponentParsingSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/ComponentParsingSpecs.cs deleted file mode 100644 index f723a21c..00000000 --- a/DiscordChatExporter.Cli.Tests/Specs/ComponentParsingSpecs.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Text.Json; -using DiscordChatExporter.Core.Discord.Data; -using FluentAssertions; -using Xunit; - -namespace DiscordChatExporter.Cli.Tests.Specs; - -public class ComponentParsingSpecs -{ - [Fact] - public void I_can_parse_a_link_button_component_from_a_message_payload() - { - // Arrange - using var document = JsonDocument.Parse( - """ - { - "id": "123456789012345678", - "type": 0, - "author": { - "id": "987654321098765432", - "username": "Tester", - "discriminator": "0", - "avatar": null - }, - "timestamp": "2026-02-25T00:00:00.000000+00:00", - "content": "", - "attachments": [], - "components": [ - { - "type": 1, - "components": [ - { - "type": 2, - "style": 5, - "label": "Direct Link", - "url": "https://www.example.com", - "custom_id": null, - "sku_id": null, - "disabled": false, - "emoji": { - "id": null, - "name": "📎", - "animated": false - } - } - ] - } - ], - "embeds": [], - "sticker_items": [], - "reactions": [], - "mentions": [] - } - """ - ); - - // Act - var message = Message.Parse(document.RootElement); - - // Assert - message.Components.Should().HaveCount(1); - message.IsEmpty.Should().BeFalse(); - - var actionRow = message.Components[0]; - actionRow.Components.Should().HaveCount(1); - - var button = actionRow.Components[0]; - button.Style.Should().Be(DiscordChatExporter.Core.Discord.Data.Components.ButtonStyle.Link); - button.Label.Should().Be("Direct Link"); - button - .Url.Should() - .Be( - "https://www.example.com" - ); - button.IsUrlButton.Should().BeTrue(); - button.IsDisabled.Should().BeFalse(); - - button.Emoji.Should().NotBeNull(); - button.Emoji!.Id.Should().BeNull(); - button.Emoji.Name.Should().Be("📎"); - button.Emoji.Code.Should().Be("paperclip"); - } -} From a54e011ad86487c571d7d3cce9c538e27deb7607 Mon Sep 17 00:00:00 2001 From: Solareon <769465+solareon@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:02:10 +0100 Subject: [PATCH 3/6] fix: add themeing support for dark/light mode for buttons --- DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml index a43a4db3..e2286d67 100644 --- a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml @@ -487,8 +487,8 @@ } .chatlog__component-button--link { - background-color: #4e5058; - color: #ffffff; + background-color: @Themed("#4e5058", "#97979f"); + color: @Themed("#ffffff", "#2F3035"); } .chatlog__component-button--premium { From 09f9f387d7a8ba7c15e73f6f9ecc6d8dc6cd8eec Mon Sep 17 00:00:00 2001 From: Solareon <769465+solareon@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:10:56 +0100 Subject: [PATCH 4/6] chore: add todo for other component types --- .../Discord/Data/Components/ActionRowComponent.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs b/DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs index 6b597dd8..4c012f3d 100644 --- a/DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs +++ b/DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs @@ -22,7 +22,7 @@ public partial record ActionRowComponent var components = json.GetPropertyOrNull("components") ?.EnumerateArrayOrNull() - ?.Where(c => c.GetPropertyOrNull("type")?.GetInt32OrNull() == 2) + ?.Where(c => c.GetPropertyOrNull("type")?.GetInt32OrNull() == 2) // TODO: support other component types (selects) ?.Select(ButtonComponent.Parse) .ToArray() ?? []; From 1a671b31e628f013ff6b8a75787ef8f256af2ab8 Mon Sep 17 00:00:00 2001 From: Solareon <769465+solareon@users.noreply.github.com> Date: Fri, 27 Feb 2026 09:12:49 +0100 Subject: [PATCH 5/6] fix: remove unused import from test --- PKGBUILD | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 PKGBUILD diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 00000000..84942f94 --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,152 @@ +# Maintainer: Vitalii Kuzhdin + +_sdk=10.0 +_Name="DiscordChatExporter" +pkgbase="discord-chat-exporter" +pkgname=( + "${pkgbase}-core" + "${pkgbase}-cli" + "${pkgbase}-gui" +) +pkgver=2.46.1 +pkgrel=1 +pkgdesc="Exports Discord chat logs to a file" +arch=( + 'aarch64' + 'armv7h' + 'x86_64' +) +url="https://github.com/Tyrrrz/${_Name}" +license=( + 'MIT' +) +depends=( + "dotnet-runtime-${_sdk}" +) +makedepends=( + "dotnet-sdk-${_sdk}" + 'gendesk' +) +options=( + '!strip' + '!debug' +) +_pkgsrc="${_Name}-${pkgver}" +source=( + "${pkgbase}_xdg_settings.patch" +) +b2sums=('ec3740a7c60b0f5fc2773e991e6cde9b4116d77d50094b237e118f456d9273c18a8e3bc2da2ff8a86eb35fa7df4f81c94759467b415f53e4794fb7a4e0929a91') + +if [ "${CARCH}" = 'aarch64' ]; then _msarch=arm64; +elif [ "${CARCH}" = 'armv7h' ]; then _msarch=arm; +elif [ "${CARCH}" = 'x86_64' ]; then _msarch=x64; fi + +_source() { + export NUGET_PACKAGES="${srcdir}/.nuget" + export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true + export DOTNET_NOLOGO=true + export DOTNET_CLI_TELEMETRY_OPTOUT=true +} + +prepare() { + _source + local dotnet_restore_options=( + --runtime "linux-${_msarch}" + --locked-mode + ) + + mkdir -p "${srcdir}/${_pkgsrc}" + rsync -a --delete \ + --exclude='/.git' \ + --exclude='/src' \ + --exclude='/pkg' \ + --exclude='/.nuget' \ + "${startdir}/" "${srcdir}/${_pkgsrc}/" + + cd "${srcdir}/${_pkgsrc}" + patch -Np1 -i "${srcdir}/${pkgbase}_xdg_settings.patch" + + for dir in Core Cli Gui; do + dotnet restore "${dotnet_restore_options[@]}" "${_Name}.${dir}" + done +} + +build() { + _source + local dotnet_publish_options=( + --configuration Release + --framework "net${_sdk}" + --no-restore + # --output build + --no-self-contained + --runtime "linux-${_msarch}" + -p:DebugType=None + -p:DebugSymbols=false + -p:Version="${pkgver%%.[A-Za-z]*}" + -p:PublishTrimmed=false + -p:PublishMacOSBundle=false + ) + + cd "${srcdir}" + gendesk -f -n \ + --pkgname "${pkgbase}-gui" \ + --pkgdesc "${pkgdesc}" \ + --name "Discord Chat Exporter (GUI)" \ + --exec "${pkgbase}-gui" \ + --icon "${pkgbase}" \ + --categories "Utility" + + cd "${_pkgsrc}" + dotnet publish "${dotnet_publish_options[@]}" --output build-core "${_Name}.Core" + + mkdir -p build-{cli,gui} + cp -aT build-core build-cli + cp -aT build-core build-gui + + dotnet publish "${dotnet_publish_options[@]}" --output build-cli "${_Name}.Cli" + dotnet publish "${dotnet_publish_options[@]}" --output build-gui "${_Name}.Gui" + + find build-core -type f | while read -r f; do + rel="${f#build-core/}" + rm -f "build-cli/$rel" "build-gui/$rel" + done +} + +package_discord-chat-exporter-core() { + pkgdesc+=" - Core" + + cd "${srcdir}/${_pkgsrc}" + install -vd "${pkgdir}/usr/lib/${pkgbase}" + cp -vaT --no-preserve=ownership "build-core" "${pkgdir}/usr/lib/${pkgbase}" + + install -vDm644 "Readme.md" "${pkgdir}/usr/share/doc/${pkgbase}/README.md" + install -vDm644 "License.txt" "${pkgdir}/usr/share/licenses/${pkgbase}/LICENSE" + install -vDm644 "favicon.png" "${pkgdir}/usr/share/pixmaps/${pkgbase}.png" +} + +package_discord-chat-exporter-cli() { + pkgdesc+=" - CLI" + depends+=( + "${pkgbase}-core>=${pkgver}-${pkgrel}" + ) + + cd "${srcdir}/${_pkgsrc}" + install -vd "${pkgdir}/usr/bin" "${pkgdir}/usr/lib/${pkgbase}" + cp -vaT --no-preserve=ownership "build-cli" "${pkgdir}/usr/lib/${pkgbase}" + ln -vsf "/usr/lib/${pkgbase}/${_Name}.Cli" "${pkgdir}/usr/bin/${pkgname}" +} + +package_discord-chat-exporter-gui() { + pkgdesc+=" - GUI" + depends+=( + "${pkgbase}-core>=${pkgver}-${pkgrel}" + ) + + cd "${srcdir}" + install -vDm644 "${pkgname}.desktop" "${pkgdir}/usr/share/applications/${pkgname}.desktop" + + cd "${_pkgsrc}" + install -vd "${pkgdir}/usr/bin" "${pkgdir}/usr/lib/${pkgbase}" + cp -vaT --no-preserve=ownership "build-gui" "${pkgdir}/usr/lib/${pkgbase}/" + ln -vsf "/usr/lib/${pkgbase}/${_Name}" "${pkgdir}/usr/bin/${pkgname}" +} From 0414da2fb71fdd8010ac2c8ad54b8ac32f9b8b3e Mon Sep 17 00:00:00 2001 From: Solareon <769465+solareon@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:59:29 +0100 Subject: [PATCH 6/6] refactor: pt 1 of redesign for generic components structure --- .../Specs/JsonContentSpecs.cs | 2 +- .../Data/Components/ActionRowComponent.cs | 32 ---- .../Data/Components/MessageComponent.cs | 49 ++++++ .../Data/Components/MessageComponentKind.cs | 22 +++ .../Discord/Data/Message.cs | 22 +-- .../Exporting/JsonMessageWriter.cs | 66 ++++---- .../Exporting/MessageGroupTemplate.cshtml | 6 +- PKGBUILD | 152 ------------------ 8 files changed, 118 insertions(+), 233 deletions(-) delete mode 100644 DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs create mode 100644 DiscordChatExporter.Core/Discord/Data/Components/MessageComponent.cs create mode 100644 DiscordChatExporter.Core/Discord/Data/Components/MessageComponentKind.cs delete mode 100644 PKGBUILD diff --git a/DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs index 0f9701cf..ec1e7f1f 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs @@ -88,4 +88,4 @@ public class JsonContentSpecs "866674314627121232" ); } -} \ No newline at end of file +} diff --git a/DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs b/DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs deleted file mode 100644 index 4c012f3d..00000000 --- a/DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Text.Json; -using JsonExtensions.Reading; - -namespace DiscordChatExporter.Core.Discord.Data.Components; - -// https://discord.com/developers/docs/components/reference#action-row -public partial record ActionRowComponent(IReadOnlyList Components) -{ - public bool HasButtons => Components.Any(); -} - -public partial record ActionRowComponent -{ - public static ActionRowComponent? Parse(JsonElement json) - { - var type = json.GetPropertyOrNull("type")?.GetInt32OrNull(); - if (type != 1) - return null; - - var components = - json.GetPropertyOrNull("components") - ?.EnumerateArrayOrNull() - ?.Where(c => c.GetPropertyOrNull("type")?.GetInt32OrNull() == 2) // TODO: support other component types (selects) - ?.Select(ButtonComponent.Parse) - .ToArray() - ?? []; - - return new ActionRowComponent(components); - } -} diff --git a/DiscordChatExporter.Core/Discord/Data/Components/MessageComponent.cs b/DiscordChatExporter.Core/Discord/Data/Components/MessageComponent.cs new file mode 100644 index 00000000..82622aa9 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Components/MessageComponent.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using DiscordChatExporter.Core.Utils.Extensions; +using JsonExtensions.Reading; + +namespace DiscordChatExporter.Core.Discord.Data.Components; + +// https://docs.discord.com/developers/components/reference#what-is-a-component +public partial record MessageComponent( + MessageComponentKind Kind, + IReadOnlyList Components, + ButtonComponent? Button +) +{ + public bool HasButtons => Button is not null || Components.Any(c => c.HasButtons); + + public IReadOnlyList Buttons => + Components.Select(c => c.Button).WhereNotNull().ToArray(); +} + +public partial record MessageComponent +{ + public static MessageComponent? Parse(JsonElement json) + { + var rawType = json.GetPropertyOrNull("type")?.GetInt32OrNull(); + if (rawType is null) + return null; + + var type = rawType.Value; + if (!Enum.IsDefined(typeof(MessageComponentKind), type)) + return null; + + var kind = (MessageComponentKind)type; + + var components = + json.GetPropertyOrNull("components") + ?.EnumerateArrayOrNull() + ?.Select(Parse) + .WhereNotNull() + .ToArray() + ?? []; + + var button = kind == MessageComponentKind.Button ? ButtonComponent.Parse(json) : null; + + return new MessageComponent(kind, components, button); + } +} diff --git a/DiscordChatExporter.Core/Discord/Data/Components/MessageComponentKind.cs b/DiscordChatExporter.Core/Discord/Data/Components/MessageComponentKind.cs new file mode 100644 index 00000000..a7e1c122 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Components/MessageComponentKind.cs @@ -0,0 +1,22 @@ +namespace DiscordChatExporter.Core.Discord.Data.Components; + +// https://discord.com/developers/docs/components/reference#component-object-component-types +public enum MessageComponentKind +{ + ActionRow = 1, + Button = 2, + StringSelect = 3, + TextInput = 4, + UserSelect = 5, + RoleSelect = 6, + MentionableSelect = 7, + ChannelSelect = 8, + Section = 9, + TextDisplay = 10, + Thumbnail = 11, + MediaGallery = 12, + File = 13, + Separator = 14, + Container = 17, + Label = 18, +} diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index 60932828..7f4b1a16 100644 --- a/DiscordChatExporter.Core/Discord/Data/Message.cs +++ b/DiscordChatExporter.Core/Discord/Data/Message.cs @@ -22,9 +22,9 @@ public partial record Message( bool IsPinned, string Content, IReadOnlyList Attachments, - IReadOnlyList Components, IReadOnlyList Embeds, IReadOnlyList Stickers, + IReadOnlyList Components, IReadOnlyList Reactions, IReadOnlyList MentionedUsers, MessageReference? Reference, @@ -152,14 +152,6 @@ public partial record Message .ToArray() ?? []; - var components = - json.GetPropertyOrNull("components") - ?.EnumerateArrayOrNull() - ?.Select(ActionRowComponent.Parse) - .WhereNotNull() - .ToArray() - ?? []; - var embeds = NormalizeEmbeds( json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ?? [] @@ -172,6 +164,14 @@ public partial record Message .ToArray() ?? []; + var components = + json.GetPropertyOrNull("components") + ?.EnumerateArrayOrNull() + ?.Select(MessageComponent.Parse) + .WhereNotNull() + .ToArray() + ?? []; + var reactions = json.GetPropertyOrNull("reactions") ?.EnumerateArrayOrNull() @@ -209,9 +209,9 @@ public partial record Message isPinned, content, attachments, - components, embeds, stickers, + components, reactions, mentionedUsers, messageReference, @@ -220,4 +220,4 @@ public partial record Message interaction ); } -} \ No newline at end of file +} diff --git a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs index c3c38739..988b4464 100644 --- a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs @@ -353,7 +353,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) _writer.WriteEndObject(); await _writer.FlushAsync(cancellationToken); } - + private async ValueTask WriteStickerAsync( Sticker sticker, CancellationToken cancellationToken = default @@ -373,45 +373,43 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) await _writer.FlushAsync(cancellationToken); } - private async ValueTask WriteButtonComponentAsync( - ButtonComponent button, - { - _writer.WriteStartObject(); - - _writer.WriteString("type", "Button"); - _writer.WriteString("style", button.Style.ToString()); - _writer.WriteString("label", button.Label); - _writer.WriteString("url", button.Url); - _writer.WriteString("customId", button.CustomId); - _writer.WriteString("skuId", button.SkuId?.ToString()); - _writer.WriteBoolean("isDisabled", button.IsDisabled); - - if (button.Emoji is not null) - { - _writer.WritePropertyName("emoji"); - await WriteEmojiAsync(button.Emoji, cancellationToken); - } - - _writer.WriteEndObject(); - await _writer.FlushAsync(cancellationToken); - } - - private async ValueTask WriteActionRowComponentAsync( - ActionRowComponent actionRow, + private async ValueTask WriteComponentAsync( + MessageComponent component, CancellationToken cancellationToken = default ) { _writer.WriteStartObject(); - _writer.WriteString("type", "ActionRow"); + _writer.WriteString("type", component.Kind.ToString()); - _writer.WriteStartArray("components"); - foreach (var button in actionRow.Components) - await WriteButtonComponentAsync(button, cancellationToken); - _writer.WriteEndArray(); + if (component.Button is not null) + { + _writer.WriteString("style", component.Button.Style.ToString()); + _writer.WriteString("label", component.Button.Label); + _writer.WriteString("url", component.Button.Url); + _writer.WriteString("customId", component.Button.CustomId); + _writer.WriteString("skuId", component.Button.SkuId?.ToString()); + _writer.WriteBoolean("isDisabled", component.Button.IsDisabled); + + if (component.Button.Emoji is not null) + { + _writer.WritePropertyName("emoji"); + await WriteEmojiAsync(component.Button.Emoji, cancellationToken); + } + } + + if (component.Components.Any()) + { + _writer.WriteStartArray("components"); + + foreach (var child in component.Components) + await WriteComponentAsync(child, cancellationToken); + + _writer.WriteEndArray(); + } _writer.WriteEndObject(); - await _writer.FlushAsync(cancellationToken); + await _writer.FlushAsync(cancellationToken); } public override async ValueTask WritePreambleAsync( @@ -524,7 +522,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) _writer.WriteStartArray("components"); foreach (var component in message.Components) - await WriteActionRowComponentAsync(component, cancellationToken); + await WriteComponentAsync(component, cancellationToken); _writer.WriteEndArray(); @@ -696,4 +694,4 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) await _writer.DisposeAsync(); await base.DisposeAsync(); } -} \ No newline at end of file +} diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index b14e4122..7777619f 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -733,13 +733,13 @@ } @* Components *@ - @if (message.Components.Any(c => c.HasButtons)) + @if (message.Components.Any(c => c.Kind == MessageComponentKind.ActionRow && c.HasButtons)) {
- @foreach (var actionRow in message.Components.Where(c => c.HasButtons)) + @foreach (var actionRow in message.Components.Where(c => c.Kind == MessageComponentKind.ActionRow && c.HasButtons)) {
- @foreach (var button in actionRow.Components) + @foreach (var button in actionRow.Buttons) { var styleClass = button.Style switch { ButtonStyle.Primary => "chatlog__component-button--primary", diff --git a/PKGBUILD b/PKGBUILD deleted file mode 100644 index 84942f94..00000000 --- a/PKGBUILD +++ /dev/null @@ -1,152 +0,0 @@ -# Maintainer: Vitalii Kuzhdin - -_sdk=10.0 -_Name="DiscordChatExporter" -pkgbase="discord-chat-exporter" -pkgname=( - "${pkgbase}-core" - "${pkgbase}-cli" - "${pkgbase}-gui" -) -pkgver=2.46.1 -pkgrel=1 -pkgdesc="Exports Discord chat logs to a file" -arch=( - 'aarch64' - 'armv7h' - 'x86_64' -) -url="https://github.com/Tyrrrz/${_Name}" -license=( - 'MIT' -) -depends=( - "dotnet-runtime-${_sdk}" -) -makedepends=( - "dotnet-sdk-${_sdk}" - 'gendesk' -) -options=( - '!strip' - '!debug' -) -_pkgsrc="${_Name}-${pkgver}" -source=( - "${pkgbase}_xdg_settings.patch" -) -b2sums=('ec3740a7c60b0f5fc2773e991e6cde9b4116d77d50094b237e118f456d9273c18a8e3bc2da2ff8a86eb35fa7df4f81c94759467b415f53e4794fb7a4e0929a91') - -if [ "${CARCH}" = 'aarch64' ]; then _msarch=arm64; -elif [ "${CARCH}" = 'armv7h' ]; then _msarch=arm; -elif [ "${CARCH}" = 'x86_64' ]; then _msarch=x64; fi - -_source() { - export NUGET_PACKAGES="${srcdir}/.nuget" - export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true - export DOTNET_NOLOGO=true - export DOTNET_CLI_TELEMETRY_OPTOUT=true -} - -prepare() { - _source - local dotnet_restore_options=( - --runtime "linux-${_msarch}" - --locked-mode - ) - - mkdir -p "${srcdir}/${_pkgsrc}" - rsync -a --delete \ - --exclude='/.git' \ - --exclude='/src' \ - --exclude='/pkg' \ - --exclude='/.nuget' \ - "${startdir}/" "${srcdir}/${_pkgsrc}/" - - cd "${srcdir}/${_pkgsrc}" - patch -Np1 -i "${srcdir}/${pkgbase}_xdg_settings.patch" - - for dir in Core Cli Gui; do - dotnet restore "${dotnet_restore_options[@]}" "${_Name}.${dir}" - done -} - -build() { - _source - local dotnet_publish_options=( - --configuration Release - --framework "net${_sdk}" - --no-restore - # --output build - --no-self-contained - --runtime "linux-${_msarch}" - -p:DebugType=None - -p:DebugSymbols=false - -p:Version="${pkgver%%.[A-Za-z]*}" - -p:PublishTrimmed=false - -p:PublishMacOSBundle=false - ) - - cd "${srcdir}" - gendesk -f -n \ - --pkgname "${pkgbase}-gui" \ - --pkgdesc "${pkgdesc}" \ - --name "Discord Chat Exporter (GUI)" \ - --exec "${pkgbase}-gui" \ - --icon "${pkgbase}" \ - --categories "Utility" - - cd "${_pkgsrc}" - dotnet publish "${dotnet_publish_options[@]}" --output build-core "${_Name}.Core" - - mkdir -p build-{cli,gui} - cp -aT build-core build-cli - cp -aT build-core build-gui - - dotnet publish "${dotnet_publish_options[@]}" --output build-cli "${_Name}.Cli" - dotnet publish "${dotnet_publish_options[@]}" --output build-gui "${_Name}.Gui" - - find build-core -type f | while read -r f; do - rel="${f#build-core/}" - rm -f "build-cli/$rel" "build-gui/$rel" - done -} - -package_discord-chat-exporter-core() { - pkgdesc+=" - Core" - - cd "${srcdir}/${_pkgsrc}" - install -vd "${pkgdir}/usr/lib/${pkgbase}" - cp -vaT --no-preserve=ownership "build-core" "${pkgdir}/usr/lib/${pkgbase}" - - install -vDm644 "Readme.md" "${pkgdir}/usr/share/doc/${pkgbase}/README.md" - install -vDm644 "License.txt" "${pkgdir}/usr/share/licenses/${pkgbase}/LICENSE" - install -vDm644 "favicon.png" "${pkgdir}/usr/share/pixmaps/${pkgbase}.png" -} - -package_discord-chat-exporter-cli() { - pkgdesc+=" - CLI" - depends+=( - "${pkgbase}-core>=${pkgver}-${pkgrel}" - ) - - cd "${srcdir}/${_pkgsrc}" - install -vd "${pkgdir}/usr/bin" "${pkgdir}/usr/lib/${pkgbase}" - cp -vaT --no-preserve=ownership "build-cli" "${pkgdir}/usr/lib/${pkgbase}" - ln -vsf "/usr/lib/${pkgbase}/${_Name}.Cli" "${pkgdir}/usr/bin/${pkgname}" -} - -package_discord-chat-exporter-gui() { - pkgdesc+=" - GUI" - depends+=( - "${pkgbase}-core>=${pkgver}-${pkgrel}" - ) - - cd "${srcdir}" - install -vDm644 "${pkgname}.desktop" "${pkgdir}/usr/share/applications/${pkgname}.desktop" - - cd "${_pkgsrc}" - install -vd "${pkgdir}/usr/bin" "${pkgdir}/usr/lib/${pkgbase}" - cp -vaT --no-preserve=ownership "build-gui" "${pkgdir}/usr/lib/${pkgbase}/" - ln -vsf "/usr/lib/${pkgbase}/${_Name}" "${pkgdir}/usr/bin/${pkgname}" -}