├── .assets ├── list.png └── output.png ├── .docs ├── Getting-started.md ├── Message-filters.md ├── Readme.md ├── Scheduling-Linux.md ├── Scheduling-Windows.md ├── Token-and-IDs.md ├── Troubleshooting.md ├── Using-the-CLI.md └── Using-the-GUI.md ├── .github └── workflows │ └── deploy.yml ├── .gitignore ├── Directory.Build.props ├── DiscordChatExporter.Cli.Tests ├── DiscordChatExporter.Cli.Tests.csproj ├── Infra │ ├── ChannelIds.cs │ ├── ExportWrapper.cs │ └── Secrets.cs ├── Readme.md ├── Specs │ ├── CsvContentSpecs.cs │ ├── DateRangeSpecs.cs │ ├── FilterSpecs.cs │ ├── HtmlAttachmentSpecs.cs │ ├── HtmlContentSpecs.cs │ ├── HtmlEmbedSpecs.cs │ ├── HtmlGroupingSpecs.cs │ ├── HtmlMarkdownSpecs.cs │ ├── HtmlMentionSpecs.cs │ ├── HtmlReplySpecs.cs │ ├── HtmlStickerSpecs.cs │ ├── JsonAttachmentSpecs.cs │ ├── JsonContentSpecs.cs │ ├── JsonEmbedSpecs.cs │ ├── JsonEmojiSpecs.cs │ ├── JsonMentionSpecs.cs │ ├── JsonStickerSpecs.cs │ ├── PartitioningSpecs.cs │ ├── PlainTextContentSpecs.cs │ └── SelfContainedSpecs.cs ├── Utils │ ├── Extensions │ │ └── StringExtensions.cs │ ├── Html.cs │ ├── TempDir.cs │ └── TempFile.cs └── xunit.runner.json ├── DiscordChatExporter.Cli ├── Commands │ ├── Base │ │ ├── DiscordCommandBase.cs │ │ └── ExportCommandBase.cs │ ├── Converters │ │ ├── ThreadInclusionModeBindingConverter.cs │ │ └── TruthyBooleanBindingConverter.cs │ ├── ExportAllCommand.cs │ ├── ExportChannelsCommand.cs │ ├── ExportDirectMessagesCommand.cs │ ├── ExportGuildCommand.cs │ ├── GetChannelsCommand.cs │ ├── GetDirectChannelsCommand.cs │ ├── GetGuildsCommand.cs │ ├── GuideCommand.cs │ └── Shared │ │ └── ThreadInclusionMode.cs ├── DiscordChatExporter.Cli.csproj ├── Program.cs └── Utils │ └── Extensions │ └── ConsoleExtensions.cs ├── DiscordChatExporter.Core ├── Discord │ ├── Data │ │ ├── Application.cs │ │ ├── ApplicationFlags.cs │ │ ├── Attachment.cs │ │ ├── Channel.cs │ │ ├── ChannelConnection.cs │ │ ├── ChannelKind.cs │ │ ├── Common │ │ │ ├── FileSize.cs │ │ │ ├── IHasId.cs │ │ │ └── ImageCdn.cs │ │ ├── Embeds │ │ │ ├── Embed.cs │ │ │ ├── EmbedAuthor.cs │ │ │ ├── EmbedField.cs │ │ │ ├── EmbedFooter.cs │ │ │ ├── EmbedImage.cs │ │ │ ├── EmbedKind.cs │ │ │ ├── EmbedVideo.cs │ │ │ ├── SpotifyTrackEmbedProjection.cs │ │ │ ├── TwitchClipEmbedProjection.cs │ │ │ └── YouTubeVideoEmbedProjection.cs │ │ ├── Emoji.cs │ │ ├── EmojiIndex.cs │ │ ├── Guild.cs │ │ ├── Interaction.cs │ │ ├── Invite.cs │ │ ├── Member.cs │ │ ├── Message.cs │ │ ├── MessageFlags.cs │ │ ├── MessageKind.cs │ │ ├── MessageReference.cs │ │ ├── Reaction.cs │ │ ├── Role.cs │ │ ├── Sticker.cs │ │ ├── StickerFormat.cs │ │ └── User.cs │ ├── DiscordClient.cs │ ├── Dump │ │ ├── DataDump.cs │ │ └── DataDumpChannel.cs │ ├── RateLimitPreference.cs │ ├── Snowflake.cs │ └── TokenKind.cs ├── DiscordChatExporter.Core.csproj ├── Exceptions │ ├── ChannelEmptyException.cs │ └── DiscordChatExporterException.cs ├── Exporting │ ├── ChannelExporter.cs │ ├── CsvMessageWriter.cs │ ├── ExportAssetDownloader.cs │ ├── ExportContext.cs │ ├── ExportFormat.cs │ ├── ExportRequest.cs │ ├── Filtering │ │ ├── BinaryExpressionKind.cs │ │ ├── BinaryExpressionMessageFilter.cs │ │ ├── ContainsMessageFilter.cs │ │ ├── FromMessageFilter.cs │ │ ├── HasMessageFilter.cs │ │ ├── MentionsMessageFilter.cs │ │ ├── MessageContentMatchKind.cs │ │ ├── MessageFilter.cs │ │ ├── NegatedMessageFilter.cs │ │ ├── NullMessageFilter.cs │ │ ├── Parsing │ │ │ └── FilterGrammar.cs │ │ └── ReactionMessageFilter.cs │ ├── HtmlMarkdownVisitor.cs │ ├── HtmlMessageExtensions.cs │ ├── HtmlMessageWriter.cs │ ├── JsonMessageWriter.cs │ ├── MessageExporter.cs │ ├── MessageGroupTemplate.cshtml │ ├── MessageWriter.cs │ ├── Partitioning │ │ ├── FileSizePartitionLimit.cs │ │ ├── MessageCountPartitionLimit.cs │ │ ├── NullPartitionLimit.cs │ │ └── PartitionLimit.cs │ ├── PlainTextMarkdownVisitor.cs │ ├── PlainTextMessageExtensions.cs │ ├── PlainTextMessageWriter.cs │ ├── PostambleTemplate.cshtml │ └── PreambleTemplate.cshtml ├── Markdown │ ├── EmojiNode.cs │ ├── FormattingKind.cs │ ├── FormattingNode.cs │ ├── HeadingNode.cs │ ├── IContainerNode.cs │ ├── InlineCodeBlockNode.cs │ ├── LinkNode.cs │ ├── ListItemNode.cs │ ├── ListNode.cs │ ├── MarkdownNode.cs │ ├── MentionKind.cs │ ├── MentionNode.cs │ ├── MultiLineCodeBlockNode.cs │ ├── Parsing │ │ ├── AggregateMatcher.cs │ │ ├── IMatcher.cs │ │ ├── MarkdownContext.cs │ │ ├── MarkdownParser.cs │ │ ├── MarkdownVisitor.cs │ │ ├── ParsedMatch.cs │ │ ├── RegexMatcher.cs │ │ ├── StringMatcher.cs │ │ └── StringSegment.cs │ ├── TextNode.cs │ └── TimestampNode.cs └── Utils │ ├── Docker.cs │ ├── Extensions │ ├── AsyncCollectionExtensions.cs │ ├── BinaryExtensions.cs │ ├── CollectionExtensions.cs │ ├── ColorExtensions.cs │ ├── ExceptionExtensions.cs │ ├── GenericExtensions.cs │ ├── HttpExtensions.cs │ ├── StringExtensions.cs │ ├── SuperpowerExtensions.cs │ └── TimeSpanExtensions.cs │ ├── Http.cs │ ├── PathEx.cs │ ├── Url.cs │ └── UrlBuilder.cs ├── DiscordChatExporter.Gui ├── App.axaml ├── App.axaml.cs ├── Converters │ ├── ChannelToHierarchicalNameStringConverter.cs │ ├── ExportFormatToStringConverter.cs │ ├── LocaleToDisplayNameStringConverter.cs │ ├── RateLimitPreferenceToStringConverter.cs │ └── SnowflakeToTimestampStringConverter.cs ├── DiscordChatExporter.Gui.csproj ├── Framework │ ├── DialogManager.cs │ ├── DialogVIewModelBase.cs │ ├── SnackbarManager.cs │ ├── ThemeVariant.cs │ ├── UserControl.cs │ ├── ViewManager.cs │ ├── ViewModelBase.cs │ ├── ViewModelManager.cs │ └── Window.cs ├── Models │ └── ThreadInclusionMode.cs ├── Program.cs ├── Publish-MacOSBundle.ps1 ├── Services │ ├── SettingsService.cs │ └── UpdateService.cs ├── Utils │ ├── Disposable.cs │ ├── DisposableCollector.cs │ ├── Extensions │ │ ├── AvaloniaExtensions.cs │ │ ├── DisposableExtensions.cs │ │ └── NotifyPropertyChangedExtensions.cs │ ├── Internationalization.cs │ ├── NativeMethods.cs │ └── ProcessEx.cs ├── ViewModels │ ├── Components │ │ └── DashboardViewModel.cs │ ├── Dialogs │ │ ├── ExportSetupViewModel.cs │ │ ├── MessageBoxViewModel.cs │ │ └── SettingsViewModel.cs │ └── MainViewModel.cs └── Views │ ├── Components │ ├── DashboardView.axaml │ └── DashboardView.axaml.cs │ ├── Controls │ ├── HyperLink.axaml │ └── HyperLink.axaml.cs │ ├── Dialogs │ ├── ExportSetupView.axaml │ ├── ExportSetupView.axaml.cs │ ├── MessageBoxView.axaml │ ├── MessageBoxView.axaml.cs │ ├── SettingsView.axaml │ └── SettingsView.axaml.cs │ ├── MainView.axaml │ └── MainView.axaml.cs ├── DiscordChatExporter.sln ├── License.txt ├── NuGet.config ├── Readme.md ├── favicon.icns ├── favicon.ico └── favicon.png /.assets/list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nulldg/DiscordChatExporterPlus/027afa72d5b3ff60b95fe59bb8faa57bdc894776/.assets/list.png -------------------------------------------------------------------------------- /.assets/output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nulldg/DiscordChatExporterPlus/027afa72d5b3ff60b95fe59bb8faa57bdc894776/.assets/output.png -------------------------------------------------------------------------------- /.docs/Getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | Welcome to the getting started page! 4 | Here you'll learn how to use every **DiscordChatExporterPlus** (DCE for short) feature. 5 | For other things you can do with DCE, check out the [Guides](Readme.md#guides) section. 6 | 7 | If you still have unanswered questions after reading this page or if you have encountered a problem, please visit our [FAQ & Troubleshooting](Troubleshooting.md) section. 8 | 9 | The information presented on this page is valid for **all** platforms. 10 | 11 | ## GUI or CLI? 12 | 13 | ![GUI vs CLI](https://i.imgur.com/j9OTxRB.png) 14 | 15 | **DCEp** has two different versions: 16 | 17 | - **Graphical User Interface** (**GUI**) - it's the preferred version for newcomers as it is easy to use. 18 | You can get it by [downloading](https://github.com/nulldg/DiscordChatExporterPlus/releases/latest) the `DiscordChatExporterPlus.*.zip` file. 19 | - **Command-line Interface** (**CLI**) - offers greater flexibility and more features for advanced users, such as export scheduling, ID lists, and more specific date ranges. 20 | You can get it by [downloading](https://github.com/nulldg/DiscordChatExporterPlus/releases/latest) the `DiscordChatExporterPlus.Cli.*.zip` file. 21 | 22 | There are dedicated guides for each version: 23 | 24 | - [Using the GUI](Using-the-GUI.md) 25 | - [Using the CLI](Using-the-CLI.md) 26 | 27 | ## File formats 28 | 29 | ### HTML 30 | 31 | ![](https://i.imgur.com/S7lBTkV.png) 32 | The HTML format replicates Discord's interface, making it the most user-friendly option. 33 | It's the best format for attachment preview and sharing. 34 | You can open `.html` files with a web browser, such as Google Chrome. 35 | 36 | > **Warning**: 37 | > If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed. 38 | > Export using the "Download assets" (`--media`) option to avoid this. 39 | 40 | ### Plain Text 41 | 42 | 43 | 44 | The Plain Text format formats messages as plain text, and has the smallest size. 45 | You can open `.txt` files with a text editor, such as Notepad. 46 | 47 | ### JSON 48 | 49 | 50 | 51 | The JSON format contains more technical information and is easily parsable. 52 | You can open `.json` files with a text editor, such as Notepad. 53 | 54 | ### CSV 55 | 56 | ![](https://i.imgur.com/VEVUsKs.png) 57 | ![](https://i.imgur.com/1vPmQqQ.png) 58 | 59 | The CSV format allows for easy parsing of the chat log. Depending on your needs, the JSON format might be better. 60 | You can open `.csv` files with a text editor, such as Notepad, or a spreadsheet app, like Microsoft Excel and Google Sheets. -------------------------------------------------------------------------------- /.docs/Message-filters.md: -------------------------------------------------------------------------------- 1 | # Message filters 2 | 3 | You can use a special notation to filter messages that you want to have included in an export. The notation syntax is designed to mimic Discord's search query syntax, but with additional capabilities. 4 | 5 | To configure a filter, specify it in advanced export parameters when using the GUI or by passing the `--filter` option when using the CLI. For the CLI version, see also [caveats](#cli-caveats). 6 | 7 | ## Examples 8 | 9 | - Filter by user 10 | 11 | ```console 12 | from:Tyrrrz 13 | ``` 14 | 15 | - Filter by user (with discriminator) 16 | 17 | ```console 18 | from:Tyrrrz#1234 19 | ``` 20 | 21 | - Filter by message content (allowed values: `link`, `embed`, `file`, `video`, `image`, `sound`) 22 | 23 | ```console 24 | has:image 25 | ``` 26 | 27 | - Filter by mentioned user (same rules apply as with `from:` filter) 28 | 29 | ```console 30 | mentions:Tyrrrz#1234 31 | ``` 32 | 33 | - Filter by contained text (has word "hello" and word "world" somewhere in the message text): 34 | 35 | ```console 36 | hello world 37 | ``` 38 | 39 | - Filter by contained text (has the string "hello world" somewhere in the message text): 40 | 41 | ```console 42 | "hello world" 43 | ``` 44 | 45 | - Combine multiple filters ('and'): 46 | 47 | ```console 48 | from:Tyrrrz has:image 49 | ``` 50 | 51 | - Same thing but with an explicit operator: 52 | 53 | ```console 54 | from:Tyrrrz & has:image 55 | ``` 56 | 57 | - Combine multiple filters ('or'): 58 | 59 | ```console 60 | from:Tyrrrz | from:"96-LB" 61 | ``` 62 | 63 | - Combine multiple filters using groups: 64 | 65 | ```console 66 | (from:Tyrrrz | from:"96-LB") has:image 67 | ``` 68 | 69 | - Negate a filter: 70 | 71 | ```console 72 | -from:Tyrrrz | -has:image 73 | ``` 74 | 75 | - Negate a grouped filter: 76 | 77 | ```console 78 | -(from:Tyrrrz has:image) 79 | ``` 80 | 81 | - Escape special characters (`-` is escaped below, so it's not parsed as negation operator): 82 | 83 | ```console 84 | from:96\-LB 85 | ``` 86 | 87 | ## CLI Caveats 88 | 89 | In most cases, you will need to enclose your filter in quotes (`"`) to escape characters that may have special meaning in your shell: 90 | 91 | ```console 92 | $ ./DiscordChatExporterPlus.Cli export [...] --filter "from:Tyrrrz has:image" 93 | ``` 94 | 95 | If you need to include quotes inside the filter itself as well, use single quotes (`'`) for those instead: 96 | 97 | ```console 98 | $ ./DiscordChatExporterPlus.Cli export [...] --filter "from:Tyrrrz 'hello world'" 99 | ``` 100 | 101 | Additionally, negated filters (those that start with `-`) may cause parsing issues even when enclosed in quotes. To avoid this, use the tilde (`~`) character instead of the dash (`-`): 102 | 103 | ```console 104 | $ ./DiscordChatExporterPlus.Cli export [...] --filter ~from:Tyrrrz 105 | ``` 106 | -------------------------------------------------------------------------------- /.docs/Readme.md: -------------------------------------------------------------------------------- 1 | # Home 2 | 3 | ## Installation & Usage 4 | 5 | - Getting started: 6 | - [Using the GUI](Using-the-GUI.md) 7 | - [Using the CLI](Using-the-CLI.md) 8 | - [File formats](Getting-started.md#file-formats) 9 | 10 | ## Guides 11 | 12 | - [How to get Token and Channel IDs](Token-and-IDs.md) 13 | - [How to use message filters](Message-filters.md) 14 | - Export scheduling with CLI: 15 | - [Windows](Scheduling-Windows.md) 16 | - [macOS](Scheduling-MacOS.md) 17 | - [Linux](Scheduling-Linux.md) 18 | 19 | ## Video tutorial 20 | 21 | - Video by [NoIntro Tutorials](https://youtube.com/channel/UCFezKSxdNKJe77-hYiuXu3Q) (using DiscordChatExporter GUI) 22 | 23 | [![Video tutorial](https://i.ytimg.com/vi/jjtu0VQXV7I/hqdefault.jpg)](https://youtube.com/watch?v=jjtu0VQXV7I) 24 | 25 | ## FAQ & Troubleshooting 26 | 27 | - [General questions](Troubleshooting.md#general) 28 | - [First steps help](Troubleshooting.md#first-steps) 29 | - [It's crashing/failing](Troubleshooting.md#DCE-is-crashingfailing) 30 | - [Errors](Troubleshooting.md#errors) 31 | - [**More help**](Troubleshooting.md) 32 | -------------------------------------------------------------------------------- /.docs/Scheduling-Windows.md: -------------------------------------------------------------------------------- 1 | ## Creating the script 2 | 3 | 1. Open a text editor such as Notepad and paste: 4 | 5 | ```powershell 6 | # Info: https://github.com/nulldg/DiscordChatExporterPlus/blob/master/.docs 7 | 8 | $TOKEN = "tokenhere" 9 | $CHANNEL = "channelhere" 10 | $EXEPATH = "exefolderhere" 11 | $FILENAME = "filenamehere" 12 | $EXPORTDIRECTORY = "dirhere" 13 | $EXPORTFORMAT = "formathere" 14 | # Available export formats: PlainText, HtmlDark, HtmlLight, Json, Csv 15 | 16 | cd $EXEPATH 17 | 18 | ./DiscordChatExporterPlus.Cli export -t $TOKEN -c $CHANNEL -f $EXPORTFORMAT -o "$FILENAME.tmp" 19 | 20 | $Date = Get-Date -Format "yyyy-MM-dd-HH-mm" 21 | 22 | If($EXPORTFORMAT -match "PlainText"){mv "$FILENAME.tmp" -Destination "$EXPORTDIRECTORY\$FILENAME-$Date.txt"} 23 | ElseIf($EXPORTFORMAT -match "HtmlDark"){mv "$FILENAME.tmp" -Destination "$EXPORTDIRECTORY\$FILENAME-$Date.html"} 24 | ElseIf($EXPORTFORMAT -match "HtmlLight"){mv "$FILENAME.tmp" -Destination "$EXPORTDIRECTORY\$FILENAME-$Date.html"} 25 | ElseIf($EXPORTFORMAT -match "Json"){mv "$FILENAME.tmp" -Destination "$EXPORTDIRECTORY\$FILENAME-$Date.json"} 26 | ElseIf($EXPORTFORMAT -match "Csv"){mv "$FILENAME.tmp" -Destination "$EXPORTDIRECTORY\$FILENAME-$Date.csv"} 27 | exit 28 | ``` 29 | 30 | 2. Replace: 31 | 32 | - `tokenhere` with your [Token](Token-and-IDs.md) 33 | - `channelhere` with a [Channel ID](Token-and-IDs.md) 34 | - `exefolderhere` with the .exe **directory's path** (e.g. C:\Users\User\Desktop\DiscordChatExporterPlus) 35 | - `filenamehere` with a filename without spaces 36 | - `dirhere` with the export directory (e.g. C:\Users\User\Documents\Exports) 37 | - `formathere` with one of the available export formats 38 | 39 | Make sure not to delete the quotes (") 40 | 41 | 3. Save the file as `filename.ps1`, not as `.txt` 42 | 43 | > **Note**: You can also modify the script to use other options, such as `include-threads` or switch to a different command, e. g. `exportguild`. 44 | 45 | ## Export at Startup 46 | 47 | 1. Press Windows + R, type `shell:startup` and press ENTER 48 | 2. Paste `filename.ps1` or a shortcut into this folder 49 | 50 | ## Scheduling with Task Scheduler 51 | 52 | Please note that your computer must be turned on for the export to happen. 53 | 54 | 1. Press Windows + R, type `taskschd.msc` and press ENTER 55 | 2. Select `Task Scheduler Library`, create a Basic Task, and follow the instructions on-screen 56 | 57 | 58 | 59 | ![Screenshot from Task Scheduler](https://i.imgur.com/m2DKhA8.png) 60 | 61 | 3. At 'Start a Program', write `powershell -file -ExecutionPolicy ByPass -WindowStyle Hidden "C:\path\to\filename.ps1"` in the Program/script text box 62 | 63 | ![](https://i.imgur.com/FGtWRod.png) 64 | 65 | 4. Click 'Yes' 66 | 67 | ![](https://i.imgur.com/DuaRBt3.png) 68 | 69 | 5. Click 'Finish' 70 | 71 | ![](https://i.imgur.com/LHgXp9Q.png) 72 | 73 | --- 74 | 75 | Special thanks to [@Yudi](https://github.com/Yudi) 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # User-specific files 2 | .vs/ 3 | .idea/ 4 | *.suo 5 | *.user 6 | 7 | # Build results 8 | bin/ 9 | obj/ 10 | 11 | # Test results 12 | TestResults/ -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | 999.9.9-dev 6 | Tyrrrz 7 | Copyright (c) Oleksii Holub 8 | preview 9 | enable 10 | true 11 | false 12 | 13 | 14 | -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/DiscordChatExporter.Cli.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | false 4 | true 5 | d1fe5ae2-2a19-404d-a36e-81ba9eada1c1 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Infra/ChannelIds.cs: -------------------------------------------------------------------------------- 1 | using DiscordChatExporter.Core.Discord; 2 | 3 | namespace DiscordChatExporter.Cli.Tests.Infra; 4 | 5 | public static class ChannelIds 6 | { 7 | public static Snowflake AttachmentTestCases { get; } = Snowflake.Parse("885587741654536192"); 8 | 9 | public static Snowflake DateRangeTestCases { get; } = Snowflake.Parse("866674248747319326"); 10 | 11 | public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687"); 12 | 13 | public static Snowflake EmojiTestCases { get; } = Snowflake.Parse("866768438290415636"); 14 | 15 | public static Snowflake GroupingTestCases { get; } = Snowflake.Parse("992092091545034842"); 16 | 17 | public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020"); 18 | 19 | public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521"); 20 | 21 | public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794"); 22 | 23 | public static Snowflake ReplyTestCases { get; } = Snowflake.Parse("866459871934677052"); 24 | 25 | public static Snowflake SelfContainedTestCases { get; } = Snowflake.Parse("887441432678379560"); 26 | 27 | public static Snowflake StickerTestCases { get; } = Snowflake.Parse("939668868253769729"); 28 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Infra/Secrets.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using Microsoft.Extensions.Configuration; 4 | 5 | namespace DiscordChatExporter.Cli.Tests.Infra; 6 | 7 | internal static class Secrets 8 | { 9 | private static readonly IConfigurationRoot Configuration = new ConfigurationBuilder() 10 | .AddUserSecrets(Assembly.GetExecutingAssembly()) 11 | .AddEnvironmentVariables() 12 | .Build(); 13 | 14 | public static string DiscordToken => 15 | Configuration["DISCORD_TOKEN"] ?? 16 | throw new InvalidOperationException("Discord token not provided for tests."); 17 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Readme.md: -------------------------------------------------------------------------------- 1 | # DiscordChatExporter Tests 2 | 3 | This test suite runs against a real Discord server, specifically created to exercise different behaviors required by the test scenarios. 4 | In order to run these tests locally, you need to join the test server and configure your authentication token. 5 | 6 | 1. [Join the test server](https://discord.gg/eRV8Vap5bm) 7 | 2. Locate your Discord authentication token 8 | 3. Add your token to user secrets: `dotnet user-secrets set DISCORD_TOKEN ` 9 | 4. Run the tests: `dotnet test` 10 | 11 | > **Note**: 12 | > If you want to add a new test case, please let me know and I will give you the required permissions on the server. -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/CsvContentSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using DiscordChatExporter.Cli.Tests.Infra; 3 | using FluentAssertions; 4 | using Xunit; 5 | 6 | namespace DiscordChatExporter.Cli.Tests.Specs; 7 | 8 | public class CsvContentSpecs 9 | { 10 | [Fact] 11 | public async Task I_can_export_a_channel_in_the_CSV_format() 12 | { 13 | // Act 14 | var document = await ExportWrapper.ExportAsCsvAsync(ChannelIds.DateRangeTestCases); 15 | 16 | // Assert 17 | document.Should().ContainAll( 18 | "tyrrrz", 19 | "Hello world", 20 | "Goodbye world", 21 | "Foo bar", 22 | "Hurdle Durdle", 23 | "One", 24 | "Two", 25 | "Three", 26 | "Yeet" 27 | ); 28 | } 29 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/HtmlAttachmentSpecs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using AngleSharp.Dom; 5 | using DiscordChatExporter.Cli.Tests.Infra; 6 | using DiscordChatExporter.Core.Discord; 7 | using FluentAssertions; 8 | using Xunit; 9 | 10 | namespace DiscordChatExporter.Cli.Tests.Specs; 11 | 12 | public class HtmlAttachmentSpecs 13 | { 14 | [Fact] 15 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_generic_attachment() 16 | { 17 | // Act 18 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 19 | ChannelIds.AttachmentTestCases, 20 | Snowflake.Parse("885587844989612074") 21 | ); 22 | 23 | // Assert 24 | message.Text().Should().ContainAll( 25 | "Generic file attachment", 26 | "Test.txt", 27 | "11 bytes" 28 | ); 29 | 30 | message 31 | .QuerySelectorAll("a") 32 | .Select(e => e.GetAttribute("href")) 33 | .Should() 34 | .Contain(u => u.Contains("Test.txt", StringComparison.Ordinal)); 35 | } 36 | 37 | [Fact] 38 | public async Task I_can_export_a_channel_that_contains_a_message_with_an_image_attachment() 39 | { 40 | // Act 41 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 42 | ChannelIds.AttachmentTestCases, 43 | Snowflake.Parse("885654862656843786") 44 | ); 45 | 46 | // Assert 47 | message.Text().Should().Contain("Image attachment"); 48 | 49 | message 50 | .QuerySelectorAll("img") 51 | .Select(e => e.GetAttribute("src")) 52 | .Should() 53 | .Contain(u => u.Contains("bird-thumbnail.png", StringComparison.Ordinal)); 54 | } 55 | 56 | [Fact] 57 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_video_attachment() 58 | { 59 | // https://github.com/Tyrrrz/DiscordChatExporter/issues/333 60 | 61 | // Act 62 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 63 | ChannelIds.AttachmentTestCases, 64 | Snowflake.Parse("885655761919836171") 65 | ); 66 | 67 | // Assert 68 | message.Text().Should().Contain("Video attachment"); 69 | 70 | var videoUrl = message.QuerySelector("video source")?.GetAttribute("src"); 71 | videoUrl 72 | .Should() 73 | .StartWith( 74 | "https://cdn.discordapp.com/attachments/885587741654536192/885655761512968233/file_example_MP4_640_3MG.mp4" 75 | ); 76 | } 77 | 78 | [Fact] 79 | public async Task I_can_export_a_channel_that_contains_a_message_with_an_audio_attachment() 80 | { 81 | // https://github.com/Tyrrrz/DiscordChatExporter/issues/333 82 | 83 | // Act 84 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 85 | ChannelIds.AttachmentTestCases, 86 | Snowflake.Parse("885656175620808734") 87 | ); 88 | 89 | // Assert 90 | message.Text().Should().Contain("Audio attachment"); 91 | 92 | var audioUrl = message.QuerySelector("audio source")?.GetAttribute("src"); 93 | audioUrl 94 | .Should() 95 | .StartWith( 96 | "https://cdn.discordapp.com/attachments/885587741654536192/885656175348187146/file_example_MP3_1MG.mp3" 97 | ); 98 | } 99 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/HtmlContentSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using AngleSharp.Dom; 4 | using DiscordChatExporter.Cli.Tests.Infra; 5 | using FluentAssertions; 6 | using Xunit; 7 | 8 | namespace DiscordChatExporter.Cli.Tests.Specs; 9 | 10 | public class HtmlContentSpecs 11 | { 12 | [Fact] 13 | public async Task I_can_export_a_channel_in_the_HTML_format() 14 | { 15 | // Act 16 | var messages = await ExportWrapper.GetMessagesAsHtmlAsync(ChannelIds.DateRangeTestCases); 17 | 18 | // Assert 19 | messages.Select(e => e.GetAttribute("data-message-id")).Should().Equal( 20 | "866674314627121232", 21 | "866710679758045195", 22 | "866732113319428096", 23 | "868490009366396958", 24 | "868505966528835604", 25 | "868505969821364245", 26 | "868505973294268457", 27 | "885169254029213696" 28 | ); 29 | 30 | messages.SelectMany(e => e.Text()).Should().ContainInOrder( 31 | "Hello world", 32 | "Goodbye world", 33 | "Foo bar", 34 | "Hurdle Durdle", 35 | "One", 36 | "Two", 37 | "Three", 38 | "Yeet" 39 | ); 40 | } 41 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/HtmlGroupingSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using AngleSharp.Dom; 5 | using CliFx.Infrastructure; 6 | using DiscordChatExporter.Cli.Commands; 7 | using DiscordChatExporter.Cli.Tests.Infra; 8 | using DiscordChatExporter.Cli.Tests.Utils; 9 | using DiscordChatExporter.Core.Exporting; 10 | using FluentAssertions; 11 | using Xunit; 12 | 13 | namespace DiscordChatExporter.Cli.Tests.Specs; 14 | 15 | public class HtmlGroupingSpecs 16 | { 17 | [Fact] 18 | public async Task I_can_export_a_channel_and_the_messages_are_grouped_according_to_their_author_and_timestamps() 19 | { 20 | // https://github.com/Tyrrrz/DiscordChatExporter/issues/152 21 | 22 | // Arrange 23 | using var file = TempFile.Create(); 24 | 25 | // Act 26 | await new ExportChannelsCommand 27 | { 28 | Token = Secrets.DiscordToken, 29 | ChannelIds = [ChannelIds.GroupingTestCases], 30 | ExportFormat = ExportFormat.HtmlDark, 31 | OutputPath = file.Path, 32 | }.ExecuteAsync(new FakeConsole()); 33 | 34 | // Assert 35 | var messageGroups = Html 36 | .Parse(await File.ReadAllTextAsync(file.Path)) 37 | .QuerySelectorAll(".chatlog__message-group"); 38 | 39 | messageGroups.Should().HaveCount(2); 40 | 41 | messageGroups[0] 42 | .QuerySelectorAll(".chatlog__content") 43 | .Select(e => e.Text()) 44 | .Should() 45 | .ContainInOrder( 46 | "First", 47 | "Second", 48 | "Third", 49 | "Fourth", 50 | "Fifth", 51 | "Sixth", 52 | "Seventh", 53 | "Eighth", 54 | "Ninth", 55 | "Tenth" 56 | ); 57 | 58 | messageGroups[1] 59 | .QuerySelectorAll(".chatlog__content") 60 | .Select(e => e.Text()) 61 | .Should() 62 | .ContainInOrder( 63 | "Eleventh", 64 | "Twelveth", 65 | "Thirteenth", 66 | "Fourteenth", 67 | "Fifteenth" 68 | ); 69 | } 70 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/HtmlMentionSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using AngleSharp.Dom; 3 | using DiscordChatExporter.Cli.Tests.Infra; 4 | using DiscordChatExporter.Core.Discord; 5 | using FluentAssertions; 6 | using Xunit; 7 | 8 | namespace DiscordChatExporter.Cli.Tests.Specs; 9 | 10 | public class HtmlMentionSpecs 11 | { 12 | [Fact] 13 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_user_mention() 14 | { 15 | // Act 16 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 17 | ChannelIds.MentionTestCases, 18 | Snowflake.Parse("866458840245076028") 19 | ); 20 | 21 | // Assert 22 | message.Text().Should().Contain("User mention: @Tyrrrz"); 23 | message.InnerHtml.Should().Contain("tyrrrz"); 24 | } 25 | 26 | [Fact] 27 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_text_channel_mention() 28 | { 29 | // Act 30 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 31 | ChannelIds.MentionTestCases, 32 | Snowflake.Parse("866459040480624680") 33 | ); 34 | 35 | // Assert 36 | message.Text().Should().Contain("Text channel mention: #mention-tests"); 37 | } 38 | 39 | [Fact] 40 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_voice_channel_mention() 41 | { 42 | // Act 43 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 44 | ChannelIds.MentionTestCases, 45 | Snowflake.Parse("866459175462633503") 46 | ); 47 | 48 | // Assert 49 | message.Text().Should().Contain("Voice channel mention: 🔊general"); 50 | } 51 | 52 | [Fact] 53 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_role_mention() 54 | { 55 | // Act 56 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 57 | ChannelIds.MentionTestCases, 58 | Snowflake.Parse("866459254693429258") 59 | ); 60 | 61 | // Assert 62 | message.Text().Should().Contain("Role mention: @Role 1"); 63 | } 64 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/HtmlReplySpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using AngleSharp.Dom; 3 | using DiscordChatExporter.Cli.Tests.Infra; 4 | using DiscordChatExporter.Core.Discord; 5 | using FluentAssertions; 6 | using Xunit; 7 | 8 | namespace DiscordChatExporter.Cli.Tests.Specs; 9 | 10 | public class HtmlReplySpecs 11 | { 12 | [Fact] 13 | public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_another_message() 14 | { 15 | // Act 16 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 17 | ChannelIds.ReplyTestCases, 18 | Snowflake.Parse("866460738239725598") 19 | ); 20 | 21 | // Assert 22 | message.Text().Should().Contain("reply to original"); 23 | message.QuerySelector(".chatlog__reply-link")?.Text().Should().Contain("original"); 24 | } 25 | 26 | [Fact] 27 | public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_a_deleted_message() 28 | { 29 | // https://github.com/Tyrrrz/DiscordChatExporter/issues/645 30 | 31 | // Act 32 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 33 | ChannelIds.ReplyTestCases, 34 | Snowflake.Parse("866460975388819486") 35 | ); 36 | 37 | // Assert 38 | message.Text().Should().Contain("reply to deleted"); 39 | message.QuerySelector(".chatlog__reply-link")?.Text().Should().Contain( 40 | "Original message was deleted or could not be loaded." 41 | ); 42 | } 43 | 44 | [Fact] 45 | public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_an_empty_message_with_an_attachment() 46 | { 47 | // https://github.com/Tyrrrz/DiscordChatExporter/issues/634 48 | 49 | // Act 50 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 51 | ChannelIds.ReplyTestCases, 52 | Snowflake.Parse("866462470335627294") 53 | ); 54 | 55 | // Assert 56 | message.Text().Should().Contain("reply to attachment"); 57 | message.QuerySelector(".chatlog__reply-link")?.Text().Should().Contain("Click to see attachment"); 58 | } 59 | 60 | [Fact] 61 | public async Task I_can_export_a_channel_that_contains_a_message_that_replies_to_an_interaction() 62 | { 63 | // https://github.com/Tyrrrz/DiscordChatExporter/issues/569 64 | 65 | // Act 66 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 67 | ChannelIds.ReplyTestCases, 68 | Snowflake.Parse("1075152916417085492") 69 | ); 70 | 71 | // Assert 72 | message.Text().Should().Contain("used /poll"); 73 | } 74 | 75 | [Fact] 76 | public async Task I_can_export_a_channel_that_contains_a_message_cross_posted_from_another_guild() 77 | { 78 | // https://github.com/Tyrrrz/DiscordChatExporter/issues/633 79 | 80 | // Act 81 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 82 | ChannelIds.ReplyTestCases, 83 | Snowflake.Parse("1072165330853576876") 84 | ); 85 | 86 | // Assert 87 | message.Text().Should().Contain("This is a test message from an announcement channel on another server"); 88 | message.Text().Should().Contain("SERVER"); 89 | message.QuerySelector(".chatlog__reply-link").Should().BeNull(); 90 | } 91 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/HtmlStickerSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using DiscordChatExporter.Cli.Tests.Infra; 3 | using DiscordChatExporter.Core.Discord; 4 | using FluentAssertions; 5 | using Xunit; 6 | 7 | namespace DiscordChatExporter.Cli.Tests.Specs; 8 | 9 | public class HtmlStickerSpecs 10 | { 11 | [Fact] 12 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_PNG_sticker() 13 | { 14 | // Act 15 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 16 | ChannelIds.StickerTestCases, 17 | Snowflake.Parse("939670623158943754") 18 | ); 19 | 20 | // Assert 21 | var stickerUrl = message.QuerySelector("[title='rock'] img")?.GetAttribute("src"); 22 | stickerUrl.Should().StartWith("https://cdn.discordapp.com/stickers/904215665597120572.png"); 23 | } 24 | 25 | [Fact] 26 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_Lottie_sticker() 27 | { 28 | // Act 29 | var message = await ExportWrapper.GetMessageAsHtmlAsync( 30 | ChannelIds.StickerTestCases, 31 | Snowflake.Parse("939670526517997590") 32 | ); 33 | 34 | // Assert 35 | var stickerUrl = message 36 | .QuerySelector("[title='Yikes'] [data-source]") 37 | ?.GetAttribute("data-source"); 38 | 39 | stickerUrl 40 | .Should() 41 | .StartWith("https://cdn.discordapp.com/stickers/816087132447178774.json"); 42 | } 43 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using DiscordChatExporter.Cli.Tests.Infra; 4 | using FluentAssertions; 5 | using Xunit; 6 | 7 | namespace DiscordChatExporter.Cli.Tests.Specs; 8 | 9 | public class JsonContentSpecs 10 | { 11 | [Fact] 12 | public async Task I_can_export_a_channel_in_the_JSON_format() 13 | { 14 | // Act 15 | var messages = await ExportWrapper.GetMessagesAsJsonAsync(ChannelIds.DateRangeTestCases); 16 | 17 | // Assert 18 | messages.Select(j => j.GetProperty("id").GetString()).Should().Equal( 19 | "866674314627121232", 20 | "866710679758045195", 21 | "866732113319428096", 22 | "868490009366396958", 23 | "868505966528835604", 24 | "868505969821364245", 25 | "868505973294268457", 26 | "885169254029213696" 27 | ); 28 | 29 | messages.Select(j => j.GetProperty("content").GetString()).Should().Equal( 30 | "Hello world", 31 | "Goodbye world", 32 | "Foo bar", 33 | "Hurdle Durdle", 34 | "One", 35 | "Two", 36 | "Three", 37 | "Yeet" 38 | ); 39 | } 40 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/JsonEmbedSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using DiscordChatExporter.Cli.Tests.Infra; 4 | using DiscordChatExporter.Core.Discord; 5 | using FluentAssertions; 6 | using Xunit; 7 | 8 | namespace DiscordChatExporter.Cli.Tests.Specs; 9 | 10 | public class JsonEmbedSpecs 11 | { 12 | [Fact] 13 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_rich_embed() 14 | { 15 | // Act 16 | var message = await ExportWrapper.GetMessageAsJsonAsync( 17 | ChannelIds.EmbedTestCases, 18 | Snowflake.Parse("866769910729146400") 19 | ); 20 | 21 | // Assert 22 | var embed = message.GetProperty("embeds").EnumerateArray().Single(); 23 | embed.GetProperty("title").GetString().Should().Be("Embed title"); 24 | embed.GetProperty("url").GetString().Should().Be("https://example.com"); 25 | embed.GetProperty("timestamp").GetString().Should().Be("2021-07-14T21:00:00+00:00"); 26 | embed.GetProperty("description").GetString().Should().Be("**Embed** _description_"); 27 | embed.GetProperty("color").GetString().Should().Be("#58B9FF"); 28 | 29 | var embedAuthor = embed.GetProperty("author"); 30 | embedAuthor.GetProperty("name").GetString().Should().Be("Embed author"); 31 | embedAuthor.GetProperty("url").GetString().Should().Be("https://example.com/author"); 32 | embedAuthor.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace(); 33 | 34 | var embedThumbnail = embed.GetProperty("thumbnail"); 35 | embedThumbnail.GetProperty("url").GetString().Should().NotBeNullOrWhiteSpace(); 36 | embedThumbnail.GetProperty("width").GetInt32().Should().Be(120); 37 | embedThumbnail.GetProperty("height").GetInt32().Should().Be(120); 38 | 39 | var embedFooter = embed.GetProperty("footer"); 40 | embedFooter.GetProperty("text").GetString().Should().Be("Embed footer"); 41 | embedFooter.GetProperty("iconUrl").GetString().Should().NotBeNullOrWhiteSpace(); 42 | 43 | var embedFields = embed.GetProperty("fields").EnumerateArray().ToArray(); 44 | embedFields.Should().HaveCount(3); 45 | embedFields[0].GetProperty("name").GetString().Should().Be("Field 1"); 46 | embedFields[0].GetProperty("value").GetString().Should().Be("Value 1"); 47 | embedFields[0].GetProperty("isInline").GetBoolean().Should().BeTrue(); 48 | embedFields[1].GetProperty("name").GetString().Should().Be("Field 2"); 49 | embedFields[1].GetProperty("value").GetString().Should().Be("Value 2"); 50 | embedFields[1].GetProperty("isInline").GetBoolean().Should().BeTrue(); 51 | embedFields[2].GetProperty("name").GetString().Should().Be("Field 3"); 52 | embedFields[2].GetProperty("value").GetString().Should().Be("Value 3"); 53 | embedFields[2].GetProperty("isInline").GetBoolean().Should().BeTrue(); 54 | } 55 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/JsonMentionSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using DiscordChatExporter.Cli.Tests.Infra; 4 | using DiscordChatExporter.Core.Discord; 5 | using FluentAssertions; 6 | using Xunit; 7 | 8 | namespace DiscordChatExporter.Cli.Tests.Specs; 9 | 10 | public class JsonMentionSpecs 11 | { 12 | [Fact] 13 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_user_mention() 14 | { 15 | // Act 16 | var message = await ExportWrapper.GetMessageAsJsonAsync( 17 | ChannelIds.MentionTestCases, 18 | Snowflake.Parse("866458840245076028") 19 | ); 20 | 21 | // Assert 22 | message.GetProperty("content").GetString().Should().Be("User mention: @Tyrrrz"); 23 | 24 | message 25 | .GetProperty("mentions") 26 | .EnumerateArray() 27 | .Select(j => j.GetProperty("id").GetString()) 28 | .Should() 29 | .Contain("128178626683338752"); 30 | } 31 | 32 | [Fact] 33 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_text_channel_mention() 34 | { 35 | // Act 36 | var message = await ExportWrapper.GetMessageAsJsonAsync( 37 | ChannelIds.MentionTestCases, 38 | Snowflake.Parse("866459040480624680") 39 | ); 40 | 41 | // Assert 42 | message.GetProperty("content").GetString().Should().Be("Text channel mention: #mention-tests"); 43 | } 44 | 45 | [Fact] 46 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_voice_channel_mention() 47 | { 48 | // Act 49 | var message = await ExportWrapper.GetMessageAsJsonAsync( 50 | ChannelIds.MentionTestCases, 51 | Snowflake.Parse("866459175462633503") 52 | ); 53 | 54 | // Assert 55 | message.GetProperty("content").GetString().Should().Be("Voice channel mention: #general [voice]"); 56 | } 57 | 58 | [Fact] 59 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_role_mention() 60 | { 61 | // Act 62 | var message = await ExportWrapper.GetMessageAsJsonAsync( 63 | ChannelIds.MentionTestCases, 64 | Snowflake.Parse("866459254693429258") 65 | ); 66 | 67 | // Assert 68 | message.GetProperty("content").GetString().Should().Be("Role mention: @Role 1"); 69 | } 70 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/JsonStickerSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using DiscordChatExporter.Cli.Tests.Infra; 4 | using DiscordChatExporter.Core.Discord; 5 | using FluentAssertions; 6 | using Xunit; 7 | 8 | namespace DiscordChatExporter.Cli.Tests.Specs; 9 | 10 | public class JsonStickerSpecs 11 | { 12 | [Fact] 13 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_PNG_sticker() 14 | { 15 | // Act 16 | var message = await ExportWrapper.GetMessageAsJsonAsync( 17 | ChannelIds.StickerTestCases, 18 | Snowflake.Parse("939670623158943754") 19 | ); 20 | 21 | // Assert 22 | var sticker = message 23 | .GetProperty("stickers") 24 | .EnumerateArray() 25 | .Single(); 26 | 27 | sticker.GetProperty("id").GetString().Should().Be("904215665597120572"); 28 | sticker.GetProperty("name").GetString().Should().Be("rock"); 29 | sticker.GetProperty("format").GetString().Should().Be("Apng"); 30 | sticker 31 | .GetProperty("sourceUrl") 32 | .GetString() 33 | .Should() 34 | .StartWith("https://cdn.discordapp.com/stickers/904215665597120572.png"); 35 | } 36 | 37 | [Fact] 38 | public async Task I_can_export_a_channel_that_contains_a_message_with_a_Lottie_sticker() 39 | { 40 | // Act 41 | var message = await ExportWrapper.GetMessageAsJsonAsync( 42 | ChannelIds.StickerTestCases, 43 | Snowflake.Parse("939670526517997590") 44 | ); 45 | 46 | // Assert 47 | var sticker = message 48 | .GetProperty("stickers") 49 | .EnumerateArray() 50 | .Single(); 51 | 52 | sticker.GetProperty("id").GetString().Should().Be("816087132447178774"); 53 | sticker.GetProperty("name").GetString().Should().Be("Yikes"); 54 | sticker.GetProperty("format").GetString().Should().Be("Lottie"); 55 | sticker 56 | .GetProperty("sourceUrl") 57 | .GetString() 58 | .Should() 59 | .StartWith("https://cdn.discordapp.com/stickers/816087132447178774.json"); 60 | } 61 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/PartitioningSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using CliFx.Infrastructure; 4 | using DiscordChatExporter.Cli.Commands; 5 | using DiscordChatExporter.Cli.Tests.Infra; 6 | using DiscordChatExporter.Cli.Tests.Utils; 7 | using DiscordChatExporter.Core.Exporting; 8 | using DiscordChatExporter.Core.Exporting.Partitioning; 9 | using FluentAssertions; 10 | using Xunit; 11 | 12 | namespace DiscordChatExporter.Cli.Tests.Specs; 13 | 14 | public class PartitioningSpecs 15 | { 16 | [Fact] 17 | public async Task I_can_export_a_channel_with_partitioning_based_on_message_count() 18 | { 19 | // Arrange 20 | using var dir = TempDir.Create(); 21 | var filePath = Path.Combine(dir.Path, "output.html"); 22 | 23 | // Act 24 | await new ExportChannelsCommand 25 | { 26 | Token = Secrets.DiscordToken, 27 | ChannelIds = [ChannelIds.DateRangeTestCases], 28 | ExportFormat = ExportFormat.HtmlDark, 29 | OutputPath = filePath, 30 | PartitionLimit = PartitionLimit.Parse("3"), 31 | }.ExecuteAsync(new FakeConsole()); 32 | 33 | // Assert 34 | Directory.EnumerateFiles(dir.Path, "output*") 35 | .Should() 36 | .HaveCount(3); 37 | } 38 | 39 | [Fact] 40 | public async Task I_can_export_a_channel_with_partitioning_based_on_file_size() 41 | { 42 | // Arrange 43 | using var dir = TempDir.Create(); 44 | var filePath = Path.Combine(dir.Path, "output.html"); 45 | 46 | // Act 47 | await new ExportChannelsCommand 48 | { 49 | Token = Secrets.DiscordToken, 50 | ChannelIds = [ChannelIds.DateRangeTestCases], 51 | ExportFormat = ExportFormat.HtmlDark, 52 | OutputPath = filePath, 53 | PartitionLimit = PartitionLimit.Parse("1kb"), 54 | }.ExecuteAsync(new FakeConsole()); 55 | 56 | // Assert 57 | Directory.EnumerateFiles(dir.Path, "output*") 58 | .Should() 59 | .HaveCount(8); 60 | } 61 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/PlainTextContentSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using DiscordChatExporter.Cli.Tests.Infra; 3 | using FluentAssertions; 4 | using Xunit; 5 | 6 | namespace DiscordChatExporter.Cli.Tests.Specs; 7 | 8 | public class PlainTextContentSpecs 9 | { 10 | [Fact] 11 | public async Task I_can_export_a_channel_in_the_TXT_format() 12 | { 13 | // Act 14 | var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.DateRangeTestCases); 15 | 16 | // Assert 17 | document.Should().ContainAll( 18 | "tyrrrz", 19 | "Hello world", 20 | "Goodbye world", 21 | "Foo bar", 22 | "Hurdle Durdle", 23 | "One", 24 | "Two", 25 | "Three", 26 | "Yeet" 27 | ); 28 | } 29 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Specs/SelfContainedSpecs.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using CliFx.Infrastructure; 5 | using DiscordChatExporter.Cli.Commands; 6 | using DiscordChatExporter.Cli.Tests.Infra; 7 | using DiscordChatExporter.Cli.Tests.Utils; 8 | using DiscordChatExporter.Core.Exporting; 9 | using FluentAssertions; 10 | using Xunit; 11 | 12 | namespace DiscordChatExporter.Cli.Tests.Specs; 13 | 14 | public class SelfContainedSpecs 15 | { 16 | [Fact] 17 | public async Task I_can_export_a_channel_and_download_all_referenced_assets() 18 | { 19 | // Arrange 20 | using var dir = TempDir.Create(); 21 | var filePath = Path.Combine(dir.Path, "output.html"); 22 | 23 | // Act 24 | await new ExportChannelsCommand 25 | { 26 | Token = Secrets.DiscordToken, 27 | ChannelIds = [ChannelIds.SelfContainedTestCases], 28 | ExportFormat = ExportFormat.HtmlDark, 29 | OutputPath = filePath, 30 | ShouldDownloadAssets = true, 31 | }.ExecuteAsync(new FakeConsole()); 32 | 33 | // Assert 34 | Html 35 | .Parse(await File.ReadAllTextAsync(filePath)) 36 | .QuerySelectorAll("body [src]") 37 | .Select(e => e.GetAttribute("src")!) 38 | .Select(f => Path.GetFullPath(f, dir.Path)) 39 | .All(File.Exists) 40 | .Should() 41 | .BeTrue(); 42 | } 43 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Utils/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace DiscordChatExporter.Cli.Tests.Utils.Extensions; 4 | 5 | internal static class StringExtensions 6 | { 7 | public static string ReplaceWhiteSpace(this string str, string replacement = " ") 8 | { 9 | var buffer = new StringBuilder(str.Length); 10 | 11 | foreach (var ch in str) 12 | buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch); 13 | 14 | return buffer.ToString(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Utils/Html.cs: -------------------------------------------------------------------------------- 1 | using AngleSharp.Html.Dom; 2 | using AngleSharp.Html.Parser; 3 | 4 | namespace DiscordChatExporter.Cli.Tests.Utils; 5 | 6 | internal static class Html 7 | { 8 | private static readonly IHtmlParser Parser = new HtmlParser(); 9 | 10 | public static IHtmlDocument Parse(string source) => Parser.ParseDocument(source); 11 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Utils/TempDir.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Reflection; 4 | using PathEx = System.IO.Path; 5 | 6 | namespace DiscordChatExporter.Cli.Tests.Utils; 7 | 8 | internal partial class TempDir(string path) : IDisposable 9 | { 10 | public string Path { get; } = path; 11 | 12 | public void Dispose() 13 | { 14 | try 15 | { 16 | Directory.Delete(Path, true); 17 | } 18 | catch (DirectoryNotFoundException) { } 19 | } 20 | } 21 | 22 | internal partial class TempDir 23 | { 24 | public static TempDir Create() 25 | { 26 | var dirPath = PathEx.Combine( 27 | PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location) 28 | ?? Directory.GetCurrentDirectory(), 29 | "Temp", 30 | Guid.NewGuid().ToString() 31 | ); 32 | 33 | Directory.CreateDirectory(dirPath); 34 | 35 | return new TempDir(dirPath); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/Utils/TempFile.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Reflection; 4 | using PathEx = System.IO.Path; 5 | 6 | namespace DiscordChatExporter.Cli.Tests.Utils; 7 | 8 | internal partial class TempFile(string path) : IDisposable 9 | { 10 | public string Path { get; } = path; 11 | 12 | public void Dispose() 13 | { 14 | try 15 | { 16 | File.Delete(Path); 17 | } 18 | catch (FileNotFoundException) { } 19 | } 20 | } 21 | 22 | internal partial class TempFile 23 | { 24 | public static TempFile Create() 25 | { 26 | var dirPath = PathEx.Combine( 27 | PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location) 28 | ?? Directory.GetCurrentDirectory(), 29 | "Temp" 30 | ); 31 | 32 | Directory.CreateDirectory(dirPath); 33 | 34 | var filePath = PathEx.Combine(dirPath, Guid.NewGuid() + ".tmp"); 35 | 36 | return new TempFile(filePath); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /DiscordChatExporter.Cli.Tests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", 3 | "methodDisplayOptions": "all", 4 | "methodDisplay": "method" 5 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli/Commands/Base/DiscordCommandBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Threading.Tasks; 4 | using CliFx; 5 | using CliFx.Attributes; 6 | using CliFx.Infrastructure; 7 | using DiscordChatExporter.Core.Discord; 8 | using DiscordChatExporter.Core.Utils; 9 | 10 | namespace DiscordChatExporter.Cli.Commands.Base; 11 | 12 | public abstract class DiscordCommandBase : ICommand 13 | { 14 | [CommandOption( 15 | "token", 16 | 't', 17 | EnvironmentVariable = "DISCORD_TOKEN", 18 | Description = "Authentication token." 19 | )] 20 | public required string Token { get; init; } 21 | 22 | [Obsolete("This option doesn't do anything. Kept for backwards compatibility.")] 23 | [CommandOption( 24 | "bot", 25 | 'b', 26 | EnvironmentVariable = "DISCORD_TOKEN_BOT", 27 | Description = "This option doesn't do anything. Kept for backwards compatibility." 28 | )] 29 | public bool IsBotToken { get; init; } = false; 30 | 31 | [CommandOption( 32 | "respect-rate-limits", 33 | Description = "Whether to respect advisory rate limits. " 34 | + "If disabled, only hard rate limits (i.e. 429 responses) will be respected." 35 | )] 36 | public bool ShouldRespectRateLimits { get; init; } = true; 37 | 38 | [field: AllowNull, MaybeNull] 39 | protected DiscordClient Discord => 40 | field ??= new DiscordClient( 41 | Token, 42 | ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll 43 | ); 44 | 45 | public virtual ValueTask ExecuteAsync(IConsole console) 46 | { 47 | #pragma warning disable CS0618 48 | // Warn if the bot option is used 49 | if (IsBotToken) 50 | { 51 | using (console.WithForegroundColor(ConsoleColor.DarkYellow)) 52 | { 53 | console.Error.WriteLine( 54 | "Warning: The --bot option is deprecated and should not be used. " 55 | + "The token type is now inferred automatically. " 56 | + "Please update your workflows as this option may be completely removed in a future version." 57 | ); 58 | } 59 | } 60 | #pragma warning restore CS0618 61 | 62 | // Note about interactivity for Docker 63 | if (console.IsOutputRedirected && Docker.IsRunningInContainer) 64 | { 65 | console.Error.WriteLine( 66 | "Note: Output streams are redirected, rich console interactions are disabled. " 67 | + "If you are running this command in Docker, consider allocating a pseudo-terminal for better user experience (docker run -it ...)." 68 | ); 69 | } 70 | 71 | return default; 72 | } 73 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli/Commands/Converters/ThreadInclusionModeBindingConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CliFx.Extensibility; 3 | using DiscordChatExporter.Cli.Commands.Shared; 4 | 5 | namespace DiscordChatExporter.Cli.Commands.Converters; 6 | 7 | internal class ThreadInclusionModeBindingConverter : BindingConverter 8 | { 9 | public override ThreadInclusionMode Convert(string? rawValue) 10 | { 11 | // Empty or unset value is treated as 'active' to match the previous behavior 12 | if (string.IsNullOrWhiteSpace(rawValue)) 13 | return ThreadInclusionMode.Active; 14 | 15 | // Boolean 'true' is treated as 'active', boolean 'false' is treated as 'none' 16 | if (bool.TryParse(rawValue, out var boolValue)) 17 | return boolValue ? ThreadInclusionMode.Active : ThreadInclusionMode.None; 18 | 19 | // Otherwise, fall back to regular enum parsing 20 | return Enum.Parse(rawValue, true); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /DiscordChatExporter.Cli/Commands/Converters/TruthyBooleanBindingConverter.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using CliFx.Extensibility; 3 | 4 | namespace DiscordChatExporter.Cli.Commands.Converters; 5 | 6 | internal class TruthyBooleanBindingConverter : BindingConverter 7 | { 8 | public override bool Convert(string? rawValue) 9 | { 10 | // Empty or unset value is treated as 'true', to match the regular boolean behavior 11 | if (string.IsNullOrWhiteSpace(rawValue)) 12 | return true; 13 | 14 | // Number '1' is treated as 'true', other numbers are treated as 'false' 15 | if (int.TryParse(rawValue, CultureInfo.InvariantCulture, out var intValue)) 16 | return intValue == 1; 17 | 18 | // Otherwise, fall back to regular boolean parsing 19 | return bool.Parse(rawValue); 20 | } 21 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli/Commands/ExportChannelsCommand.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using CliFx.Attributes; 4 | using CliFx.Infrastructure; 5 | using DiscordChatExporter.Cli.Commands.Base; 6 | using DiscordChatExporter.Core.Discord; 7 | using DiscordChatExporter.Core.Discord.Data; 8 | using DiscordChatExporter.Core.Utils.Extensions; 9 | 10 | namespace DiscordChatExporter.Cli.Commands; 11 | 12 | [Command("export", Description = "Exports one or multiple channels.")] 13 | public class ExportChannelsCommand : ExportCommandBase 14 | { 15 | // TODO: change this to plural (breaking change) 16 | [CommandOption( 17 | "channel", 18 | 'c', 19 | Description = 20 | "Channel ID(s). " + 21 | "If provided with category ID(s), all channels inside those categories will be exported." 22 | )] 23 | public required IReadOnlyList ChannelIds { get; init; } 24 | 25 | public override async ValueTask ExecuteAsync(IConsole console) 26 | { 27 | await base.ExecuteAsync(console); 28 | 29 | var cancellationToken = console.RegisterCancellationHandler(); 30 | 31 | await console.Output.WriteLineAsync("Resolving channel(s)..."); 32 | 33 | var channels = new List(); 34 | var channelsByGuild = new Dictionary>(); 35 | 36 | foreach (var channelId in ChannelIds) 37 | { 38 | var channel = await Discord.GetChannelAsync(channelId, cancellationToken); 39 | 40 | // Unwrap categories 41 | if (channel.IsCategory) 42 | { 43 | var guildChannels = 44 | channelsByGuild.GetValueOrDefault(channel.GuildId) 45 | ?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken); 46 | 47 | foreach (var guildChannel in guildChannels) 48 | { 49 | if (guildChannel.Parent?.Id == channel.Id) 50 | channels.Add(guildChannel); 51 | } 52 | 53 | // Cache the guild channels to avoid redundant work 54 | channelsByGuild[channel.GuildId] = guildChannels; 55 | } 56 | else 57 | { 58 | channels.Add(channel); 59 | } 60 | } 61 | 62 | await ExportAsync(console, channels); 63 | } 64 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli/Commands/ExportDirectMessagesCommand.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using CliFx.Attributes; 3 | using CliFx.Infrastructure; 4 | using DiscordChatExporter.Cli.Commands.Base; 5 | using DiscordChatExporter.Core.Discord.Data; 6 | using DiscordChatExporter.Core.Utils.Extensions; 7 | 8 | namespace DiscordChatExporter.Cli.Commands; 9 | 10 | [Command("exportdm", Description = "Exports all direct message channels.")] 11 | public class ExportDirectMessagesCommand : ExportCommandBase 12 | { 13 | public override async ValueTask ExecuteAsync(IConsole console) 14 | { 15 | await base.ExecuteAsync(console); 16 | 17 | var cancellationToken = console.RegisterCancellationHandler(); 18 | 19 | await console.Output.WriteLineAsync("Fetching channels..."); 20 | var channels = await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken); 21 | 22 | await ExportAsync(console, channels); 23 | } 24 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli/Commands/ExportGuildCommand.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using CliFx.Attributes; 4 | using CliFx.Infrastructure; 5 | using DiscordChatExporter.Cli.Commands.Base; 6 | using DiscordChatExporter.Cli.Utils.Extensions; 7 | using DiscordChatExporter.Core.Discord; 8 | using DiscordChatExporter.Core.Discord.Data; 9 | using Spectre.Console; 10 | 11 | namespace DiscordChatExporter.Cli.Commands; 12 | 13 | [Command("exportguild", Description = "Exports all channels within the specified server.")] 14 | public class ExportGuildCommand : ExportCommandBase 15 | { 16 | [CommandOption("guild", 'g', Description = "Server ID.")] 17 | public required Snowflake GuildId { get; init; } 18 | 19 | [CommandOption( 20 | "include-vc", 21 | Description = "Include voice channels." 22 | )] 23 | public bool IncludeVoiceChannels { get; init; } = true; 24 | 25 | public override async ValueTask ExecuteAsync(IConsole console) 26 | { 27 | await base.ExecuteAsync(console); 28 | 29 | var cancellationToken = console.RegisterCancellationHandler(); 30 | var channels = new List(); 31 | 32 | await console.Output.WriteLineAsync("Fetching channels..."); 33 | 34 | var fetchedChannelsCount = 0; 35 | await console 36 | .CreateStatusTicker() 37 | .StartAsync( 38 | "...", 39 | async ctx => 40 | { 41 | await foreach ( 42 | var channel in Discord.GetGuildChannelsAsync(GuildId, cancellationToken) 43 | ) 44 | { 45 | if (channel.IsCategory) 46 | continue; 47 | 48 | if (!IncludeVoiceChannels && channel.IsVoice) 49 | continue; 50 | 51 | channels.Add(channel); 52 | 53 | ctx.Status(Markup.Escape($"Fetched '{channel.GetHierarchicalName()}'.")); 54 | 55 | fetchedChannelsCount++; 56 | } 57 | } 58 | ); 59 | 60 | await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s)."); 61 | 62 | await ExportAsync(console, channels); 63 | } 64 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli/Commands/GetDirectChannelsCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using CliFx.Attributes; 5 | using CliFx.Infrastructure; 6 | using DiscordChatExporter.Cli.Commands.Base; 7 | using DiscordChatExporter.Core.Discord.Data; 8 | using DiscordChatExporter.Core.Utils.Extensions; 9 | 10 | namespace DiscordChatExporter.Cli.Commands; 11 | 12 | [Command("dm", Description = "Gets the list of all direct message channels.")] 13 | public class GetDirectChannelsCommand : DiscordCommandBase 14 | { 15 | public override async ValueTask ExecuteAsync(IConsole console) 16 | { 17 | await base.ExecuteAsync(console); 18 | 19 | var cancellationToken = console.RegisterCancellationHandler(); 20 | 21 | var channels = ( 22 | await Discord.GetGuildChannelsAsync(Guild.DirectMessages.Id, cancellationToken) 23 | ) 24 | .OrderByDescending(c => c.LastMessageId) 25 | .ThenBy(c => c.Name) 26 | .ToArray(); 27 | 28 | var channelIdMaxLength = channels 29 | .Select(c => c.Id.ToString().Length) 30 | .OrderDescending() 31 | .FirstOrDefault(); 32 | 33 | foreach (var channel in channels) 34 | { 35 | // Channel ID 36 | await console.Output.WriteAsync( 37 | channel.Id.ToString().PadRight(channelIdMaxLength, ' ') 38 | ); 39 | 40 | // Separator 41 | using (console.WithForegroundColor(ConsoleColor.DarkGray)) 42 | await console.Output.WriteAsync(" | "); 43 | 44 | // Channel name 45 | using (console.WithForegroundColor(ConsoleColor.White)) 46 | await console.Output.WriteLineAsync(channel.GetHierarchicalName()); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli/Commands/GetGuildsCommand.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using CliFx.Attributes; 5 | using CliFx.Infrastructure; 6 | using DiscordChatExporter.Cli.Commands.Base; 7 | using DiscordChatExporter.Core.Discord.Data; 8 | using DiscordChatExporter.Core.Utils.Extensions; 9 | 10 | namespace DiscordChatExporter.Cli.Commands; 11 | 12 | [Command("guilds", Description = "Gets the list of accessible servers.")] 13 | public class GetGuildsCommand : DiscordCommandBase 14 | { 15 | public override async ValueTask ExecuteAsync(IConsole console) 16 | { 17 | await base.ExecuteAsync(console); 18 | 19 | var cancellationToken = console.RegisterCancellationHandler(); 20 | 21 | var guilds = (await Discord.GetUserGuildsAsync(cancellationToken)) 22 | // Show direct messages first 23 | .OrderByDescending(g => g.Id == Guild.DirectMessages.Id) 24 | .ThenBy(g => g.Name) 25 | .ToArray(); 26 | 27 | var guildIdMaxLength = guilds 28 | .Select(g => g.Id.ToString().Length) 29 | .OrderDescending() 30 | .FirstOrDefault(); 31 | 32 | foreach (var guild in guilds) 33 | { 34 | // Guild ID 35 | await console.Output.WriteAsync( 36 | guild.Id.ToString().PadRight(guildIdMaxLength, ' ') 37 | ); 38 | 39 | // Separator 40 | using (console.WithForegroundColor(ConsoleColor.DarkGray)) 41 | await console.Output.WriteAsync(" | "); 42 | 43 | // Guild name 44 | using (console.WithForegroundColor(ConsoleColor.White)) 45 | await console.Output.WriteLineAsync(guild.Name); 46 | } 47 | } 48 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Cli/Commands/Shared/ThreadInclusionMode.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Cli.Commands.Shared; 2 | 3 | public enum ThreadInclusionMode 4 | { 5 | None, 6 | Active, 7 | All, 8 | } 9 | -------------------------------------------------------------------------------- /DiscordChatExporter.Cli/DiscordChatExporter.Cli.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Exe 4 | ..\favicon.ico 5 | embedded 6 | DiscordChatExporterPlus.Cli 7 | true 8 | false 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /DiscordChatExporter.Cli/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.CodeAnalysis; 2 | using System.Threading.Tasks; 3 | using CliFx; 4 | using DiscordChatExporter.Cli.Commands; 5 | using DiscordChatExporter.Cli.Commands.Converters; 6 | using DiscordChatExporter.Core.Exporting.Filtering; 7 | using DiscordChatExporter.Core.Exporting.Partitioning; 8 | 9 | namespace DiscordChatExporter.Cli; 10 | 11 | public static class Program 12 | { 13 | // Explicit references because CliFx relies on reflection and we're publishing with trimming enabled 14 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportAllCommand))] 15 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportChannelsCommand))] 16 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportDirectMessagesCommand))] 17 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportGuildCommand))] 18 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetChannelsCommand))] 19 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetDirectChannelsCommand))] 20 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetGuildsCommand))] 21 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GuideCommand))] 22 | [DynamicDependency( 23 | DynamicallyAccessedMemberTypes.All, 24 | typeof(ThreadInclusionModeBindingConverter) 25 | )] 26 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TruthyBooleanBindingConverter))] 27 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PartitionLimit))] 28 | [DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageFilter))] 29 | public static async Task Main(string[] args) => 30 | await new CliApplicationBuilder() 31 | .AddCommand() 32 | .AddCommand() 33 | .AddCommand() 34 | .AddCommand() 35 | .AddCommand() 36 | .AddCommand() 37 | .AddCommand() 38 | .AddCommand() 39 | .Build() 40 | .RunAsync(args); 41 | } 42 | -------------------------------------------------------------------------------- /DiscordChatExporter.Cli/Utils/Extensions/ConsoleExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using CliFx.Infrastructure; 4 | using Spectre.Console; 5 | 6 | namespace DiscordChatExporter.Cli.Utils.Extensions; 7 | 8 | internal static class ConsoleExtensions 9 | { 10 | public static IAnsiConsole CreateAnsiConsole(this IConsole console) => 11 | AnsiConsole.Create( 12 | new AnsiConsoleSettings 13 | { 14 | Ansi = AnsiSupport.Detect, 15 | ColorSystem = ColorSystemSupport.Detect, 16 | Out = new AnsiConsoleOutput(console.Output), 17 | } 18 | ); 19 | 20 | public static Status CreateStatusTicker(this IConsole console) => 21 | console.CreateAnsiConsole().Status().AutoRefresh(true); 22 | 23 | public static Progress CreateProgressTicker(this IConsole console) => 24 | console 25 | .CreateAnsiConsole() 26 | .Progress() 27 | .AutoClear(false) 28 | .AutoRefresh(true) 29 | .HideCompleted(false) 30 | .Columns( 31 | new TaskDescriptionColumn { Alignment = Justify.Left }, 32 | new ProgressBarColumn(), 33 | new PercentageColumn() 34 | ); 35 | 36 | public static async ValueTask StartTaskAsync( 37 | this ProgressContext context, 38 | string description, 39 | Func performOperationAsync 40 | ) 41 | { 42 | // Description cannot be empty 43 | // https://github.com/Tyrrrz/DiscordChatExporter/issues/1133 44 | var actualDescription = !string.IsNullOrWhiteSpace(description) ? description : "..."; 45 | 46 | var progressTask = context.AddTask( 47 | actualDescription, 48 | new ProgressTaskSettings { MaxValue = 1 } 49 | ); 50 | 51 | try 52 | { 53 | await performOperationAsync(progressTask); 54 | } 55 | finally 56 | { 57 | progressTask.Value = progressTask.MaxValue; 58 | progressTask.StopTask(); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Application.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using DiscordChatExporter.Core.Utils.Extensions; 3 | using JsonExtensions.Reading; 4 | 5 | namespace DiscordChatExporter.Core.Discord.Data; 6 | 7 | // https://discord.com/developers/docs/resources/application#application-object 8 | public partial record Application(Snowflake Id, string Name, ApplicationFlags Flags) 9 | { 10 | public bool IsMessageContentIntentEnabled { get; } = 11 | Flags.HasFlag(ApplicationFlags.GatewayMessageContent) 12 | || Flags.HasFlag(ApplicationFlags.GatewayMessageContentLimited); 13 | } 14 | 15 | public partial record Application 16 | { 17 | public static Application Parse(JsonElement json) 18 | { 19 | var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); 20 | var name = json.GetProperty("name").GetNonWhiteSpaceString(); 21 | 22 | var flags = 23 | json.GetPropertyOrNull("flags")?.GetInt32OrNull()?.Pipe(x => (ApplicationFlags)x) 24 | ?? ApplicationFlags.None; 25 | 26 | return new Application(id, name, flags); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/ApplicationFlags.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace DiscordChatExporter.Core.Discord.Data; 4 | 5 | // https://discord.com/developers/docs/resources/application#application-object-application-flags 6 | [Flags] 7 | public enum ApplicationFlags 8 | { 9 | None = 0, 10 | ApplicationAutoModerationRuleCreateBadge = 64, 11 | GatewayPresence = 4096, 12 | GatewayPresenceLimited = 8192, 13 | GatewayGuildMembers = 16384, 14 | GatewayGuildMembersLimited = 32768, 15 | VerificationPendingGuildLimit = 65536, 16 | Embedded = 131072, 17 | GatewayMessageContent = 262144, 18 | GatewayMessageContentLimited = 524288, 19 | ApplicationCommandBadge = 8388608, 20 | } 21 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Attachment.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text.Json; 4 | using DiscordChatExporter.Core.Discord.Data.Common; 5 | using DiscordChatExporter.Core.Utils.Extensions; 6 | using JsonExtensions.Reading; 7 | 8 | namespace DiscordChatExporter.Core.Discord.Data; 9 | 10 | // https://discord.com/developers/docs/resources/channel#attachment-object 11 | public partial record Attachment( 12 | Snowflake Id, 13 | string Url, 14 | string FileName, 15 | string? Description, 16 | int? Width, 17 | int? Height, 18 | FileSize FileSize) : IHasId 19 | { 20 | public string FileExtension { get; } = Path.GetExtension(FileName); 21 | 22 | public bool IsImage => 23 | string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase) || 24 | string.Equals(FileExtension, ".jpeg", StringComparison.OrdinalIgnoreCase) || 25 | string.Equals(FileExtension, ".png", StringComparison.OrdinalIgnoreCase) || 26 | string.Equals(FileExtension, ".gif", StringComparison.OrdinalIgnoreCase) || 27 | string.Equals(FileExtension, ".bmp", StringComparison.OrdinalIgnoreCase) || 28 | string.Equals(FileExtension, ".webp", StringComparison.OrdinalIgnoreCase); 29 | 30 | public bool IsVideo => 31 | string.Equals(FileExtension, ".gifv", StringComparison.OrdinalIgnoreCase) || 32 | string.Equals(FileExtension, ".mp4", StringComparison.OrdinalIgnoreCase) || 33 | string.Equals(FileExtension, ".webm", StringComparison.OrdinalIgnoreCase) || 34 | string.Equals(FileExtension, ".mov", StringComparison.OrdinalIgnoreCase); 35 | 36 | public bool IsAudio => 37 | string.Equals(FileExtension, ".mp3", StringComparison.OrdinalIgnoreCase) || 38 | string.Equals(FileExtension, ".wav", StringComparison.OrdinalIgnoreCase) || 39 | string.Equals(FileExtension, ".ogg", StringComparison.OrdinalIgnoreCase) || 40 | string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase) || 41 | string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase); 42 | 43 | public bool IsSpoiler { get; } = FileName.StartsWith("SPOILER_", StringComparison.Ordinal); 44 | } 45 | 46 | public partial record Attachment 47 | { 48 | public static Attachment Parse(JsonElement json) 49 | { 50 | var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); 51 | var url = json.GetProperty("url").GetNonWhiteSpaceString(); 52 | var fileName = json.GetProperty("filename").GetNonNullString(); 53 | var description = json.GetPropertyOrNull("description")?.GetNonWhiteSpaceStringOrNull(); 54 | var width = json.GetPropertyOrNull("width")?.GetInt32OrNull(); 55 | var height = json.GetPropertyOrNull("height")?.GetInt32OrNull(); 56 | var fileSize = json.GetProperty("size").GetInt64().Pipe(FileSize.FromBytes); 57 | 58 | return new Attachment(id, url, fileName, description, width, height, fileSize); 59 | } 60 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/ChannelConnection.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | namespace DiscordChatExporter.Core.Discord.Data; 5 | 6 | public record ChannelConnection(Channel Channel, IReadOnlyList Children) 7 | { 8 | public static IReadOnlyList BuildTree(IReadOnlyList channels) 9 | { 10 | IReadOnlyList GetChildren(Channel parent) => 11 | channels 12 | .Where(c => c.Parent?.Id == parent.Id) 13 | .Select(c => new ChannelConnection(c, GetChildren(c))) 14 | .ToArray(); 15 | 16 | return channels 17 | .Where(c => c.Parent is null) 18 | .Select(c => new ChannelConnection(c, GetChildren(c))) 19 | .ToArray(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/ChannelKind.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Discord.Data; 2 | 3 | // https://discord.com/developers/docs/resources/channel#channel-object-channel-types 4 | public enum ChannelKind 5 | { 6 | GuildTextChat = 0, 7 | DirectTextChat = 1, 8 | GuildVoiceChat = 2, 9 | DirectGroupTextChat = 3, 10 | GuildCategory = 4, 11 | GuildNews = 5, 12 | GuildNewsThread = 10, 13 | GuildPublicThread = 11, 14 | GuildPrivateThread = 12, 15 | GuildStageVoice = 13, 16 | GuildDirectory = 14, 17 | GuildForum = 15, 18 | } 19 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Common/FileSize.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Globalization; 4 | 5 | namespace DiscordChatExporter.Core.Discord.Data.Common; 6 | 7 | // Loosely based on https://github.com/omar/ByteSize (MIT license) 8 | public readonly partial record struct FileSize(long TotalBytes) 9 | { 10 | public double TotalKiloBytes => TotalBytes / 1024.0; 11 | public double TotalMegaBytes => TotalKiloBytes / 1024.0; 12 | public double TotalGigaBytes => TotalMegaBytes / 1024.0; 13 | 14 | private double GetLargestWholeNumberValue() 15 | { 16 | if (Math.Abs(TotalGigaBytes) >= 1) 17 | return TotalGigaBytes; 18 | 19 | if (Math.Abs(TotalMegaBytes) >= 1) 20 | return TotalMegaBytes; 21 | 22 | if (Math.Abs(TotalKiloBytes) >= 1) 23 | return TotalKiloBytes; 24 | 25 | return TotalBytes; 26 | } 27 | 28 | private string GetLargestWholeNumberSymbol() 29 | { 30 | if (Math.Abs(TotalGigaBytes) >= 1) 31 | return "GB"; 32 | 33 | if (Math.Abs(TotalMegaBytes) >= 1) 34 | return "MB"; 35 | 36 | if (Math.Abs(TotalKiloBytes) >= 1) 37 | return "KB"; 38 | 39 | return "bytes"; 40 | } 41 | 42 | [ExcludeFromCodeCoverage] 43 | public override string ToString() => 44 | string.Create(CultureInfo.InvariantCulture, $"{GetLargestWholeNumberValue():0.##} {GetLargestWholeNumberSymbol()}"); 45 | } 46 | 47 | public partial record struct FileSize 48 | { 49 | public static FileSize FromBytes(long bytes) => new(bytes); 50 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Common/IHasId.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Discord.Data.Common; 2 | 3 | public interface IHasId 4 | { 5 | Snowflake Id { get; } 6 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Common/ImageCdn.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using System.Linq; 4 | 5 | namespace DiscordChatExporter.Core.Discord.Data.Common; 6 | 7 | // https://discord.com/developers/docs/reference#image-formatting 8 | public static class ImageCdn 9 | { 10 | // Standard emoji are rendered through Twemoji 11 | public static string GetStandardEmojiUrl(string emojiName) 12 | { 13 | var runes = emojiName.EnumerateRunes().ToArray(); 14 | 15 | // Variant selector rune is skipped in Twemoji IDs, 16 | // except when the emoji also contains a zero-width joiner. 17 | // VS = 0xfe0f; ZWJ = 0x200d. 18 | var filteredRunes = runes.Any(r => r.Value == 0x200d) 19 | ? runes 20 | : runes.Where(r => r.Value != 0xfe0f); 21 | 22 | var twemojiId = string.Join( 23 | "-", 24 | filteredRunes.Select(r => r.Value.ToString("x", CultureInfo.InvariantCulture)) 25 | ); 26 | 27 | return $"https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/{twemojiId}.svg"; 28 | } 29 | 30 | public static string GetCustomEmojiUrl(Snowflake emojiId, bool isAnimated = false) => 31 | isAnimated 32 | ? $"https://cdn.discordapp.com/emojis/{emojiId}.gif" 33 | : $"https://cdn.discordapp.com/emojis/{emojiId}.png"; 34 | 35 | public static string GetGuildIconUrl(Snowflake guildId, string iconHash, int size = 512) => 36 | iconHash.StartsWith("a_", StringComparison.Ordinal) 37 | ? $"https://cdn.discordapp.com/icons/{guildId}/{iconHash}.gif?size={size}" 38 | : $"https://cdn.discordapp.com/icons/{guildId}/{iconHash}.png?size={size}"; 39 | 40 | public static string GetChannelIconUrl(Snowflake channelId, string iconHash, int size = 512) => 41 | iconHash.StartsWith("a_", StringComparison.Ordinal) 42 | ? $"https://cdn.discordapp.com/channel-icons/{channelId}/{iconHash}.gif?size={size}" 43 | : $"https://cdn.discordapp.com/channel-icons/{channelId}/{iconHash}.png?size={size}"; 44 | 45 | public static string GetUserAvatarUrl(Snowflake userId, string avatarHash, int size = 512) => 46 | avatarHash.StartsWith("a_", StringComparison.Ordinal) 47 | ? $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.gif?size={size}" 48 | : $"https://cdn.discordapp.com/avatars/{userId}/{avatarHash}.png?size={size}"; 49 | 50 | public static string GetFallbackUserAvatarUrl(int index = 0) => 51 | $"https://cdn.discordapp.com/embed/avatars/{index}.png"; 52 | 53 | public static string GetMemberAvatarUrl(Snowflake guildId, Snowflake userId, string avatarHash, int size = 512) => 54 | avatarHash.StartsWith("a_", StringComparison.Ordinal) 55 | ? $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{avatarHash}.gif?size={size}" 56 | : $"https://cdn.discordapp.com/guilds/{guildId}/users/{userId}/avatars/{avatarHash}.png?size={size}"; 57 | 58 | public static string GetStickerUrl(Snowflake stickerId, string format = "png") => 59 | $"https://cdn.discordapp.com/stickers/{stickerId}.{format}"; 60 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Embeds/EmbedAuthor.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using JsonExtensions.Reading; 3 | 4 | namespace DiscordChatExporter.Core.Discord.Data.Embeds; 5 | 6 | // https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure 7 | public record EmbedAuthor( 8 | string? Name, 9 | string? Url, 10 | string? IconUrl, 11 | string? IconProxyUrl) 12 | { 13 | public static EmbedAuthor Parse(JsonElement json) 14 | { 15 | var name = json.GetPropertyOrNull("name")?.GetStringOrNull(); 16 | var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); 17 | var iconUrl = json.GetPropertyOrNull("icon_url")?.GetNonWhiteSpaceStringOrNull(); 18 | var iconProxyUrl = json.GetPropertyOrNull("proxy_icon_url")?.GetNonWhiteSpaceStringOrNull(); 19 | 20 | return new EmbedAuthor(name, url, iconUrl, iconProxyUrl); 21 | } 22 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Embeds/EmbedField.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using JsonExtensions.Reading; 3 | 4 | namespace DiscordChatExporter.Core.Discord.Data.Embeds; 5 | 6 | // https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure 7 | public record EmbedField( 8 | string Name, 9 | string Value, 10 | bool IsInline) 11 | { 12 | public static EmbedField Parse(JsonElement json) 13 | { 14 | var name = json.GetProperty("name").GetNonNullString(); 15 | var value = json.GetProperty("value").GetNonNullString(); 16 | var isInline = json.GetPropertyOrNull("inline")?.GetBooleanOrNull() ?? false; 17 | 18 | return new EmbedField(name, value, isInline); 19 | } 20 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Embeds/EmbedFooter.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using JsonExtensions.Reading; 3 | 4 | namespace DiscordChatExporter.Core.Discord.Data.Embeds; 5 | 6 | // https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure 7 | public record EmbedFooter( 8 | string Text, 9 | string? IconUrl, 10 | string? IconProxyUrl) 11 | { 12 | public static EmbedFooter Parse(JsonElement json) 13 | { 14 | var text = json.GetProperty("text").GetNonNullString(); 15 | var iconUrl = json.GetPropertyOrNull("icon_url")?.GetNonWhiteSpaceStringOrNull(); 16 | var iconProxyUrl = json.GetPropertyOrNull("proxy_icon_url")?.GetNonWhiteSpaceStringOrNull(); 17 | 18 | return new EmbedFooter(text, iconUrl, iconProxyUrl); 19 | } 20 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Embeds/EmbedImage.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using JsonExtensions.Reading; 3 | 4 | namespace DiscordChatExporter.Core.Discord.Data.Embeds; 5 | 6 | // https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure 7 | public record EmbedImage( 8 | string? Url, 9 | string? ProxyUrl, 10 | int? Width, 11 | int? Height) 12 | { 13 | public static EmbedImage Parse(JsonElement json) 14 | { 15 | var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); 16 | var proxyUrl = json.GetPropertyOrNull("proxy_url")?.GetNonWhiteSpaceStringOrNull(); 17 | var width = json.GetPropertyOrNull("width")?.GetInt32OrNull(); 18 | var height = json.GetPropertyOrNull("height")?.GetInt32OrNull(); 19 | 20 | return new EmbedImage(url, proxyUrl, width, height); 21 | } 22 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Embeds/EmbedKind.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Discord.Data.Embeds; 2 | 3 | // https://discord.com/developers/docs/resources/channel#embed-object-embed-types 4 | public enum EmbedKind 5 | { 6 | Rich, 7 | Image, 8 | Video, 9 | Gifv, 10 | Link, 11 | } 12 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Embeds/EmbedVideo.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using JsonExtensions.Reading; 3 | 4 | namespace DiscordChatExporter.Core.Discord.Data.Embeds; 5 | 6 | // https://discord.com/developers/docs/resources/channel#embed-object-embed-video-structure 7 | public record EmbedVideo( 8 | string? Url, 9 | string? ProxyUrl, 10 | int? Width, 11 | int? Height) 12 | { 13 | public static EmbedVideo Parse(JsonElement json) 14 | { 15 | var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); 16 | var proxyUrl = json.GetPropertyOrNull("proxy_url")?.GetNonWhiteSpaceStringOrNull(); 17 | var width = json.GetPropertyOrNull("width")?.GetInt32OrNull(); 18 | var height = json.GetPropertyOrNull("height")?.GetInt32OrNull(); 19 | 20 | return new EmbedVideo(url, proxyUrl, width, height); 21 | } 22 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Embeds/SpotifyTrackEmbedProjection.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace DiscordChatExporter.Core.Discord.Data.Embeds; 4 | 5 | public partial record SpotifyTrackEmbedProjection(string TrackId) 6 | { 7 | public string Url { get; } = $"https://open.spotify.com/embed/track/{TrackId}"; 8 | } 9 | 10 | public partial record SpotifyTrackEmbedProjection 11 | { 12 | private static string? TryParseTrackId(string embedUrl) 13 | { 14 | // https://open.spotify.com/track/1LHZMWefF9502NPfArRfvP?si=3efac6ce9be04f0a 15 | var trackId = Regex 16 | .Match(embedUrl, @"spotify\.com/track/(.*?)(?:\?|&|/|$)") 17 | .Groups[1] 18 | .Value; 19 | 20 | if (!string.IsNullOrWhiteSpace(trackId)) 21 | return trackId; 22 | 23 | return null; 24 | } 25 | 26 | public static SpotifyTrackEmbedProjection? TryResolve(Embed embed) 27 | { 28 | if (embed.Kind != EmbedKind.Link) 29 | return null; 30 | 31 | if (string.IsNullOrWhiteSpace(embed.Url)) 32 | return null; 33 | 34 | var trackId = TryParseTrackId(embed.Url); 35 | if (string.IsNullOrWhiteSpace(trackId)) 36 | return null; 37 | 38 | return new SpotifyTrackEmbedProjection(trackId); 39 | } 40 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Embeds/TwitchClipEmbedProjection.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace DiscordChatExporter.Core.Discord.Data.Embeds; 4 | 5 | public partial record TwitchClipEmbedProjection(string ClipId) 6 | { 7 | public string Url { get; } = $"https://clips.twitch.tv/embed?clip={ClipId}&parent=localhost"; 8 | } 9 | 10 | public partial record TwitchClipEmbedProjection 11 | { 12 | private static string? TryParseClipId(string embedUrl) 13 | { 14 | // https://clips.twitch.tv/SpookyTenuousPidgeonPanicVis 15 | { 16 | var clipId = Regex 17 | .Match(embedUrl, @"clips\.twitch\.tv/(.*?)(?:\?|&|/|$)") 18 | .Groups[1] 19 | .Value; 20 | 21 | if (!string.IsNullOrWhiteSpace(clipId)) 22 | return clipId; 23 | } 24 | 25 | // https://twitch.tv/clip/SpookyTenuousPidgeonPanicVis 26 | { 27 | var clipId = Regex 28 | .Match(embedUrl, @"twitch\.tv/clip/(.*?)(?:\?|&|/|$)") 29 | .Groups[1] 30 | .Value; 31 | 32 | if (!string.IsNullOrWhiteSpace(clipId)) 33 | return clipId; 34 | } 35 | 36 | return null; 37 | } 38 | 39 | public static TwitchClipEmbedProjection? TryResolve(Embed embed) 40 | { 41 | if (embed.Kind != EmbedKind.Video) 42 | return null; 43 | 44 | if (string.IsNullOrWhiteSpace(embed.Url)) 45 | return null; 46 | 47 | var clipId = TryParseClipId(embed.Url); 48 | if (string.IsNullOrWhiteSpace(clipId)) 49 | return null; 50 | 51 | return new TwitchClipEmbedProjection(clipId); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Embeds/YouTubeVideoEmbedProjection.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace DiscordChatExporter.Core.Discord.Data.Embeds; 4 | 5 | public partial record YouTubeVideoEmbedProjection(string VideoId) 6 | { 7 | public string Url { get; } = $"https://www.youtube.com/embed/{VideoId}"; 8 | } 9 | 10 | public partial record YouTubeVideoEmbedProjection 11 | { 12 | // Adapted from YoutubeExplode 13 | // https://github.com/Tyrrrz/YoutubeExplode/blob/bc700b631bd105d0be208a88116347034bdca88b/YoutubeExplode/Videos/VideoId.cs#L40-L62 14 | private static string? TryParseVideoId(string embedUrl) 15 | { 16 | // Regular URL 17 | // https://www.youtube.com/watch?v=yIVRs6YSbOM 18 | var regularMatch = Regex.Match(embedUrl, @"youtube\..+?/watch.*?v=(.*?)(?:&|/|$)").Groups[1].Value; 19 | if (!string.IsNullOrWhiteSpace(regularMatch)) 20 | return regularMatch; 21 | 22 | // Short URL 23 | // https://youtu.be/yIVRs6YSbOM 24 | var shortMatch = Regex.Match(embedUrl, @"youtu\.be/(.*?)(?:\?|&|/|$)").Groups[1].Value; 25 | if (!string.IsNullOrWhiteSpace(shortMatch)) 26 | return shortMatch; 27 | 28 | // Embed URL 29 | // https://www.youtube.com/embed/yIVRs6YSbOM 30 | var embedMatch = Regex.Match(embedUrl, @"youtube\..+?/embed/(.*?)(?:\?|&|/|$)").Groups[1].Value; 31 | if (!string.IsNullOrWhiteSpace(embedMatch)) 32 | return embedMatch; 33 | 34 | // Shorts URL 35 | // https://www.youtube.com/shorts/sKL1vjP0tIo 36 | var shortsMatch = Regex.Match(embedUrl, @"youtube\..+?/shorts/(.*?)(?:\?|&|/|$)").Groups[1].Value; 37 | if (!string.IsNullOrWhiteSpace(shortsMatch)) 38 | return shortsMatch; 39 | 40 | return null; 41 | } 42 | 43 | public static YouTubeVideoEmbedProjection? TryResolve(Embed embed) 44 | { 45 | if (embed.Kind != EmbedKind.Video) 46 | return null; 47 | 48 | if (string.IsNullOrWhiteSpace(embed.Url)) 49 | return null; 50 | 51 | var videoId = TryParseVideoId(embed.Url); 52 | if (string.IsNullOrWhiteSpace(videoId)) 53 | return null; 54 | 55 | return new YouTubeVideoEmbedProjection(videoId); 56 | } 57 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Emoji.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using DiscordChatExporter.Core.Discord.Data.Common; 3 | using DiscordChatExporter.Core.Utils; 4 | using DiscordChatExporter.Core.Utils.Extensions; 5 | using JsonExtensions.Reading; 6 | 7 | namespace DiscordChatExporter.Core.Discord.Data; 8 | 9 | // https://discord.com/developers/docs/resources/emoji#emoji-object 10 | public partial record Emoji( 11 | // Only present on custom emoji 12 | Snowflake? Id, 13 | // Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂) 14 | string Name, 15 | bool IsAnimated 16 | ) 17 | { 18 | public bool IsCustomEmoji { get; } = Id is not null; 19 | 20 | // Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile) 21 | public string Code { get; } = Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name; 22 | 23 | public string ImageUrl { get; } = 24 | Id is not null 25 | ? ImageCdn.GetCustomEmojiUrl(Id.Value, IsAnimated) 26 | : ImageCdn.GetStandardEmojiUrl(Name); 27 | } 28 | 29 | public partial record Emoji 30 | { 31 | public static Emoji Parse(JsonElement json) 32 | { 33 | var id = json.GetPropertyOrNull("id") 34 | ?.GetNonWhiteSpaceStringOrNull() 35 | ?.Pipe(Snowflake.Parse); 36 | 37 | // Names may be missing on custom emoji within reactions 38 | var name = 39 | json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji"; 40 | 41 | var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false; 42 | 43 | return new Emoji(id, name, isAnimated); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Guild.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using DiscordChatExporter.Core.Discord.Data.Common; 3 | using DiscordChatExporter.Core.Utils.Extensions; 4 | using JsonExtensions.Reading; 5 | 6 | namespace DiscordChatExporter.Core.Discord.Data; 7 | 8 | // https://discord.com/developers/docs/resources/guild#guild-object 9 | public partial record Guild(Snowflake Id, string Name, string IconUrl) : IHasId 10 | { 11 | public bool IsDirect { get; } = Id == Snowflake.Zero; 12 | } 13 | 14 | public partial record Guild 15 | { 16 | // Direct messages are encapsulated within a special pseudo-guild for consistency 17 | public static Guild DirectMessages { get; } = new( 18 | Snowflake.Zero, 19 | "Direct Messages", 20 | ImageCdn.GetFallbackUserAvatarUrl() 21 | ); 22 | 23 | public static Guild Parse(JsonElement json) 24 | { 25 | var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); 26 | var name = json.GetProperty("name").GetNonNullString(); 27 | 28 | var iconUrl = 29 | json 30 | .GetPropertyOrNull("icon")? 31 | .GetNonWhiteSpaceStringOrNull()? 32 | .Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? 33 | ImageCdn.GetFallbackUserAvatarUrl(); 34 | 35 | return new Guild(id, name, iconUrl); 36 | } 37 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Interaction.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using DiscordChatExporter.Core.Utils.Extensions; 3 | using JsonExtensions.Reading; 4 | 5 | namespace DiscordChatExporter.Core.Discord.Data; 6 | 7 | // https://discord.com/developers/docs/interactions/receiving-and-responding#message-interaction-object 8 | public record Interaction(Snowflake Id, string Name, User User) 9 | { 10 | public static Interaction Parse(JsonElement json) 11 | { 12 | var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); 13 | var name = json.GetProperty("name").GetNonNullString(); // may be empty, but not null 14 | var user = json.GetProperty("user").Pipe(User.Parse); 15 | 16 | return new Interaction(id, name, user); 17 | } 18 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Invite.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.RegularExpressions; 3 | using DiscordChatExporter.Core.Utils.Extensions; 4 | using JsonExtensions.Reading; 5 | 6 | namespace DiscordChatExporter.Core.Discord.Data; 7 | 8 | // https://discord.com/developers/docs/resources/invite#invite-object 9 | public record Invite( 10 | string Code, 11 | Guild Guild, 12 | Channel? Channel) 13 | { 14 | public static string? TryGetCodeFromUrl(string url) => 15 | Regex.Match(url, @"^https?://discord\.gg/(\w+)/?$").Groups[1].Value.NullIfWhiteSpace(); 16 | 17 | public static Invite Parse(JsonElement json) 18 | { 19 | var code = json.GetProperty("code").GetNonWhiteSpaceString(); 20 | var guild = json.GetPropertyOrNull("guild")?.Pipe(Guild.Parse) ?? Guild.DirectMessages; 21 | var channel = json.GetPropertyOrNull("channel")?.Pipe(c => Channel.Parse(c)); 22 | 23 | return new Invite(code, guild, channel); 24 | } 25 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Member.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Text.Json; 4 | using DiscordChatExporter.Core.Discord.Data.Common; 5 | using DiscordChatExporter.Core.Utils.Extensions; 6 | using JsonExtensions.Reading; 7 | 8 | namespace DiscordChatExporter.Core.Discord.Data; 9 | 10 | // https://discord.com/developers/docs/resources/guild#guild-member-object 11 | public partial record Member( 12 | User User, 13 | string? DisplayName, 14 | string? AvatarUrl, 15 | IReadOnlyList RoleIds 16 | ) : IHasId 17 | { 18 | public Snowflake Id { get; } = User.Id; 19 | } 20 | 21 | public partial record Member 22 | { 23 | public static Member CreateFallback(User user) => new(user, null, null, []); 24 | 25 | public static Member Parse(JsonElement json, Snowflake? guildId = null) 26 | { 27 | var user = json.GetProperty("user").Pipe(User.Parse); 28 | var displayName = json.GetPropertyOrNull("nick")?.GetNonWhiteSpaceStringOrNull(); 29 | 30 | var roleIds = 31 | json.GetPropertyOrNull("roles") 32 | ?.EnumerateArray() 33 | .Select(j => j.GetNonWhiteSpaceString()) 34 | .Select(Snowflake.Parse) 35 | .ToArray() ?? []; 36 | 37 | var avatarUrl = guildId is not null 38 | ? json.GetPropertyOrNull("avatar") 39 | ?.GetNonWhiteSpaceStringOrNull() 40 | ?.Pipe(h => ImageCdn.GetMemberAvatarUrl(guildId.Value, user.Id, h)) 41 | : null; 42 | 43 | return new Member(user, displayName, avatarUrl, roleIds); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/MessageFlags.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace DiscordChatExporter.Core.Discord.Data; 4 | 5 | // https://discord.com/developers/docs/resources/channel#message-object-message-flags 6 | [Flags] 7 | public enum MessageFlags 8 | { 9 | None = 0, 10 | CrossPosted = 1, 11 | CrossPost = 2, 12 | SuppressEmbeds = 4, 13 | SourceMessageDeleted = 8, 14 | Urgent = 16, 15 | HasThread = 32, 16 | Ephemeral = 64, 17 | Loading = 128, 18 | } 19 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/MessageKind.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Discord.Data; 2 | 3 | // https://discord.com/developers/docs/resources/channel#message-object-message-types 4 | public enum MessageKind 5 | { 6 | Default = 0, 7 | RecipientAdd = 1, 8 | RecipientRemove = 2, 9 | Call = 3, 10 | ChannelNameChange = 4, 11 | ChannelIconChange = 5, 12 | ChannelPinnedMessage = 6, 13 | GuildMemberJoin = 7, 14 | ThreadCreated = 18, 15 | Reply = 19, 16 | } 17 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/MessageReference.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using DiscordChatExporter.Core.Utils.Extensions; 3 | using JsonExtensions.Reading; 4 | 5 | namespace DiscordChatExporter.Core.Discord.Data; 6 | 7 | // https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure 8 | public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowflake? GuildId) 9 | { 10 | public static MessageReference Parse(JsonElement json) 11 | { 12 | var messageId = json 13 | .GetPropertyOrNull("message_id")? 14 | .GetNonWhiteSpaceStringOrNull()? 15 | .Pipe(Snowflake.Parse); 16 | 17 | var channelId = json 18 | .GetPropertyOrNull("channel_id")? 19 | .GetNonWhiteSpaceStringOrNull()? 20 | .Pipe(Snowflake.Parse); 21 | 22 | var guildId = json 23 | .GetPropertyOrNull("guild_id")? 24 | .GetNonWhiteSpaceStringOrNull()? 25 | .Pipe(Snowflake.Parse); 26 | 27 | return new MessageReference(messageId, channelId, guildId); 28 | } 29 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Reaction.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using DiscordChatExporter.Core.Utils.Extensions; 3 | 4 | namespace DiscordChatExporter.Core.Discord.Data; 5 | 6 | // https://discord.com/developers/docs/resources/channel#reaction-object 7 | public record Reaction(Emoji Emoji, int Count) 8 | { 9 | public static Reaction Parse(JsonElement json) 10 | { 11 | var emoji = json.GetProperty("emoji").Pipe(Emoji.Parse); 12 | var count = json.GetProperty("count").GetInt32(); 13 | 14 | return new Reaction(emoji, count); 15 | } 16 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Role.cs: -------------------------------------------------------------------------------- 1 | using System.Drawing; 2 | using System.Text.Json; 3 | using DiscordChatExporter.Core.Discord.Data.Common; 4 | using DiscordChatExporter.Core.Utils.Extensions; 5 | using JsonExtensions.Reading; 6 | 7 | namespace DiscordChatExporter.Core.Discord.Data; 8 | 9 | // https://discord.com/developers/docs/topics/permissions#role-object 10 | public record Role(Snowflake Id, string Name, int Position, Color? Color) : IHasId 11 | { 12 | public static Role Parse(JsonElement json) 13 | { 14 | var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); 15 | var name = json.GetProperty("name").GetNonNullString(); 16 | var position = json.GetProperty("position").GetInt32(); 17 | 18 | var color = json 19 | .GetPropertyOrNull("color")? 20 | .GetInt32OrNull()? 21 | .Pipe(System.Drawing.Color.FromArgb) 22 | .ResetAlpha() 23 | .NullIf(c => c.ToRgb() <= 0); 24 | 25 | return new Role(id, name, position, color); 26 | } 27 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/Sticker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | using DiscordChatExporter.Core.Discord.Data.Common; 4 | using DiscordChatExporter.Core.Utils.Extensions; 5 | using JsonExtensions.Reading; 6 | 7 | namespace DiscordChatExporter.Core.Discord.Data; 8 | 9 | // https://discord.com/developers/docs/resources/sticker#sticker-resource 10 | public partial record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl) 11 | { 12 | public bool IsImage { get; } = Format != StickerFormat.Lottie; 13 | } 14 | 15 | public partial record Sticker 16 | { 17 | public static Sticker Parse(JsonElement json) 18 | { 19 | var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); 20 | var name = json.GetProperty("name").GetNonNullString(); 21 | var format = json.GetProperty("format_type").GetInt32().Pipe(t => (StickerFormat)t); 22 | 23 | var sourceUrl = ImageCdn.GetStickerUrl( 24 | id, 25 | format switch 26 | { 27 | StickerFormat.Png => "png", 28 | StickerFormat.Apng => "png", 29 | StickerFormat.Lottie => "json", 30 | StickerFormat.Gif => "gif", 31 | _ => throw new InvalidOperationException($"Unknown sticker format '{format}'."), 32 | } 33 | ); 34 | 35 | return new Sticker(id, name, format, sourceUrl); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/StickerFormat.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Discord.Data; 2 | 3 | public enum StickerFormat 4 | { 5 | Png = 1, 6 | Apng = 2, 7 | Lottie = 3, 8 | Gif = 4, 9 | } 10 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Data/User.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using DiscordChatExporter.Core.Discord.Data.Common; 3 | using DiscordChatExporter.Core.Utils.Extensions; 4 | using JsonExtensions.Reading; 5 | 6 | namespace DiscordChatExporter.Core.Discord.Data; 7 | 8 | // https://discord.com/developers/docs/resources/user#user-object 9 | public partial record User( 10 | Snowflake Id, 11 | bool IsBot, 12 | // Remove after Discord migrates all accounts to the new system. 13 | // With that, also remove the DiscriminatorFormatted and FullName properties. 14 | // Replace existing calls to FullName with Name (not DisplayName). 15 | int? Discriminator, 16 | string Name, 17 | string DisplayName, 18 | string AvatarUrl 19 | ) : IHasId 20 | { 21 | public string DiscriminatorFormatted { get; } = 22 | Discriminator is not null ? $"{Discriminator:0000}" : "0000"; 23 | 24 | // This effectively represents the user's true identity. 25 | // In the old system, this is formed from the username and discriminator. 26 | // In the new system, the username is already the user's unique identifier. 27 | public string FullName => Discriminator is not null ? $"{Name}#{DiscriminatorFormatted}" : Name; 28 | } 29 | 30 | public partial record User 31 | { 32 | public static User Parse(JsonElement json) 33 | { 34 | var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); 35 | var isBot = json.GetPropertyOrNull("bot")?.GetBooleanOrNull() ?? false; 36 | 37 | var discriminator = json.GetPropertyOrNull("discriminator") 38 | ?.GetNonWhiteSpaceStringOrNull() 39 | ?.Pipe(int.Parse) 40 | .NullIfDefault(); 41 | 42 | var name = json.GetProperty("username").GetNonNullString(); 43 | var displayName = 44 | json.GetPropertyOrNull("global_name")?.GetNonWhiteSpaceStringOrNull() ?? name; 45 | 46 | var avatarIndex = discriminator % 5 ?? (int)((id.Value >> 22) % 6); 47 | 48 | var avatarUrl = 49 | json.GetPropertyOrNull("avatar") 50 | ?.GetNonWhiteSpaceStringOrNull() 51 | ?.Pipe(h => ImageCdn.GetUserAvatarUrl(id, h)) 52 | ?? ImageCdn.GetFallbackUserAvatarUrl(avatarIndex); 53 | 54 | return new User(id, isBot, discriminator, name, displayName, avatarUrl); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Dump/DataDump.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO.Compression; 4 | using System.Text.Json; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using JsonExtensions.Reading; 8 | 9 | namespace DiscordChatExporter.Core.Discord.Dump; 10 | 11 | public partial class DataDump(IReadOnlyList channels) 12 | { 13 | public IReadOnlyList Channels { get; } = channels; 14 | } 15 | 16 | public partial class DataDump 17 | { 18 | public static DataDump Parse(JsonElement json) 19 | { 20 | var channels = new List(); 21 | 22 | foreach (var property in json.EnumerateObjectOrEmpty()) 23 | { 24 | var channelId = Snowflake.Parse(property.Name); 25 | var channelName = property.Value.GetString(); 26 | 27 | // Null items refer to deleted channels 28 | if (channelName is null) 29 | continue; 30 | 31 | var channel = new DataDumpChannel(channelId, channelName); 32 | channels.Add(channel); 33 | } 34 | 35 | return new DataDump(channels); 36 | } 37 | 38 | public static async ValueTask LoadAsync( 39 | string zipFilePath, 40 | CancellationToken cancellationToken = default 41 | ) 42 | { 43 | using var archive = ZipFile.OpenRead(zipFilePath); 44 | 45 | var entry = archive.GetEntry("messages/index.json"); 46 | if (entry is null) 47 | { 48 | throw new InvalidOperationException( 49 | "Failed to locate the channel index inside the data package." 50 | ); 51 | } 52 | 53 | await using var stream = entry.Open(); 54 | using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken); 55 | 56 | return Parse(document.RootElement); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Dump/DataDumpChannel.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Discord.Dump; 2 | 3 | public record DataDumpChannel(Snowflake Id, string Name); 4 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/RateLimitPreference.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace DiscordChatExporter.Core.Discord; 4 | 5 | [Flags] 6 | public enum RateLimitPreference 7 | { 8 | IgnoreAll = 0, 9 | RespectForUserTokens = 0b1, 10 | RespectForBotTokens = 0b10, 11 | RespectAll = RespectForUserTokens | RespectForBotTokens, 12 | } 13 | 14 | public static class RateLimitPreferenceExtensions 15 | { 16 | internal static bool IsRespectedFor( 17 | this RateLimitPreference rateLimitPreference, 18 | TokenKind tokenKind 19 | ) => 20 | tokenKind switch 21 | { 22 | TokenKind.User => (rateLimitPreference & RateLimitPreference.RespectForUserTokens) != 0, 23 | TokenKind.Bot => (rateLimitPreference & RateLimitPreference.RespectForBotTokens) != 0, 24 | _ => throw new ArgumentOutOfRangeException(nameof(tokenKind)), 25 | }; 26 | 27 | public static string GetDisplayName(this RateLimitPreference rateLimitPreference) => 28 | rateLimitPreference switch 29 | { 30 | RateLimitPreference.IgnoreAll => "Always ignore", 31 | RateLimitPreference.RespectForUserTokens => "Respect for user tokens", 32 | RateLimitPreference.RespectForBotTokens => "Respect for bot tokens", 33 | RateLimitPreference.RespectAll => "Always respect", 34 | _ => throw new ArgumentOutOfRangeException(nameof(rateLimitPreference)), 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/Snowflake.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Globalization; 4 | 5 | namespace DiscordChatExporter.Core.Discord; 6 | 7 | public readonly partial record struct Snowflake(ulong Value) 8 | { 9 | public DateTimeOffset ToDate() => DateTimeOffset.FromUnixTimeMilliseconds( 10 | (long)((Value >> 22) + 1420070400000UL) 11 | ).ToLocalTime(); 12 | 13 | [ExcludeFromCodeCoverage] 14 | public override string ToString() => Value.ToString(CultureInfo.InvariantCulture); 15 | } 16 | 17 | public partial record struct Snowflake 18 | { 19 | public static Snowflake Zero { get; } = new(0); 20 | 21 | public static Snowflake FromDate(DateTimeOffset instant) => new( 22 | ((ulong)instant.ToUnixTimeMilliseconds() - 1420070400000UL) << 22 23 | ); 24 | 25 | public static Snowflake? TryParse(string? value, IFormatProvider? formatProvider = null) 26 | { 27 | if (string.IsNullOrWhiteSpace(value)) 28 | return null; 29 | 30 | // As number 31 | if (ulong.TryParse(value, NumberStyles.None, formatProvider, out var number)) 32 | return new Snowflake(number); 33 | 34 | // As date 35 | if (DateTimeOffset.TryParse(value, formatProvider, DateTimeStyles.None, out var instant)) 36 | return FromDate(instant); 37 | 38 | return null; 39 | } 40 | 41 | public static Snowflake Parse(string value, IFormatProvider? formatProvider) => 42 | TryParse(value, formatProvider) 43 | ?? throw new FormatException($"Invalid snowflake '{value}'."); 44 | 45 | public static Snowflake Parse(string value) => Parse(value, null); 46 | } 47 | 48 | public partial record struct Snowflake : IComparable, IComparable 49 | { 50 | public int CompareTo(Snowflake other) => Value.CompareTo(other.Value); 51 | 52 | public int CompareTo(object? obj) 53 | { 54 | if (obj is not Snowflake other) 55 | throw new ArgumentException($"Object must be of type {nameof(Snowflake)}."); 56 | 57 | return Value.CompareTo(other.Value); 58 | } 59 | 60 | public static bool operator >(Snowflake left, Snowflake right) => left.CompareTo(right) > 0; 61 | 62 | public static bool operator <(Snowflake left, Snowflake right) => left.CompareTo(right) < 0; 63 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Discord/TokenKind.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Discord; 2 | 3 | public enum TokenKind 4 | { 5 | User, 6 | Bot, 7 | } 8 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/DiscordChatExporter.Core.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | embedded 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exceptions/ChannelEmptyException.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Exceptions; 2 | 3 | public class ChannelEmptyException(string message) : DiscordChatExporterException(message); 4 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exceptions/DiscordChatExporterException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace DiscordChatExporter.Core.Exceptions; 4 | 5 | public class DiscordChatExporterException( 6 | string message, 7 | bool isFatal = false, 8 | Exception? innerException = null 9 | ) : Exception(message, innerException) 10 | { 11 | public bool IsFatal { get; } = isFatal; 12 | } 13 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/ExportFormat.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace DiscordChatExporter.Core.Exporting; 4 | 5 | public enum ExportFormat 6 | { 7 | PlainText, 8 | HtmlDark, 9 | HtmlLight, 10 | Csv, 11 | Json, 12 | } 13 | 14 | public static class ExportFormatExtensions 15 | { 16 | public static string GetFileExtension(this ExportFormat format) => 17 | format switch 18 | { 19 | ExportFormat.PlainText => "txt", 20 | ExportFormat.HtmlDark => "html", 21 | ExportFormat.HtmlLight => "html", 22 | ExportFormat.Csv => "csv", 23 | ExportFormat.Json => "json", 24 | _ => throw new ArgumentOutOfRangeException(nameof(format)), 25 | }; 26 | 27 | public static string GetDisplayName(this ExportFormat format) => 28 | format switch 29 | { 30 | ExportFormat.PlainText => "TXT", 31 | ExportFormat.HtmlDark => "HTML (Dark)", 32 | ExportFormat.HtmlLight => "HTML (Light)", 33 | ExportFormat.Csv => "CSV", 34 | ExportFormat.Json => "JSON", 35 | _ => throw new ArgumentOutOfRangeException(nameof(format)), 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Filtering/BinaryExpressionKind.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Exporting.Filtering; 2 | 3 | internal enum BinaryExpressionKind 4 | { 5 | Or, 6 | And, 7 | } 8 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Filtering/BinaryExpressionMessageFilter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using DiscordChatExporter.Core.Discord.Data; 3 | 4 | namespace DiscordChatExporter.Core.Exporting.Filtering; 5 | 6 | internal class BinaryExpressionMessageFilter( 7 | MessageFilter first, 8 | MessageFilter second, 9 | BinaryExpressionKind kind 10 | ) : MessageFilter 11 | { 12 | public override bool IsMatch(Message message) => 13 | kind switch 14 | { 15 | BinaryExpressionKind.Or => first.IsMatch(message) || second.IsMatch(message), 16 | BinaryExpressionKind.And => first.IsMatch(message) && second.IsMatch(message), 17 | _ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'."), 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Text.RegularExpressions; 3 | using DiscordChatExporter.Core.Discord.Data; 4 | 5 | namespace DiscordChatExporter.Core.Exporting.Filtering; 6 | 7 | internal class ContainsMessageFilter(string text) : MessageFilter 8 | { 9 | // Match content within word boundaries, between spaces, or as the whole input. 10 | // For example, "max" shouldn't match on content "our maximum effort", 11 | // but should match on content "our max effort". 12 | // Also, "(max)" should match on content "our (max) effort", even though 13 | // parentheses are not considered word characters. 14 | // https://github.com/Tyrrrz/DiscordChatExporter/issues/909 15 | private bool IsMatch(string? content) => 16 | !string.IsNullOrWhiteSpace(content) 17 | && Regex.IsMatch( 18 | content, 19 | @"(?:\b|\s|^)" + Regex.Escape(text) + @"(?:\b|\s|$)", 20 | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant 21 | ); 22 | 23 | public override bool IsMatch(Message message) => 24 | IsMatch(message.Content) 25 | || message.Embeds.Any(e => 26 | IsMatch(e.Title) 27 | || IsMatch(e.Author?.Name) 28 | || IsMatch(e.Description) 29 | || IsMatch(e.Footer?.Text) 30 | || e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value)) 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Filtering/FromMessageFilter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using DiscordChatExporter.Core.Discord.Data; 3 | 4 | namespace DiscordChatExporter.Core.Exporting.Filtering; 5 | 6 | internal class FromMessageFilter(string value) : MessageFilter 7 | { 8 | public override bool IsMatch(Message message) => 9 | string.Equals(value, message.Author.Name, StringComparison.OrdinalIgnoreCase) 10 | || string.Equals(value, message.Author.DisplayName, StringComparison.OrdinalIgnoreCase) 11 | || string.Equals(value, message.Author.FullName, StringComparison.OrdinalIgnoreCase) 12 | || string.Equals(value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase); 13 | } 14 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Filtering/HasMessageFilter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using DiscordChatExporter.Core.Discord.Data; 4 | using DiscordChatExporter.Core.Markdown.Parsing; 5 | 6 | namespace DiscordChatExporter.Core.Exporting.Filtering; 7 | 8 | internal class HasMessageFilter(MessageContentMatchKind kind) : MessageFilter 9 | { 10 | public override bool IsMatch(Message message) => 11 | kind switch 12 | { 13 | MessageContentMatchKind.Link => MarkdownParser.ExtractLinks(message.Content).Any(), 14 | MessageContentMatchKind.Embed => message.Embeds.Any(), 15 | MessageContentMatchKind.File => message.Attachments.Any(), 16 | MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo), 17 | MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage), 18 | MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio), 19 | MessageContentMatchKind.Pin => message.IsPinned, 20 | MessageContentMatchKind.Invite => MarkdownParser 21 | .ExtractLinks(message.Content) 22 | .Select(l => l.Url) 23 | .Select(Invite.TryGetCodeFromUrl) 24 | .Any(c => !string.IsNullOrWhiteSpace(c)), 25 | _ => throw new InvalidOperationException( 26 | $"Unknown message content match kind '{kind}'." 27 | ), 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Filtering/MentionsMessageFilter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using DiscordChatExporter.Core.Discord.Data; 4 | 5 | namespace DiscordChatExporter.Core.Exporting.Filtering; 6 | 7 | internal class MentionsMessageFilter(string value) : MessageFilter 8 | { 9 | public override bool IsMatch(Message message) => 10 | message.MentionedUsers.Any(user => 11 | string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase) 12 | || string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase) 13 | || string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase) 14 | || string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase) 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Filtering/MessageContentMatchKind.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Exporting.Filtering; 2 | 3 | internal enum MessageContentMatchKind 4 | { 5 | Link, 6 | Embed, 7 | File, 8 | Video, 9 | Image, 10 | Sound, 11 | Pin, 12 | Invite, 13 | } 14 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Filtering/MessageFilter.cs: -------------------------------------------------------------------------------- 1 | using DiscordChatExporter.Core.Discord.Data; 2 | using DiscordChatExporter.Core.Exporting.Filtering.Parsing; 3 | using Superpower; 4 | 5 | namespace DiscordChatExporter.Core.Exporting.Filtering; 6 | 7 | public abstract partial class MessageFilter 8 | { 9 | public abstract bool IsMatch(Message message); 10 | } 11 | 12 | public partial class MessageFilter 13 | { 14 | public static MessageFilter Null { get; } = new NullMessageFilter(); 15 | 16 | public static MessageFilter Parse(string value) => FilterGrammar.Filter.Parse(value); 17 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Filtering/NegatedMessageFilter.cs: -------------------------------------------------------------------------------- 1 | using DiscordChatExporter.Core.Discord.Data; 2 | 3 | namespace DiscordChatExporter.Core.Exporting.Filtering; 4 | 5 | internal class NegatedMessageFilter(MessageFilter filter) : MessageFilter 6 | { 7 | public override bool IsMatch(Message message) => !filter.IsMatch(message); 8 | } 9 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Filtering/NullMessageFilter.cs: -------------------------------------------------------------------------------- 1 | using DiscordChatExporter.Core.Discord.Data; 2 | 3 | namespace DiscordChatExporter.Core.Exporting.Filtering; 4 | 5 | internal class NullMessageFilter : MessageFilter 6 | { 7 | public override bool IsMatch(Message message) => true; 8 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Filtering/ReactionMessageFilter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using DiscordChatExporter.Core.Discord.Data; 4 | 5 | namespace DiscordChatExporter.Core.Exporting.Filtering; 6 | 7 | internal class ReactionMessageFilter(string value) : MessageFilter 8 | { 9 | public override bool IsMatch(Message message) => 10 | message.Reactions.Any(r => 11 | string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase) 12 | || string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase) 13 | || string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase) 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/HtmlMessageExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using DiscordChatExporter.Core.Discord.Data; 3 | using DiscordChatExporter.Core.Discord.Data.Embeds; 4 | 5 | namespace DiscordChatExporter.Core.Exporting; 6 | 7 | internal static class HtmlMessageExtensions 8 | { 9 | // Message content is hidden if it's a link to an embedded media 10 | // https://github.com/Tyrrrz/DiscordChatExporter/issues/682 11 | public static bool IsContentHidden(this Message message) 12 | { 13 | if (message.Embeds.Count != 1) 14 | return false; 15 | 16 | var embed = message.Embeds[0]; 17 | 18 | return 19 | string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase) && 20 | embed.Kind is EmbedKind.Image or EmbedKind.Gifv; 21 | } 22 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/MessageWriter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using DiscordChatExporter.Core.Discord.Data; 6 | 7 | namespace DiscordChatExporter.Core.Exporting; 8 | 9 | internal abstract class MessageWriter(Stream stream, ExportContext context) : IAsyncDisposable 10 | { 11 | protected Stream Stream { get; } = stream; 12 | 13 | protected ExportContext Context { get; } = context; 14 | 15 | public long MessagesWritten { get; private set; } 16 | 17 | public long BytesWritten => Stream.Length; 18 | 19 | public virtual ValueTask WritePreambleAsync(CancellationToken cancellationToken = default) => default; 20 | 21 | public virtual ValueTask WriteMessageAsync(Message message, CancellationToken cancellationToken = default) 22 | { 23 | MessagesWritten++; 24 | return default; 25 | } 26 | 27 | public virtual ValueTask WritePostambleAsync(CancellationToken cancellationToken = default) => default; 28 | 29 | public virtual async ValueTask DisposeAsync() => await Stream.DisposeAsync(); 30 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Partitioning/FileSizePartitionLimit.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Exporting.Partitioning; 2 | 3 | internal class FileSizePartitionLimit(long limit) : PartitionLimit 4 | { 5 | public override bool IsReached(long messagesWritten, long bytesWritten) => 6 | bytesWritten >= limit; 7 | } 8 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Partitioning/MessageCountPartitionLimit.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Exporting.Partitioning; 2 | 3 | internal class MessageCountPartitionLimit(long limit) : PartitionLimit 4 | { 5 | public override bool IsReached(long messagesWritten, long bytesWritten) => 6 | messagesWritten >= limit; 7 | } 8 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Partitioning/NullPartitionLimit.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Exporting.Partitioning; 2 | 3 | internal class NullPartitionLimit : PartitionLimit 4 | { 5 | public override bool IsReached(long messagesWritten, long bytesWritten) => false; 6 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/Partitioning/PartitionLimit.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using System.Text.RegularExpressions; 4 | 5 | namespace DiscordChatExporter.Core.Exporting.Partitioning; 6 | 7 | public abstract partial class PartitionLimit 8 | { 9 | public abstract bool IsReached(long messagesWritten, long bytesWritten); 10 | } 11 | 12 | public partial class PartitionLimit 13 | { 14 | public static PartitionLimit Null { get; } = new NullPartitionLimit(); 15 | 16 | private static long? TryParseFileSizeBytes(string value, IFormatProvider? formatProvider = null) 17 | { 18 | var match = Regex.Match(value, @"^\s*(\d+[\.,]?\d*)\s*(\w)?b\s*$", RegexOptions.IgnoreCase); 19 | 20 | // Number part 21 | if (!double.TryParse( 22 | match.Groups[1].Value, 23 | NumberStyles.Float, 24 | formatProvider, 25 | out var number)) 26 | { 27 | return null; 28 | } 29 | 30 | // Magnitude part 31 | var magnitude = match.Groups[2].Value.ToUpperInvariant() switch 32 | { 33 | "G" => 1_000_000_000, 34 | "M" => 1_000_000, 35 | "K" => 1_000, 36 | "" => 1, 37 | _ => -1, 38 | }; 39 | 40 | if (magnitude < 0) 41 | { 42 | return null; 43 | } 44 | 45 | return (long) (number * magnitude); 46 | } 47 | 48 | public static PartitionLimit? TryParse(string value, IFormatProvider? formatProvider = null) 49 | { 50 | var fileSizeLimit = TryParseFileSizeBytes(value, formatProvider); 51 | if (fileSizeLimit is not null) 52 | return new FileSizePartitionLimit(fileSizeLimit.Value); 53 | 54 | if (int.TryParse(value, NumberStyles.Integer, formatProvider, out var messageCountLimit)) 55 | return new MessageCountPartitionLimit(messageCountLimit); 56 | 57 | return null; 58 | } 59 | 60 | public static PartitionLimit Parse(string value, IFormatProvider? formatProvider = null) => 61 | TryParse(value, formatProvider) ?? throw new FormatException($"Invalid partition limit '{value}'."); 62 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/PlainTextMessageExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Linq; 3 | using DiscordChatExporter.Core.Discord.Data; 4 | using DiscordChatExporter.Core.Utils.Extensions; 5 | 6 | namespace DiscordChatExporter.Core.Exporting; 7 | 8 | internal static class PlainTextMessageExtensions 9 | { 10 | public static string GetFallbackContent(this Message message) => 11 | message.Kind switch 12 | { 13 | MessageKind.RecipientAdd => message.MentionedUsers.Any() 14 | ? $"Added {message.MentionedUsers.First().DisplayName} to the group." 15 | : "Added a recipient.", 16 | 17 | MessageKind.RecipientRemove => message.MentionedUsers.Any() 18 | ? message.Author.Id == message.MentionedUsers.First().Id 19 | ? "Left the group." 20 | : $"Removed {message.MentionedUsers.First().DisplayName} from the group." 21 | : "Removed a recipient.", 22 | 23 | MessageKind.Call => 24 | $"Started a call that lasted { 25 | message 26 | .CallEndedTimestamp? 27 | .Pipe(t => t - message.Timestamp) 28 | .Pipe(t => t.TotalMinutes) 29 | .ToString("n0", CultureInfo.InvariantCulture) ?? "0" 30 | } minutes.", 31 | 32 | MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content) 33 | ? $"Changed the channel name: {message.Content}" 34 | : "Changed the channel name.", 35 | 36 | MessageKind.ChannelIconChange => "Changed the channel icon.", 37 | MessageKind.ChannelPinnedMessage => "Pinned a message.", 38 | MessageKind.ThreadCreated => "Started a thread.", 39 | MessageKind.GuildMemberJoin => "Joined the server.", 40 | 41 | _ => message.Content, 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Exporting/PostambleTemplate.cshtml: -------------------------------------------------------------------------------- 1 | @using System 2 | 3 | @inherits RazorBlade.HtmlTemplate 4 | 5 | @functions { 6 | public required ExportContext Context { get; init; } 7 | 8 | public required long MessagesWritten { get; init; } 9 | } 10 | 11 | @{ 12 | /* Close elements opened by preamble */ 13 | } 14 | 15 | 16 | 17 | 18 |
19 |
Exported @MessagesWritten.ToString("n0", Context.Request.CultureInfo) message(s)
20 |
Timezone: UTC@((Context.Request.IsUtcNormalizationEnabled ? 0 : TimeZoneInfo.Local.BaseUtcOffset.TotalHours).ToString("+#.#;-#.#;+0", Context.Request.CultureInfo))
21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/EmojiNode.cs: -------------------------------------------------------------------------------- 1 | using DiscordChatExporter.Core.Discord; 2 | using DiscordChatExporter.Core.Discord.Data; 3 | 4 | namespace DiscordChatExporter.Core.Markdown; 5 | 6 | internal record EmojiNode( 7 | // Only present on custom emoji 8 | Snowflake? Id, 9 | // Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂) 10 | string Name, 11 | bool IsAnimated 12 | ) : MarkdownNode 13 | { 14 | // This coupling is unsound from the domain-design perspective, but it helps us reuse 15 | // some code for now. We can refactor this later, if the coupling becomes a problem. 16 | private readonly Emoji _emoji = new(Id, Name, IsAnimated); 17 | 18 | public EmojiNode(string name) 19 | : this(null, name, false) { } 20 | 21 | public bool IsCustomEmoji => _emoji.IsCustomEmoji; 22 | 23 | // Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile) 24 | public string Code => _emoji.Code; 25 | 26 | public string ImageUrl => _emoji.ImageUrl; 27 | } 28 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/FormattingKind.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Markdown; 2 | 3 | internal enum FormattingKind 4 | { 5 | Bold, 6 | Italic, 7 | Underline, 8 | Strikethrough, 9 | Spoiler, 10 | Quote, 11 | } 12 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/FormattingNode.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace DiscordChatExporter.Core.Markdown; 4 | 5 | internal record FormattingNode( 6 | FormattingKind Kind, 7 | IReadOnlyList Children 8 | ) : MarkdownNode, IContainerNode; -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/HeadingNode.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace DiscordChatExporter.Core.Markdown; 4 | 5 | internal record HeadingNode( 6 | int Level, 7 | IReadOnlyList Children 8 | ) : MarkdownNode, IContainerNode; -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/IContainerNode.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace DiscordChatExporter.Core.Markdown; 4 | 5 | internal interface IContainerNode 6 | { 7 | IReadOnlyList Children { get; } 8 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/InlineCodeBlockNode.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Markdown; 2 | 3 | internal record InlineCodeBlockNode(string Code) : MarkdownNode; -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/LinkNode.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace DiscordChatExporter.Core.Markdown; 4 | 5 | // Named links can contain child nodes (e.g. [**bold URL**](https://test.com)) 6 | internal record LinkNode(string Url, IReadOnlyList Children) 7 | : MarkdownNode, 8 | IContainerNode 9 | { 10 | public LinkNode(string url) 11 | : this(url, [new TextNode(url)]) { } 12 | } 13 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/ListItemNode.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace DiscordChatExporter.Core.Markdown; 4 | 5 | internal record ListItemNode(IReadOnlyList Children) : MarkdownNode, IContainerNode; -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/ListNode.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace DiscordChatExporter.Core.Markdown; 4 | 5 | internal record ListNode(IReadOnlyList Items) : MarkdownNode; -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/MarkdownNode.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Markdown; 2 | 3 | internal abstract record MarkdownNode; -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/MentionKind.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Markdown; 2 | 3 | internal enum MentionKind 4 | { 5 | Everyone, 6 | Here, 7 | User, 8 | Channel, 9 | Role, 10 | } 11 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/MentionNode.cs: -------------------------------------------------------------------------------- 1 | using DiscordChatExporter.Core.Discord; 2 | 3 | namespace DiscordChatExporter.Core.Markdown; 4 | 5 | // Null ID means it's a meta mention or an invalid mention 6 | internal record MentionNode(Snowflake? TargetId, MentionKind Kind) : MarkdownNode; -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/MultiLineCodeBlockNode.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Markdown; 2 | 3 | internal record MultiLineCodeBlockNode(string Language, string Code) : MarkdownNode; -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/Parsing/AggregateMatcher.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace DiscordChatExporter.Core.Markdown.Parsing; 4 | 5 | internal class AggregateMatcher( 6 | params IReadOnlyList> matchers 7 | ) : IMatcher 8 | { 9 | public ParsedMatch? TryMatch(TContext context, StringSegment segment) 10 | { 11 | ParsedMatch? earliestMatch = null; 12 | 13 | // Try to match the input with each matcher and get the match with the lowest start index 14 | foreach (var matcher in matchers) 15 | { 16 | // Try to match 17 | var match = matcher.TryMatch(context, segment); 18 | 19 | // If there's no match - continue 20 | if (match is null) 21 | continue; 22 | 23 | // If this match is earlier than previous earliest - replace 24 | if ( 25 | earliestMatch is null 26 | || match.Segment.StartIndex < earliestMatch.Segment.StartIndex 27 | ) 28 | { 29 | earliestMatch = match; 30 | } 31 | 32 | // If the earliest match starts at the very beginning - break, 33 | // because it's impossible to find a match earlier than that 34 | if (earliestMatch.Segment.StartIndex == segment.StartIndex) 35 | break; 36 | } 37 | 38 | return earliestMatch; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/Parsing/IMatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace DiscordChatExporter.Core.Markdown.Parsing; 5 | 6 | internal interface IMatcher 7 | { 8 | ParsedMatch? TryMatch(TContext context, StringSegment segment); 9 | } 10 | 11 | internal static class MatcherExtensions 12 | { 13 | public static IEnumerable> MatchAll( 14 | this IMatcher matcher, 15 | TContext context, 16 | StringSegment segment, 17 | Func transformFallback 18 | ) 19 | { 20 | // Loop through segments divided by individual matches 21 | var currentIndex = segment.StartIndex; 22 | while (currentIndex < segment.EndIndex) 23 | { 24 | // Find a match within this segment 25 | var match = matcher.TryMatch( 26 | context, 27 | segment.Relocate(currentIndex, segment.EndIndex - currentIndex) 28 | ); 29 | 30 | if (match is null) 31 | break; 32 | 33 | // If this match doesn't start immediately at the current position - transform and yield fallback first 34 | if (match.Segment.StartIndex > currentIndex) 35 | { 36 | var fallbackSegment = segment.Relocate( 37 | currentIndex, 38 | match.Segment.StartIndex - currentIndex 39 | ); 40 | 41 | yield return new ParsedMatch( 42 | fallbackSegment, 43 | transformFallback(context, fallbackSegment) 44 | ); 45 | } 46 | 47 | yield return match; 48 | 49 | // Shift current index to the end of the match 50 | currentIndex = match.Segment.StartIndex + match.Segment.Length; 51 | } 52 | 53 | // If EOL hasn't been reached - transform and yield remaining part as fallback 54 | if (currentIndex < segment.EndIndex) 55 | { 56 | var fallbackSegment = segment.Relocate(currentIndex, segment.EndIndex - currentIndex); 57 | 58 | yield return new ParsedMatch( 59 | fallbackSegment, 60 | transformFallback(context, fallbackSegment) 61 | ); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/Parsing/MarkdownContext.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Markdown.Parsing; 2 | 3 | internal readonly record struct MarkdownContext(int Depth = 0); 4 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/Parsing/ParsedMatch.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Markdown.Parsing; 2 | 3 | internal class ParsedMatch(StringSegment segment, T value) 4 | { 5 | public StringSegment Segment { get; } = segment; 6 | 7 | public T Value { get; } = value; 8 | } 9 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/Parsing/RegexMatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.RegularExpressions; 3 | 4 | namespace DiscordChatExporter.Core.Markdown.Parsing; 5 | 6 | internal class RegexMatcher( 7 | Regex regex, 8 | Func transform 9 | ) : IMatcher 10 | { 11 | public ParsedMatch? TryMatch(TContext context, StringSegment segment) 12 | { 13 | var match = regex.Match(segment.Source, segment.StartIndex, segment.Length); 14 | if (!match.Success) 15 | return null; 16 | 17 | // Overload regex.Match(string, int, int) doesn't take the whole string into account, 18 | // it effectively functions as a match check on a substring. 19 | // Which is super weird because regex.Match(string, int) takes the whole input in context. 20 | // So in order to properly account for ^/$ regex tokens, we need to make sure that 21 | // the expression also matches on the bigger part of the input. 22 | if (!regex.IsMatch(segment.Source[..segment.EndIndex], segment.StartIndex)) 23 | return null; 24 | 25 | var segmentMatch = segment.Relocate(match); 26 | var value = transform(context, segmentMatch, match); 27 | 28 | return value is not null ? new ParsedMatch(segmentMatch, value) : null; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/Parsing/StringMatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace DiscordChatExporter.Core.Markdown.Parsing; 4 | 5 | internal class StringMatcher( 6 | string needle, 7 | StringComparison comparison, 8 | Func transform 9 | ) : IMatcher 10 | { 11 | public StringMatcher(string needle, Func transform) 12 | : this(needle, StringComparison.Ordinal, transform) { } 13 | 14 | public ParsedMatch? TryMatch(TContext context, StringSegment segment) 15 | { 16 | var index = segment.Source.IndexOf(needle, segment.StartIndex, segment.Length, comparison); 17 | 18 | if (index < 0) 19 | return null; 20 | 21 | var segmentMatch = segment.Relocate(index, needle.Length); 22 | var value = transform(context, segmentMatch); 23 | 24 | return value is not null ? new ParsedMatch(segmentMatch, value) : null; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/Parsing/StringSegment.cs: -------------------------------------------------------------------------------- 1 | using System.Text.RegularExpressions; 2 | 3 | namespace DiscordChatExporter.Core.Markdown.Parsing; 4 | 5 | internal readonly record struct StringSegment(string Source, int StartIndex, int Length) 6 | { 7 | public int EndIndex => StartIndex + Length; 8 | 9 | public StringSegment(string target) 10 | : this(target, 0, target.Length) 11 | { 12 | } 13 | 14 | public StringSegment Relocate(int newStartIndex, int newLength) => new(Source, newStartIndex, newLength); 15 | 16 | public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length); 17 | 18 | public override string ToString() => Source.Substring(StartIndex, Length); 19 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/TextNode.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Core.Markdown; 2 | 3 | internal record TextNode(string Text) : MarkdownNode; -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Markdown/TimestampNode.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace DiscordChatExporter.Core.Markdown; 4 | 5 | // Null date means invalid timestamp 6 | internal record TimestampNode(DateTimeOffset? Instant, string? Format) : MarkdownNode 7 | { 8 | public static TimestampNode Invalid { get; } = new(null, null); 9 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/Docker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace DiscordChatExporter.Core.Utils; 4 | 5 | public static class Docker 6 | { 7 | public static bool IsRunningInContainer { get; } = 8 | Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; 9 | } 10 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/Extensions/AsyncCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Runtime.CompilerServices; 3 | using System.Threading.Tasks; 4 | 5 | namespace DiscordChatExporter.Core.Utils.Extensions; 6 | 7 | public static class AsyncCollectionExtensions 8 | { 9 | private static async ValueTask> CollectAsync( 10 | this IAsyncEnumerable asyncEnumerable) 11 | { 12 | var list = new List(); 13 | 14 | await foreach (var i in asyncEnumerable) 15 | list.Add(i); 16 | 17 | return list; 18 | } 19 | 20 | public static ValueTaskAwaiter> GetAwaiter( 21 | this IAsyncEnumerable asyncEnumerable) => 22 | asyncEnumerable.CollectAsync().GetAwaiter(); 23 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/Extensions/BinaryExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using System.Text; 3 | 4 | namespace DiscordChatExporter.Core.Utils.Extensions; 5 | 6 | public static class BinaryExtensions 7 | { 8 | public static string ToHex(this byte[] data, bool isUpperCase = true) 9 | { 10 | var buffer = new StringBuilder(2 * data.Length); 11 | 12 | foreach (var b in data) 13 | { 14 | buffer.Append( 15 | b.ToString(isUpperCase ? "X2" : "x2", CultureInfo.InvariantCulture) 16 | ); 17 | } 18 | 19 | return buffer.ToString(); 20 | } 21 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/Extensions/CollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace DiscordChatExporter.Core.Utils.Extensions; 4 | 5 | public static class CollectionExtensions 6 | { 7 | public static IEnumerable ToSingletonEnumerable(this T obj) 8 | { 9 | yield return obj; 10 | } 11 | 12 | public static IEnumerable<(T value, int index)> WithIndex(this IEnumerable source) 13 | { 14 | var i = 0; 15 | foreach (var o in source) 16 | yield return (o, i++); 17 | } 18 | 19 | public static IEnumerable WhereNotNull(this IEnumerable source) where T : class 20 | { 21 | foreach (var o in source) 22 | { 23 | if (o is not null) 24 | yield return o; 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/Extensions/ColorExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Drawing; 2 | 3 | namespace DiscordChatExporter.Core.Utils.Extensions; 4 | 5 | public static class ColorExtensions 6 | { 7 | public static Color WithAlpha(this Color color, int alpha) => Color.FromArgb(alpha, color); 8 | 9 | public static Color ResetAlpha(this Color color) => color.WithAlpha(255); 10 | 11 | public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff; 12 | 13 | public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}"; 14 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/Extensions/ExceptionExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace DiscordChatExporter.Core.Utils.Extensions; 5 | 6 | public static class ExceptionExtensions 7 | { 8 | private static void PopulateChildren(this Exception exception, ICollection children) 9 | { 10 | if (exception is AggregateException aggregateException) 11 | { 12 | foreach (var innerException in aggregateException.InnerExceptions) 13 | { 14 | children.Add(innerException); 15 | PopulateChildren(innerException, children); 16 | } 17 | } 18 | else if (exception.InnerException is not null) 19 | { 20 | children.Add(exception.InnerException); 21 | PopulateChildren(exception.InnerException, children); 22 | } 23 | } 24 | 25 | public static IReadOnlyList GetSelfAndChildren(this Exception exception) 26 | { 27 | var children = new List {exception}; 28 | PopulateChildren(exception, children); 29 | return children; 30 | } 31 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/Extensions/GenericExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace DiscordChatExporter.Core.Utils.Extensions; 5 | 6 | public static class GenericExtensions 7 | { 8 | public static TOut Pipe(this TIn input, Func transform) => transform(input); 9 | 10 | public static T? NullIf(this T value, Func predicate) where T : struct => 11 | !predicate(value) 12 | ? value 13 | : null; 14 | 15 | public static T? NullIfDefault(this T value) where T : struct => 16 | value.NullIf(v => EqualityComparer.Default.Equals(v, default)); 17 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/Extensions/HttpExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Net.Http.Headers; 2 | 3 | namespace DiscordChatExporter.Core.Utils.Extensions; 4 | 5 | public static class HttpExtensions 6 | { 7 | public static string? TryGetValue(this HttpHeaders headers, string name) => 8 | headers.TryGetValues(name, out var values) 9 | ? string.Concat(values) 10 | : null; 11 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/Extensions/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | 4 | namespace DiscordChatExporter.Core.Utils.Extensions; 5 | 6 | public static class StringExtensions 7 | { 8 | public static string? NullIfWhiteSpace(this string str) => 9 | !string.IsNullOrWhiteSpace(str) 10 | ? str 11 | : null; 12 | 13 | public static string Truncate(this string str, int charCount) => 14 | str.Length > charCount 15 | ? str[..charCount] 16 | : str; 17 | 18 | public static string ToSpaceSeparatedWords(this string str) 19 | { 20 | var builder = new StringBuilder(str.Length * 2); 21 | 22 | foreach (var c in str) 23 | { 24 | if (char.IsUpper(c) && builder.Length > 0) 25 | builder.Append(' '); 26 | 27 | builder.Append(c); 28 | } 29 | 30 | return builder.ToString(); 31 | } 32 | 33 | public static T? ParseEnumOrNull(this string str, bool ignoreCase = true) 34 | where T : struct, Enum => Enum.TryParse(str, ignoreCase, out var result) ? result : null; 35 | 36 | public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) => 37 | builder.Length > 0 38 | ? builder.Append(value) 39 | : builder; 40 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/Extensions/SuperpowerExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using Superpower; 4 | using Superpower.Parsers; 5 | 6 | namespace DiscordChatExporter.Core.Utils.Extensions; 7 | 8 | public static class SuperpowerExtensions 9 | { 10 | public static TextParser Text(this TextParser parser) => 11 | parser.Select(chars => new string(chars)); 12 | 13 | public static TextParser Token(this TextParser parser) => 14 | parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany()); 15 | 16 | // Only used for debugging while writing Superpower parsers. 17 | // From https://twitter.com/nblumhardt/status/1389349059786264578 18 | [ExcludeFromCodeCoverage] 19 | public static TextParser Log(this TextParser parser, string description) => i => 20 | { 21 | Console.WriteLine($"Trying {description} ->"); 22 | var r = parser(i); 23 | Console.WriteLine($"Result was {r}"); 24 | return r; 25 | }; 26 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/Extensions/TimeSpanExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace DiscordChatExporter.Core.Utils.Extensions; 4 | 5 | public static class TimeSpanExtensions 6 | { 7 | public static TimeSpan Clamp(this TimeSpan value, TimeSpan min, TimeSpan max) 8 | { 9 | if (value < min) 10 | return min; 11 | 12 | if (value > max) 13 | return max; 14 | 15 | return value; 16 | } 17 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/Http.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Net; 4 | using System.Net.Http; 5 | using System.Net.Sockets; 6 | using System.Security.Authentication; 7 | using System.Threading.Tasks; 8 | using DiscordChatExporter.Core.Utils.Extensions; 9 | using Polly; 10 | using Polly.Retry; 11 | 12 | namespace DiscordChatExporter.Core.Utils; 13 | 14 | public static class Http 15 | { 16 | public static HttpClient Client { get; } = new(); 17 | 18 | private static bool IsRetryableStatusCode(HttpStatusCode statusCode) => 19 | statusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout 20 | || 21 | // Treat all server-side errors as retryable 22 | // https://github.com/Tyrrrz/DiscordChatExporter/issues/908 23 | (int)statusCode >= 500; 24 | 25 | private static bool IsRetryableException(Exception exception) => 26 | exception 27 | .GetSelfAndChildren() 28 | .Any(ex => 29 | ex is TimeoutException or SocketException or AuthenticationException 30 | || ex is HttpRequestException hrex 31 | && IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK) 32 | ); 33 | 34 | public static ResiliencePipeline ResiliencePipeline { get; } = 35 | new ResiliencePipelineBuilder() 36 | .AddRetry( 37 | new RetryStrategyOptions 38 | { 39 | ShouldHandle = new PredicateBuilder().Handle(IsRetryableException), 40 | MaxRetryAttempts = 4, 41 | BackoffType = DelayBackoffType.Exponential, 42 | Delay = TimeSpan.FromSeconds(1), 43 | } 44 | ) 45 | .Build(); 46 | 47 | public static ResiliencePipeline ResponseResiliencePipeline { get; } = 48 | new ResiliencePipelineBuilder() 49 | .AddRetry( 50 | new RetryStrategyOptions 51 | { 52 | ShouldHandle = new PredicateBuilder() 53 | .Handle(IsRetryableException) 54 | .HandleResult(m => IsRetryableStatusCode(m.StatusCode)), 55 | MaxRetryAttempts = 8, 56 | DelayGenerator = args => 57 | { 58 | // If rate-limited, use retry-after header as the guide. 59 | // The response can be null here if an exception was thrown. 60 | if (args.Outcome.Result?.Headers.RetryAfter?.Delta is { } retryAfter) 61 | { 62 | // Add some buffer just in case 63 | return ValueTask.FromResult( 64 | retryAfter + TimeSpan.FromSeconds(1) 65 | ); 66 | } 67 | 68 | return ValueTask.FromResult( 69 | TimeSpan.FromSeconds(Math.Pow(2, args.AttemptNumber) + 1) 70 | ); 71 | }, 72 | } 73 | ) 74 | .Build(); 75 | } 76 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/PathEx.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Frozen; 3 | using System.IO; 4 | using System.Text; 5 | 6 | namespace DiscordChatExporter.Core.Utils; 7 | 8 | public static class PathEx 9 | { 10 | private static readonly FrozenSet InvalidFileNameChars = 11 | [ 12 | .. Path.GetInvalidFileNameChars(), 13 | ]; 14 | 15 | public static string EscapeFileName(string path) 16 | { 17 | var buffer = new StringBuilder(path.Length); 18 | 19 | foreach (var c in path) 20 | buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_'); 21 | 22 | // File names cannot end with a dot on Windows 23 | // https://github.com/Tyrrrz/DiscordChatExporter/issues/977 24 | if (OperatingSystem.IsWindows()) 25 | { 26 | while (buffer.Length > 0 && buffer[^1] == '.') 27 | buffer.Remove(buffer.Length - 1, 1); 28 | } 29 | 30 | return buffer.ToString(); 31 | } 32 | 33 | public static bool IsDirectoryPath(string path) => 34 | path.EndsWith(Path.DirectorySeparatorChar) || 35 | path.EndsWith(Path.AltDirectorySeparatorChar); 36 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/Url.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | 4 | namespace DiscordChatExporter.Core.Utils; 5 | 6 | public static class Url 7 | { 8 | public static string EncodeFilePath(string filePath) 9 | { 10 | var buffer = new StringBuilder(); 11 | var position = 0; 12 | 13 | while (true) 14 | { 15 | if (position >= filePath.Length) 16 | break; 17 | 18 | var separatorIndex = filePath.IndexOfAny([':', '/', '\\'], position); 19 | if (separatorIndex < 0) 20 | { 21 | buffer.Append(Uri.EscapeDataString(filePath[position..])); 22 | break; 23 | } 24 | 25 | // Append the segment 26 | buffer.Append(Uri.EscapeDataString(filePath[position..separatorIndex])); 27 | 28 | // Append the separator 29 | buffer.Append( 30 | filePath[separatorIndex] switch 31 | { 32 | // Normalize slashes 33 | '\\' => '/', 34 | var c => c, 35 | } 36 | ); 37 | 38 | position = separatorIndex + 1; 39 | } 40 | 41 | return buffer.ToString(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /DiscordChatExporter.Core/Utils/UrlBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using DiscordChatExporter.Core.Utils.Extensions; 6 | 7 | namespace DiscordChatExporter.Core.Utils; 8 | 9 | public class UrlBuilder 10 | { 11 | private string _path = ""; 12 | 13 | private readonly Dictionary _queryParameters = new( 14 | StringComparer.OrdinalIgnoreCase 15 | ); 16 | 17 | public UrlBuilder SetPath(string path) 18 | { 19 | _path = path; 20 | return this; 21 | } 22 | 23 | public UrlBuilder SetQueryParameter(string key, string? value, bool ignoreUnsetValue = true) 24 | { 25 | if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value)) 26 | return this; 27 | 28 | var keyEncoded = Uri.EscapeDataString(key); 29 | var valueEncoded = value?.Pipe(Uri.EscapeDataString); 30 | _queryParameters[keyEncoded] = valueEncoded; 31 | 32 | return this; 33 | } 34 | 35 | public string Build() 36 | { 37 | var buffer = new StringBuilder(); 38 | 39 | buffer.Append(_path); 40 | 41 | if (_queryParameters.Any()) 42 | { 43 | buffer 44 | .Append('?') 45 | .AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}")); 46 | } 47 | 48 | return buffer.ToString(); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Converters/ChannelToHierarchicalNameStringConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using Avalonia.Data.Converters; 4 | using DiscordChatExporter.Core.Discord.Data; 5 | 6 | namespace DiscordChatExporter.Gui.Converters; 7 | 8 | public class ChannelToHierarchicalNameStringConverter : IValueConverter 9 | { 10 | public static ChannelToHierarchicalNameStringConverter Instance { get; } = new(); 11 | 12 | public object? Convert( 13 | object? value, 14 | Type targetType, 15 | object? parameter, 16 | CultureInfo culture 17 | ) => value is Channel channel ? channel.GetHierarchicalName() : null; 18 | 19 | public object ConvertBack( 20 | object? value, 21 | Type targetType, 22 | object? parameter, 23 | CultureInfo culture 24 | ) => throw new NotSupportedException(); 25 | } 26 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Converters/ExportFormatToStringConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using Avalonia.Data.Converters; 4 | using DiscordChatExporter.Core.Exporting; 5 | 6 | namespace DiscordChatExporter.Gui.Converters; 7 | 8 | public class ExportFormatToStringConverter : IValueConverter 9 | { 10 | public static ExportFormatToStringConverter Instance { get; } = new(); 11 | 12 | public object? Convert( 13 | object? value, 14 | Type targetType, 15 | object? parameter, 16 | CultureInfo culture 17 | ) => value is ExportFormat format ? format.GetDisplayName() : default; 18 | 19 | public object ConvertBack( 20 | object? value, 21 | Type targetType, 22 | object? parameter, 23 | CultureInfo culture 24 | ) => throw new NotSupportedException(); 25 | } 26 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Converters/LocaleToDisplayNameStringConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using Avalonia.Data.Converters; 4 | 5 | namespace DiscordChatExporter.Gui.Converters; 6 | 7 | public class LocaleToDisplayNameStringConverter : IValueConverter 8 | { 9 | public static LocaleToDisplayNameStringConverter Instance { get; } = new(); 10 | 11 | public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => 12 | value is string locale && !string.IsNullOrWhiteSpace(locale) 13 | ? CultureInfo.GetCultureInfo(locale).DisplayName 14 | : "System"; 15 | 16 | public object ConvertBack( 17 | object? value, 18 | Type targetType, 19 | object? parameter, 20 | CultureInfo culture 21 | ) => throw new NotSupportedException(); 22 | } 23 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Converters/RateLimitPreferenceToStringConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using Avalonia.Data.Converters; 4 | using DiscordChatExporter.Core.Discord; 5 | 6 | namespace DiscordChatExporter.Gui.Converters; 7 | 8 | public class RateLimitPreferenceToStringConverter : IValueConverter 9 | { 10 | public static RateLimitPreferenceToStringConverter Instance { get; } = new(); 11 | 12 | public object? Convert( 13 | object? value, 14 | Type targetType, 15 | object? parameter, 16 | CultureInfo culture 17 | ) => 18 | value is RateLimitPreference rateLimitPreference 19 | ? rateLimitPreference.GetDisplayName() 20 | : default; 21 | 22 | public object ConvertBack( 23 | object? value, 24 | Type targetType, 25 | object? parameter, 26 | CultureInfo culture 27 | ) => throw new NotSupportedException(); 28 | } 29 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Converters/SnowflakeToTimestampStringConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using Avalonia.Data.Converters; 4 | using DiscordChatExporter.Core.Discord; 5 | 6 | namespace DiscordChatExporter.Gui.Converters; 7 | 8 | public class SnowflakeToTimestampStringConverter : IValueConverter 9 | { 10 | public static SnowflakeToTimestampStringConverter Instance { get; } = new(); 11 | 12 | public object? Convert( 13 | object? value, 14 | Type targetType, 15 | object? parameter, 16 | CultureInfo culture 17 | ) => value is Snowflake snowflake ? snowflake.ToDate().ToString("g", culture) : null; 18 | 19 | public object ConvertBack( 20 | object? value, 21 | Type targetType, 22 | object? parameter, 23 | CultureInfo culture 24 | ) => throw new NotSupportedException(); 25 | } 26 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/DiscordChatExporter.Gui.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | WinExe 4 | DiscordChatExporterPlus 5 | ..\favicon.ico 6 | true 7 | false 8 | true 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Framework/DialogManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using AsyncKeyedLock; 7 | using Avalonia; 8 | using Avalonia.Platform.Storage; 9 | using DialogHostAvalonia; 10 | using DiscordChatExporter.Gui.Utils.Extensions; 11 | 12 | namespace DiscordChatExporter.Gui.Framework; 13 | 14 | public class DialogManager : IDisposable 15 | { 16 | private readonly AsyncNonKeyedLocker _dialogLock = new(); 17 | 18 | public async Task ShowDialogAsync(DialogViewModelBase dialog) 19 | { 20 | using (await _dialogLock.LockAsync()) 21 | { 22 | await DialogHost.Show( 23 | dialog, 24 | // It's fine to await in a void method here because it's an event handler 25 | // ReSharper disable once AsyncVoidLambda 26 | async (object _, DialogOpenedEventArgs args) => 27 | { 28 | await dialog.WaitForCloseAsync(); 29 | 30 | try 31 | { 32 | args.Session.Close(); 33 | } 34 | catch (InvalidOperationException) 35 | { 36 | // Dialog host is already processing a close operation 37 | } 38 | } 39 | ); 40 | 41 | return dialog.DialogResult; 42 | } 43 | } 44 | 45 | public async Task PromptSaveFilePathAsync( 46 | IReadOnlyList? fileTypes = null, 47 | string defaultFilePath = "" 48 | ) 49 | { 50 | var topLevel = 51 | Application.Current?.ApplicationLifetime?.TryGetTopLevel() 52 | ?? throw new ApplicationException("Could not find the top-level visual element."); 53 | 54 | var file = await topLevel.StorageProvider.SaveFilePickerAsync( 55 | new FilePickerSaveOptions 56 | { 57 | FileTypeChoices = fileTypes, 58 | SuggestedFileName = defaultFilePath, 59 | DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.'), 60 | } 61 | ); 62 | 63 | return file?.Path.LocalPath; 64 | } 65 | 66 | public async Task PromptDirectoryPathAsync(string defaultDirPath = "") 67 | { 68 | var topLevel = 69 | Application.Current?.ApplicationLifetime?.TryGetTopLevel() 70 | ?? throw new ApplicationException("Could not find the top-level visual element."); 71 | 72 | var startLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync( 73 | defaultDirPath 74 | ); 75 | 76 | var folderPickResult = await topLevel.StorageProvider.OpenFolderPickerAsync( 77 | new FolderPickerOpenOptions 78 | { 79 | AllowMultiple = false, 80 | SuggestedStartLocation = startLocation, 81 | } 82 | ); 83 | 84 | return folderPickResult.FirstOrDefault()?.Path.LocalPath; 85 | } 86 | 87 | public void Dispose() => _dialogLock.Dispose(); 88 | } 89 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Framework/DialogVIewModelBase.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using CommunityToolkit.Mvvm.ComponentModel; 3 | using CommunityToolkit.Mvvm.Input; 4 | 5 | namespace DiscordChatExporter.Gui.Framework; 6 | 7 | public abstract partial class DialogViewModelBase : ViewModelBase 8 | { 9 | private readonly TaskCompletionSource _closeTcs = new( 10 | TaskCreationOptions.RunContinuationsAsynchronously 11 | ); 12 | 13 | [ObservableProperty] 14 | public partial T? DialogResult { get; set; } 15 | 16 | [RelayCommand] 17 | protected void Close(T dialogResult) 18 | { 19 | DialogResult = dialogResult; 20 | _closeTcs.TrySetResult(dialogResult); 21 | } 22 | 23 | public async Task WaitForCloseAsync() => await _closeTcs.Task; 24 | } 25 | 26 | public abstract class DialogViewModelBase : DialogViewModelBase; 27 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Framework/SnackbarManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Avalonia.Threading; 3 | using Material.Styles.Controls; 4 | using Material.Styles.Models; 5 | 6 | namespace DiscordChatExporter.Gui.Framework; 7 | 8 | public class SnackbarManager 9 | { 10 | private readonly TimeSpan _defaultDuration = TimeSpan.FromSeconds(5); 11 | 12 | public void Notify(string message, TimeSpan? duration = null) => 13 | SnackbarHost.Post( 14 | new SnackbarModel(message, duration ?? _defaultDuration), 15 | null, 16 | DispatcherPriority.Normal 17 | ); 18 | 19 | public void Notify( 20 | string message, 21 | string actionText, 22 | Action actionHandler, 23 | TimeSpan? duration = null 24 | ) => 25 | SnackbarHost.Post( 26 | new SnackbarModel( 27 | message, 28 | duration ?? _defaultDuration, 29 | new SnackbarButtonModel { Text = actionText, Action = actionHandler } 30 | ), 31 | null, 32 | DispatcherPriority.Normal 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Framework/ThemeVariant.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Gui.Framework; 2 | 3 | public enum ThemeVariant 4 | { 5 | System, 6 | Light, 7 | Dark, 8 | } 9 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Framework/UserControl.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Avalonia.Controls; 3 | 4 | namespace DiscordChatExporter.Gui.Framework; 5 | 6 | public class UserControl : UserControl 7 | { 8 | public new TDataContext DataContext 9 | { 10 | get => 11 | base.DataContext is TDataContext dataContext 12 | ? dataContext 13 | : throw new InvalidCastException( 14 | $"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'." 15 | ); 16 | set => base.DataContext = value; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Framework/ViewManager.cs: -------------------------------------------------------------------------------- 1 | using Avalonia.Controls; 2 | using Avalonia.Controls.Templates; 3 | using DiscordChatExporter.Gui.ViewModels; 4 | using DiscordChatExporter.Gui.ViewModels.Components; 5 | using DiscordChatExporter.Gui.ViewModels.Dialogs; 6 | using DiscordChatExporter.Gui.Views; 7 | using DiscordChatExporter.Gui.Views.Components; 8 | using DiscordChatExporter.Gui.Views.Dialogs; 9 | 10 | namespace DiscordChatExporter.Gui.Framework; 11 | 12 | public partial class ViewManager 13 | { 14 | private Control? TryCreateView(ViewModelBase viewModel) => 15 | viewModel switch 16 | { 17 | MainViewModel => new MainView(), 18 | DashboardViewModel => new DashboardView(), 19 | ExportSetupViewModel => new ExportSetupView(), 20 | MessageBoxViewModel => new MessageBoxView(), 21 | SettingsViewModel => new SettingsView(), 22 | _ => null, 23 | }; 24 | 25 | public Control? TryBindView(ViewModelBase viewModel) 26 | { 27 | var view = TryCreateView(viewModel); 28 | if (view is null) 29 | return null; 30 | 31 | view.DataContext ??= viewModel; 32 | 33 | return view; 34 | } 35 | } 36 | 37 | public partial class ViewManager : IDataTemplate 38 | { 39 | bool IDataTemplate.Match(object? data) => data is ViewModelBase; 40 | 41 | Control? ITemplate.Build(object? data) => 42 | data is ViewModelBase viewModel ? TryBindView(viewModel) : null; 43 | } 44 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Framework/ViewModelBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using CommunityToolkit.Mvvm.ComponentModel; 3 | 4 | namespace DiscordChatExporter.Gui.Framework; 5 | 6 | public abstract class ViewModelBase : ObservableObject, IDisposable 7 | { 8 | ~ViewModelBase() => Dispose(false); 9 | 10 | protected void OnAllPropertiesChanged() => OnPropertyChanged(string.Empty); 11 | 12 | protected virtual void Dispose(bool disposing) { } 13 | 14 | public void Dispose() 15 | { 16 | Dispose(true); 17 | GC.SuppressFinalize(this); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Framework/ViewModelManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using DiscordChatExporter.Core.Discord.Data; 4 | using DiscordChatExporter.Gui.ViewModels; 5 | using DiscordChatExporter.Gui.ViewModels.Components; 6 | using DiscordChatExporter.Gui.ViewModels.Dialogs; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | namespace DiscordChatExporter.Gui.Framework; 10 | 11 | public class ViewModelManager(IServiceProvider services) 12 | { 13 | public MainViewModel CreateMainViewModel() => services.GetRequiredService(); 14 | 15 | public DashboardViewModel CreateDashboardViewModel() => 16 | services.GetRequiredService(); 17 | 18 | public ExportSetupViewModel CreateExportSetupViewModel( 19 | Guild guild, 20 | IReadOnlyList channels 21 | ) 22 | { 23 | var viewModel = services.GetRequiredService(); 24 | 25 | viewModel.Guild = guild; 26 | viewModel.Channels = channels; 27 | 28 | return viewModel; 29 | } 30 | 31 | public MessageBoxViewModel CreateMessageBoxViewModel( 32 | string title, 33 | string message, 34 | string? okButtonText, 35 | string? cancelButtonText 36 | ) 37 | { 38 | var viewModel = services.GetRequiredService(); 39 | 40 | viewModel.Title = title; 41 | viewModel.Message = message; 42 | viewModel.DefaultButtonText = okButtonText; 43 | viewModel.CancelButtonText = cancelButtonText; 44 | 45 | return viewModel; 46 | } 47 | 48 | public MessageBoxViewModel CreateMessageBoxViewModel(string title, string message) => 49 | CreateMessageBoxViewModel(title, message, "CLOSE", null); 50 | 51 | public SettingsViewModel CreateSettingsViewModel() => 52 | services.GetRequiredService(); 53 | } 54 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Framework/Window.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Avalonia.Controls; 3 | 4 | namespace DiscordChatExporter.Gui.Framework; 5 | 6 | public class Window : Window 7 | { 8 | public new TDataContext DataContext 9 | { 10 | get => 11 | base.DataContext is TDataContext dataContext 12 | ? dataContext 13 | : throw new InvalidCastException( 14 | $"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'." 15 | ); 16 | set => base.DataContext = value; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Models/ThreadInclusionMode.cs: -------------------------------------------------------------------------------- 1 | namespace DiscordChatExporter.Gui.Models; 2 | 3 | public enum ThreadInclusionMode 4 | { 5 | None, 6 | Active, 7 | All, 8 | } 9 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using Avalonia; 4 | using DiscordChatExporter.Gui.Utils; 5 | 6 | namespace DiscordChatExporter.Gui; 7 | 8 | public static class Program 9 | { 10 | private static Assembly Assembly { get; } = Assembly.GetExecutingAssembly(); 11 | 12 | public static string Name { get; } = Assembly.GetName().Name ?? "DiscordChatExporterPlus"; 13 | 14 | public static Version Version { get; } = Assembly.GetName().Version ?? new Version(0, 0, 0); 15 | 16 | public static string VersionString { get; } = Version.ToString(3); 17 | 18 | public static string ProjectUrl { get; } = "https://github.com/nulldg/DiscordChatExporterPlus"; 19 | 20 | public static string ProjectReleasesUrl { get; } = $"{ProjectUrl}/releases"; 21 | 22 | public static string ProjectDocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs"; 23 | 24 | public static AppBuilder BuildAvaloniaApp() => 25 | AppBuilder.Configure().UsePlatformDetect().LogToTrace(); 26 | 27 | [STAThread] 28 | public static int Main(string[] args) 29 | { 30 | // Build and run the app 31 | var builder = BuildAvaloniaApp(); 32 | 33 | try 34 | { 35 | return builder.StartWithClassicDesktopLifetime(args); 36 | } 37 | catch (Exception ex) 38 | { 39 | if (OperatingSystem.IsWindows()) 40 | _ = NativeMethods.Windows.MessageBox(0, ex.ToString(), "Fatal Error", 0x10); 41 | 42 | throw; 43 | } 44 | finally 45 | { 46 | // Clean up after application shutdown 47 | if (builder.Instance is IDisposable disposableApp) 48 | disposableApp.Dispose(); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Publish-MacOSBundle.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [Parameter(Mandatory=$true)] 3 | [string]$PublishDirPath, 4 | 5 | [Parameter(Mandatory=$true)] 6 | [string]$IconsFilePath, 7 | 8 | [Parameter(Mandatory=$true)] 9 | [string]$FullVersion, 10 | 11 | [Parameter(Mandatory=$true)] 12 | [string]$ShortVersion 13 | ) 14 | 15 | $ErrorActionPreference = "Stop" 16 | 17 | # Setup paths 18 | $tempDirPath = Join-Path $PublishDirPath "../publish-macos-app-temp" 19 | $bundleName = "DiscordChatExporter.app" 20 | $bundleDirPath = Join-Path $tempDirPath $bundleName 21 | $contentsDirPath = Join-Path $bundleDirPath "Contents" 22 | $macosDirPath = Join-Path $contentsDirPath "MacOS" 23 | $resourcesDirPath = Join-Path $contentsDirPath "Resources" 24 | 25 | try { 26 | # Initialize the bundle's directory structure 27 | New-Item -Path $bundleDirPath -ItemType Directory -Force 28 | New-Item -Path $contentsDirPath -ItemType Directory -Force 29 | New-Item -Path $macosDirPath -ItemType Directory -Force 30 | New-Item -Path $resourcesDirPath -ItemType Directory -Force 31 | 32 | # Copy icons into the .app's Resources folder 33 | Copy-Item -Path $IconsFilePath -Destination (Join-Path $resourcesDirPath "AppIcon.icns") -Force 34 | 35 | # Generate the Info.plist metadata file with the app information 36 | $plistContent = @" 37 | 38 | 39 | 40 | 41 | CFBundleDisplayName 42 | DiscordChatExporter 43 | CFBundleName 44 | DiscordChatExporter 45 | CFBundleExecutable 46 | DiscordChatExporter 47 | NSHumanReadableCopyright 48 | © Oleksii Holub 49 | CFBundleIdentifier 50 | me.Tyrrrz.DiscordChatExporter 51 | CFBundleSpokenName 52 | Discord Chat Exporter 53 | CFBundleIconFile 54 | AppIcon 55 | CFBundleIconName 56 | AppIcon 57 | CFBundleVersion 58 | $FullVersion 59 | CFBundleShortVersionString 60 | $ShortVersion 61 | NSHighResolutionCapable 62 | 63 | CFBundlePackageType 64 | APPL 65 | 66 | 67 | "@ 68 | 69 | Set-Content -Path (Join-Path $contentsDirPath "Info.plist") -Value $plistContent 70 | 71 | # Delete the previous bundle if it exists 72 | if (Test-Path (Join-Path $PublishDirPath $bundleName)) { 73 | Remove-Item -Path (Join-Path $PublishDirPath $bundleName) -Recurse -Force 74 | } 75 | 76 | # Move all files from the publish directory into the MacOS directory 77 | Get-ChildItem -Path $PublishDirPath | ForEach-Object { 78 | Move-Item -Path $_.FullName -Destination $macosDirPath -Force 79 | } 80 | 81 | # Move the final bundle into the publish directory for upload 82 | Move-Item -Path $bundleDirPath -Destination $PublishDirPath -Force 83 | } 84 | finally { 85 | # Clean up the temporary directory 86 | Remove-Item -Path $tempDirPath -Recurse -Force 87 | } -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Services/SettingsService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text.Json.Serialization; 4 | using Cogwheel; 5 | using CommunityToolkit.Mvvm.ComponentModel; 6 | using DiscordChatExporter.Core.Discord; 7 | using DiscordChatExporter.Core.Exporting; 8 | using DiscordChatExporter.Gui.Framework; 9 | using DiscordChatExporter.Gui.Models; 10 | 11 | namespace DiscordChatExporter.Gui.Services; 12 | 13 | [ObservableObject] 14 | public partial class SettingsService() 15 | : SettingsBase( 16 | Path.Combine(AppContext.BaseDirectory, "Settings.dat"), 17 | SerializerContext.Default 18 | ) 19 | { 20 | [ObservableProperty] 21 | public partial ThemeVariant Theme { get; set; } 22 | 23 | [ObservableProperty] 24 | public partial bool IsAutoUpdateEnabled { get; set; } = true; 25 | 26 | [ObservableProperty] 27 | public partial bool IsTokenPersisted { get; set; } = true; 28 | 29 | [ObservableProperty] 30 | public partial RateLimitPreference RateLimitPreference { get; set; } = 31 | RateLimitPreference.RespectAll; 32 | 33 | [ObservableProperty] 34 | public partial ThreadInclusionMode ThreadInclusionMode { get; set; } 35 | 36 | [ObservableProperty] 37 | public partial string? Locale { get; set; } 38 | 39 | [ObservableProperty] 40 | public partial bool IsUtcNormalizationEnabled { get; set; } 41 | 42 | [ObservableProperty] 43 | public partial int ParallelLimit { get; set; } = 1; 44 | 45 | [ObservableProperty] 46 | public partial string? LastToken { get; set; } 47 | 48 | [ObservableProperty] 49 | public partial ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark; 50 | 51 | [ObservableProperty] 52 | public partial string? LastPartitionLimitValue { get; set; } 53 | 54 | [ObservableProperty] 55 | public partial string? LastMessageFilterValue { get; set; } 56 | 57 | [ObservableProperty] 58 | public partial bool LastShouldFormatMarkdown { get; set; } = true; 59 | 60 | [ObservableProperty] 61 | public partial bool LastShouldDownloadAssets { get; set; } 62 | 63 | [ObservableProperty] 64 | public partial bool LastShouldReuseAssets { get; set; } 65 | 66 | [ObservableProperty] 67 | public partial string? LastAssetsDirPath { get; set; } 68 | 69 | public override void Save() 70 | { 71 | // Clear the token if it's not supposed to be persisted 72 | var lastToken = LastToken; 73 | if (!IsTokenPersisted) 74 | LastToken = null; 75 | 76 | base.Save(); 77 | 78 | LastToken = lastToken; 79 | } 80 | } 81 | 82 | public partial class SettingsService 83 | { 84 | [JsonSerializable(typeof(SettingsService))] 85 | private partial class SerializerContext : JsonSerializerContext; 86 | } 87 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Services/UpdateService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.InteropServices; 3 | using System.Threading.Tasks; 4 | using Onova; 5 | using Onova.Exceptions; 6 | using Onova.Services; 7 | 8 | namespace DiscordChatExporter.Gui.Services; 9 | 10 | public class UpdateService(SettingsService settingsService) : IDisposable 11 | { 12 | private readonly IUpdateManager? _updateManager = OperatingSystem.IsWindows() 13 | ? new UpdateManager( 14 | new GithubPackageResolver( 15 | "nulldg", 16 | "DiscordChatExporterPlus", 17 | // Examples: 18 | // DiscordChatExporterPlus.win-arm64.zip 19 | // DiscordChatExporterPlus.win-x64.zip 20 | // DiscordChatExporterPlus.linux-x64.zip 21 | $"DiscordChatExporterPlus.{RuntimeInformation.RuntimeIdentifier}.zip" 22 | ), 23 | new ZipPackageExtractor() 24 | ) 25 | : null; 26 | 27 | private Version? _updateVersion; 28 | private bool _updatePrepared; 29 | private bool _updaterLaunched; 30 | 31 | public async ValueTask CheckForUpdatesAsync() 32 | { 33 | if (_updateManager is null) 34 | return null; 35 | 36 | if (!settingsService.IsAutoUpdateEnabled) 37 | return null; 38 | 39 | var check = await _updateManager.CheckForUpdatesAsync(); 40 | return check.CanUpdate ? check.LastVersion : null; 41 | } 42 | 43 | public async ValueTask PrepareUpdateAsync(Version version) 44 | { 45 | if (_updateManager is null) 46 | return; 47 | 48 | if (!settingsService.IsAutoUpdateEnabled) 49 | return; 50 | 51 | try 52 | { 53 | await _updateManager.PrepareUpdateAsync(_updateVersion = version); 54 | _updatePrepared = true; 55 | } 56 | catch (UpdaterAlreadyLaunchedException) 57 | { 58 | // Ignore race conditions 59 | } 60 | catch (LockFileNotAcquiredException) 61 | { 62 | // Ignore race conditions 63 | } 64 | } 65 | 66 | public void FinalizeUpdate(bool needRestart) 67 | { 68 | if (_updateManager is null) 69 | return; 70 | 71 | if (!settingsService.IsAutoUpdateEnabled) 72 | return; 73 | 74 | if (_updateVersion is null || !_updatePrepared || _updaterLaunched) 75 | return; 76 | 77 | try 78 | { 79 | _updateManager.LaunchUpdater(_updateVersion, needRestart); 80 | _updaterLaunched = true; 81 | } 82 | catch (UpdaterAlreadyLaunchedException) 83 | { 84 | // Ignore race conditions 85 | } 86 | catch (LockFileNotAcquiredException) 87 | { 88 | // Ignore race conditions 89 | } 90 | } 91 | 92 | public void Dispose() => _updateManager?.Dispose(); 93 | } 94 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Utils/Disposable.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace DiscordChatExporter.Gui.Utils; 4 | 5 | internal class Disposable(Action dispose) : IDisposable 6 | { 7 | public static IDisposable Create(Action dispose) => new Disposable(dispose); 8 | 9 | public void Dispose() => dispose(); 10 | } 11 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Utils/DisposableCollector.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using DiscordChatExporter.Gui.Utils.Extensions; 4 | 5 | namespace DiscordChatExporter.Gui.Utils; 6 | 7 | internal class DisposableCollector : IDisposable 8 | { 9 | private readonly object _lock = new(); 10 | private readonly List _items = []; 11 | 12 | public void Add(IDisposable item) 13 | { 14 | lock (_lock) 15 | { 16 | _items.Add(item); 17 | } 18 | } 19 | 20 | public void Dispose() 21 | { 22 | lock (_lock) 23 | { 24 | _items.DisposeAll(); 25 | _items.Clear(); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Utils/Extensions/AvaloniaExtensions.cs: -------------------------------------------------------------------------------- 1 | using Avalonia.Controls; 2 | using Avalonia.Controls.ApplicationLifetimes; 3 | using Avalonia.VisualTree; 4 | 5 | namespace DiscordChatExporter.Gui.Utils.Extensions; 6 | 7 | internal static class AvaloniaExtensions 8 | { 9 | public static Window? TryGetMainWindow(this IApplicationLifetime lifetime) => 10 | lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime 11 | ? desktopLifetime.MainWindow 12 | : null; 13 | 14 | public static TopLevel? TryGetTopLevel(this IApplicationLifetime lifetime) => 15 | lifetime.TryGetMainWindow() 16 | ?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel; 17 | 18 | public static bool TryShutdown(this IApplicationLifetime lifetime, int exitCode = 0) 19 | { 20 | if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) 21 | { 22 | return desktopLifetime.TryShutdown(exitCode); 23 | } 24 | 25 | if (lifetime is IControlledApplicationLifetime controlledLifetime) 26 | { 27 | controlledLifetime.Shutdown(exitCode); 28 | return true; 29 | } 30 | 31 | return false; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Utils/Extensions/DisposableExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace DiscordChatExporter.Gui.Utils.Extensions; 6 | 7 | internal static class DisposableExtensions 8 | { 9 | public static void DisposeAll(this IEnumerable disposables) 10 | { 11 | var exceptions = default(List); 12 | 13 | foreach (var disposable in disposables) 14 | { 15 | try 16 | { 17 | disposable.Dispose(); 18 | } 19 | catch (Exception ex) 20 | { 21 | (exceptions ??= []).Add(ex); 22 | } 23 | } 24 | 25 | if (exceptions?.Any() == true) 26 | throw new AggregateException(exceptions); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Utils/Extensions/NotifyPropertyChangedExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using System.Linq.Expressions; 4 | using System.Reflection; 5 | 6 | namespace DiscordChatExporter.Gui.Utils.Extensions; 7 | 8 | internal static class NotifyPropertyChangedExtensions 9 | { 10 | public static IDisposable WatchProperty( 11 | this TOwner owner, 12 | Expression> propertyExpression, 13 | Action callback, 14 | bool watchInitialValue = false 15 | ) 16 | where TOwner : INotifyPropertyChanged 17 | { 18 | var memberExpression = propertyExpression.Body as MemberExpression; 19 | if (memberExpression?.Member is not PropertyInfo property) 20 | throw new ArgumentException("Provided expression must reference a property."); 21 | 22 | void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) 23 | { 24 | if ( 25 | string.IsNullOrWhiteSpace(args.PropertyName) 26 | || string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal) 27 | ) 28 | { 29 | callback(); 30 | } 31 | } 32 | 33 | owner.PropertyChanged += OnPropertyChanged; 34 | 35 | if (watchInitialValue) 36 | callback(); 37 | 38 | return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); 39 | } 40 | 41 | public static IDisposable WatchAllProperties( 42 | this TOwner owner, 43 | Action callback, 44 | bool watchInitialValues = false 45 | ) 46 | where TOwner : INotifyPropertyChanged 47 | { 48 | void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback(); 49 | owner.PropertyChanged += OnPropertyChanged; 50 | 51 | if (watchInitialValues) 52 | callback(); 53 | 54 | return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Utils/Internationalization.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | 3 | namespace DiscordChatExporter.Gui.Utils; 4 | 5 | internal static class Internationalization 6 | { 7 | public static bool Is24Hours => 8 | string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator) 9 | && string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator); 10 | 11 | public static string AvaloniaClockIdentifier => Is24Hours ? "24HourClock" : "12HourClock"; 12 | } 13 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Utils/NativeMethods.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | 3 | namespace DiscordChatExporter.Gui.Utils; 4 | 5 | internal static class NativeMethods 6 | { 7 | public static class Windows 8 | { 9 | [DllImport("user32.dll", SetLastError = true)] 10 | public static extern int MessageBox(nint hWnd, string text, string caption, uint type); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Utils/ProcessEx.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace DiscordChatExporter.Gui.Utils; 4 | 5 | internal static class ProcessEx 6 | { 7 | public static void StartShellExecute(string path) 8 | { 9 | using var process = new Process(); 10 | process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true }; 11 | 12 | process.Start(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/ViewModels/Dialogs/MessageBoxViewModel.cs: -------------------------------------------------------------------------------- 1 | using CommunityToolkit.Mvvm.ComponentModel; 2 | using DiscordChatExporter.Gui.Framework; 3 | 4 | namespace DiscordChatExporter.Gui.ViewModels.Dialogs; 5 | 6 | public partial class MessageBoxViewModel : DialogViewModelBase 7 | { 8 | [ObservableProperty] 9 | public partial string? Title { get; set; } = "Title"; 10 | 11 | [ObservableProperty] 12 | public partial string? Message { get; set; } = "Message"; 13 | 14 | [ObservableProperty] 15 | [NotifyPropertyChangedFor(nameof(IsDefaultButtonVisible))] 16 | [NotifyPropertyChangedFor(nameof(ButtonsCount))] 17 | public partial string? DefaultButtonText { get; set; } = "OK"; 18 | 19 | [ObservableProperty] 20 | [NotifyPropertyChangedFor(nameof(IsCancelButtonVisible))] 21 | [NotifyPropertyChangedFor(nameof(ButtonsCount))] 22 | public partial string? CancelButtonText { get; set; } = "Cancel"; 23 | 24 | public bool IsDefaultButtonVisible => !string.IsNullOrWhiteSpace(DefaultButtonText); 25 | 26 | public bool IsCancelButtonVisible => !string.IsNullOrWhiteSpace(CancelButtonText); 27 | 28 | public int ButtonsCount => (IsDefaultButtonVisible ? 1 : 0) + (IsCancelButtonVisible ? 1 : 0); 29 | } 30 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/ViewModels/MainViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Avalonia; 4 | using CommunityToolkit.Mvvm.Input; 5 | using DiscordChatExporter.Gui.Framework; 6 | using DiscordChatExporter.Gui.Services; 7 | using DiscordChatExporter.Gui.Utils; 8 | using DiscordChatExporter.Gui.Utils.Extensions; 9 | using DiscordChatExporter.Gui.ViewModels.Components; 10 | 11 | namespace DiscordChatExporter.Gui.ViewModels; 12 | 13 | public partial class MainViewModel( 14 | ViewModelManager viewModelManager, 15 | SnackbarManager snackbarManager, 16 | SettingsService settingsService, 17 | UpdateService updateService 18 | ) : ViewModelBase 19 | { 20 | public string Title { get; } = $"{Program.Name} v{Program.VersionString}"; 21 | 22 | public DashboardViewModel Dashboard { get; } = viewModelManager.CreateDashboardViewModel(); 23 | 24 | private async Task CheckForUpdatesAsync() 25 | { 26 | try 27 | { 28 | var updateVersion = await updateService.CheckForUpdatesAsync(); 29 | if (updateVersion is null) 30 | return; 31 | 32 | snackbarManager.Notify($"Downloading update to {Program.Name} v{updateVersion}..."); 33 | await updateService.PrepareUpdateAsync(updateVersion); 34 | 35 | snackbarManager.Notify( 36 | "Update has been downloaded and will be installed when you exit", 37 | "INSTALL NOW", 38 | () => 39 | { 40 | updateService.FinalizeUpdate(true); 41 | 42 | if (Application.Current?.ApplicationLifetime?.TryShutdown(2) != true) 43 | Environment.Exit(2); 44 | } 45 | ); 46 | } 47 | catch 48 | { 49 | // Failure to update shouldn't crash the application 50 | snackbarManager.Notify("Failed to perform application update"); 51 | } 52 | } 53 | 54 | [RelayCommand] 55 | private async Task InitializeAsync() 56 | { 57 | await CheckForUpdatesAsync(); 58 | } 59 | 60 | protected override void Dispose(bool disposing) 61 | { 62 | if (disposing) 63 | { 64 | // Save settings 65 | settingsService.Save(); 66 | 67 | // Finalize pending updates 68 | updateService.FinalizeUpdate(false); 69 | } 70 | 71 | base.Dispose(disposing); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Views/Components/DashboardView.axaml.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using Avalonia.Controls; 3 | using Avalonia.Interactivity; 4 | using DiscordChatExporter.Core.Discord.Data; 5 | using DiscordChatExporter.Gui.Framework; 6 | using DiscordChatExporter.Gui.ViewModels.Components; 7 | 8 | namespace DiscordChatExporter.Gui.Views.Components; 9 | 10 | public partial class DashboardView : UserControl 11 | { 12 | public DashboardView() => InitializeComponent(); 13 | 14 | private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) 15 | { 16 | DataContext.InitializeCommand.Execute(null); 17 | TokenValueTextBox.Focus(); 18 | } 19 | 20 | private void AvailableGuildsListBox_OnSelectionChanged( 21 | object? sender, 22 | SelectionChangedEventArgs args 23 | ) => DataContext.PullChannelsCommand.Execute(null); 24 | 25 | private void AvailableChannelsTreeView_OnSelectionChanged( 26 | object? sender, 27 | SelectionChangedEventArgs args 28 | ) 29 | { 30 | // Hack: unselect categories because they cannot be exported 31 | foreach ( 32 | var item in args.AddedItems.OfType().Where(x => x.Channel.IsCategory) 33 | ) 34 | { 35 | if (AvailableChannelsTreeView.TreeContainerFromItem(item) is TreeViewItem container) 36 | container.IsSelected = false; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml: -------------------------------------------------------------------------------- 1 |  6 | 12 | 13 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Views/Controls/HyperLink.axaml.cs: -------------------------------------------------------------------------------- 1 | using System.Windows.Input; 2 | using Avalonia; 3 | using Avalonia.Controls; 4 | using Avalonia.Input; 5 | 6 | namespace DiscordChatExporter.Gui.Views.Controls; 7 | 8 | public partial class HyperLink : UserControl 9 | { 10 | public static readonly StyledProperty TextProperty = 11 | TextBlock.TextProperty.AddOwner(); 12 | 13 | public static readonly StyledProperty CommandProperty = 14 | Button.CommandProperty.AddOwner(); 15 | 16 | public static readonly StyledProperty CommandParameterProperty = 17 | Button.CommandParameterProperty.AddOwner(); 18 | 19 | public HyperLink() => InitializeComponent(); 20 | 21 | public string? Text 22 | { 23 | get => GetValue(TextProperty); 24 | set => SetValue(TextProperty, value); 25 | } 26 | 27 | public ICommand? Command 28 | { 29 | get => GetValue(CommandProperty); 30 | set => SetValue(CommandProperty, value); 31 | } 32 | 33 | public object? CommandParameter 34 | { 35 | get => GetValue(CommandParameterProperty); 36 | set => SetValue(CommandParameterProperty, value); 37 | } 38 | 39 | private void TextBlock_OnPointerReleased(object? sender, PointerReleasedEventArgs args) 40 | { 41 | if (Command is null) 42 | return; 43 | 44 | if (!Command.CanExecute(CommandParameter)) 45 | return; 46 | 47 | Command.Execute(CommandParameter); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Views/Dialogs/ExportSetupView.axaml.cs: -------------------------------------------------------------------------------- 1 | using Avalonia.Interactivity; 2 | using DiscordChatExporter.Gui.Framework; 3 | using DiscordChatExporter.Gui.ViewModels.Dialogs; 4 | 5 | namespace DiscordChatExporter.Gui.Views.Dialogs; 6 | 7 | public partial class ExportSetupView : UserControl 8 | { 9 | public ExportSetupView() => InitializeComponent(); 10 | 11 | private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) => 12 | DataContext.InitializeCommand.Execute(null); 13 | } 14 | -------------------------------------------------------------------------------- /DiscordChatExporter.Gui/Views/Dialogs/MessageBoxView.axaml: -------------------------------------------------------------------------------- 1 |  9 | 10 | 11 | 19 | 20 | 21 | 26 | 27 | 31 | 32 | 33 | 34 | 39 | 40 | 50 | 51 | 52 |