Compare commits

...

56 Commits

Author SHA1 Message Date
Tyrrrz 0fa5e93f0d Use .NET 9 2024-11-12 22:19:58 +02:00
Tyrrrz 7b892c8e5b Fix some text alignment issues with the new version of Avalonia 2024-11-07 18:46:32 +02:00
Oleksii Holub 692d886442 Remove obsolete/no-op ServicePoint references/calls (#1314) 2024-11-06 20:55:04 +02:00
Tyrrrz e664eab5fb Fix recursive initialization issue 2024-11-06 20:16:53 +02:00
Tyrrrz 1fb6156187 Refactor 2024-11-06 19:36:34 +02:00
Oleksii Holub 789e5af8ba Include inline emoji in JSON export (#1311) 2024-11-06 19:23:40 +02:00
dependabot[bot] 9c15baf799 Bump the actions group with 5 updates (#1312)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-02 00:47:52 +02:00
dependabot[bot] 56fa6ee150 Bump the nuget group with 7 updates (#1313)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-02 00:47:21 +02:00
Tyrrrz 09e0b3f133 Format stuff 2024-10-26 21:41:16 +03:00
Tyrrrz e8192b2b53 Improve wording 2024-10-23 23:54:30 +03:00
LostXOR 34e9b0bfcc Add info for --filter in CLI docs (#1310) 2024-10-23 03:44:42 +03:00
Kaden abd7adc9e0 Add %d template token to CLI docs (#1306) 2024-10-14 13:37:25 +03:00
dependabot[bot] f5cdd5c8be Bump the nuget group with 8 updates (#1302)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-01 21:10:21 +03:00
dependabot[bot] 9fb78509b4 Bump actions/checkout from 4.1.7 to 4.2.0 in the actions group (#1303)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-01 21:10:13 +03:00
Andrew Low 161e91ccf4 Add missing tzdata package to docker build (#1296) 2024-09-23 05:55:47 +03:00
dependabot[bot] 8519ec0fff Bump the nuget group with 9 updates (#1283)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-01 19:05:04 +03:00
dependabot[bot] 9f8f718c94 Bump actions/upload-artifact from 4.3.4 to 4.4.0 in the actions group (#1282)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-01 19:03:41 +03:00
Mark Cilia Vincenti bb14e3431d Bump AsyncKeyedLock to 7.0.1 (#1276) 2024-08-26 17:23:29 +03:00
Tyrrrz aa377f3131 Don't use AppDomain 2024-08-18 19:03:22 +03:00
Oleksii Holub 522789e01d Limit recursion depth in markdown parser to prevent stack overflow (#1273) 2024-08-14 23:52:03 +03:00
Tyrrrz 10adba3a4d Format schformat 2024-08-11 00:10:23 +03:00
Tyrrrz 73e2e0624b Use a few new APIs 2024-08-10 22:28:07 +03:00
Tyrrrz c978a4cf60 Fix popup background 2024-08-01 23:22:03 +03:00
dependabot[bot] 6d82930505 Bump the actions group with 4 updates (#1267)
Bumps the actions group with 4 updates: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action), [actions/upload-artifact](https://github.com/actions/upload-artifact), [actions/setup-dotnet](https://github.com/actions/setup-dotnet) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `docker/setup-buildx-action` from 3.3.0 to 3.6.1
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/d70bba72b1f3fd22344832f00baa16ece964efeb...988b5a0280414f521da01fcc63a27aeeb4b104db)

Updates `actions/upload-artifact` from 4.3.3 to 4.3.4
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/65462800fd760344b1a7b4382951275a0abb4808...0b2256b8c012f0828dc542b3febcab082c67f72b)

Updates `actions/setup-dotnet` from 4.0.0 to 4.0.1
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/4d6c8fcf3c8f7a60068d26b594648e99df24cee3...6bd8b7f7774af54e05809fcc5431931b3eb1ddee)

Updates `actions/download-artifact` from 4.1.7 to 4.1.8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/65a9edc5881444af0b9093a5e628f2fe47ea3b2e...fa0a91b85d4f404e444e00e005971372dc801d16)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-dotnet
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-01 22:56:18 +03:00
dependabot[bot] 4e98c79313 Bump the nuget group with 8 updates (#1268)
Bumps the nuget group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [xunit](https://github.com/xunit/xunit) | `2.8.1` | `2.9.0` |
| [xunit.runner.visualstudio](https://github.com/xunit/visualstudio.xunit) | `2.8.1` | `2.8.2` |
| [AsyncKeyedLock](https://github.com/MarkCiliaVincenti/AsyncKeyedLock) | `6.4.2` | `7.0.0` |
| [YoutubeExplode](https://github.com/Tyrrrz/YoutubeExplode) | `6.3.16` | `6.4.0` |
| [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.0.11` | `11.1.1` |
| [Avalonia.Desktop](https://github.com/AvaloniaUI/Avalonia) | `11.0.11` | `11.1.1` |
| [Avalonia.Diagnostics](https://github.com/AvaloniaUI/Avalonia) | `11.0.11` | `11.1.1` |
| [Material.Avalonia](https://github.com/AvaloniaCommunity/Material.Avalonia) | `3.6.0` | `3.7.2` |
| [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.0.11` | `11.1.0` |


Updates `xunit` from 2.8.1 to 2.9.0
- [Commits](https://github.com/xunit/xunit/compare/2.8.1...2.9.0)

Updates `xunit.runner.visualstudio` from 2.8.1 to 2.8.2
- [Release notes](https://github.com/xunit/visualstudio.xunit/releases)
- [Commits](https://github.com/xunit/visualstudio.xunit/compare/2.8.1...2.8.2)

Updates `AsyncKeyedLock` from 6.4.2 to 7.0.0
- [Release notes](https://github.com/MarkCiliaVincenti/AsyncKeyedLock/releases)
- [Commits](https://github.com/MarkCiliaVincenti/AsyncKeyedLock/compare/6.4.2...7.0.0)

Updates `YoutubeExplode` from 6.3.16 to 6.4.0
- [Release notes](https://github.com/Tyrrrz/YoutubeExplode/releases)
- [Commits](https://github.com/Tyrrrz/YoutubeExplode/compare/6.3.16...6.4)

Updates `Avalonia` from 11.0.11 to 11.1.1
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.0.11...11.1.1)

Updates `Avalonia.Desktop` from 11.0.11 to 11.1.1
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.0.11...11.1.1)

Updates `Avalonia` from 11.0.11 to 11.1.1
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.0.11...11.1.1)

Updates `Avalonia.Diagnostics` from 11.0.11 to 11.1.1
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.0.11...11.1.1)

Updates `Avalonia` from 11.0.11 to 11.1.1
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.0.11...11.1.1)

Updates `Material.Avalonia` from 3.6.0 to 3.7.2
- [Release notes](https://github.com/AvaloniaCommunity/Material.Avalonia/releases)
- [Commits](https://github.com/AvaloniaCommunity/Material.Avalonia/compare/v3.6.0...v3.7.2)

Updates `Avalonia` from 11.0.11 to 11.1.0
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.0.11...11.1.1)

---
updated-dependencies:
- dependency-name: xunit
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: xunit.runner.visualstudio
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: AsyncKeyedLock
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: nuget
- dependency-name: YoutubeExplode
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Avalonia.Desktop
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Avalonia.Diagnostics
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Material.Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-01 22:55:58 +03:00
Tyrrrz 42e6de359e Invert WatchProperty(...) methods' watchInitialValue 2024-07-19 02:01:00 +03:00
dependabot[bot] 281e0f608a Bump the actions group with 2 updates (#1256)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 18:27:02 +03:00
dependabot[bot] c63313e328 Bump the nuget group with 7 updates (#1255)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-01 18:26:47 +03:00
LostXOR 767ca1b177 Remove unnecessary prefixes from CLI docs (#1254)
Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
2024-06-30 01:37:15 +03:00
Oleksii Holub 429801183c Warn when using development build (#1248) 2024-06-01 00:58:24 +03:00
Tyrrrz 520c06dceb Clean up 2024-06-01 00:36:24 +03:00
dependabot[bot] 46d8deba87 Bump the nuget group with 3 updates (#1247)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-31 03:29:50 +03:00
dependabot[bot] 0e13be2fdf Bump the actions group with 6 updates (#1246)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-31 03:29:08 +03:00
Oleksii Holub 15f8a13f02 Create dependabot.yml 2024-05-31 03:23:48 +03:00
Oleksii Holub c8ea365c04 Strip symbols (#1242) 2024-05-28 00:57:11 +03:00
Oleksii Holub 949abc2582 Fix --partition and --filter options not working due to assembly trimming (#1240) 2024-05-26 23:12:05 +03:00
Tyrrrz 3b3423dd9b Clean up platform checks in update service 2024-05-24 01:28:35 +03:00
Tyrrrz 8380e88744 Consistent platform checks 2024-05-23 18:03:52 +03:00
Tyrrrz f1bc5a2266 Remove NoWarn in the CLI project 2024-05-23 17:23:49 +03:00
Tyrrrz dc35f25293 Suppress warnings more intelligently 2024-05-23 01:57:19 +03:00
Tyrrrz 0e3969ca2d Use compile-time serialization for settings 2024-05-22 21:41:22 +03:00
Oleksii Holub 91b7486f45 Publish trimmed builds (#1235) 2024-05-21 22:02:46 +03:00
Oleksii Holub b7548756a8 Use self-contained builds (#1224) 2024-05-16 20:42:15 +03:00
Tyrrrz 8f9c0f8660 Clean up 2024-05-15 23:16:14 +03:00
Tyrrrz b12be796c7 Simplify 2024-05-14 00:05:46 +03:00
Oleksii Holub 7a69c87b56 Use a 3-way theme switcher instead of a 2-way switcher (#1233) 2024-05-13 23:56:21 +03:00
Tyrrrz 9e7ad4d85c Fix naming inconsistency 2024-05-13 22:12:43 +03:00
Tyrrrz 0fb4bea64d Update issue forms 2024-05-05 20:00:28 +03:00
Tyrrrz d8abd011e5 Add helper link for OS/arch in readme 2024-05-02 02:13:17 +03:00
Oleksii Holub 9a5697b5b5 Skip invalid reactions instead of crashing (#1227) 2024-04-30 20:08:03 +03:00
LostXOR 886b8442d3 Improve documentation (#1225) 2024-04-30 00:07:29 +03:00
Tyrrrz bedf7de8c6 Update readme 2024-04-29 19:55:14 +03:00
Tyrrrz 5ef329b63c Remove unneeded attribute 2024-04-29 02:39:08 +03:00
Tyrrrz 060e55a0d7 Fix tiny issues in the readme 2024-04-28 19:33:01 +03:00
Oleksii Holub 61444aad30 Set correct file permissions for Linux/OSX release assets (#1223) 2024-04-28 18:53:36 +03:00
Tyrrrz 9b3421e8a5 Remove changelog notification 2024-04-28 03:06:02 +03:00
104 changed files with 1380 additions and 1117 deletions
-51
View File
@@ -1,51 +0,0 @@
# Install .NET runtime
**.NET 8.0 Runtime** is required by **DiscordChatExporter**.
> **Note**:
> Installing the **.NET Runtime** is not required if you running **DiscordChatExporter** using [Docker](Docker.md).
## Windows
Both the GUI and the CLI flavors of **DiscordChatExporter** for Windows come with a bootstrapper that should automatically install all the required prerequisites. Simply run the application and follow the presented instructions.
If, for some reason, you need to install the runtime manually, use one of the download links below:
- [.NET 8.0 Runtime for **Windows x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-windows-x86-installer)
- [.NET 8.0 Runtime for **Windows x86**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-windows-x64-installer)
- [.NET 8.0 Runtime for **Windows arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-windows-arm64-installer)
> **Note**:
> [How can I tell if my computer is running a 32-bit (x86) or a 64-bit (x64) version of Windows?](https://support.microsoft.com/help/15056/windows-32-64-bit-faq)
## macOS
Use one of the download links below:
- [.NET 8.0 Runtime for **macOS x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-macos-x64-installer)
- [.NET 8.0 Runtime for **macOS arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-macos-arm64-installer)
> **Note**:
> [Is my Mac using an Intel processor (x64) or Apple Silicon (arm64)?](https://support.apple.com/HT211814)
## Linux
Installing the .NET Runtime works differently depending on which Linux distribution you use. Check out the instructions for your distro:
- [Ubuntu](https://docs.microsoft.com/dotnet/core/install/linux-ubuntu)
- [Debian](https://docs.microsoft.com/dotnet/core/install/linux-debian)
- [Fedora](https://docs.microsoft.com/dotnet/core/install/linux-fedora)
For other distros, please check the _'Install on Linux'_ menu on the left of [this page](https://docs.microsoft.com/dotnet/core/install/linux).
## Verify installation (optional)
You can check which version of **.NET Runtime** is installed by running the following command in a terminal:
```console
$ dotnet --info
.NET runtimes installed:
Microsoft.NETCore.App 7.0.x [C:\path\to\dotnet\shared\Microsoft.NETCore.App]
Microsoft.WindowsDesktop.App 7.0.x [C:\path\to\dotnet\shared\Microsoft.WindowsDesktop.App]
```
+2 -2
View File
@@ -35,13 +35,13 @@ You can open `.html` files with a web browser, such as Google Chrome.
> **Warning**: > **Warning**:
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed. > If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed.
> Export using the "Download referenced assets" (`--media`) option to avoid this. > Export using the "Download assets" (`--media`) option to avoid this.
### Plain Text ### Plain Text
<img src="https://i.imgur.com/PbUyRXD.png" height="400"/> <img src="https://i.imgur.com/PbUyRXD.png" height="400"/>
The Plain Text format is the best option for archiving due to its small size. The Plain Text format formats messages as plain text, and has the smallest size.
You can open `.txt` files with a text editor, such as Notepad. You can open `.txt` files with a text editor, such as Notepad.
### JSON ### JSON
-2
View File
@@ -2,7 +2,6 @@
## Installation & Usage ## Installation & Usage
- [Get .NET Runtime](Dotnet.md)
- Getting started: - Getting started:
- [Using the GUI](Using-the-GUI.md) - [Using the GUI](Using-the-GUI.md)
- [Using the CLI](Using-the-CLI.md) - [Using the CLI](Using-the-CLI.md)
@@ -28,6 +27,5 @@
- [General questions](Troubleshooting.md#general) - [General questions](Troubleshooting.md#general)
- [First steps help](Troubleshooting.md#first-steps) - [First steps help](Troubleshooting.md#first-steps)
- [It's crashing/failing](Troubleshooting.md#DCE-is-crashingfailing) - [It's crashing/failing](Troubleshooting.md#DCE-is-crashingfailing)
- [.NET Core Runtime is required](Troubleshooting.md#net-core-runtime-is-required)
- [Errors](Troubleshooting.md#errors) - [Errors](Troubleshooting.md#errors)
- [**More help**](Troubleshooting.md) - [**More help**](Troubleshooting.md)
-2
View File
@@ -1,7 +1,5 @@
# Scheduling exports with Cron # Scheduling exports with Cron
Make sure you already have **DiscordChatExporter.CLI** downloaded and **.NET Runtime** installed.
## Creating the script ## Creating the script
1. Open Terminal and create a new text file with `nano /path/to/DiscordChatExporter/cron.sh` 1. Open Terminal and create a new text file with `nano /path/to/DiscordChatExporter/cron.sh`
-2
View File
@@ -1,7 +1,5 @@
# Scheduling exports on macOS # Scheduling exports on macOS
Make sure you already have **DiscordChatExporter.CLI** downloaded and **.NET Runtime** installed.
## Creating the script ## Creating the script
1. Open TextEdit.app and create a new file 1. Open TextEdit.app and create a new file
-3
View File
@@ -1,8 +1,5 @@
# Scheduling exports on Windows # Scheduling exports on Windows
We'll be using [DiscordChatExporter CLI](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest), PowerShell, and Task Scheduler.
Make sure you already have **DiscordChatExporter.CLI** downloaded and **.NET Runtime** installed.
## Creating the script ## Creating the script
1. Open a text editor such as Notepad and paste: 1. Open a text editor such as Notepad and paste:
+1 -11
View File
@@ -70,7 +70,7 @@ Check the following page: [Obtaining token](Token-and-IDs.md)
### When I open DCE a black window pops up quickly or nothing shows up ### When I open DCE a black window pops up quickly or nothing shows up
If you have [.NET Runtime correctly installed](Dotnet.md), you might have downloaded the CLI flavor, try [downloading the GUI](Getting-started.md#gui-or-cli) instead. You might have downloaded the CLI flavor of the app, which is meant to be run in a terminal. Try [downloading the GUI](Getting-started.md#gui-or-cli) instead if that's what you want.
### How can I set DCE to export automatically at certain times? ### How can I set DCE to export automatically at certain times?
@@ -84,16 +84,6 @@ Check the following pages to learn how to schedule **DiscordChatExporter.CLI** r
Try opening it with a different program, try partitioning or use a different file format, like `PlainText`. Try opening it with a different program, try partitioning or use a different file format, like `PlainText`.
### DCE is crashing/failing
Check the following page: [Installing .NET Core Runtime](Dotnet.md)
If you already have .NET Core installed, please check if your problem is a [known issue](https://github.com/Tyrrrz/DiscordChatExporter/issues?q=is%3Aissue) before [opening a bug report](https://github.com/Tyrrrz/DiscordChatExporter/issues/new).
### .NET Core Runtime is required
Check the following page: [Installing .NET Core Runtime](Dotnet.md)
### I see messages in the export, but they have no content ### I see messages in the export, but they have no content
Your bot is missing the 'Message Content Intent'. Go to the [Discord Developer Portal](https://discord.com/developers/applications), navigate to the 'Bot' section and enable it. Your bot is missing the 'Message Content Intent'. Go to the [Discord Developer Portal](https://discord.com/developers/applications), navigate to the 'Bot' section and enable it.
+47 -40
View File
@@ -1,39 +1,35 @@
# Using the CLI # Using the CLI
## Guide
> **Note**:
> Make sure you have [.NET Core installed](Dotnet.md) before attempting to run the commands below.
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
## Step 1 ## Step 1
After extracting the `.zip`, open Command Prompt, aka `cmd` (`Terminal` on **macOS** and **Linux**). After extracting the `.zip` archive, open your preferred terminal.
## Step 2 ## Step 2
Change the current directory to DCE's folder with `cd C:\path\to\directory`, then press ENTER to run the command. Change the current directory to DCE's folder with `cd C:\path\to\DiscordChatExporter` (`cd /path/to/DiscordChatExporter` on **MacOS** and **Linux**), then press ENTER to run the command.
**Windows** users can quickly get the directory's path by clicking the address bar while inside the folder. **Windows** users can quickly get the folder's path by clicking the address bar while inside the folder.
![Copy path from Explorer](https://i.imgur.com/XncnhC2.gif) ![Copy path from Explorer](https://i.imgur.com/XncnhC2.gif)
**macOS** users can select the `.exe`, hit Command+I (⌘I), and copy what's after `Where:` to get the directory **macOS** users can press Command+Option+C (⌘⌥C) while inside the folder (or selecting it) to copy its path to the clipboard.
![Mac info panel](https://camo.githubusercontent.com/3c51a904b0099c9f68a4797461d4a7914902fc04/68747470733a2f2f692e696d6775722e636f6d2f323975364e79782e706e67)
You can also drag and drop the folder on **every platform**. You can also drag and drop the folder on **every platform**.
![Drag and drop folder](https://i.imgur.com/sOpZQAb.gif) ![Drag and drop folder](https://i.imgur.com/sOpZQAb.gif)
## Step 3 ## Step 3
Now we're ready to run the commands. The examples on this page follow the Windows file path format, change the file Now we're ready to run the commands.
paths according to your system.
Type the following command in your terminal of choice, then press ENTER to run it. This will list all available subcommands and options. Type the following command in your terminal of choice, then press ENTER to run it. This will list all available subcommands and options.
```console ```console
$ ./DiscordChatExporter.Cli ./DiscordChatExporter.Cli
``` ```
> **Note**:
> On Windows, if you're using the default Command Prompt (`cmd`), omit the leading `./` at the start of the command.
> **Docker** users, please refer to the [Docker usage instructions](Docker.md). > **Docker** users, please refer to the [Docker usage instructions](Docker.md).
## CLI commands ## CLI commands
@@ -54,13 +50,13 @@ To use the commands, you'll need a token. For the instructions on how to get a t
To get help with a specific command, run: To get help with a specific command, run:
```console ```console
$ ./DiscordChatExporter.Cli command --help ./DiscordChatExporter.Cli command --help
``` ```
For example, to figure out how to use the `export` command, run: For example, to figure out how to use the `export` command, run:
```console ```console
$ ./DiscordChatExporter.Cli export --help ./DiscordChatExporter.Cli export --help
``` ```
## Export a specific channel ## Export a specific channel
@@ -68,7 +64,7 @@ $ ./DiscordChatExporter.Cli export --help
You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`. You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
``` ```
#### Changing the format #### Changing the format
@@ -77,7 +73,7 @@ You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json`
format is `HtmlDark`. format is `HtmlDark`.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json
``` ```
#### Changing the output filename #### Changing the output filename
@@ -85,7 +81,7 @@ $ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json
You can change the filename by using `-o name.ext`. e.g. for the `HTML` format: You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
``` ```
#### Changing the output directory #### Changing the output directory
@@ -95,7 +91,7 @@ extension.
If any of the folders in the path have a space in its name, escape them with quotes ("). If any of the folders in the path have a space in its name, escape them with quotes (").
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports"
``` ```
#### Changing the filename and output directory #### Changing the filename and output directory
@@ -105,7 +101,7 @@ Note that the filename must have an extension, otherwise it will be considered a
If any of the folders in the path have a space in its name, escape them with quotes ("). If any of the folders in the path have a space in its name, escape them with quotes (").
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\myserver.html" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\myserver.html"
``` ```
#### Generating the filename and output directory dynamically #### Generating the filename and output directory dynamically
@@ -113,7 +109,7 @@ $ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports
You can use template tokens to generate the output file path based on the server and channel metadata. You can use template tokens to generate the output file path based on the server and channel metadata.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html"
``` ```
Assuming you are exporting a channel named `"my-channel"` in the `"Text channels"` category from a server Assuming you are exporting a channel named `"my-channel"` in the `"Text channels"` category from a server
@@ -132,6 +128,7 @@ Here is the full list of supported template tokens:
- `%P` - category position - `%P` - category position
- `%a` - the "after" date - `%a` - the "after" date
- `%b` - the "before" date - `%b` - the "before" date
- `%d` - the current date
- `%%` - escapes `%` - `%%` - escapes `%`
#### Partitioning #### Partitioning
@@ -140,13 +137,13 @@ You can use partitioning to split files after a given number of messages or file
For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files. For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 10 ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 10
``` ```
A 45 MB channel set to be partitioned every 20 MB will output 3 files. A 45 MB channel set to be partitioned every 20 MB will output 3 files.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb
``` ```
#### Downloading assets #### Downloading assets
@@ -157,7 +154,7 @@ downloaded when using the plain text (TXT) export format.
A folder containing the assets will be created along with the exported chat. They must be kept together. A folder containing the assets will be created along with the exported chat. They must be kept together.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
``` ```
#### Reusing assets #### Reusing assets
@@ -166,7 +163,7 @@ Previously downloaded assets can be reused to skip redundant downloads as long a
same folder. Using this option can speed up future exports. This option requires the `--media` option. same folder. Using this option can speed up future exports. This option requires the `--media` option.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --reuse-media ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --reuse-media
``` ```
#### Changing the media directory #### Changing the media directory
@@ -175,7 +172,7 @@ By default, the media directory is created alongside the exported chat. You can
providing a path that ends with a slash. All of the exported media will be stored in this directory. providing a path that ends with a slash. All of the exported media will be stored in this directory.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --media-dir "C:\Discord Media" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --media-dir "C:\Discord Media"
``` ```
#### Changing the date format #### Changing the date format
@@ -184,7 +181,7 @@ You can customize how dates are formatted in the exported files by using `--loca
locales. The default locale is `en-US`. locales. The default locale is `en-US`.
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
``` ```
#### Date ranges #### Date ranges
@@ -193,14 +190,14 @@ $ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019: Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019:
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18 ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
``` ```
**Messages sent after a date** **Messages sent after a date**
Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM: Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM:
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
``` ```
**Messages sent in a date range** **Messages sent in a date range**
@@ -208,7 +205,7 @@ Use `--before` and `--after` to export messages sent during the provided date ra
September 17th, 2019 11:34 PM and September 18th: September 17th, 2019 11:34 PM and September 18th:
```console ```console
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" --before "2019-09-18" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" --before "2019-09-18"
``` ```
You can try different formats like `17-SEP-2019 11:34 PM` or even refine your ranges down to You can try different formats like `17-SEP-2019 11:34 PM` or even refine your ranges down to
@@ -217,12 +214,22 @@ Don't forget to quote (") the date if it has spaces!
More info about .NET date More info about .NET date
formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings). formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
#### Filtering messages
Use `--filter` to filter what messages are included in the export.
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --filter "from:Tyrrrz has:image"
```
Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Message-filters.md).
### Export channels from a specific server ### Export channels from a specific server
To export all channels in a specific server, use the `exportguild` command and provide the server ID through the `-g|--guild` option: To export all channels in a specific server, use the `exportguild` command and provide the server ID through the `-g|--guild` option:
```console ```console
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
``` ```
#### Including threads #### Including threads
@@ -232,7 +239,7 @@ specifying which threads should be included. It has possible values of `none`, `
threads should be included. To include both active and archived threads, use `--include-threads all`. threads should be included. To include both active and archived threads, use `--include-threads all`.
```console ```console
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-threads all ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-threads all
``` ```
#### Including voice channels #### Including voice channels
@@ -242,7 +249,7 @@ specifying whether to include voice channels in the export. It has possible valu
voice channels, use `--include-vc false`. voice channels, use `--include-vc false`.
```console ```console
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
``` ```
### Export all channels ### Export all channels
@@ -250,7 +257,7 @@ $ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc fals
To export all accessible channels, use the `exportall` command: To export all accessible channels, use the `exportall` command:
```console ```console
$ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
``` ```
#### Excluding DMs #### Excluding DMs
@@ -258,7 +265,7 @@ $ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
To exclude DMs, add the `--include-dm false` option. To exclude DMs, add the `--include-dm false` option.
```console ```console
$ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
``` ```
### List channels in a server ### List channels in a server
@@ -266,7 +273,7 @@ $ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
To list the channels available in a specific server, use the `channels` command and provide the server ID through the `-g|--guild` option: To list the channels available in a specific server, use the `channels` command and provide the server ID through the `-g|--guild` option:
```console ```console
$ ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814 ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
``` ```
### List direct message channels ### List direct message channels
@@ -274,7 +281,7 @@ $ ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
To list all DM channels accessible to the current account, use the `dm` command: To list all DM channels accessible to the current account, use the `dm` command:
```console ```console
$ ./DiscordChatExporter.Cli dm -t "mfa.Ifrn" ./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
``` ```
### List servers ### List servers
@@ -282,5 +289,5 @@ $ ./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
To list all servers accessible by the current account, use the `guilds` command: To list all servers accessible by the current account, use the `guilds` command:
```console ```console
$ ./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt ./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
``` ```
+11 -18
View File
@@ -10,7 +10,7 @@
### Step 1 ### Step 1
After extracting the `.zip`, open `DiscordChatExporter.exe` After extracting the `.zip`, run `DiscordChatExporter.exe` (Windows), or `DiscordChatExporter` (Mac OS and Linux).
### Step 2 ### Step 2
@@ -61,37 +61,30 @@ In this screen you can customize the following:
## Settings ## Settings
- **Auto-update** - **Auto-update** - Perform automatic updates on every launch.
Perform automatic updates on every launch.
Default: Enabled Default: Enabled
> **Note**: > **Note**:
> Keep this option enabled to receive the latest features and bug fixes! > Keep this option enabled to receive the latest features and bug fixes!
- **Dark mode** - **Dark mode** - Use darker colors in the UI (User Interface).
Use darker colors in the UI (User Interface).
Default: Disabled Default: Disabled
- **Persist token** - **Persist token** - Persist last used token between sessions.
Persist last used token between sessions.
Default: Enabled Default: Enabled
- **Show threads** - **Show threads** - Controls whether threads are shown in the channel list.
Controls whether threads are shown in the channel list.
Default: none Default: none
- **Locale** - **Locale** - Customize how dates are formatted in the exported files.
Customize how dates are formatted in the exported files.
- **Date format** - **Date format** - Customize how dates are formatted in the exported files in the settings menu ().
Customize how dates are formatted in the exported files in the settings menu ().
- **Parallel limit** - **Parallel limit** - The number of channels that will be exported at the same time.
The number of channels that will be exported at the same time.
Default: 1 Default: 1
- **Normalize to UTC**
Convert all dates to UTC before exporting.
> **Note**: > **Note**:
> Try to keep this number low so that your account doesn't get flagged. > Try to keep this number low so that your account doesn't get flagged.
- **Normalize to UTC** - Convert all dates to UTC before exporting.
+2
View File
@@ -100,6 +100,8 @@ body:
required: true required: true
- label: I have sponsored this project - label: I have sponsored this project
required: false required: false
- label: I have not read any of the above and just checked all the boxes to submit the issue
required: false
- type: markdown - type: markdown
attributes: attributes:
+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: monthly
labels:
- enhancement
groups:
actions:
patterns:
- "*"
- package-ecosystem: docker
directory: "/"
schedule:
interval: monthly
labels:
- enhancement
groups:
docker:
patterns:
- "*"
- package-ecosystem: nuget
directory: "/"
schedule:
interval: monthly
labels:
- enhancement
groups:
nuget:
patterns:
- "*"
+5 -5
View File
@@ -23,10 +23,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Docker Buildx - name: Install Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
- name: Build image - name: Build image
run: > run: >
@@ -37,7 +37,7 @@ jobs:
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar --output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with: with:
name: DiscordChatExporter.Cli.Docker name: DiscordChatExporter.Cli.Docker
path: DiscordChatExporter.Cli.Docker.tar path: DiscordChatExporter.Cli.Docker.tar
@@ -55,10 +55,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Docker Buildx - name: Install Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
- name: Login to DockerHub - name: Login to DockerHub
run: > run: >
+20 -17
View File
@@ -26,12 +26,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
with: with:
dotnet-version: 8.0.x dotnet-version: 9.0.x
- name: Verify formatting - name: Verify formatting
run: > run: >
@@ -51,12 +51,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
with: with:
dotnet-version: 8.0.x dotnet-version: 9.0.x
- name: Run tests - name: Run tests
env: env:
@@ -72,7 +72,7 @@ jobs:
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@f30e4959ba63075080d4f7f90cacc18d9f3fafd7 # v4.0.0 uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
@@ -99,8 +99,7 @@ jobs:
# GUI assets aren't suffixed, unlike the CLI assets # GUI assets aren't suffixed, unlike the CLI assets
asset: DiscordChatExporter asset: DiscordChatExporter
# Need to run on Windows because of DotnetRuntimeBootstrapper's dependency on Ressy runs-on: ${{ startsWith(matrix.rid, 'win-') && 'windows-latest' || startsWith(matrix.rid, 'osx-') && 'macos-latest' || 'ubuntu-latest' }}
runs-on: windows-latest
timeout-minutes: 10 timeout-minutes: 10
permissions: permissions:
@@ -109,12 +108,12 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0 uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
with: with:
dotnet-version: 8.0.x dotnet-version: 9.0.x
- name: Publish app - name: Publish app
run: > run: >
@@ -124,10 +123,10 @@ jobs:
--output ${{ matrix.app }}/bin/publish/ --output ${{ matrix.app }}/bin/publish/
--configuration Release --configuration Release
--runtime ${{ matrix.rid }} --runtime ${{ matrix.rid }}
--no-self-contained --self-contained
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0 uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
with: with:
name: ${{ matrix.asset }}.${{ matrix.rid }} name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/bin/publish/ path: ${{ matrix.app }}/bin/publish/
@@ -192,11 +191,15 @@ jobs:
steps: steps:
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1 uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with: with:
name: ${{ matrix.asset }}.${{ matrix.rid }} name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/ path: ${{ matrix.app }}/
- name: Set permissions
if: ${{ !startsWith(matrix.rid, 'win-') }}
run: chmod +x ${{ matrix.app }}/${{ matrix.asset }}
- name: Create package - name: Create package
# Change into the artifacts directory to avoid including the directory itself in the zip archive # Change into the artifacts directory to avoid including the directory itself in the zip archive
working-directory: ${{ matrix.app }}/ working-directory: ${{ matrix.app }}/
@@ -220,7 +223,7 @@ jobs:
steps: steps:
- name: Notify Discord - name: Notify Discord
uses: tyrrrz/action-http-request@64c70c67f5ebc54d4c7ea09cbe3553322778afd5 # 1.1.2 uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3
with: with:
url: ${{ secrets.DISCORD_WEBHOOK }} url: ${{ secrets.DISCORD_WEBHOOK }}
method: POST method: POST
+2 -1
View File
@@ -1,13 +1,14 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Version>999.9.9-dev</Version> <Version>999.9.9-dev</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright> <Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
@@ -13,16 +13,16 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.1.2" /> <PackageReference Include="AngleSharp" Version="1.1.2" />
<PackageReference Include="coverlet.collector" Version="6.0.2" PrivateAssets="all" /> <PackageReference Include="coverlet.collector" Version="6.0.2" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="0.29.2" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="6.12.0" /> <PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.8.0" /> <PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0" PrivateAssets="all" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -10,6 +10,8 @@ public static class ChannelIds
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687"); public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
public static Snowflake EmojiTestCases { get; } = Snowflake.Parse("866768438290415636");
public static Snowflake GroupingTestCases { get; } = Snowflake.Parse("992092091545034842"); public static Snowflake GroupingTestCases { get; } = Snowflake.Parse("992092091545034842");
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020"); public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
@@ -19,12 +19,7 @@ namespace DiscordChatExporter.Cli.Tests.Infra;
public static class ExportWrapper public static class ExportWrapper
{ {
private static readonly AsyncKeyedLocker<string> Locker = private static readonly AsyncKeyedLocker<string> Locker = new();
new(o =>
{
o.PoolSize = 20;
o.PoolInitialFill = 1;
});
private static readonly string DirPath = Path.Combine( private static readonly string DirPath = Path.Combine(
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
@@ -61,7 +56,7 @@ public static class ExportWrapper
ExportFormat = format, ExportFormat = format,
OutputPath = filePath, OutputPath = filePath,
Locale = "en-US", Locale = "en-US",
IsUtcNormalizationEnabled = true IsUtcNormalizationEnabled = true,
}.ExecuteAsync(console); }.ExecuteAsync(console);
} }
@@ -30,7 +30,7 @@ public class DateRangeSpecs
ChannelIds = [ChannelIds.DateRangeTestCases], ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
After = Snowflake.FromDate(after) After = Snowflake.FromDate(after),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -50,7 +50,7 @@ public class DateRangeSpecs
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero) new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero),
], ],
o => o =>
o.Using<DateTimeOffset>(ctx => o.Using<DateTimeOffset>(ctx =>
@@ -74,7 +74,7 @@ public class DateRangeSpecs
ChannelIds = [ChannelIds.DateRangeTestCases], ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
Before = Snowflake.FromDate(before) Before = Snowflake.FromDate(before),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -92,7 +92,7 @@ public class DateRangeSpecs
[ [
new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero), new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero), new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero) new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero),
], ],
o => o =>
o.Using<DateTimeOffset>(ctx => o.Using<DateTimeOffset>(ctx =>
@@ -118,7 +118,7 @@ public class DateRangeSpecs
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
Before = Snowflake.FromDate(before), Before = Snowflake.FromDate(before),
After = Snowflake.FromDate(after) After = Snowflake.FromDate(after),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -137,7 +137,7 @@ public class DateRangeSpecs
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero) new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
], ],
o => o =>
o.Using<DateTimeOffset>(ctx => o.Using<DateTimeOffset>(ctx =>
@@ -29,7 +29,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases], ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("some text") MessageFilter = MessageFilter.Parse("some text"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -54,7 +54,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases], ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("from:Tyrrrz") MessageFilter = MessageFilter.Parse("from:Tyrrrz"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -79,7 +79,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases], ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:image") MessageFilter = MessageFilter.Parse("has:image"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -104,7 +104,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases], ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:pin") MessageFilter = MessageFilter.Parse("has:pin"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -129,7 +129,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases], ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:invite") MessageFilter = MessageFilter.Parse("has:invite"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -154,7 +154,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases], ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz") MessageFilter = MessageFilter.Parse("mentions:Tyrrrz"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -28,7 +28,7 @@ public class HtmlGroupingSpecs
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = [ChannelIds.GroupingTestCases], ChannelIds = [ChannelIds.GroupingTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = file.Path OutputPath = file.Path,
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -0,0 +1,69 @@
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonEmojiSpecs
{
[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_inline_emoji_and_have_them_listed_separately()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.EmojiTestCases,
Snowflake.Parse("866768521052553216")
);
// Assert
var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
inlineEmojis.Should().HaveCount(4);
inlineEmojis[0].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[0].GetProperty("name").GetString().Should().Be("🙂");
inlineEmojis[0].GetProperty("code").GetString().Should().Be("slight_smile");
inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
inlineEmojis[1].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[1].GetProperty("name").GetString().Should().Be("😦");
inlineEmojis[1].GetProperty("code").GetString().Should().Be("frowning");
inlineEmojis[1].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[1].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
inlineEmojis[2].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[2].GetProperty("name").GetString().Should().Be("😔");
inlineEmojis[2].GetProperty("code").GetString().Should().Be("pensive");
inlineEmojis[2].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[2].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
inlineEmojis[3].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[3].GetProperty("name").GetString().Should().Be("😂");
inlineEmojis[3].GetProperty("code").GetString().Should().Be("joy");
inlineEmojis[3].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[3].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
}
[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_custom_inline_emoji_and_have_them_listed_separately()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.EmojiTestCases,
Snowflake.Parse("1299804867447230594")
);
// Assert
var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
inlineEmojis.Should().HaveCount(1);
inlineEmojis[0].GetProperty("id").GetString().Should().Be("754441880066064584");
inlineEmojis[0].GetProperty("name").GetString().Should().Be("lemon_blush");
inlineEmojis[0].GetProperty("code").GetString().Should().Be("lemon_blush");
inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
}
}
@@ -27,7 +27,7 @@ public class PartitioningSpecs
ChannelIds = [ChannelIds.DateRangeTestCases], ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
PartitionLimit = PartitionLimit.Parse("3") PartitionLimit = PartitionLimit.Parse("3"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -48,7 +48,7 @@ public class PartitioningSpecs
ChannelIds = [ChannelIds.DateRangeTestCases], ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
PartitionLimit = PartitionLimit.Parse("1kb") PartitionLimit = PartitionLimit.Parse("1kb"),
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
@@ -27,7 +27,7 @@ public class SelfContainedSpecs
ChannelIds = [ChannelIds.SelfContainedTestCases], ChannelIds = [ChannelIds.SelfContainedTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
ShouldDownloadAssets = true ShouldDownloadAssets = true,
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
// Assert // Assert
+5 -2
View File
@@ -1,7 +1,7 @@
# -- Build # -- Build
# Specify the platform here so that we pull the SDK image matching the host platform, # Specify the platform here so that we pull the SDK image matching the host platform,
# instead of the target platform specified during build by the `--platform` option. # instead of the target platform specified during build by the `--platform` option.
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
# Expose the target architecture set by the `docker build --platform` option, so that # Expose the target architecture set by the `docker build --platform` option, so that
# we can build the assembly for the correct platform. # we can build the assembly for the correct platform.
@@ -30,7 +30,7 @@ RUN dotnet publish DiscordChatExporter.Cli \
# -- Run # -- Run
# Use `runtime-deps` instead of `runtime` because we have a self-contained assembly # Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine AS run FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:9.0-alpine AS run
LABEL org.opencontainers.image.title="DiscordChatExporter.Cli" LABEL org.opencontainers.image.title="DiscordChatExporter.Cli"
LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file." LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
@@ -45,6 +45,9 @@ ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
ENV LC_ALL=en_US.UTF-8 ENV LC_ALL=en_US.UTF-8
ENV LANG=en_US.UTF-8 ENV LANG=en_US.UTF-8
# Alpine is missing tzdata, which we need to support timezones
RUN apk add --no-cache tzdata
# Use a non-root user to ensure that the files shared with the host are accessible by the host user # Use a non-root user to ensure that the files shared with the host are accessible by the host user
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851 # https://github.com/Tyrrrz/DiscordChatExporter/issues/851
# https://github.com/Tyrrrz/DiscordChatExporter/issues/1174 # https://github.com/Tyrrrz/DiscordChatExporter/issues/1174
@@ -196,7 +196,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
new ParallelOptions new ParallelOptions
{ {
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit), MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
CancellationToken = cancellationToken CancellationToken = cancellationToken,
}, },
async (channel, innerCancellationToken) => async (channel, innerCancellationToken) =>
{ {
@@ -4,5 +4,5 @@ public enum ThreadInclusionMode
{ {
None, None,
Active, Active,
All All,
} }
@@ -3,13 +3,14 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<ApplicationIcon>..\favicon.ico</ApplicationIcon> <ApplicationIcon>..\favicon.ico</ApplicationIcon>
<PublishTrimmed>true</PublishTrimmed>
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="2.3.5" /> <PackageReference Include="CliFx" Version="2.3.5" />
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="0.29.2" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" /> <PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.4" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="Spectre.Console" Version="0.49.1" /> <PackageReference Include="Spectre.Console" Version="0.49.1" />
</ItemGroup> </ItemGroup>
+40 -2
View File
@@ -1,3 +1,41 @@
using CliFx; using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CliFx;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
return await new CliApplicationBuilder().AddCommandsFromThisAssembly().Build().RunAsync(args); namespace DiscordChatExporter.Cli;
public static class Program
{
// Explicit references because CliFx relies on reflection and we're publishing with trimming enabled
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportAllCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportDirectMessagesCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportGuildCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetDirectChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetGuildsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GuideCommand))]
[DynamicDependency(
DynamicallyAccessedMemberTypes.All,
typeof(ThreadInclusionModeBindingConverter)
)]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TruthyBooleanBindingConverter))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PartitionLimit))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageFilter))]
public static async Task<int> Main(string[] args) =>
await new CliApplicationBuilder()
.AddCommand<ExportAllCommand>()
.AddCommand<ExportChannelsCommand>()
.AddCommand<ExportDirectMessagesCommand>()
.AddCommand<ExportGuildCommand>()
.AddCommand<GetChannelsCommand>()
.AddCommand<GetDirectChannelsCommand>()
.AddCommand<GetGuildsCommand>()
.AddCommand<GuideCommand>()
.Build()
.RunAsync(args);
}
@@ -13,7 +13,7 @@ internal static class ConsoleExtensions
{ {
Ansi = AnsiSupport.Detect, Ansi = AnsiSupport.Detect,
ColorSystem = ColorSystemSupport.Detect, ColorSystem = ColorSystemSupport.Detect,
Out = new AnsiConsoleOutput(console.Output) Out = new AnsiConsoleOutput(console.Output),
} }
); );
@@ -7,7 +7,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/application#application-object // https://discord.com/developers/docs/resources/application#application-object
public partial record Application(Snowflake Id, string Name, ApplicationFlags Flags) public partial record Application(Snowflake Id, string Name, ApplicationFlags Flags)
{ {
public bool IsMessageContentIntentEnabled => public bool IsMessageContentIntentEnabled { get; } =
Flags.HasFlag(ApplicationFlags.GatewayMessageContent) Flags.HasFlag(ApplicationFlags.GatewayMessageContent)
|| Flags.HasFlag(ApplicationFlags.GatewayMessageContentLimited); || Flags.HasFlag(ApplicationFlags.GatewayMessageContentLimited);
} }
@@ -16,5 +16,5 @@ public enum ApplicationFlags
Embedded = 131072, Embedded = 131072,
GatewayMessageContent = 262144, GatewayMessageContent = 262144,
GatewayMessageContentLimited = 524288, GatewayMessageContentLimited = 524288,
ApplicationCommandBadge = 8388608 ApplicationCommandBadge = 8388608,
} }
@@ -18,7 +18,7 @@ public partial record Attachment(
FileSize FileSize FileSize FileSize
) : IHasId ) : IHasId
{ {
public string FileExtension => Path.GetExtension(FileName); public string FileExtension { get; } = Path.GetExtension(FileName);
public bool IsImage => public bool IsImage =>
string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase) string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase)
@@ -41,7 +41,7 @@ public partial record Attachment(
|| string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase) || string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase)
|| string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase); || string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase);
public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal); public bool IsSpoiler { get; } = FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
} }
public partial record Attachment public partial record Attachment
@@ -21,21 +21,23 @@ public partial record Channel(
Snowflake? LastMessageId Snowflake? LastMessageId
) : IHasId ) : IHasId
{ {
public bool IsDirect => Kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat; public bool IsDirect { get; } =
Kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat;
public bool IsGuild => !IsDirect; public bool IsGuild => !IsDirect;
public bool IsCategory => Kind == ChannelKind.GuildCategory; public bool IsCategory { get; } = Kind == ChannelKind.GuildCategory;
public bool IsVoice => Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice; public bool IsVoice { get; } =
Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
public bool IsThread => public bool IsThread { get; } =
Kind Kind
is ChannelKind.GuildNewsThread is ChannelKind.GuildNewsThread
or ChannelKind.GuildPublicThread or ChannelKind.GuildPublicThread
or ChannelKind.GuildPrivateThread; or ChannelKind.GuildPrivateThread;
public bool IsEmpty => LastMessageId is null; public bool IsEmpty { get; } = LastMessageId is null;
public IEnumerable<Channel> GetParents() public IEnumerable<Channel> GetParents()
{ {
@@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Discord.Data;
public record ChannelConnection(Channel Channel, IReadOnlyList<ChannelConnection> Children)
{
public static IReadOnlyList<ChannelConnection> BuildTree(IReadOnlyList<Channel> channels)
{
IReadOnlyList<ChannelConnection> GetChildren(Channel parent) =>
channels
.Where(c => c.Parent?.Id == parent.Id)
.Select(c => new ChannelConnection(c, GetChildren(c)))
.ToArray();
return channels
.Where(c => c.Parent is null)
.Select(c => new ChannelConnection(c, GetChildren(c)))
.ToArray();
}
}
@@ -14,5 +14,5 @@ public enum ChannelKind
GuildPrivateThread = 12, GuildPrivateThread = 12,
GuildStageVoice = 13, GuildStageVoice = 13,
GuildDirectory = 14, GuildDirectory = 14,
GuildForum = 15 GuildForum = 15,
} }
@@ -1,21 +0,0 @@
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Discord.Data;
public record ChannelNode(Channel Channel, IReadOnlyList<ChannelNode> Children)
{
public static IReadOnlyList<ChannelNode> BuildTree(IReadOnlyList<Channel> channels)
{
IReadOnlyList<ChannelNode> GetChildren(Channel parent) =>
channels
.Where(c => c.Parent?.Id == parent.Id)
.Select(c => new ChannelNode(c, GetChildren(c)))
.ToArray();
return channels
.Where(c => c.Parent is null)
.Select(c => new ChannelNode(c, GetChildren(c)))
.ToArray();
}
}
@@ -7,5 +7,5 @@ public enum EmbedKind
Image, Image,
Video, Video,
Gifv, Gifv,
Link Link,
} }
@@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
public partial record SpotifyTrackEmbedProjection(string TrackId) public partial record SpotifyTrackEmbedProjection(string TrackId)
{ {
public string Url => $"https://open.spotify.com/embed/track/{TrackId}"; public string Url { get; } = $"https://open.spotify.com/embed/track/{TrackId}";
} }
public partial record SpotifyTrackEmbedProjection public partial record SpotifyTrackEmbedProjection
@@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
public partial record TwitchClipEmbedProjection(string ClipId) public partial record TwitchClipEmbedProjection(string ClipId)
{ {
public string Url => $"https://clips.twitch.tv/embed?clip={ClipId}&parent=localhost"; public string Url { get; } = $"https://clips.twitch.tv/embed?clip={ClipId}&parent=localhost";
} }
public partial record TwitchClipEmbedProjection public partial record TwitchClipEmbedProjection
@@ -2,7 +2,7 @@
public partial record YouTubeVideoEmbedProjection(string VideoId) public partial record YouTubeVideoEmbedProjection(string VideoId)
{ {
public string Url => $"https://www.youtube.com/embed/{VideoId}"; public string Url { get; } = $"https://www.youtube.com/embed/{VideoId}";
} }
public partial record YouTubeVideoEmbedProjection public partial record YouTubeVideoEmbedProjection
+11 -20
View File
@@ -1,5 +1,4 @@
using System; using System.Text.Json;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Utils; using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@@ -13,29 +12,22 @@ public partial record Emoji(
Snowflake? Id, Snowflake? Id,
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂) // Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
string Name, string Name,
bool IsAnimated, bool IsAnimated
string ImageUrl
) )
{ {
public bool IsCustomEmoji { get; } = Id is not null;
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile) // Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
public string Code => Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name; public string Code { get; } = Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
public string ImageUrl { get; } =
Id is not null
? ImageCdn.GetCustomEmojiUrl(Id.Value, IsAnimated)
: ImageCdn.GetStandardEmojiUrl(Name);
} }
public partial record Emoji public partial record Emoji
{ {
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
{
// Custom emoji
if (id is not null)
return ImageCdn.GetCustomEmojiUrl(id.Value, isAnimated);
// Standard emoji
if (!string.IsNullOrWhiteSpace(name))
return ImageCdn.GetStandardEmojiUrl(name);
throw new InvalidOperationException("Either the emoji ID or name should be provided.");
}
public static Emoji Parse(JsonElement json) public static Emoji Parse(JsonElement json)
{ {
var id = json.GetPropertyOrNull("id") var id = json.GetPropertyOrNull("id")
@@ -47,8 +39,7 @@ public partial record Emoji
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji"; json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji";
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false; var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
var imageUrl = GetImageUrl(id, name, isAnimated);
return new Emoji(id, name, isAnimated, imageUrl); return new Emoji(id, name, isAnimated);
} }
} }
@@ -3548,7 +3548,7 @@ internal static class EmojiIndex
["🇸🇯"] = "flag_sj", ["🇸🇯"] = "flag_sj",
["🇹🇦"] = "flag_ta", ["🇹🇦"] = "flag_ta",
["🇺🇲"] = "flag_um", ["🇺🇲"] = "flag_um",
["🇺🇳"] = "united_nations" ["🇺🇳"] = "united_nations",
}; };
private static Dictionary<string, string> _fromCodes = private static Dictionary<string, string> _fromCodes =
@@ -8880,7 +8880,7 @@ internal static class EmojiIndex
["flag_sj"] = "🇸🇯", ["flag_sj"] = "🇸🇯",
["flag_ta"] = "🇹🇦", ["flag_ta"] = "🇹🇦",
["flag_um"] = "🇺🇲", ["flag_um"] = "🇺🇲",
["united_nations"] = "🇺🇳" ["united_nations"] = "🇺🇳",
}; };
public static string? TryGetCode(string name) => _toCodes.GetValueOrDefault(name); public static string? TryGetCode(string name) => _toCodes.GetValueOrDefault(name);
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/guild#guild-object // https://discord.com/developers/docs/resources/guild#guild-object
public partial record Guild(Snowflake Id, string Name, string IconUrl) : IHasId public partial record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
{ {
public bool IsDirect => Id == DirectMessages.Id; public bool IsDirect { get; } = Id == Snowflake.Zero;
} }
public partial record Guild public partial record Guild
@@ -15,7 +15,7 @@ public partial record Member(
IReadOnlyList<Snowflake> RoleIds IReadOnlyList<Snowflake> RoleIds
) : IHasId ) : IHasId
{ {
public Snowflake Id => User.Id; public Snowflake Id { get; } = User.Id;
} }
public partial record Member public partial record Member
@@ -30,15 +30,15 @@ public partial record Message(
Interaction? Interaction Interaction? Interaction
) : IHasId ) : IHasId
{ {
public bool IsSystemNotification => public bool IsSystemNotification { get; } =
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated; Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
public bool IsReply => Kind == MessageKind.Reply; public bool IsReply { get; } = Kind == MessageKind.Reply;
// App interactions are rendered as replies in the Discord client, but they are not actually replies // App interactions are rendered as replies in the Discord client, but they are not actually replies
public bool IsReplyLike => IsReply || Interaction is not null; public bool IsReplyLike => IsReply || Interaction is not null;
public bool IsEmpty => public bool IsEmpty { get; } =
string.IsNullOrWhiteSpace(Content) string.IsNullOrWhiteSpace(Content)
&& !Attachments.Any() && !Attachments.Any()
&& !Embeds.Any() && !Embeds.Any()
@@ -14,5 +14,5 @@ public enum MessageFlags
Urgent = 16, Urgent = 16,
HasThread = 32, HasThread = 32,
Ephemeral = 64, Ephemeral = 64,
Loading = 128 Loading = 128,
} }
@@ -12,5 +12,5 @@ public enum MessageKind
ChannelPinnedMessage = 6, ChannelPinnedMessage = 6,
GuildMemberJoin = 7, GuildMemberJoin = 7,
ThreadCreated = 18, ThreadCreated = 18,
Reply = 19 Reply = 19,
} }
@@ -9,7 +9,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/sticker#sticker-resource // https://discord.com/developers/docs/resources/sticker#sticker-resource
public partial record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl) public partial record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl)
{ {
public bool IsImage => Format != StickerFormat.Lottie; public bool IsImage { get; } = Format != StickerFormat.Lottie;
} }
public partial record Sticker public partial record Sticker
@@ -28,7 +28,7 @@ public partial record Sticker
StickerFormat.Apng => "png", StickerFormat.Apng => "png",
StickerFormat.Lottie => "json", StickerFormat.Lottie => "json",
StickerFormat.Gif => "gif", StickerFormat.Gif => "gif",
_ => throw new InvalidOperationException($"Unknown sticker format '{format}'.") _ => throw new InvalidOperationException($"Unknown sticker format '{format}'."),
} }
); );
@@ -5,5 +5,5 @@ public enum StickerFormat
Png = 1, Png = 1,
Apng = 2, Apng = 2,
Lottie = 3, Lottie = 3,
Gif = 4 Gif = 4,
} }
@@ -18,7 +18,7 @@ public partial record User(
string AvatarUrl string AvatarUrl
) : IHasId ) : IHasId
{ {
public string DiscriminatorFormatted => public string DiscriminatorFormatted { get; } =
Discriminator is not null ? $"{Discriminator:0000}" : "0000"; Discriminator is not null ? $"{Discriminator:0000}" : "0000";
// This effectively represents the user's true identity. // This effectively represents the user's true identity.
@@ -133,25 +133,21 @@ public class DiscordClient(string token)
{ {
throw response.StatusCode switch throw response.StatusCode switch
{ {
HttpStatusCode.Unauthorized HttpStatusCode.Unauthorized => throw new DiscordChatExporterException(
=> throw new DiscordChatExporterException( "Authentication token is invalid.",
"Authentication token is invalid.", true
true ),
),
HttpStatusCode.Forbidden HttpStatusCode.Forbidden => throw new DiscordChatExporterException(
=> throw new DiscordChatExporterException( $"Request to '{url}' failed: forbidden."
$"Request to '{url}' failed: forbidden." ),
),
HttpStatusCode.NotFound HttpStatusCode.NotFound => throw new DiscordChatExporterException(
=> throw new DiscordChatExporterException( $"Request to '{url}' failed: not found."
$"Request to '{url}' failed: not found." ),
),
_ _ => throw new DiscordChatExporterException(
=> throw new DiscordChatExporterException( $"""
$"""
Request to '{url}' failed: {response Request to '{url}' failed: {response
.StatusCode.ToString() .StatusCode.ToString()
.ToSpaceSeparatedWords() .ToSpaceSeparatedWords()
@@ -160,8 +156,8 @@ public class DiscordClient(string token)
cancellationToken cancellationToken
)} )}
""", """,
true true
) ),
}; };
} }
@@ -676,10 +672,14 @@ public class DiscordClient(string token)
.SetQueryParameter("after", currentAfter.ToString()) .SetQueryParameter("after", currentAfter.ToString())
.Build(); .Build();
var response = await GetJsonResponseAsync(url, cancellationToken); // Can be null on reactions with an emoji that has been deleted (?)
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1226
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
yield break;
var count = 0; var count = 0;
foreach (var userJson in response.EnumerateArray()) foreach (var userJson in response.Value.EnumerateArray())
{ {
var user = User.Parse(userJson); var user = User.Parse(userJson);
yield return user; yield return user;
@@ -3,5 +3,5 @@
public enum TokenKind public enum TokenKind
{ {
User, User,
Bot Bot,
} }
@@ -1,15 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncKeyedLock" Version="6.4.2" /> <PackageReference Include="AsyncKeyedLock" Version="7.0.2" />
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="0.29.2" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Polly" Version="8.3.1" /> <PackageReference Include="Polly" Version="8.4.2" />
<PackageReference Include="RazorBlade" Version="0.6.0" /> <PackageReference Include="RazorBlade" Version="0.6.0" />
<PackageReference Include="Superpower" Version="3.0.0" /> <PackageReference Include="Superpower" Version="3.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.16.0" /> <PackageReference Include="WebMarkupMin.Core" Version="2.17.0" />
<PackageReference Include="YoutubeExplode" Version="6.3.14" /> <PackageReference Include="YoutubeExplode" Version="6.4.3" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -16,12 +16,7 @@ namespace DiscordChatExporter.Core.Exporting;
internal partial class ExportAssetDownloader(string workingDirPath, bool reuse) internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
{ {
private static readonly AsyncKeyedLocker<string> Locker = private static readonly AsyncKeyedLocker<string> Locker = new();
new(o =>
{
o.PoolSize = 20;
o.PoolInitialFill = 1;
});
// File paths of the previously downloaded assets // File paths of the previously downloaded assets
private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal); private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal);
@@ -8,7 +8,7 @@ public enum ExportFormat
HtmlDark, HtmlDark,
HtmlLight, HtmlLight,
Csv, Csv,
Json Json,
} }
public static class ExportFormatExtensions public static class ExportFormatExtensions
@@ -21,7 +21,7 @@ public static class ExportFormatExtensions
ExportFormat.HtmlLight => "html", ExportFormat.HtmlLight => "html",
ExportFormat.Csv => "csv", ExportFormat.Csv => "csv",
ExportFormat.Json => "json", ExportFormat.Json => "json",
_ => throw new ArgumentOutOfRangeException(nameof(format)) _ => throw new ArgumentOutOfRangeException(nameof(format)),
}; };
public static string GetDisplayName(this ExportFormat format) => public static string GetDisplayName(this ExportFormat format) =>
@@ -32,6 +32,6 @@ public static class ExportFormatExtensions
ExportFormat.HtmlLight => "HTML (Light)", ExportFormat.HtmlLight => "HTML (Light)",
ExportFormat.Csv => "CSV", ExportFormat.Csv => "CSV",
ExportFormat.Json => "JSON", ExportFormat.Json => "JSON",
_ => throw new ArgumentOutOfRangeException(nameof(format)) _ => throw new ArgumentOutOfRangeException(nameof(format)),
}; };
} }
@@ -172,24 +172,21 @@ public partial class ExportRequest
"%C" => channel.Name, "%C" => channel.Name,
"%p" => channel.Position?.ToString(CultureInfo.InvariantCulture) ?? "0", "%p" => channel.Position?.ToString(CultureInfo.InvariantCulture) ?? "0",
"%P" "%P" => channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture)
=> channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture) ?? "0",
?? "0",
"%a" "%a" => after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
=> after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "",
?? "", "%b" => before
"%b" ?.ToDate()
=> before?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) .ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "",
?? "", "%d" => DateTimeOffset.Now.ToString(
"%d" "yyyy-MM-dd",
=> DateTimeOffset.Now.ToString( CultureInfo.InvariantCulture
"yyyy-MM-dd", ),
CultureInfo.InvariantCulture
),
"%%" => "%", "%%" => "%",
_ => m.Value _ => m.Value,
} }
) )
); );
@@ -3,5 +3,5 @@
internal enum BinaryExpressionKind internal enum BinaryExpressionKind
{ {
Or, Or,
And And,
} }
@@ -14,6 +14,6 @@ internal class BinaryExpressionMessageFilter(
{ {
BinaryExpressionKind.Or => first.IsMatch(message) || second.IsMatch(message), BinaryExpressionKind.Or => first.IsMatch(message) || second.IsMatch(message),
BinaryExpressionKind.And => first.IsMatch(message) && second.IsMatch(message), BinaryExpressionKind.And => first.IsMatch(message) && second.IsMatch(message),
_ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'.") _ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'."),
}; };
} }
@@ -17,15 +17,13 @@ internal class HasMessageFilter(MessageContentMatchKind kind) : MessageFilter
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage), MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio), MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
MessageContentMatchKind.Pin => message.IsPinned, MessageContentMatchKind.Pin => message.IsPinned,
MessageContentMatchKind.Invite MessageContentMatchKind.Invite => MarkdownParser
=> MarkdownParser .ExtractLinks(message.Content)
.ExtractLinks(message.Content) .Select(l => l.Url)
.Select(l => l.Url) .Select(Invite.TryGetCodeFromUrl)
.Select(Invite.TryGetCodeFromUrl) .Any(c => !string.IsNullOrWhiteSpace(c)),
.Any(c => !string.IsNullOrWhiteSpace(c)), _ => throw new InvalidOperationException(
_ $"Unknown message content match kind '{kind}'."
=> throw new InvalidOperationException( ),
$"Unknown message content match kind '{kind}'."
)
}; };
} }
@@ -9,5 +9,5 @@ internal enum MessageContentMatchKind
Image, Image,
Sound, Sound,
Pin, Pin,
Invite Invite,
} }
@@ -125,7 +125,7 @@ internal static class FilterGrammar
op switch op switch
{ {
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or), '|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And) _ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And),
} }
); );
@@ -5,7 +5,6 @@ using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown; using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing; using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@@ -34,58 +33,51 @@ internal partial class HtmlMarkdownVisitor(
{ {
var (openingTag, closingTag) = formatting.Kind switch var (openingTag, closingTag) = formatting.Kind switch
{ {
FormattingKind.Bold FormattingKind.Bold => (
=> ( // lang=html
// lang=html "<strong>",
"<strong>", // lang=html
// lang=html "</strong>"
"</strong>" ),
),
FormattingKind.Italic FormattingKind.Italic => (
=> ( // lang=html
// lang=html "<em>",
"<em>", // lang=html
// lang=html "</em>"
"</em>" ),
),
FormattingKind.Underline FormattingKind.Underline => (
=> ( // lang=html
// lang=html "<u>",
"<u>", // lang=html
// lang=html "</u>"
"</u>" ),
),
FormattingKind.Strikethrough FormattingKind.Strikethrough => (
=> ( // lang=html
// lang=html "<s>",
"<s>", // lang=html
// lang=html "</s>"
"</s>" ),
),
FormattingKind.Spoiler FormattingKind.Spoiler => (
=> ( // lang=html
// lang=html """<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""", // lang=html
// lang=html """</span>"""
"""</span>""" ),
),
FormattingKind.Quote FormattingKind.Quote => (
=> ( // lang=html
// lang=html """<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""", // lang=html
// lang=html """</div></div>"""
"""</div></div>""" ),
),
_ _ => throw new InvalidOperationException(
=> throw new InvalidOperationException( $"Unknown formatting kind '{formatting.Kind}'."
$"Unknown formatting kind '{formatting.Kind}'." ),
)
}; };
buffer.Append(openingTag); buffer.Append(openingTag);
@@ -217,7 +209,6 @@ internal partial class HtmlMarkdownVisitor(
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = isJumbo ? "chatlog__emoji--large" : ""; var jumboClass = isJumbo ? "chatlog__emoji--large" : "";
buffer.Append( buffer.Append(
@@ -228,7 +219,7 @@ internal partial class HtmlMarkdownVisitor(
class="chatlog__emoji {jumboClass}" class="chatlog__emoji {jumboClass}"
alt="{emoji.Name}" alt="{emoji.Name}"
title="{emoji.Code}" title="{emoji.Code}"
src="{await context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}"> src="{await context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)}">
""" """
); );
} }
@@ -15,7 +15,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
private readonly TextWriter _writer = new StreamWriter(stream); private readonly TextWriter _writer = new StreamWriter(stream);
private readonly HtmlMinifier _minifier = new(); private readonly HtmlMinifier _minifier = new();
private readonly List<Message> _messageGroup = new(); private readonly List<Message> _messageGroup = [];
private bool CanJoinGroup(Message message) private bool CanJoinGroup(Message message)
{ {
@@ -90,7 +90,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
await new MessageGroupTemplate await new MessageGroupTemplate
{ {
Context = Context, Context = Context,
Messages = messages Messages = messages,
}.RenderAsync(cancellationToken) }.RenderAsync(cancellationToken)
) )
); );
@@ -131,7 +131,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
await new PostambleTemplate await new PostambleTemplate
{ {
Context = Context, Context = Context,
MessagesWritten = MessagesWritten MessagesWritten = MessagesWritten,
}.RenderAsync(cancellationToken) }.RenderAsync(cancellationToken)
) )
); );
@@ -1,12 +1,14 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds; using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Writing; using JsonExtensions.Writing;
@@ -25,7 +27,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
Indented = true, Indented = true,
// Validation errors may mask actual failures // Validation errors may mask actual failures
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413 // https://github.com/Tyrrrz/DiscordChatExporter/issues/413
SkipValidation = true SkipValidation = true,
} }
); );
@@ -37,22 +39,31 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken) ? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
: markdown; : markdown;
private async ValueTask WriteUserAsync(User user, CancellationToken cancellationToken = default) private async ValueTask WriteUserAsync(
User user,
bool includeRoles = true,
CancellationToken cancellationToken = default
)
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
_writer.WriteString("id", user.Id.ToString()); _writer.WriteString("id", user.Id.ToString());
_writer.WriteString("name", user.Name); _writer.WriteString("name", user.Name);
_writer.WriteString("discriminator", user.DiscriminatorFormatted); _writer.WriteString("discriminator", user.DiscriminatorFormatted);
_writer.WriteString( _writer.WriteString(
"nickname", "nickname",
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
); );
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex()); _writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
_writer.WriteBoolean("isBot", user.IsBot); _writer.WriteBoolean("isBot", user.IsBot);
_writer.WritePropertyName("roles"); if (includeRoles)
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken); {
_writer.WritePropertyName("roles");
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
}
_writer.WriteString( _writer.WriteString(
"avatarUrl", "avatarUrl",
@@ -66,6 +77,26 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
} }
private async ValueTask WriteEmojiAsync(
Emoji emoji,
CancellationToken cancellationToken = default
)
{
_writer.WriteStartObject();
_writer.WriteString("id", emoji.Id.ToString());
_writer.WriteString("name", emoji.Name);
_writer.WriteString("code", emoji.Code);
_writer.WriteBoolean("isAnimated", emoji.IsAnimated);
_writer.WriteString(
"imageUrl",
await Context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)
);
_writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken);
}
private async ValueTask WriteRolesAsync( private async ValueTask WriteRolesAsync(
IReadOnlyList<Role> roles, IReadOnlyList<Role> roles,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
@@ -273,6 +304,26 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteEndArray(); _writer.WriteEndArray();
// Inline emoji
_writer.WriteStartArray("inlineEmojis");
if (!string.IsNullOrWhiteSpace(embed.Description))
{
foreach (
var emoji in MarkdownParser
.ExtractEmojis(embed.Description)
.DistinctBy(e => e.Name, StringComparer.Ordinal)
)
{
await WriteEmojiAsync(
new Emoji(emoji.Id, emoji.Name, emoji.IsAnimated),
cancellationToken
);
}
}
_writer.WriteEndArray();
_writer.WriteEndObject(); _writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
} }
@@ -373,7 +424,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
// Author // Author
_writer.WritePropertyName("author"); _writer.WritePropertyName("author");
await WriteUserAsync(message.Author, cancellationToken); await WriteUserAsync(message.Author, true, cancellationToken);
// Attachments // Attachments
_writer.WriteStartArray("attachments"); _writer.WriteStartArray("attachments");
@@ -431,20 +482,14 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteStartObject(); _writer.WriteStartObject();
// Emoji // Emoji
_writer.WriteStartObject("emoji"); _writer.WritePropertyName("emoji");
_writer.WriteString("id", reaction.Emoji.Id.ToString()); await WriteEmojiAsync(reaction.Emoji, cancellationToken);
_writer.WriteString("name", reaction.Emoji.Name);
_writer.WriteString("code", reaction.Emoji.Code);
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
_writer.WriteString(
"imageUrl",
await Context.ResolveAssetUrlAsync(reaction.Emoji.ImageUrl, cancellationToken)
);
_writer.WriteEndObject();
_writer.WriteNumber("count", reaction.Count); _writer.WriteNumber("count", reaction.Count);
// Reaction authors
_writer.WriteStartArray("users"); _writer.WriteStartArray("users");
await foreach ( await foreach (
var user in Context.Discord.GetMessageReactionsAsync( var user in Context.Discord.GetMessageReactionsAsync(
Context.Request.Channel.Id, Context.Request.Channel.Id,
@@ -454,28 +499,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
) )
) )
{ {
_writer.WriteStartObject(); await WriteUserAsync(user, false, cancellationToken);
// Write limited user information without color and roles,
// so we can avoid fetching guild member information for each user.
_writer.WriteString("id", user.Id.ToString());
_writer.WriteString("name", user.Name);
_writer.WriteString("discriminator", user.DiscriminatorFormatted);
_writer.WriteString(
"nickname",
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
);
_writer.WriteBoolean("isBot", user.IsBot);
_writer.WriteString(
"avatarUrl",
await Context.ResolveAssetUrlAsync(
Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl,
cancellationToken
)
);
_writer.WriteEndObject();
} }
_writer.WriteEndArray(); _writer.WriteEndArray();
@@ -487,9 +511,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
// Mentions // Mentions
_writer.WriteStartArray("mentions"); _writer.WriteStartArray("mentions");
foreach (var user in message.MentionedUsers) foreach (var user in message.MentionedUsers)
await WriteUserAsync(user, cancellationToken); await WriteUserAsync(user, true, cancellationToken);
_writer.WriteEndArray(); _writer.WriteEndArray();
@@ -512,11 +535,28 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteString("name", message.Interaction.Name); _writer.WriteString("name", message.Interaction.Name);
_writer.WritePropertyName("user"); _writer.WritePropertyName("user");
await WriteUserAsync(message.Interaction.User, cancellationToken); await WriteUserAsync(message.Interaction.User, true, cancellationToken);
_writer.WriteEndObject(); _writer.WriteEndObject();
} }
// Inline emoji
_writer.WriteStartArray("inlineEmojis");
foreach (
var emoji in MarkdownParser
.ExtractEmojis(message.Content)
.DistinctBy(e => e.Name, StringComparer.Ordinal)
)
{
await WriteEmojiAsync(
new Emoji(emoji.Id, emoji.Name, emoji.IsAnimated),
cancellationToken
);
}
_writer.WriteEndArray();
_writer.WriteEndObject(); _writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken); await _writer.FlushAsync(cancellationToken);
} }
@@ -100,13 +100,15 @@ internal partial class MessageExporter
ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context), ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context),
ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context), ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context),
ExportFormat.HtmlDark => new HtmlMessageWriter(File.Create(filePath), context, "Dark"), ExportFormat.HtmlDark => new HtmlMessageWriter(File.Create(filePath), context, "Dark"),
ExportFormat.HtmlLight ExportFormat.HtmlLight => new HtmlMessageWriter(
=> new HtmlMessageWriter(File.Create(filePath), context, "Light"), File.Create(filePath),
context,
"Light"
),
ExportFormat.Json => new JsonMessageWriter(File.Create(filePath), context), ExportFormat.Json => new JsonMessageWriter(File.Create(filePath), context),
_ _ => throw new ArgumentOutOfRangeException(
=> throw new ArgumentOutOfRangeException( nameof(format),
nameof(format), $"Unknown export format '{format}'."
$"Unknown export format '{format}'." ),
)
}; };
} }
@@ -37,7 +37,7 @@ public partial class PartitionLimit
"M" => 1_000_000, "M" => 1_000_000,
"K" => 1_000, "K" => 1_000,
"" => 1, "" => 1,
_ => -1 _ => -1,
}; };
if (magnitude < 0) if (magnitude < 0)
@@ -10,20 +10,18 @@ internal static class PlainTextMessageExtensions
public static string GetFallbackContent(this Message message) => public static string GetFallbackContent(this Message message) =>
message.Kind switch message.Kind switch
{ {
MessageKind.RecipientAdd MessageKind.RecipientAdd => message.MentionedUsers.Any()
=> message.MentionedUsers.Any() ? $"Added {message.MentionedUsers.First().DisplayName} to the group."
? $"Added {message.MentionedUsers.First().DisplayName} to the group." : "Added a recipient.",
: "Added a recipient.",
MessageKind.RecipientRemove MessageKind.RecipientRemove => message.MentionedUsers.Any()
=> message.MentionedUsers.Any() ? message.Author.Id == message.MentionedUsers.First().Id
? message.Author.Id == message.MentionedUsers.First().Id ? "Left the group."
? "Left the group." : $"Removed {message.MentionedUsers.First().DisplayName} from the group."
: $"Removed {message.MentionedUsers.First().DisplayName} from the group." : "Removed a recipient.",
: "Removed a recipient.",
MessageKind.Call MessageKind.Call =>
=> $"Started a call that lasted { $"Started a call that lasted {
message message
.CallEndedTimestamp? .CallEndedTimestamp?
.Pipe(t => t - message.Timestamp) .Pipe(t => t - message.Timestamp)
@@ -31,16 +29,15 @@ internal static class PlainTextMessageExtensions
.ToString("n0", CultureInfo.InvariantCulture) ?? "0" .ToString("n0", CultureInfo.InvariantCulture) ?? "0"
} minutes.", } minutes.",
MessageKind.ChannelNameChange MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
=> !string.IsNullOrWhiteSpace(message.Content) ? $"Changed the channel name: {message.Content}"
? $"Changed the channel name: {message.Content}" : "Changed the channel name.",
: "Changed the channel name.",
MessageKind.ChannelIconChange => "Changed the channel icon.", MessageKind.ChannelIconChange => "Changed the channel icon.",
MessageKind.ChannelPinnedMessage => "Pinned a message.", MessageKind.ChannelPinnedMessage => "Pinned a message.",
MessageKind.ThreadCreated => "Started a thread.", MessageKind.ThreadCreated => "Started a thread.",
MessageKind.GuildMemberJoin => "Joined the server.", MessageKind.GuildMemberJoin => "Joined the server.",
_ => message.Content _ => message.Content,
}; };
} }
+11 -5
View File
@@ -1,5 +1,5 @@
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils; using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Markdown; namespace DiscordChatExporter.Core.Markdown;
@@ -11,11 +11,17 @@ internal record EmojiNode(
bool IsAnimated bool IsAnimated
) : MarkdownNode ) : MarkdownNode
{ {
public bool IsCustomEmoji => Id is not null; // This coupling is unsound from the domain-design perspective, but it helps us reuse
// some code for now. We can refactor this later, if the coupling becomes a problem.
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile) private readonly Emoji _emoji = new(Id, Name, IsAnimated);
public string Code => IsCustomEmoji ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
public EmojiNode(string name) public EmojiNode(string name)
: this(null, name, false) { } : this(null, name, false) { }
public bool IsCustomEmoji => _emoji.IsCustomEmoji;
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
public string Code => _emoji.Code;
public string ImageUrl => _emoji.ImageUrl;
} }
@@ -7,5 +7,5 @@ internal enum FormattingKind
Underline, Underline,
Strikethrough, Strikethrough,
Spoiler, Spoiler,
Quote Quote,
} }
@@ -6,5 +6,5 @@ internal enum MentionKind
Here, Here,
User, User,
Channel, Channel,
Role Role,
} }
@@ -2,20 +2,19 @@
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class AggregateMatcher<T>(IReadOnlyList<IMatcher<T>> matchers) : IMatcher<T> internal class AggregateMatcher<TContext, TValue>(
params IReadOnlyList<IMatcher<TContext, TValue>> matchers
) : IMatcher<TContext, TValue>
{ {
public AggregateMatcher(params IMatcher<T>[] matchers) public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
: this((IReadOnlyList<IMatcher<T>>)matchers) { }
public ParsedMatch<T>? TryMatch(StringSegment segment)
{ {
ParsedMatch<T>? earliestMatch = null; ParsedMatch<TValue>? earliestMatch = null;
// Try to match the input with each matcher and get the match with the lowest start index // Try to match the input with each matcher and get the match with the lowest start index
foreach (var matcher in matchers) foreach (var matcher in matchers)
{ {
// Try to match // Try to match
var match = matcher.TryMatch(segment); var match = matcher.TryMatch(context, segment);
// If there's no match - continue // If there's no match - continue
if (match is null) if (match is null)
@@ -26,7 +25,9 @@ internal class AggregateMatcher<T>(IReadOnlyList<IMatcher<T>> matchers) : IMatch
earliestMatch is null earliestMatch is null
|| match.Segment.StartIndex < earliestMatch.Segment.StartIndex || match.Segment.StartIndex < earliestMatch.Segment.StartIndex
) )
{
earliestMatch = match; earliestMatch = match;
}
// If the earliest match starts at the very beginning - break, // If the earliest match starts at the very beginning - break,
// because it's impossible to find a match earlier than that // because it's impossible to find a match earlier than that
@@ -3,17 +3,18 @@ using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal interface IMatcher<T> internal interface IMatcher<in TContext, TValue>
{ {
ParsedMatch<T>? TryMatch(StringSegment segment); ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment);
} }
internal static class MatcherExtensions internal static class MatcherExtensions
{ {
public static IEnumerable<ParsedMatch<T>> MatchAll<T>( public static IEnumerable<ParsedMatch<TValue>> MatchAll<TContext, TValue>(
this IMatcher<T> matcher, this IMatcher<TContext, TValue> matcher,
TContext context,
StringSegment segment, StringSegment segment,
Func<StringSegment, T> transformFallback Func<TContext, StringSegment, TValue> transformFallback
) )
{ {
// Loop through segments divided by individual matches // Loop through segments divided by individual matches
@@ -22,6 +23,7 @@ internal static class MatcherExtensions
{ {
// Find a match within this segment // Find a match within this segment
var match = matcher.TryMatch( var match = matcher.TryMatch(
context,
segment.Relocate(currentIndex, segment.EndIndex - currentIndex) segment.Relocate(currentIndex, segment.EndIndex - currentIndex)
); );
@@ -36,9 +38,9 @@ internal static class MatcherExtensions
match.Segment.StartIndex - currentIndex match.Segment.StartIndex - currentIndex
); );
yield return new ParsedMatch<T>( yield return new ParsedMatch<TValue>(
fallbackSegment, fallbackSegment,
transformFallback(fallbackSegment) transformFallback(context, fallbackSegment)
); );
} }
@@ -53,7 +55,10 @@ internal static class MatcherExtensions
{ {
var fallbackSegment = segment.Relocate(currentIndex, segment.EndIndex - currentIndex); var fallbackSegment = segment.Relocate(currentIndex, segment.EndIndex - currentIndex);
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment)); yield return new ParsedMatch<TValue>(
fallbackSegment,
transformFallback(context, fallbackSegment)
);
} }
} }
} }
@@ -0,0 +1,3 @@
namespace DiscordChatExporter.Core.Markdown.Parsing;
internal readonly record struct MarkdownContext(int Depth = 0);
@@ -23,15 +23,15 @@ internal static partial class MarkdownParser
/* Formatting */ /* Formatting */
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> BoldFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// There must be exactly two closing asterisks. // There must be exactly two closing asterisks.
new Regex(@"\*\*(.+?)\*\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"\*\*(.+?)\*\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1]))) (c, s, m) => new FormattingNode(FormattingKind.Bold, Parse(c, s.Relocate(m.Groups[1])))
); );
private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> ItalicFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// There must be exactly one closing asterisk. // There must be exactly one closing asterisk.
// Opening asterisk must not be followed by whitespace. // Opening asterisk must not be followed by whitespace.
// Closing asterisk must not be preceded by whitespace. // Closing asterisk must not be preceded by whitespace.
@@ -39,156 +39,174 @@ internal static partial class MarkdownParser
@"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)", @"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)",
DefaultRegexOptions | RegexOptions.Singleline DefaultRegexOptions | RegexOptions.Singleline
), ),
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1]))) (c, s, m) =>
new FormattingNode(FormattingKind.Italic, Parse(c, s.Relocate(m.Groups[1])))
); );
private static readonly IMatcher<MarkdownNode> ItalicBoldFormattingNodeMatcher = private static readonly IMatcher<
new RegexMatcher<MarkdownNode>( MarkdownContext,
// There must be exactly three closing asterisks. MarkdownNode
new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline), > ItalicBoldFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
(s, m) => // There must be exactly three closing asterisks.
new FormattingNode( new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
FormattingKind.Italic, (c, s, m) =>
Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher) new FormattingNode(
) FormattingKind.Italic,
); Parse(c, s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher)
private static readonly IMatcher<MarkdownNode> ItalicAltFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
// Closing underscore must not be followed by a word character.
new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> UnderlineFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
// There must be exactly two closing underscores.
new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
// There must be exactly three closing underscores.
new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) =>
new FormattingNode(
FormattingKind.Italic,
Parse(s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher)
)
);
private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) =>
new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> SpoilerFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher =
new RegexMatcher<MarkdownNode>(
// Include the linebreak in the content so that the lines are preserved in quotes.
new Regex(@"^>\s(.+\n?)", DefaultRegexOptions),
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
new RegexMatcher<MarkdownNode>(
// Include the linebreaks in the content, so that the lines are preserved in quotes.
// Empty content is allowed within quotes.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1115
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions),
(s, m) =>
new FormattingNode(
FormattingKind.Quote,
m.Groups[1].Captures.SelectMany(c => Parse(s.Relocate(c))).ToArray()
)
);
private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> HeadingNodeMatcher =
new RegexMatcher<MarkdownNode>(
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions),
(s, m) => new HeadingNode(m.Groups[1].Length, Parse(s.Relocate(m.Groups[2])))
);
private static readonly IMatcher<MarkdownNode> ListNodeMatcher = new RegexMatcher<MarkdownNode>(
// Can be preceded by whitespace, which specifies the list's nesting level.
// Following lines that start with (level+1) whitespace are considered part of the list item.
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions),
(s, m) =>
new ListNode(
m.Groups[2].Captures.Select(c => new ListItemNode(Parse(s.Relocate(c)))).ToArray()
) )
); );
/* Code blocks */ private static readonly IMatcher<MarkdownContext, MarkdownNode> ItalicAltFormattingNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher = // Closing underscore must not be followed by a word character.
new RegexMatcher<MarkdownNode>( new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
// One or two backticks are allowed, but they must match on both sides. (c, s, m) =>
new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline), new FormattingNode(FormattingKind.Italic, Parse(c, s.Relocate(m.Groups[1])))
(_, m) => new InlineCodeBlockNode(m.Groups[2].Value)
); );
private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> UnderlineFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// There must be exactly two closing underscores.
new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(FormattingKind.Underline, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<
MarkdownContext,
MarkdownNode
> ItalicUnderlineFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
// There must be exactly three closing underscores.
new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(
FormattingKind.Italic,
Parse(c, s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher)
)
);
private static readonly IMatcher<
MarkdownContext,
MarkdownNode
> StrikethroughFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(FormattingKind.Strikethrough, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> SpoilerFormattingNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(FormattingKind.Spoiler, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> SingleLineQuoteNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Include the linebreak in the content so that the lines are preserved in quotes.
new Regex(@"^>\s(.+\n?)", DefaultRegexOptions),
(c, s, m) => new FormattingNode(FormattingKind.Quote, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<
MarkdownContext,
MarkdownNode
> RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
// Include the linebreaks in the content, so that the lines are preserved in quotes.
// Empty content is allowed within quotes.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1115
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions),
(c, s, m) =>
new FormattingNode(
FormattingKind.Quote,
m.Groups[1].Captures.SelectMany(r => Parse(c, s.Relocate(r))).ToArray()
)
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> MultiLineQuoteNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) => new FormattingNode(FormattingKind.Quote, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> HeadingNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions),
(c, s, m) => new HeadingNode(m.Groups[1].Length, Parse(c, s.Relocate(m.Groups[2])))
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> ListNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Can be preceded by whitespace, which specifies the list's nesting level.
// Following lines that start with (level+1) whitespace are considered part of the list item.
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions),
(c, s, m) =>
new ListNode(
m.Groups[2]
.Captures.Select(x => new ListItemNode(Parse(c, s.Relocate(x))))
.ToArray()
)
);
/* Code blocks */
private static readonly IMatcher<MarkdownContext, MarkdownNode> InlineCodeBlockNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// One or two backticks are allowed, but they must match on both sides.
new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline),
(_, _, m) => new InlineCodeBlockNode(m.Groups[2].Value)
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> MultiLineCodeBlockNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Language identifier is one word immediately after opening backticks, followed immediately by a linebreak. // Language identifier is one word immediately after opening backticks, followed immediately by a linebreak.
// Blank lines at the beginning and at the end of content are trimmed. // Blank lines at the beginning and at the end of content are trimmed.
new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline), new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
(_, m) => (_, _, m) =>
new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n')) new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
); );
/* Mentions */ /* Mentions */
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> EveryoneMentionNodeMatcher =
new StringMatcher<MarkdownNode>( new StringMatcher<MarkdownContext, MarkdownNode>(
"@everyone", "@everyone",
_ => new MentionNode(null, MentionKind.Everyone) (_, _) => new MentionNode(null, MentionKind.Everyone)
); );
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> HereMentionNodeMatcher =
new StringMatcher<MarkdownNode>("@here", _ => new MentionNode(null, MentionKind.Here)); new StringMatcher<MarkdownContext, MarkdownNode>(
"@here",
(_, _) => new MentionNode(null, MentionKind.Here)
);
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> UserMentionNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <@123456> or <@!123456> // Capture <@123456> or <@!123456>
new Regex(@"<@!?(\d+)>", DefaultRegexOptions), new Regex(@"<@!?(\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User) (_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User)
); );
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> ChannelMentionNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <#123456> // Capture <#123456>
new Regex(@"<\#!?(\d+)>", DefaultRegexOptions), new Regex(@"<\#!?(\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel) (_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel)
); );
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> RoleMentionNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <@&123456> // Capture <@&123456>
new Regex(@"<@&(\d+)>", DefaultRegexOptions), new Regex(@"<@&(\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role) (_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role)
); );
/* Emoji */ /* Emoji */
private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex( new Regex(
@"(" @"("
+ +
@@ -239,21 +257,21 @@ internal static partial class MarkdownParser
+ @")", + @")",
DefaultRegexOptions DefaultRegexOptions
), ),
(_, m) => new EmojiNode(m.Groups[1].Value) (_, _, m) => new EmojiNode(m.Groups[1].Value)
); );
private static readonly IMatcher<MarkdownNode> CodedStandardEmojiNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> CodedStandardEmojiNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture :thinking: // Capture :thinking:
new Regex(@":([\w_]+):", DefaultRegexOptions), new Regex(@":([\w_]+):", DefaultRegexOptions),
(_, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n)) (_, _, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n))
); );
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> CustomEmojiNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <:lul:123456> or <a:lul:123456> // Capture <:lul:123456> or <a:lul:123456>
new Regex(@"<(a)?:(.+?):(\d+?)>", DefaultRegexOptions), new Regex(@"<(a)?:(.+?):(\d+?)>", DefaultRegexOptions),
(_, m) => (_, _, m) =>
new EmojiNode( new EmojiNode(
Snowflake.TryParse(m.Groups[3].Value), Snowflake.TryParse(m.Groups[3].Value),
m.Groups[2].Value, m.Groups[2].Value,
@@ -263,70 +281,72 @@ internal static partial class MarkdownParser
/* Links */ /* Links */
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> AutoLinkNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Any non-whitespace character after http:// or https:// // Any non-whitespace character after http:// or https://
// until the last punctuation character or whitespace. // until the last punctuation character or whitespace.
new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions), new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions),
(_, m) => new LinkNode(m.Groups[1].Value) (_, _, m) => new LinkNode(m.Groups[1].Value)
); );
private static readonly IMatcher<MarkdownNode> HiddenLinkNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> HiddenLinkNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Same as auto link but also surrounded by angular brackets // Same as auto link but also surrounded by angular brackets
new Regex(@"<(https?://\S*[^\.,:;""'\s])>", DefaultRegexOptions), new Regex(@"<(https?://\S*[^\.,:;""'\s])>", DefaultRegexOptions),
(_, m) => new LinkNode(m.Groups[1].Value) (_, _, m) => new LinkNode(m.Groups[1].Value)
); );
private static readonly IMatcher<MarkdownNode> MaskedLinkNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> MaskedLinkNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture [title](link) // Capture [title](link)
new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions), new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions),
(s, m) => new LinkNode(m.Groups[2].Value, Parse(s.Relocate(m.Groups[1]))) (c, s, m) => new LinkNode(m.Groups[2].Value, Parse(c, s.Relocate(m.Groups[1])))
); );
/* Text */ /* Text */
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> ShrugTextNodeMatcher =
new StringMatcher<MarkdownNode>( new StringMatcher<MarkdownContext, MarkdownNode>(
// Capture the shrug kaomoji. // Capture the shrug kaomoji.
// This escapes it from matching for formatting. // This escapes it from matching for formatting.
@"¯\_(ツ)_/¯", @"¯\_(ツ)_/¯",
s => new TextNode(s.ToString()) (s, _) => new TextNode(s.ToString())
); );
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture some specific emoji that don't get rendered. // Capture some specific emoji that don't get rendered.
// This escapes them from matching for emoji. // This escapes them from matching for emoji.
new Regex(@"([\u26A7\u2640\u2642\u2695\u267E\u00A9\u00AE\u2122])", DefaultRegexOptions), new Regex(@"([\u26A7\u2640\u2642\u2695\u267E\u00A9\u00AE\u2122])", DefaultRegexOptions),
(_, m) => new TextNode(m.Groups[1].Value) (_, _, m) => new TextNode(m.Groups[1].Value)
); );
private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> EscapedSymbolTextNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture any "symbol/other" character or surrogate pair preceded by a backslash. // Capture any "symbol/other" character or surrogate pair preceded by a backslash.
// This escapes them from matching for emoji. // This escapes them from matching for emoji.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/230 // https://github.com/Tyrrrz/DiscordChatExporter/issues/230
new Regex(@"\\(\p{So}|\p{Cs}{2})", DefaultRegexOptions), new Regex(@"\\(\p{So}|\p{Cs}{2})", DefaultRegexOptions),
(_, m) => new TextNode(m.Groups[1].Value) (_, _, m) => new TextNode(m.Groups[1].Value)
); );
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher = private static readonly IMatcher<
new RegexMatcher<MarkdownNode>( MarkdownContext,
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash. MarkdownNode
// This escapes them from matching for formatting or other tokens. > EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions), // Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
(_, m) => new TextNode(m.Groups[1].Value) // This escapes them from matching for formatting or other tokens.
); new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions),
(_, _, m) => new TextNode(m.Groups[1].Value)
);
/* Misc */ /* Misc */
private static readonly IMatcher<MarkdownNode> TimestampNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> TimestampNodeMatcher =
new RegexMatcher<MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <t:12345678> or <t:12345678:R> // Capture <t:12345678> or <t:12345678:R>
new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions), new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions),
(_, m) => (_, _, m) =>
{ {
try try
{ {
@@ -357,10 +377,9 @@ internal static partial class MarkdownParser
null => null, null => null,
// Unknown format: throw an exception to consider this timestamp invalid // Unknown format: throw an exception to consider this timestamp invalid
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1156 // https://github.com/Tyrrrz/DiscordChatExporter/issues/1156
var f var f => throw new InvalidOperationException(
=> throw new InvalidOperationException( $"Unknown timestamp format '{f}'."
$"Unknown timestamp format '{f}'." ),
)
}; };
return new TimestampNode(instant, format); return new TimestampNode(instant, format);
@@ -382,50 +401,51 @@ internal static partial class MarkdownParser
); );
// Matchers that have similar patterns are ordered from most specific to least specific // Matchers that have similar patterns are ordered from most specific to least specific
private static readonly IMatcher<MarkdownNode> NodeMatcher = new AggregateMatcher<MarkdownNode>( private static readonly IMatcher<MarkdownContext, MarkdownNode> NodeMatcher =
// Escaped text new AggregateMatcher<MarkdownContext, MarkdownNode>(
ShrugTextNodeMatcher, // Escaped text
IgnoredEmojiTextNodeMatcher, ShrugTextNodeMatcher,
EscapedSymbolTextNodeMatcher, IgnoredEmojiTextNodeMatcher,
EscapedCharacterTextNodeMatcher, EscapedSymbolTextNodeMatcher,
// Formatting EscapedCharacterTextNodeMatcher,
ItalicBoldFormattingNodeMatcher, // Formatting
ItalicUnderlineFormattingNodeMatcher, ItalicBoldFormattingNodeMatcher,
BoldFormattingNodeMatcher, ItalicUnderlineFormattingNodeMatcher,
ItalicFormattingNodeMatcher, BoldFormattingNodeMatcher,
UnderlineFormattingNodeMatcher, ItalicFormattingNodeMatcher,
ItalicAltFormattingNodeMatcher, UnderlineFormattingNodeMatcher,
StrikethroughFormattingNodeMatcher, ItalicAltFormattingNodeMatcher,
SpoilerFormattingNodeMatcher, StrikethroughFormattingNodeMatcher,
MultiLineQuoteNodeMatcher, SpoilerFormattingNodeMatcher,
RepeatedSingleLineQuoteNodeMatcher, MultiLineQuoteNodeMatcher,
SingleLineQuoteNodeMatcher, RepeatedSingleLineQuoteNodeMatcher,
HeadingNodeMatcher, SingleLineQuoteNodeMatcher,
ListNodeMatcher, HeadingNodeMatcher,
// Code blocks ListNodeMatcher,
MultiLineCodeBlockNodeMatcher, // Code blocks
InlineCodeBlockNodeMatcher, MultiLineCodeBlockNodeMatcher,
// Mentions InlineCodeBlockNodeMatcher,
EveryoneMentionNodeMatcher, // Mentions
HereMentionNodeMatcher, EveryoneMentionNodeMatcher,
UserMentionNodeMatcher, HereMentionNodeMatcher,
ChannelMentionNodeMatcher, UserMentionNodeMatcher,
RoleMentionNodeMatcher, ChannelMentionNodeMatcher,
// Links RoleMentionNodeMatcher,
MaskedLinkNodeMatcher, // Links
AutoLinkNodeMatcher, MaskedLinkNodeMatcher,
HiddenLinkNodeMatcher, AutoLinkNodeMatcher,
// Emoji HiddenLinkNodeMatcher,
StandardEmojiNodeMatcher, // Emoji
CustomEmojiNodeMatcher, StandardEmojiNodeMatcher,
CodedStandardEmojiNodeMatcher, CustomEmojiNodeMatcher,
// Misc CodedStandardEmojiNodeMatcher,
TimestampNodeMatcher // Misc
); TimestampNodeMatcher
);
// Minimal set of matchers for non-multimedia formats (e.g. plain text) // Minimal set of matchers for non-multimedia formats (e.g. plain text)
private static readonly IMatcher<MarkdownNode> MinimalNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> MinimalNodeMatcher =
new AggregateMatcher<MarkdownNode>( new AggregateMatcher<MarkdownContext, MarkdownNode>(
// Mentions // Mentions
EveryoneMentionNodeMatcher, EveryoneMentionNodeMatcher,
HereMentionNodeMatcher, HereMentionNodeMatcher,
@@ -439,42 +459,75 @@ internal static partial class MarkdownParser
); );
private static IReadOnlyList<MarkdownNode> Parse( private static IReadOnlyList<MarkdownNode> Parse(
MarkdownContext context,
StringSegment segment, StringSegment segment,
IMatcher<MarkdownNode> matcher IMatcher<MarkdownContext, MarkdownNode> matcher
) => matcher.MatchAll(segment, s => new TextNode(s.ToString())).Select(r => r.Value).ToArray(); )
{
// Limit recursion depth to a reasonable number to prevent
// stack overflow on messages with inadvertently deep nesting.
// Example: ********************************* (repeat ad nauseam)
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1214
if (context.Depth >= 32)
return [new TextNode(segment.ToString())];
return matcher
.MatchAll(
new MarkdownContext(context.Depth + 1),
segment,
(_, s) => new TextNode(s.ToString())
)
.Select(r => r.Value)
.ToArray();
}
} }
internal static partial class MarkdownParser internal static partial class MarkdownParser
{ {
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment) => private static void Extract<TNode>(
Parse(segment, NodeMatcher); IEnumerable<MarkdownNode> nodes,
ICollection<TNode> extractedNodes
public static IReadOnlyList<MarkdownNode> Parse(string markdown) => )
Parse(new StringSegment(markdown)); where TNode : MarkdownNode
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringSegment segment) =>
Parse(segment, MinimalNodeMatcher);
public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown) =>
ParseMinimal(new StringSegment(markdown));
private static void ExtractLinks(IEnumerable<MarkdownNode> nodes, ICollection<LinkNode> links)
{ {
foreach (var node in nodes) foreach (var node in nodes)
{ {
if (node is LinkNode linkNode) if (node is TNode extractedNode)
links.Add(linkNode); extractedNodes.Add(extractedNode);
if (node is IContainerNode containerNode) if (node is IContainerNode containerNode)
ExtractLinks(containerNode.Children, links); Extract(containerNode.Children, extractedNodes);
} }
} }
public static IReadOnlyList<LinkNode> ExtractLinks(string markdown) public static IReadOnlyList<TNode> Extract<TNode>(string markdown)
where TNode : MarkdownNode
{ {
var links = new List<LinkNode>(); var extractedNodes = new List<TNode>();
ExtractLinks(Parse(markdown), links); Extract(Parse(markdown), extractedNodes);
return links; return extractedNodes;
} }
public static IReadOnlyList<LinkNode> ExtractLinks(string markdown) =>
Extract<LinkNode>(markdown);
public static IReadOnlyList<EmojiNode> ExtractEmojis(string markdown) =>
Extract<EmojiNode>(markdown);
private static IReadOnlyList<MarkdownNode> Parse(
MarkdownContext context,
StringSegment segment
) => Parse(context, segment, NodeMatcher);
public static IReadOnlyList<MarkdownNode> Parse(string markdown) =>
Parse(new MarkdownContext(), new StringSegment(markdown));
private static IReadOnlyList<MarkdownNode> ParseMinimal(
MarkdownContext context,
StringSegment segment
) => Parse(context, segment, MinimalNodeMatcher);
public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown) =>
ParseMinimal(new MarkdownContext(), new StringSegment(markdown));
} }
@@ -3,9 +3,12 @@ using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class RegexMatcher<T>(Regex regex, Func<StringSegment, Match, T?> transform) : IMatcher<T> internal class RegexMatcher<TContext, TValue>(
Regex regex,
Func<TContext, StringSegment, Match, TValue?> transform
) : IMatcher<TContext, TValue>
{ {
public ParsedMatch<T>? TryMatch(StringSegment segment) public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
{ {
var match = regex.Match(segment.Source, segment.StartIndex, segment.Length); var match = regex.Match(segment.Source, segment.StartIndex, segment.Length);
if (!match.Success) if (!match.Success)
@@ -20,8 +23,8 @@ internal class RegexMatcher<T>(Regex regex, Func<StringSegment, Match, T?> trans
return null; return null;
var segmentMatch = segment.Relocate(match); var segmentMatch = segment.Relocate(match);
var value = transform(segmentMatch, match); var value = transform(context, segmentMatch, match);
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null; return value is not null ? new ParsedMatch<TValue>(segmentMatch, value) : null;
} }
} }
@@ -2,16 +2,16 @@
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class StringMatcher<T>( internal class StringMatcher<TContext, TValue>(
string needle, string needle,
StringComparison comparison, StringComparison comparison,
Func<StringSegment, T?> transform Func<TContext, StringSegment, TValue?> transform
) : IMatcher<T> ) : IMatcher<TContext, TValue>
{ {
public StringMatcher(string needle, Func<StringSegment, T> transform) public StringMatcher(string needle, Func<TContext, StringSegment, TValue> transform)
: this(needle, StringComparison.Ordinal, transform) { } : this(needle, StringComparison.Ordinal, transform) { }
public ParsedMatch<T>? TryMatch(StringSegment segment) public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
{ {
var index = segment.Source.IndexOf(needle, segment.StartIndex, segment.Length, comparison); var index = segment.Source.IndexOf(needle, segment.StartIndex, segment.Length, comparison);
@@ -19,8 +19,8 @@ internal class StringMatcher<T>(
return null; return null;
var segmentMatch = segment.Relocate(index, needle.Length); var segmentMatch = segment.Relocate(index, needle.Length);
var value = transform(segmentMatch); var value = transform(context, segmentMatch);
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null; return value is not null ? new ParsedMatch<TValue>(segmentMatch, value) : null;
} }
} }
+2 -2
View File
@@ -39,7 +39,7 @@ public static class Http
ShouldHandle = new PredicateBuilder().Handle<Exception>(IsRetryableException), ShouldHandle = new PredicateBuilder().Handle<Exception>(IsRetryableException),
MaxRetryAttempts = 4, MaxRetryAttempts = 4,
BackoffType = DelayBackoffType.Exponential, BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromSeconds(1) Delay = TimeSpan.FromSeconds(1),
} }
) )
.Build(); .Build();
@@ -68,7 +68,7 @@ public static class Http
return ValueTask.FromResult<TimeSpan?>( return ValueTask.FromResult<TimeSpan?>(
TimeSpan.FromSeconds(Math.Pow(2, args.AttemptNumber) + 1) TimeSpan.FromSeconds(Math.Pow(2, args.AttemptNumber) + 1)
); );
} },
} }
) )
.Build(); .Build();
+6 -4
View File
@@ -1,6 +1,6 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
namespace DiscordChatExporter.Core.Utils; namespace DiscordChatExporter.Core.Utils;
@@ -8,7 +8,9 @@ namespace DiscordChatExporter.Core.Utils;
public static class PathEx public static class PathEx
{ {
private static readonly HashSet<char> InvalidFileNameChars = private static readonly HashSet<char> InvalidFileNameChars =
new(Path.GetInvalidFileNameChars()); [
.. Path.GetInvalidFileNameChars(),
];
public static string EscapeFileName(string path) public static string EscapeFileName(string path)
{ {
@@ -19,7 +21,7 @@ public static class PathEx
// File names cannot end with a dot on Windows // File names cannot end with a dot on Windows
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977 // https://github.com/Tyrrrz/DiscordChatExporter/issues/977
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) if (OperatingSystem.IsWindows())
{ {
while (buffer.Length > 0 && buffer[^1] == '.') while (buffer.Length > 0 && buffer[^1] == '.')
buffer.Remove(buffer.Length - 1, 1); buffer.Remove(buffer.Length - 1, 1);
+22 -3
View File
@@ -7,13 +7,18 @@
xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles" xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles"
xmlns:materialControls="clr-namespace:Material.Styles.Controls;assembly=Material.Styles" xmlns:materialControls="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"> xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
ActualThemeVariantChanged="Application_OnActualThemeVariantChanged">
<Application.DataTemplates> <Application.DataTemplates>
<framework:ViewManager /> <framework:ViewManager />
</Application.DataTemplates> </Application.DataTemplates>
<Application.Styles> <Application.Styles>
<materialStyles:MaterialTheme /> <!-- This theme is used as a stub to pre-load default resources, the actual colors are set through code -->
<materialStyles:MaterialTheme
BaseTheme="Light"
PrimaryColor="Grey"
SecondaryColor="DeepOrange" />
<materialIcons:MaterialIconStyles /> <materialIcons:MaterialIconStyles />
<dialogHostAvalonia:DialogHostStyles /> <dialogHostAvalonia:DialogHostStyles />
@@ -44,6 +49,7 @@
<Style Selector="dialogHostAvalonia|DialogOverlayPopupHost"> <Style Selector="dialogHostAvalonia|DialogOverlayPopupHost">
<Setter Property="Margin" Value="48" /> <Setter Property="Margin" Value="48" />
<Setter Property="Background" Value="{DynamicResource MaterialPaperBrush}" />
</Style> </Style>
<!-- Snack bar host --> <!-- Snack bar host -->
@@ -121,7 +127,7 @@
<!-- Toggle switch --> <!-- Toggle switch -->
<Style Selector="ToggleSwitch"> <Style Selector="ToggleSwitch">
<Setter Property="materialAssists:ToggleSwitchAssist.SwitchThumbOffBackground" Value="{DynamicResource MaterialBackgroundBrush}" /> <Setter Property="materialAssists:ToggleSwitchAssist.SwitchThumbOffBackground" Value="{DynamicResource ToggleBackgroundBrush}" />
</Style> </Style>
<!-- Tooltip --> <!-- Tooltip -->
@@ -132,4 +138,17 @@
<Setter Property="TextElement.FontStretch" Value="Normal" /> <Setter Property="TextElement.FontStretch" Value="Normal" />
</Style> </Style>
</Application.Styles> </Application.Styles>
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Default">
<SolidColorBrush x:Key="ToggleBackgroundBrush" Color="#FFFFFF" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="ToggleBackgroundBrush" Color="#8E8E8E" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application> </Application>
+52 -45
View File
@@ -1,5 +1,4 @@
using System; using System;
using System.Net;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
@@ -7,6 +6,8 @@ using Avalonia.Media;
using Avalonia.Platform; using Avalonia.Platform;
using DiscordChatExporter.Gui.Framework; using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.ViewModels; using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Components; using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs; using DiscordChatExporter.Gui.ViewModels.Dialogs;
@@ -16,9 +17,12 @@ using Microsoft.Extensions.DependencyInjection;
namespace DiscordChatExporter.Gui; namespace DiscordChatExporter.Gui;
public partial class App : Application, IDisposable public class App : Application, IDisposable
{ {
private readonly DisposableCollector _eventRoot = new();
private readonly ServiceProvider _services; private readonly ServiceProvider _services;
private readonly SettingsService _settingsService;
private readonly MainViewModel _mainViewModel; private readonly MainViewModel _mainViewModel;
public App() public App()
@@ -43,17 +47,50 @@ public partial class App : Application, IDisposable
services.AddTransient<SettingsViewModel>(); services.AddTransient<SettingsViewModel>();
_services = services.BuildServiceProvider(true); _services = services.BuildServiceProvider(true);
_settingsService = _services.GetRequiredService<SettingsService>();
_mainViewModel = _services.GetRequiredService<ViewModelManager>().CreateMainViewModel(); _mainViewModel = _services.GetRequiredService<ViewModelManager>().CreateMainViewModel();
// Re-initialize the theme when the user changes it
_eventRoot.Add(
_settingsService.WatchProperty(
o => o.Theme,
() =>
{
RequestedThemeVariant = _settingsService.Theme switch
{
ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light,
ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark,
_ => Avalonia.Styling.ThemeVariant.Default,
};
InitializeTheme();
}
)
);
} }
public override void Initialize() public override void Initialize()
{ {
// Increase maximum concurrent connections base.Initialize();
ServicePointManager.DefaultConnectionLimit = 20;
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }
private void InitializeTheme()
{
var actualTheme = RequestedThemeVariant?.Key switch
{
"Light" => PlatformThemeVariant.Light,
"Dark" => PlatformThemeVariant.Dark,
_ => PlatformSettings?.GetColorValues().ThemeVariant ?? PlatformThemeVariant.Light,
};
this.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme =
actualTheme == PlatformThemeVariant.Light
? Theme.Create(Theme.Light, Color.Parse("#343838"), Color.Parse("#F9A825"))
: Theme.Create(Theme.Dark, Color.Parse("#E8E8E8"), Color.Parse("#F9A825"));
}
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
{ {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
@@ -61,50 +98,20 @@ public partial class App : Application, IDisposable
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
// Set custom theme colors // Set up custom theme colors
SetDefaultTheme(); InitializeTheme();
// Load settings
_settingsService.Load();
} }
public void Dispose() => _services.Dispose(); private void Application_OnActualThemeVariantChanged(object? sender, EventArgs args) =>
} // Re-initialize the theme when the system theme changes
InitializeTheme();
public partial class App public void Dispose()
{
public static void SetLightTheme()
{ {
if (Current is null) _eventRoot.Dispose();
return; _services.Dispose();
Current.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme = Theme.Create(
Theme.Light,
Color.Parse("#343838"),
Color.Parse("#F9A825")
);
}
public static void SetDarkTheme()
{
if (Current is null)
return;
Current.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme = Theme.Create(
Theme.Dark,
Color.Parse("#E8E8E8"),
Color.Parse("#F9A825")
);
}
public static void SetDefaultTheme()
{
if (Current is null)
return;
var isDarkModeEnabledByDefault =
Current.PlatformSettings?.GetColorValues().ThemeVariant == PlatformThemeVariant.Dark;
if (isDarkModeEnabledByDefault)
SetDarkTheme();
else
SetLightTheme();
} }
} }
@@ -11,7 +11,7 @@ public class LocaleToDisplayNameStringConverter : IValueConverter
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is string locale && !string.IsNullOrWhiteSpace(locale) value is string locale && !string.IsNullOrWhiteSpace(locale)
? CultureInfo.GetCultureInfo(locale).DisplayName ? CultureInfo.GetCultureInfo(locale).DisplayName
: "System default"; : "System";
public object ConvertBack( public object ConvertBack(
object? value, object? value,
@@ -4,6 +4,9 @@
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<AssemblyName>DiscordChatExporter</AssemblyName> <AssemblyName>DiscordChatExporter</AssemblyName>
<ApplicationIcon>..\favicon.ico</ApplicationIcon> <ApplicationIcon>..\favicon.ico</ApplicationIcon>
<PublishTrimmed>true</PublishTrimmed>
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -11,21 +14,20 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.2.1" /> <PackageReference Include="AsyncImageLoader.Avalonia" Version="3.3.0" />
<PackageReference Include="Avalonia" Version="11.0.10" /> <PackageReference Include="Avalonia" Version="11.2.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.10" /> <PackageReference Include="Avalonia.Desktop" Version="11.2.0" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.10" Condition="'$(Configuration)' == 'Debug'" /> <PackageReference Include="Avalonia.Diagnostics" Version="11.2.0" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Cogwheel" Version="2.0.4" /> <PackageReference Include="Cogwheel" Version="2.1.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="0.29.2" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" /> <PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="DialogHost.Avalonia" Version="0.7.7" /> <PackageReference Include="DialogHost.Avalonia" Version="0.8.1" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.4" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="Material.Avalonia" Version="3.5.0" /> <PackageReference Include="Material.Avalonia" Version="3.7.4" />
<PackageReference Include="Material.Icons.Avalonia" Version="2.1.9" /> <PackageReference Include="Material.Icons.Avalonia" Version="2.1.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Onova" Version="2.6.11" /> <PackageReference Include="Onova" Version="2.6.12" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -56,7 +56,7 @@ public class DialogManager : IDisposable
{ {
FileTypeChoices = fileTypes, FileTypeChoices = fileTypes,
SuggestedFileName = defaultFilePath, SuggestedFileName = defaultFilePath,
DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.') DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.'),
} }
); );
@@ -77,7 +77,7 @@ public class DialogManager : IDisposable
new FolderPickerOpenOptions new FolderPickerOpenOptions
{ {
AllowMultiple = false, AllowMultiple = false,
SuggestedStartLocation = startLocation SuggestedStartLocation = startLocation,
} }
); );
@@ -0,0 +1,8 @@
namespace DiscordChatExporter.Gui.Framework;
public enum ThemeVariant
{
System,
Light,
Dark,
}
@@ -1,25 +1,31 @@
using System; using Avalonia.Controls;
using Avalonia.Controls;
using Avalonia.Controls.Templates; using Avalonia.Controls.Templates;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.Views;
using DiscordChatExporter.Gui.Views.Components;
using DiscordChatExporter.Gui.Views.Dialogs;
namespace DiscordChatExporter.Gui.Framework; namespace DiscordChatExporter.Gui.Framework;
public partial class ViewManager public partial class ViewManager
{ {
private Control? TryCreateView(ViewModelBase viewModel) =>
viewModel switch
{
MainViewModel => new MainView(),
DashboardViewModel => new DashboardView(),
ExportSetupViewModel => new ExportSetupView(),
MessageBoxViewModel => new MessageBoxView(),
SettingsViewModel => new SettingsView(),
_ => null,
};
public Control? TryBindView(ViewModelBase viewModel) public Control? TryBindView(ViewModelBase viewModel)
{ {
var name = viewModel var view = TryCreateView(viewModel);
.GetType() if (view is null)
.FullName?.Replace("ViewModel", "View", StringComparison.Ordinal);
if (string.IsNullOrWhiteSpace(name))
return null;
var type = Type.GetType(name);
if (type is null)
return null;
if (Activator.CreateInstance(type) is not Control view)
return null; return null;
view.DataContext ??= viewModel; view.DataContext ??= viewModel;
@@ -4,5 +4,5 @@ public enum ThreadInclusionMode
{ {
None, None,
Active, Active,
All All,
} }
+7 -5
View File
@@ -7,19 +7,21 @@ namespace DiscordChatExporter.Gui;
public static class Program public static class Program
{ {
private static Assembly Assembly { get; } = typeof(App).Assembly; private static Assembly Assembly { get; } = Assembly.GetExecutingAssembly();
public static string Name { get; } = Assembly.GetName().Name!; public static string Name { get; } = Assembly.GetName().Name ?? "DiscordChatExporter";
public static Version Version { get; } = Assembly.GetName().Version!; public static Version Version { get; } = Assembly.GetName().Version ?? new Version(0, 0, 0);
public static string VersionString { get; } = Version.ToString(3); public static string VersionString { get; } = Version.ToString(3);
public static bool IsDevelopmentBuild { get; } = Version.Major is <= 0 or >= 999;
public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter"; public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter";
public static string LatestReleaseUrl { get; } = ProjectUrl + "/releases/latest"; public static string ProjectReleasesUrl { get; } = $"{ProjectUrl}/releases";
public static string DocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs"; public static string ProjectDocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
public static AppBuilder BuildAvaloniaApp() => public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>().UsePlatformDetect().LogToTrace(); AppBuilder.Configure<App>().UsePlatformDetect().LogToTrace();
@@ -1,79 +1,133 @@
using System; using System;
using System.IO; using System.IO;
using Avalonia; using System.Text.Json.Serialization;
using Avalonia.Platform;
using Cogwheel; using Cogwheel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Models; using DiscordChatExporter.Gui.Models;
using Microsoft.Win32;
namespace DiscordChatExporter.Gui.Services; namespace DiscordChatExporter.Gui.Services;
// Can't use [ObservableProperty] here because System.Text.Json's source generator doesn't see
// the generated properties.
[INotifyPropertyChanged] [INotifyPropertyChanged]
public partial class SettingsService() public partial class SettingsService()
: SettingsBase(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.dat")) : SettingsBase(
Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
SerializerContext.Default
)
{ {
[ObservableProperty]
private bool _isUkraineSupportMessageEnabled = true; private bool _isUkraineSupportMessageEnabled = true;
public bool IsUkraineSupportMessageEnabled
[ObservableProperty]
private bool _isAutoUpdateEnabled = true;
[ObservableProperty]
private bool _isDarkModeEnabled;
[ObservableProperty]
private bool _isTokenPersisted = true;
[ObservableProperty]
private ThreadInclusionMode _threadInclusionMode;
[ObservableProperty]
private string? _locale;
[ObservableProperty]
private bool _isUtcNormalizationEnabled;
[ObservableProperty]
private int _parallelLimit = 1;
[ObservableProperty]
private Version? _lastAppVersion;
[ObservableProperty]
private string? _lastToken;
[ObservableProperty]
private ExportFormat _lastExportFormat = ExportFormat.HtmlDark;
[ObservableProperty]
private string? _lastPartitionLimitValue;
[ObservableProperty]
private string? _lastMessageFilterValue;
[ObservableProperty]
private bool _lastShouldFormatMarkdown = true;
[ObservableProperty]
private bool _lastShouldDownloadAssets;
[ObservableProperty]
private bool _lastShouldReuseAssets;
[ObservableProperty]
private string? _lastAssetsDirPath;
public override void Reset()
{ {
base.Reset(); get => _isUkraineSupportMessageEnabled;
set => SetProperty(ref _isUkraineSupportMessageEnabled, value);
}
// Reset the dark mode setting separately because its default value is evaluated dynamically private ThemeVariant _theme;
// and cannot be set by the field initializer. public ThemeVariant Theme
IsDarkModeEnabled = {
Application.Current?.PlatformSettings?.GetColorValues().ThemeVariant get => _theme;
== PlatformThemeVariant.Dark; set => SetProperty(ref _theme, value);
}
private bool _isAutoUpdateEnabled = true;
public bool IsAutoUpdateEnabled
{
get => _isAutoUpdateEnabled;
set => SetProperty(ref _isAutoUpdateEnabled, value);
}
private bool _isTokenPersisted = true;
public bool IsTokenPersisted
{
get => _isTokenPersisted;
set => SetProperty(ref _isTokenPersisted, value);
}
private ThreadInclusionMode _threadInclusionMode;
public ThreadInclusionMode ThreadInclusionMode
{
get => _threadInclusionMode;
set => SetProperty(ref _threadInclusionMode, value);
}
private string? _locale;
public string? Locale
{
get => _locale;
set => SetProperty(ref _locale, value);
}
private bool _isUtcNormalizationEnabled;
public bool IsUtcNormalizationEnabled
{
get => _isUtcNormalizationEnabled;
set => SetProperty(ref _isUtcNormalizationEnabled, value);
}
private int _parallelLimit = 1;
public int ParallelLimit
{
get => _parallelLimit;
set => SetProperty(ref _parallelLimit, value);
}
private string? _lastToken;
public string? LastToken
{
get => _lastToken;
set => SetProperty(ref _lastToken, value);
}
private ExportFormat _lastExportFormat = ExportFormat.HtmlDark;
public ExportFormat LastExportFormat
{
get => _lastExportFormat;
set => SetProperty(ref _lastExportFormat, value);
}
private string? _lastPartitionLimitValue;
public string? LastPartitionLimitValue
{
get => _lastPartitionLimitValue;
set => SetProperty(ref _lastPartitionLimitValue, value);
}
private string? _lastMessageFilterValue;
public string? LastMessageFilterValue
{
get => _lastMessageFilterValue;
set => SetProperty(ref _lastMessageFilterValue, value);
}
private bool _lastShouldFormatMarkdown = true;
public bool LastShouldFormatMarkdown
{
get => _lastShouldFormatMarkdown;
set => SetProperty(ref _lastShouldFormatMarkdown, value);
}
private bool _lastShouldDownloadAssets;
public bool LastShouldDownloadAssets
{
get => _lastShouldDownloadAssets;
set => SetProperty(ref _lastShouldDownloadAssets, value);
}
private bool _lastShouldReuseAssets;
public bool LastShouldReuseAssets
{
get => _lastShouldReuseAssets;
set => SetProperty(ref _lastShouldReuseAssets, value);
}
private string? _lastAssetsDirPath;
public string? LastAssetsDirPath
{
get => _lastAssetsDirPath;
set => SetProperty(ref _lastAssetsDirPath, value);
} }
public override void Save() public override void Save()
@@ -88,3 +142,9 @@ public partial class SettingsService()
LastToken = lastToken; LastToken = lastToken;
} }
} }
public partial class SettingsService
{
[JsonSerializable(typeof(SettingsService))]
private partial class SerializerContext : JsonSerializerContext;
}
@@ -9,18 +9,20 @@ namespace DiscordChatExporter.Gui.Services;
public class UpdateService(SettingsService settingsService) : IDisposable public class UpdateService(SettingsService settingsService) : IDisposable
{ {
private readonly IUpdateManager _updateManager = new UpdateManager( private readonly IUpdateManager? _updateManager = OperatingSystem.IsWindows()
new GithubPackageResolver( ? new UpdateManager(
"Tyrrrz", new GithubPackageResolver(
"DiscordChatExporter", "Tyrrrz",
// Examples: "DiscordChatExporter",
// DiscordChatExporter.win-arm64.zip // Examples:
// DiscordChatExporter.win-x64.zip // DiscordChatExporter.win-arm64.zip
// DiscordChatExporter.linux-x64.zip // DiscordChatExporter.win-x64.zip
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip" // DiscordChatExporter.linux-x64.zip
), $"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
new ZipPackageExtractor() ),
); new ZipPackageExtractor()
)
: null;
private Version? _updateVersion; private Version? _updateVersion;
private bool _updatePrepared; private bool _updatePrepared;
@@ -28,6 +30,9 @@ public class UpdateService(SettingsService settingsService) : IDisposable
public async ValueTask<Version?> CheckForUpdatesAsync() public async ValueTask<Version?> CheckForUpdatesAsync()
{ {
if (_updateManager is null)
return null;
if (!settingsService.IsAutoUpdateEnabled) if (!settingsService.IsAutoUpdateEnabled)
return null; return null;
@@ -37,6 +42,9 @@ public class UpdateService(SettingsService settingsService) : IDisposable
public async ValueTask PrepareUpdateAsync(Version version) public async ValueTask PrepareUpdateAsync(Version version)
{ {
if (_updateManager is null)
return;
if (!settingsService.IsAutoUpdateEnabled) if (!settingsService.IsAutoUpdateEnabled)
return; return;
@@ -57,11 +65,10 @@ public class UpdateService(SettingsService settingsService) : IDisposable
public void FinalizeUpdate(bool needRestart) public void FinalizeUpdate(bool needRestart)
{ {
if (!settingsService.IsAutoUpdateEnabled) if (_updateManager is null)
return; return;
// Onova only works on Windows currently if (!settingsService.IsAutoUpdateEnabled)
if (!OperatingSystem.IsWindows())
return; return;
if (_updateVersion is null || !_updatePrepared || _updaterLaunched) if (_updateVersion is null || !_updatePrepared || _updaterLaunched)
@@ -82,5 +89,5 @@ public class UpdateService(SettingsService settingsService) : IDisposable
} }
} }
public void Dispose() => _updateManager.Dispose(); public void Dispose() => _updateManager?.Dispose();
} }
@@ -1,5 +1,4 @@
using System; using Avalonia.Controls;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.VisualTree; using Avalonia.VisualTree;
@@ -11,15 +11,11 @@ internal static class NotifyPropertyChangedExtensions
this TOwner owner, this TOwner owner,
Expression<Func<TOwner, TProperty>> propertyExpression, Expression<Func<TOwner, TProperty>> propertyExpression,
Action callback, Action callback,
bool watchInitialValue = true bool watchInitialValue = false
) )
where TOwner : INotifyPropertyChanged where TOwner : INotifyPropertyChanged
{ {
var memberExpression = var memberExpression = propertyExpression.Body as MemberExpression;
propertyExpression.Body as MemberExpression
// Property value might be boxed inside a conversion expression, if the types don't match
?? (propertyExpression.Body as UnaryExpression)?.Operand as MemberExpression;
if (memberExpression?.Member is not PropertyInfo property) if (memberExpression?.Member is not PropertyInfo property)
throw new ArgumentException("Provided expression must reference a property."); throw new ArgumentException("Provided expression must reference a property.");
@@ -45,7 +41,7 @@ internal static class NotifyPropertyChangedExtensions
public static IDisposable WatchAllProperties<TOwner>( public static IDisposable WatchAllProperties<TOwner>(
this TOwner owner, this TOwner owner,
Action callback, Action callback,
bool watchInitialValues = true bool watchInitialValues = false
) )
where TOwner : INotifyPropertyChanged where TOwner : INotifyPropertyChanged
{ {
@@ -53,7 +53,7 @@ public partial class DashboardViewModel : ViewModelBase
private Guild? _selectedGuild; private Guild? _selectedGuild;
[ObservableProperty] [ObservableProperty]
private IReadOnlyList<ChannelNode>? _availableChannels; private IReadOnlyList<ChannelConnection>? _availableChannels;
public DashboardViewModel( public DashboardViewModel(
ViewModelManager viewModelManager, ViewModelManager viewModelManager,
@@ -88,7 +88,7 @@ public partial class DashboardViewModel : ViewModelBase
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1; public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
public ObservableCollection<ChannelNode> SelectedChannels { get; } = []; public ObservableCollection<ChannelConnection> SelectedChannels { get; } = [];
[RelayCommand] [RelayCommand]
private void Initialize() private void Initialize()
@@ -102,7 +102,7 @@ public partial class DashboardViewModel : ViewModelBase
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel()); await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
[RelayCommand] [RelayCommand]
private void ShowHelp() => ProcessEx.StartShellExecute(Program.DocumentationUrl); private void ShowHelp() => ProcessEx.StartShellExecute(Program.ProjectDocumentationUrl);
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token); private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
@@ -190,7 +190,7 @@ public partial class DashboardViewModel : ViewModelBase
} }
// Build a hierarchy of channels // Build a hierarchy of channels
var channelTree = ChannelNode.BuildTree( var channelTree = ChannelConnection.BuildTree(
channels channels
.OrderByDescending(c => c.IsDirect ? c.LastMessageId : null) .OrderByDescending(c => c.IsDirect ? c.LastMessageId : null)
.ThenBy(c => c.Position) .ThenBy(c => c.Position)
@@ -253,7 +253,7 @@ public partial class DashboardViewModel : ViewModelBase
channelProgressPairs, channelProgressPairs,
new ParallelOptions new ParallelOptions
{ {
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit) MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit),
}, },
async (pair, cancellationToken) => async (pair, cancellationToken) =>
{ {
@@ -138,8 +138,8 @@ public partial class ExportSetupViewModel(
[ [
new FilePickerFileType($"{extension.ToUpperInvariant()} file") new FilePickerFileType($"{extension.ToUpperInvariant()} file")
{ {
Patterns = [$"*.{extension}"] Patterns = [$"*.{extension}"],
} },
], ],
defaultFileName defaultFileName
); );
@@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using DiscordChatExporter.Gui.Framework; using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Models; using DiscordChatExporter.Gui.Models;
@@ -23,25 +22,27 @@ public class SettingsViewModel : DialogViewModelBase
_eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged)); _eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged));
} }
public IReadOnlyList<ThemeVariant> AvailableThemes { get; } = Enum.GetValues<ThemeVariant>();
public ThemeVariant Theme
{
get => _settingsService.Theme;
set => _settingsService.Theme = value;
}
public bool IsAutoUpdateEnabled public bool IsAutoUpdateEnabled
{ {
get => _settingsService.IsAutoUpdateEnabled; get => _settingsService.IsAutoUpdateEnabled;
set => _settingsService.IsAutoUpdateEnabled = value; set => _settingsService.IsAutoUpdateEnabled = value;
} }
public bool IsDarkModeEnabled
{
get => _settingsService.IsDarkModeEnabled;
set => _settingsService.IsDarkModeEnabled = value;
}
public bool IsTokenPersisted public bool IsTokenPersisted
{ {
get => _settingsService.IsTokenPersisted; get => _settingsService.IsTokenPersisted;
set => _settingsService.IsTokenPersisted = value; set => _settingsService.IsTokenPersisted = value;
} }
public IReadOnlyList<ThreadInclusionMode> AvailableThreadInclusions { get; } = public IReadOnlyList<ThreadInclusionMode> AvailableThreadInclusionModes { get; } =
Enum.GetValues<ThreadInclusionMode>(); Enum.GetValues<ThreadInclusionMode>();
public ThreadInclusionMode ThreadInclusionMode public ThreadInclusionMode ThreadInclusionMode
@@ -84,7 +85,7 @@ public class SettingsViewModel : DialogViewModelBase
"zh-CN", "zh-CN",
"ja-JP", "ja-JP",
"zh-TW", "zh-TW",
"ko-KR" "ko-KR",
]; ];
// This has to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected // This has to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
@@ -1,4 +1,5 @@
using System; using System;
using System.Diagnostics;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -46,6 +47,30 @@ public partial class MainViewModel(
ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter"); ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter");
} }
private async Task ShowDevelopmentBuildMessageAsync()
{
if (!Program.IsDevelopmentBuild)
return;
// If debugging, the user is likely a developer
if (Debugger.IsAttached)
return;
var dialog = viewModelManager.CreateMessageBoxViewModel(
"Unstable build warning",
$"""
You're using a development build of {Program.Name}. These builds are not thoroughly tested and may contain bugs.
Auto-updates are disabled for development builds. If you want to switch to a stable release, please download it manually.
""",
"SEE RELEASES",
"CLOSE"
);
if (await dialogManager.ShowDialogAsync(dialog) == true)
ProcessEx.StartShellExecute(Program.ProjectReleasesUrl);
}
private async Task CheckForUpdatesAsync() private async Task CheckForUpdatesAsync()
{ {
try try
@@ -79,36 +104,9 @@ public partial class MainViewModel(
[RelayCommand] [RelayCommand]
private async Task InitializeAsync() private async Task InitializeAsync()
{ {
// Reset settings (needed to resolve the default dark mode setting)
settingsService.Reset();
// Load settings
settingsService.Load();
// Set the correct theme
if (settingsService.IsDarkModeEnabled)
App.SetDarkTheme();
else
App.SetLightTheme();
await ShowUkraineSupportMessageAsync(); await ShowUkraineSupportMessageAsync();
await ShowDevelopmentBuildMessageAsync();
await CheckForUpdatesAsync(); await CheckForUpdatesAsync();
// App has just been updated, display the changelog
if (
settingsService.LastAppVersion is not null
&& settingsService.LastAppVersion != Program.Version
)
{
snackbarManager.Notify(
$"Successfully updated to {Program.Name} v{Program.VersionString}",
"WHAT'S NEW",
() => ProcessEx.StartShellExecute(Program.LatestReleaseUrl)
);
settingsService.LastAppVersion = Program.Version;
settingsService.Save();
}
} }
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
@@ -9,11 +9,8 @@
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles" xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
x:Name="UserControl" x:Name="UserControl"
x:DataType="components:DashboardViewModel"
Loaded="UserControl_OnLoaded"> Loaded="UserControl_OnLoaded">
<Design.DataContext>
<components:DashboardViewModel />
</Design.DataContext>
<DockPanel> <DockPanel>
<!-- Header --> <!-- Header -->
<StackPanel <StackPanel
@@ -95,126 +92,6 @@
</Style> </Style>
</Style> </Style>
</Panel.Styles> </Panel.Styles>
<!-- Placeholder / usage instructions -->
<Panel IsVisible="{Binding !AvailableGuilds.Count}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock
Margin="32,16"
FontSize="14"
FontWeight="Light"
LineHeight="23">
<!-- User token -->
<InlineUIContainer>
<materialIcons:MaterialIcon
Width="18"
Height="18"
Margin="0,-2,0,0"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
</InlineUIContainer>
<Run Text="" />
<Run
FontSize="16"
FontWeight="SemiBold"
Text="To get the token for your personal account:" />
<LineBreak />
<Run Text="* Automating user accounts is technically against TOS —" />
<Run FontWeight="SemiBold" Text="use at your own risk" /><Run Text="!" />
<LineBreak />
<Run Text="1. Open Discord in your" />
<controls:HyperLink Command="{Binding OpenDiscordCommand}" Text="web browser" />
<Run Text="and login" />
<LineBreak />
<Run Text="2. Open any server or direct message channel" />
<LineBreak />
<Run Text="3. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run FontWeight="SemiBold" Text="Network" />
<Run Text="tab" />
<LineBreak />
<Run Text="5. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+R" />
<Run Text="to reload" />
<LineBreak />
<Run Text="6. Switch between random channels to trigger network requests" />
<LineBreak />
<Run Text="7. Search for a request that starts with" />
<Run FontWeight="SemiBold" Text="messages" />
<LineBreak />
<Run Text="8. Select the" />
<Run FontWeight="SemiBold" Text="Headers" />
<Run Text="tab on the right" />
<LineBreak />
<Run Text="9. Scroll down to the" />
<Run FontWeight="SemiBold" Text="Request Headers" />
<Run Text="section" />
<LineBreak />
<Run Text="10. Copy the value of the" />
<Run FontWeight="SemiBold" Text="authorization" />
<Run Text="header" />
<LineBreak />
<LineBreak />
<!-- Bot token -->
<InlineUIContainer>
<materialIcons:MaterialIcon
Width="18"
Height="18"
Margin="0,-2,0,0"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" />
</InlineUIContainer>
<Run Text="" />
<Run
FontSize="16"
FontWeight="SemiBold"
Text="To get the token for your bot:" />
<LineBreak />
<Run Text="1. Open Discord" />
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="developer portal" />
<LineBreak />
<Run Text="2. Open your application's settings" />
<LineBreak />
<Run Text="3. Navigate to the" />
<Run FontWeight="SemiBold" Text="Bot" />
<Run Text="section on the left" />
<LineBreak />
<Run Text="4. Under" />
<Run FontWeight="SemiBold" Text="Token" />
<Run Text="click" />
<Run FontWeight="SemiBold" Text="Copy" />
<LineBreak />
<Run Text="* Your bot needs to have the" />
<Run FontWeight="SemiBold" Text="Message Content Intent" />
<Run Text="enabled to read messages" />
<LineBreak />
<LineBreak />
<Run Text="If you have questions or issues, please refer to the" />
<controls:HyperLink Command="{Binding ShowHelpCommand}" Text="documentation" />
</TextBlock>
</ScrollViewer>
</Panel>
<!-- Guilds and channels --> <!-- Guilds and channels -->
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding !!AvailableGuilds.Count}"> <Grid ColumnDefinitions="Auto,*" IsVisible="{Binding !!AvailableGuilds.Count}">
<!-- Guilds --> <!-- Guilds -->
@@ -266,7 +143,6 @@
<Border Grid.Column="1"> <Border Grid.Column="1">
<TreeView <TreeView
x:Name="AvailableChannelsTreeView" x:Name="AvailableChannelsTreeView"
AutoScrollToSelectedItem="False"
ItemsSource="{Binding AvailableChannels}" ItemsSource="{Binding AvailableChannels}"
SelectedItems="{Binding SelectedChannels}" SelectedItems="{Binding SelectedChannels}"
SelectionChanged="AvailableChannelsTreeView_OnSelectionChanged" SelectionChanged="AvailableChannelsTreeView_OnSelectionChanged"
@@ -340,6 +216,164 @@
</Border> </Border>
</Grid> </Grid>
<!-- Placeholder / usage instructions -->
<Panel IsVisible="{Binding !AvailableGuilds.Count}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock
Margin="32,16"
FontSize="14"
FontWeight="Light"
LineHeight="23">
<!-- User token -->
<InlineUIContainer>
<materialIcons:MaterialIcon
Width="18"
Height="18"
Margin="0,-2,0,0"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
</InlineUIContainer>
<Run BaselineAlignment="Center" Text="" />
<Run
BaselineAlignment="Center"
FontSize="16"
FontWeight="SemiBold"
Text="To get the token for your personal account:" />
<LineBreak />
<Run BaselineAlignment="Center" Text="* Automating user accounts is technically against TOS —" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="use at your own risk" /><Run Text="!" />
<LineBreak />
<Run BaselineAlignment="Center" Text="1. Open Discord in your" />
<controls:HyperLink Command="{Binding OpenDiscordCommand}" Text="web browser" />
<Run BaselineAlignment="Center" Text="and login" />
<LineBreak />
<Run BaselineAlignment="Center" Text="2. Open any server or direct message channel" />
<LineBreak />
<Run BaselineAlignment="Center" Text="3. Press" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Ctrl+Shift+I" />
<Run BaselineAlignment="Center" Text="to show developer tools" />
<LineBreak />
<Run BaselineAlignment="Center" Text="4. Navigate to the" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Network" />
<Run BaselineAlignment="Center" Text="tab" />
<LineBreak />
<Run BaselineAlignment="Center" Text="5. Press" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Ctrl+R" />
<Run BaselineAlignment="Center" Text="to reload" />
<LineBreak />
<Run BaselineAlignment="Center" Text="6. Switch between random channels to trigger network requests" />
<LineBreak />
<Run BaselineAlignment="Center" Text="7. Search for a request that starts with" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="messages" />
<LineBreak />
<Run BaselineAlignment="Center" Text="8. Select the" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Headers" />
<Run BaselineAlignment="Center" Text="tab on the right" />
<LineBreak />
<Run BaselineAlignment="Center" Text="9. Scroll down to the" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Request Headers" />
<Run BaselineAlignment="Center" Text="section" />
<LineBreak />
<Run BaselineAlignment="Center" Text="10. Copy the value of the" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="authorization" />
<Run BaselineAlignment="Center" Text="header" />
<LineBreak />
<LineBreak />
<!-- Bot token -->
<InlineUIContainer>
<materialIcons:MaterialIcon
Width="18"
Height="18"
Margin="0,-2,0,0"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" />
</InlineUIContainer>
<Run BaselineAlignment="Center" Text="" />
<Run
BaselineAlignment="Center"
FontSize="16"
FontWeight="SemiBold"
Text="To get the token for your bot:" />
<LineBreak />
<Run BaselineAlignment="Center" Text="1. Open Discord" />
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="developer portal" />
<LineBreak />
<Run BaselineAlignment="Center" Text="2. Open your application's settings" />
<LineBreak />
<Run BaselineAlignment="Center" Text="3. Navigate to the" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Bot" />
<Run BaselineAlignment="Center" Text="section on the left" />
<LineBreak />
<Run BaselineAlignment="Center" Text="4. Under" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Token" />
<Run BaselineAlignment="Center" Text="click" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Copy" />
<LineBreak />
<Run BaselineAlignment="Center" Text="* Your bot needs to have the" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Message Content Intent" />
<Run BaselineAlignment="Center" Text="enabled to read messages" />
<LineBreak />
<LineBreak />
<Run BaselineAlignment="Center" Text="If you have questions or issues, please refer to the" />
<controls:HyperLink Command="{Binding ShowHelpCommand}" Text="documentation" />
</TextBlock>
</ScrollViewer>
</Panel>
<!-- Export button --> <!-- Export button -->
<Button <Button
Width="56" Width="56"
@@ -28,7 +28,9 @@ public partial class DashboardView : UserControl<DashboardViewModel>
) )
{ {
// Hack: unselect categories because they cannot be exported // Hack: unselect categories because they cannot be exported
foreach (var item in args.AddedItems.OfType<ChannelNode>().Where(x => x.Channel.IsCategory)) foreach (
var item in args.AddedItems.OfType<ChannelConnection>().Where(x => x.Channel.IsCategory)
)
{ {
if (AvailableChannelsTreeView.TreeContainerFromItem(item) is TreeViewItem container) if (AvailableChannelsTreeView.TreeContainerFromItem(item) is TreeViewItem container)
container.IsSelected = false; container.IsSelected = false;
@@ -1,13 +1,14 @@
<UserControl <UserControl
x:Class="DiscordChatExporter.Gui.Views.Controls.HyperLink" x:Class="DiscordChatExporter.Gui.Views.Controls.HyperLink"
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:DiscordChatExporter.Gui.Views.Controls">
<TextBlock <TextBlock
x:Name="TextBlock" x:Name="TextBlock"
Cursor="Hand" Cursor="Hand"
Foreground="{DynamicResource MaterialSecondaryDarkBrush}" Foreground="{DynamicResource MaterialSecondaryDarkBrush}"
PointerReleased="TextBlock_OnPointerReleased" PointerReleased="TextBlock_OnPointerReleased"
Text="{Binding $parent[UserControl].Text, Mode=OneWay}"> Text="{Binding $parent[controls:HyperLink].Text, Mode=OneWay}">
<TextBlock.Styles> <TextBlock.Styles>
<Style Selector="TextBlock"> <Style Selector="TextBlock">
<Style Selector="^:pointerover"> <Style Selector="^:pointerover">
@@ -10,11 +10,8 @@
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils" xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
x:Name="UserControl" x:Name="UserControl"
Width="380" Width="380"
x:DataType="dialogs:ExportSetupViewModel"
Loaded="UserControl_OnLoaded"> Loaded="UserControl_OnLoaded">
<Design.DataContext>
<dialogs:ExportSetupViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto,*,Auto"> <Grid RowDefinitions="Auto,*,Auto">
<!-- Guild/channel info --> <!-- Guild/channel info -->
<Grid <Grid
@@ -40,7 +37,7 @@
FontWeight="Light" FontWeight="Light"
IsVisible="{Binding !IsSingleChannel}" IsVisible="{Binding !IsSingleChannel}"
TextTrimming="CharacterEllipsis"> TextTrimming="CharacterEllipsis">
<Run Text="{Binding Channels.Count, Mode=OneWay}" /> <Run Text="{Binding Channels.Count, FallbackValue=0, Mode=OneWay}" />
<Run Text="channels selected" /> <Run Text="channels selected" />
</TextBlock> </TextBlock>
@@ -4,11 +4,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs" xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:system="clr-namespace:System;assembly=System.Runtime" xmlns:system="clr-namespace:System;assembly=System.Runtime"
Width="500"> Width="500"
<Design.DataContext> x:DataType="dialogs:MessageBoxViewModel">
<dialogs:MessageBoxViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto,*,Auto"> <Grid RowDefinitions="Auto,*,Auto">
<!-- Title --> <!-- Title -->
<TextBlock <TextBlock

Some files were not shown because too many files have changed in this diff Show More