mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 06:39:33 +02:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72f9e981de | |||
| 81e286751f | |||
| 225230a20f | |||
| dea412ff25 | |||
| 95953f8cf0 | |||
| 6191855e33 | |||
| 33a975b907 | |||
| 3d353bc1e6 | |||
| f555ab3f00 | |||
| a14a17db83 | |||
| 1f63d4fa26 | |||
| d3d078735a | |||
| 58dd40bf3e | |||
| 7c79e0639b | |||
| a74ba4da0c | |||
| 8bbb8fa5ee | |||
| a91337ccac | |||
| 303f5667bf | |||
| 934cb526e9 | |||
| c33b742dce | |||
| f0a74fbc6e | |||
| 768e124370 | |||
| 8dc7eb2937 | |||
| 8fff0f4445 | |||
| 0fc8124aa0 | |||
| 64ed6acf34 | |||
| e958600073 | |||
| 714d2e6806 | |||
| fbbac2afaa | |||
| 380dd6d511 | |||
| 607ec7442f | |||
| e6f675bcaa | |||
| 42ebb9928e | |||
| 7e64aa5a8e | |||
| a383722469 | |||
| 8f3fe7e501 | |||
| 27a434a663 | |||
| a966bee140 | |||
| fb17e86146 | |||
| 8fcf5e5377 | |||
| 845e2a52fe | |||
| 187befcf11 | |||
| 9e685a0375 | |||
| 0d98e1084d | |||
| f149fad406 | |||
| 5405e619a9 | |||
| adafaaa49d | |||
| d071459a5b | |||
| cc92b2cbe6 | |||
| 08718425f1 | |||
| 540ba34fb4 | |||
| 22134108a4 | |||
| 82499af25a | |||
| 4a74ff8a02 | |||
| 7dfcf5ac19 | |||
| 19c5fb40b2 | |||
| d4fa8f0954 | |||
| 0d9168e0d2 | |||
| aa8ace8389 | |||
| 97cb8e2347 | |||
| 92ee97a5da | |||
| 612ae2e894 | |||
| 1506afc4a2 | |||
| b56c7db3ec | |||
| 59d803d9f1 | |||
| 30ba273fb1 | |||
| f9fa35b6ad | |||
| 7d2ebb04de | |||
| 1c9f4275b2 | |||
| 1fadc0755b | |||
| 7ddd55951c | |||
| 6fb197cf0b | |||
| 7add81a472 | |||
| cf7580014c | |||
| 606c082759 | |||
| 5bbb706b3c | |||
| deca3fc1bf | |||
| fca6729ef0 | |||
| 6f877cf543 | |||
| 62babc0de5 | |||
| 9e115562dc | |||
| db50a2bb96 | |||
| b39d015133 | |||
| b4c3dce760 | |||
| f31e73bb7a | |||
| 75ff5c2f7e | |||
| 7663f8ed31 | |||
| c0e7334084 | |||
| a9eca1ceb2 | |||
| 45ff1daff3 | |||
| 5299c2ebc4 | |||
| f6c189f57c | |||
| a9acf17375 | |||
| bf417db80c | |||
| d78d22d066 | |||
| 0fa5e93f0d | |||
| 7b892c8e5b | |||
| 692d886442 | |||
| e664eab5fb | |||
| 1fb6156187 | |||
| 789e5af8ba | |||
| 9c15baf799 | |||
| 56fa6ee150 | |||
| 09e0b3f133 | |||
| e8192b2b53 | |||
| 34e9b0bfcc | |||
| abd7adc9e0 | |||
| f5cdd5c8be | |||
| 9fb78509b4 | |||
| 161e91ccf4 | |||
| 8519ec0fff | |||
| 9f8f718c94 | |||
| bb14e3431d | |||
| aa377f3131 | |||
| 522789e01d | |||
| 10adba3a4d | |||
| 73e2e0624b | |||
| c978a4cf60 | |||
| 6d82930505 | |||
| 4e98c79313 | |||
| 42e6de359e | |||
| 281e0f608a | |||
| c63313e328 | |||
| 767ca1b177 | |||
| 429801183c | |||
| 520c06dceb | |||
| 46d8deba87 | |||
| 0e13be2fdf | |||
| 15f8a13f02 | |||
| c8ea365c04 | |||
| 949abc2582 | |||
| 3b3423dd9b | |||
| 8380e88744 | |||
| f1bc5a2266 |
@@ -33,7 +33,7 @@ The HTML format replicates Discord's interface, making it the most user-friendly
|
||||
It's the best format for attachment preview and sharing.
|
||||
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.
|
||||
> Export using the "Download assets" (`--media`) option to avoid this.
|
||||
|
||||
|
||||
+56
-11
@@ -1,10 +1,11 @@
|
||||
# Obtaining Token and Channel IDs
|
||||
|
||||
> **Warning**:
|
||||
> **Do not share your token!**
|
||||
> A token gives full access to an account. To reset a user token, change your account password. To reset a bot token, click on [Regenerate](#how-to-get-a-bot-token) in the bot settings.
|
||||
> [!WARNING]
|
||||
> **Do not share your token!** A token gives full access to an account.
|
||||
> To reset a user token, change your account password.
|
||||
> To reset a bot token, click on [Reset Token](#how-to-export-with-a-bot-token) in the bot settings.
|
||||
|
||||
## How to get a User Token
|
||||
## How to get a user token
|
||||
|
||||
**Caution:** [Automating user accounts violates Discord's terms of service](https://support.discord.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-) and may result in account termination. Use at your own risk.
|
||||
|
||||
@@ -26,7 +27,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
3. Type
|
||||
|
||||
```js
|
||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
||||
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.$8&&x.exports.LP&&x.exports.gK){m=x;break}}}]);m&&console.log("Token:",m.exports.LP());
|
||||
```
|
||||
|
||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||
@@ -119,7 +120,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
1. Type
|
||||
|
||||
```js
|
||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
||||
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.$8&&x.exports.LP&&x.exports.gK){m=x;break}}}]);m&&console.log("Token:",m.exports.LP());
|
||||
```
|
||||
|
||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||
@@ -254,17 +255,61 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
4. Press <kbd>Esc</kbd>. The settings page will close.
|
||||
5. To find your user token, continue [here](#in-chrome).
|
||||
|
||||
## How to get a Bot Token
|
||||
## How to export with a bot token
|
||||
|
||||
### Step 1 - Create an application
|
||||
|
||||
You can create a new application or use an existing one. If you want to create a new one:
|
||||
|
||||
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||
2. Click on **New Application** in the top right corner
|
||||
3. Enter a name for your application and click **Create**
|
||||
|
||||
### Step 2 - Invite the bot to your server
|
||||
|
||||
The bot needs to be invited to the server you'd like to export from.
|
||||
|
||||
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||
2. Navigate to **General Information** on the left
|
||||
3. Copy the **Application ID**
|
||||
4. Open the following URL in your browser, replacing `YOUR_APP_ID` with the copied Client ID:
|
||||
|
||||
<!-- Permission code 66560 corresponds to "View Channels" and "Read Message History" permissions.
|
||||
User can uncheck these when adding the bot to their server. -->
|
||||
|
||||
```
|
||||
https://discord.com/oauth2/authorize?scope=bot&permissions=66560&client_id=YOUR_APP_ID
|
||||
```
|
||||
|
||||
### Step 3 - Ensure message content intent is enabled
|
||||
|
||||
If this option is not enabled, the exported files will be empty.
|
||||
|
||||
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||
2. Open your Application's settings
|
||||
3. Navigate to the **Bot** section on the left
|
||||
4. Under **Token** click **Copy**
|
||||
4. Scroll down to the **Privileged Gateway Intents** section
|
||||
5. Enable **Message Content Intent** by toggling the switch
|
||||
|
||||
> **Warning**:
|
||||
> Your bot needs to have **Message Content Intent** enabled for it to be able to read messages!
|
||||
<img width="500" align="right" src="https://i.imgur.com/PPm2KKn.png" />
|
||||
|
||||

|
||||
### Step 4 - Copy the bot token
|
||||
|
||||
If you don't have a bot token yet or if you've lost it, follow these steps to reset it:
|
||||
|
||||
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||
2. Open your Application's settings
|
||||
3. Navigate to the **Bot** section on the left
|
||||
4. Under **Token** click **Reset Token**
|
||||
5. Click **Yes, do it!** and authenticate to confirm
|
||||
|
||||
> **Tip**:
|
||||
> As the token is only shown once, make sure to store it in a safe place. If you lose the token, you will have to reset it again.
|
||||
|
||||
> [!WARNING]
|
||||
> Resetting the token will invalidate the old one. Any integrations relying on the old token will cease to function until they are updated.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Follow the [instructions here](Token-and-IDs.md).
|
||||
|
||||
### Will I get banned if I use this?
|
||||
|
||||
Automating user accounts is technically against [TOS](https://discord.com/terms), use at your discretion. [Bot accounts](https://discord.com/developers/docs/topics/oauth2#bots) don't have this restriction.
|
||||
Automating user accounts is technically against [TOS](https://discord.com/terms), use at your discretion. [Bot accounts](https://discord.com/developers/docs/topics/oauth2#bot-users) don't have this restriction.
|
||||
|
||||
### Will the messages disappear from the exported file if I delete a message, delete my account or block a person?
|
||||
|
||||
@@ -146,6 +146,12 @@ Red Hat: `cert-sync --user /etc/pki/tls/certs/ca-bundle.crt`
|
||||
|
||||
If it still doesn't work, try mozroots: `mozroots --import --ask-remove`
|
||||
|
||||
## macOS-specific
|
||||
|
||||
### DiscordChatExporter is damaged and can’t be opened. You should move it to the Trash.
|
||||
|
||||
Check the [Using the GUI page](Using-the-GUI.md#step-1) for instructions on how to run the app.
|
||||
|
||||
---
|
||||
|
||||
> ❓ If you still have unanswered questions, feel free to [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new).
|
||||
|
||||
+44
-30
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
|
||||
@@ -18,15 +18,18 @@ You can also drag and drop the folder on **every platform**.
|
||||
|
||||
## Step 3
|
||||
|
||||
Now we're ready to run the commands. The examples on this page follow the Windows file path format, change the file
|
||||
paths according to your system.
|
||||
Now we're ready to run the commands.
|
||||
|
||||
Type the following command in your terminal of choice, then press ENTER to run it. This will list all available subcommands and options.
|
||||
|
||||
```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).
|
||||
|
||||
## CLI commands
|
||||
@@ -47,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:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli command --help
|
||||
./DiscordChatExporter.Cli command --help
|
||||
```
|
||||
|
||||
For example, to figure out how to use the `export` command, run:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export --help
|
||||
./DiscordChatExporter.Cli export --help
|
||||
```
|
||||
|
||||
## Export a specific channel
|
||||
@@ -61,7 +64,7 @@ $ ./DiscordChatExporter.Cli export --help
|
||||
You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
|
||||
```
|
||||
|
||||
#### Changing the format
|
||||
@@ -70,7 +73,7 @@ You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json`
|
||||
format is `HtmlDark`.
|
||||
|
||||
```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
|
||||
@@ -78,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:
|
||||
|
||||
```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
|
||||
@@ -88,7 +91,7 @@ extension.
|
||||
If any of the folders in the path have a space in its name, escape them with quotes (").
|
||||
|
||||
```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
|
||||
@@ -98,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 (").
|
||||
|
||||
```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
|
||||
@@ -106,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.
|
||||
|
||||
```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
|
||||
@@ -125,6 +128,7 @@ Here is the full list of supported template tokens:
|
||||
- `%P` - category position
|
||||
- `%a` - the "after" date
|
||||
- `%b` - the "before" date
|
||||
- `%d` - the current date
|
||||
- `%%` - escapes `%`
|
||||
|
||||
#### Partitioning
|
||||
@@ -133,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.
|
||||
|
||||
```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.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb
|
||||
```
|
||||
|
||||
#### Downloading assets
|
||||
@@ -150,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.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
|
||||
```
|
||||
|
||||
#### Reusing assets
|
||||
@@ -159,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.
|
||||
|
||||
```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
|
||||
@@ -168,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.
|
||||
|
||||
```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
|
||||
@@ -177,7 +181,7 @@ You can customize how dates are formatted in the exported files by using `--loca
|
||||
locales. The default locale is `en-US`.
|
||||
|
||||
```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
|
||||
@@ -186,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:
|
||||
|
||||
```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**
|
||||
Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM:
|
||||
|
||||
```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**
|
||||
@@ -201,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:
|
||||
|
||||
```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
|
||||
@@ -210,12 +214,22 @@ Don't forget to quote (") the date if it has spaces!
|
||||
More info about .NET date
|
||||
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
|
||||
|
||||
To export all channels in a specific server, use the `exportguild` command and provide the server ID through the `-g|--guild` option:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
|
||||
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
|
||||
```
|
||||
|
||||
#### Including threads
|
||||
@@ -225,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`.
|
||||
|
||||
```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
|
||||
@@ -235,7 +249,7 @@ specifying whether to include voice channels in the export. It has possible valu
|
||||
voice channels, use `--include-vc false`.
|
||||
|
||||
```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
|
||||
@@ -243,7 +257,7 @@ $ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc fals
|
||||
To export all accessible channels, use the `exportall` command:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
|
||||
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
|
||||
```
|
||||
|
||||
#### Excluding DMs
|
||||
@@ -251,7 +265,7 @@ $ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
|
||||
To exclude DMs, add the `--include-dm false` option.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
|
||||
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
|
||||
```
|
||||
|
||||
### List channels in a server
|
||||
@@ -259,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:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
|
||||
./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
|
||||
```
|
||||
|
||||
### List direct message channels
|
||||
@@ -267,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:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
|
||||
./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
|
||||
```
|
||||
|
||||
### List servers
|
||||
@@ -275,5 +289,5 @@ $ ./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
|
||||
To list all servers accessible by the current account, use the `guilds` command:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
|
||||
```
|
||||
./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
|
||||
```
|
||||
|
||||
+17
-2
@@ -10,13 +10,28 @@
|
||||
|
||||
### Step 1
|
||||
|
||||
After extracting the `.zip`, run `DiscordChatExporter.exe` (Windows), or `DiscordChatExporter` (Mac OS and Linux).
|
||||
After extracting the `.zip`, run `DiscordChatExporter.exe` **(Windows)**, or `DiscordChatExporter` **(Linux)**.
|
||||
|
||||
If you're using **macOS**, you'll need to manually grant permission for the app to run.
|
||||
If you skip these steps, the "DiscordChatExporter is damaged and can’t be opened" error will be shown.
|
||||
|
||||
1. Open Terminal.app. You can search for it in Spotlight (press <kbd>⌘</kbd> + <kbd>Space</kbd> and type "Terminal").
|
||||
2. Paste the following into the terminal window:
|
||||
```bash
|
||||
xattr -rd com.apple.quarantine
|
||||
```
|
||||
3. Hit <kbd>Space</kbd> once to add a space after the command
|
||||
4. Drag and drop DiscordChatExporter.app into the terminal window
|
||||
5. Press <kbd>Return</kbd> to run the command
|
||||
6. Open DiscordChatExporter.app normally
|
||||
|
||||
> Apple requires apps to be notarized and signed in order to run on macOS without warnings, which in turn requires an Apple Developer membership ($99/year). This open-source project is distributed for free and without commercial intent.
|
||||
|
||||
### Step 2
|
||||
|
||||
Please refer to the on-screen instructions to get your token, then paste your token in the upper text box and hit ENTER or click the arrow (→).
|
||||
|
||||
> **Warning**:
|
||||
> [!WARNING]
|
||||
> **Never share your token!**
|
||||
> A token gives full access to an account, treat it like a password.
|
||||
|
||||
|
||||
@@ -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:
|
||||
- "*"
|
||||
@@ -23,10 +23,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build image
|
||||
run: >
|
||||
@@ -36,8 +36,8 @@ jobs:
|
||||
--build-arg VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
|
||||
- name: Upload image
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: DiscordChatExporter.Cli.Docker
|
||||
path: DiscordChatExporter.Cli.Docker.tar
|
||||
@@ -55,10 +55,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
run: >
|
||||
|
||||
+32
-20
@@ -26,18 +26,29 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
# Build the project separately to discern between build and format errors
|
||||
- name: Build
|
||||
run: >
|
||||
dotnet build
|
||||
-p:CSharpier_Bypass=true
|
||||
--configuration Release
|
||||
|
||||
- name: Verify formatting
|
||||
id: verify
|
||||
run: >
|
||||
dotnet build
|
||||
-t:CSharpierFormat
|
||||
--configuration Release
|
||||
--no-restore
|
||||
|
||||
- name: Report issues
|
||||
if: ${{ failure() && steps.verify.outcome == 'failure' }}
|
||||
run: echo "::error title=Bad formatting::Formatting issues detected. Please build the solution locally to fix them."
|
||||
|
||||
test:
|
||||
# Tests need access to secrets, so we can't run them against PRs because of limited trust
|
||||
@@ -51,12 +62,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -72,7 +81,7 @@ jobs:
|
||||
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@f30e4959ba63075080d4f7f90cacc18d9f3fafd7 # v4.0.0
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -108,25 +117,24 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Publish app
|
||||
run: >
|
||||
dotnet publish ${{ matrix.app }}
|
||||
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||
-p:CSharpier_Bypass=true
|
||||
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
|
||||
--output ${{ matrix.app }}/bin/publish/
|
||||
--configuration Release
|
||||
--runtime ${{ matrix.rid }}
|
||||
--self-contained
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
|
||||
- name: Upload app binaries
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/bin/publish/
|
||||
@@ -190,15 +198,19 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1
|
||||
- name: Download app binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/
|
||||
|
||||
- name: Set permissions
|
||||
if: ${{ !startsWith(matrix.rid, 'win') }}
|
||||
run: chmod +x ${{ matrix.app }}/${{ matrix.asset }}
|
||||
if: ${{ !startsWith(matrix.rid, 'win-') }}
|
||||
run: |
|
||||
[ -f ${{ matrix.app }}/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }} || true
|
||||
|
||||
# macOS bundle
|
||||
[ -f ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} || true
|
||||
|
||||
- name: Create package
|
||||
# Change into the artifacts directory to avoid including the directory itself in the zip archive
|
||||
@@ -223,7 +235,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Notify Discord
|
||||
uses: tyrrrz/action-http-request@64c70c67f5ebc54d4c7ea09cbe3553322778afd5 # 1.1.2
|
||||
uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3
|
||||
with:
|
||||
url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
method: POST
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@ bin/
|
||||
obj/
|
||||
|
||||
# Test results
|
||||
TestResults/
|
||||
TestResults/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>999.9.9-dev</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
@@ -11,22 +10,24 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.1.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" />
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="3.0.1" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="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.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageReference Include="xunit" Version="2.8.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
|
||||
<PackageReference
|
||||
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
||||
Version="10.0.2"
|
||||
/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -10,6 +10,8 @@ public static class ChannelIds
|
||||
|
||||
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 FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
||||
|
||||
@@ -19,12 +19,7 @@ namespace DiscordChatExporter.Cli.Tests.Infra;
|
||||
|
||||
public static class ExportWrapper
|
||||
{
|
||||
private static readonly AsyncKeyedLocker<string> Locker =
|
||||
new(o =>
|
||||
{
|
||||
o.PoolSize = 20;
|
||||
o.PoolInitialFill = 1;
|
||||
});
|
||||
private static readonly AsyncKeyedLocker<string> Locker = new();
|
||||
|
||||
private static readonly string DirPath = Path.Combine(
|
||||
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
@@ -61,7 +56,7 @@ public static class ExportWrapper
|
||||
ExportFormat = format,
|
||||
OutputPath = filePath,
|
||||
Locale = "en-US",
|
||||
IsUtcNormalizationEnabled = true
|
||||
IsUtcNormalizationEnabled = true,
|
||||
}.ExecuteAsync(console);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@ In order to run these tests locally, you need to join the test server and config
|
||||
3. Add your token to user secrets: `dotnet user-secrets set DISCORD_TOKEN <token>`
|
||||
4. Run the tests: `dotnet test`
|
||||
|
||||
> **Note**:
|
||||
> [!NOTE]
|
||||
> If you want to add a new test case, please let me know and I will give you the required permissions on the server.
|
||||
@@ -30,7 +30,7 @@ public class DateRangeSpecs
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
After = Snowflake.FromDate(after)
|
||||
After = Snowflake.FromDate(after),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// 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, 39, 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.Using<DateTimeOffset>(ctx =>
|
||||
@@ -74,7 +74,7 @@ public class DateRangeSpecs
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
Before = Snowflake.FromDate(before)
|
||||
Before = Snowflake.FromDate(before),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -92,7 +92,7 @@ public class DateRangeSpecs
|
||||
[
|
||||
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, 17, 23, 58, TimeSpan.Zero)
|
||||
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero),
|
||||
],
|
||||
o =>
|
||||
o.Using<DateTimeOffset>(ctx =>
|
||||
@@ -118,7 +118,7 @@ public class DateRangeSpecs
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
Before = Snowflake.FromDate(before),
|
||||
After = Snowflake.FromDate(after)
|
||||
After = Snowflake.FromDate(after),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -137,7 +137,7 @@ public class DateRangeSpecs
|
||||
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, 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.Using<DateTimeOffset>(ctx =>
|
||||
@@ -146,4 +146,31 @@ public class DateRangeSpecs
|
||||
.WhenTypeIs<DateTimeOffset>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_filter_the_export_to_not_include_any_messages()
|
||||
{
|
||||
// Arrange
|
||||
var before = new DateTimeOffset(2020, 08, 01, 0, 0, 0, TimeSpan.Zero);
|
||||
using var file = TempFile.Create();
|
||||
|
||||
// Act
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
Before = Snowflake.FromDate(before),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||
.ToArray();
|
||||
|
||||
timestamps.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public class FilterSpecs
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("some text")
|
||||
MessageFilter = MessageFilter.Parse("some text"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -54,7 +54,7 @@ public class FilterSpecs
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("from:Tyrrrz")
|
||||
MessageFilter = MessageFilter.Parse("from:Tyrrrz"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -79,7 +79,7 @@ public class FilterSpecs
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("has:image")
|
||||
MessageFilter = MessageFilter.Parse("has:image"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -104,7 +104,7 @@ public class FilterSpecs
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("has:pin")
|
||||
MessageFilter = MessageFilter.Parse("has:pin"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -129,7 +129,7 @@ public class FilterSpecs
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("has:invite")
|
||||
MessageFilter = MessageFilter.Parse("has:invite"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -154,7 +154,7 @@ public class FilterSpecs
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz")
|
||||
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -181,8 +181,14 @@ public class HtmlEmbedSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
|
||||
iframeUrl.Should().StartWith("https://www.youtube.com/embed/qOWW4OlgbvE");
|
||||
// Check that the YouTube video thumbnail image exists with the correct video ID
|
||||
var youtubeThumbnailSrc = message
|
||||
.QuerySelectorAll("img")
|
||||
.Select(e => e.GetAttribute("src"))
|
||||
.WhereNotNull()
|
||||
.FirstOrDefault(s => s.Contains("qOWW4OlgbvE", StringComparison.Ordinal));
|
||||
|
||||
youtubeThumbnailSrc.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -28,7 +28,7 @@ public class HtmlGroupingSpecs
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = [ChannelIds.GroupingTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = file.Path
|
||||
OutputPath = file.Path,
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
@@ -19,8 +20,13 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Default timestamp: 2/12/2023 1:36 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.Contain("Default timestamp: 2/12/2023 1:36 PM");
|
||||
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -33,8 +39,8 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Short time timestamp: 1:36 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message.Text().ReplaceWhiteSpace().Should().Contain("Short time timestamp: 1:36 PM");
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -47,8 +53,8 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Long time timestamp: 1:36:12 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message.Text().ReplaceWhiteSpace().Should().Contain("Long time timestamp: 1:36:12 PM");
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -61,8 +67,8 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Short date timestamp: 2/12/2023");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message.Text().ReplaceWhiteSpace().Should().Contain("Short date timestamp: 2/12/2023");
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -75,8 +81,13 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Long date timestamp: Sunday, February 12, 2023");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.Contain("Long date timestamp: Sunday, February 12, 2023");
|
||||
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -89,8 +100,13 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
|
||||
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -105,9 +121,11 @@ public class HtmlMarkdownSpecs
|
||||
// Assert
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.Contain("Full long timestamp: Sunday, February 12, 2023 1:36:12 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -120,8 +138,13 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Relative timestamp: 2/12/2023 1:36 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.Contain("Relative timestamp: 2/12/2023 1:36 PM");
|
||||
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = filePath,
|
||||
PartitionLimit = PartitionLimit.Parse("3")
|
||||
PartitionLimit = PartitionLimit.Parse("3"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -48,7 +48,7 @@ public class PartitioningSpecs
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = filePath,
|
||||
PartitionLimit = PartitionLimit.Parse("1kb")
|
||||
PartitionLimit = PartitionLimit.Parse("1kb"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -27,7 +27,7 @@ public class SelfContainedSpecs
|
||||
ChannelIds = [ChannelIds.SelfContainedTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = filePath,
|
||||
ShouldDownloadAssets = true
|
||||
ShouldDownloadAssets = true,
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||
|
||||
internal static class StringExtensions
|
||||
{
|
||||
extension(string str)
|
||||
{
|
||||
public string ReplaceWhiteSpace(string replacement = " ")
|
||||
{
|
||||
var buffer = new StringBuilder(str.Length);
|
||||
|
||||
foreach (var ch in str)
|
||||
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using PathEx = System.IO.Path;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||
|
||||
@@ -23,8 +22,8 @@ internal partial class TempDir
|
||||
{
|
||||
public static TempDir Create()
|
||||
{
|
||||
var dirPath = PathEx.Combine(
|
||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
var dirPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
?? Directory.GetCurrentDirectory(),
|
||||
"Temp",
|
||||
Guid.NewGuid().ToString()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using PathEx = System.IO.Path;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||
|
||||
@@ -23,15 +22,15 @@ internal partial class TempFile
|
||||
{
|
||||
public static TempFile Create()
|
||||
{
|
||||
var dirPath = PathEx.Combine(
|
||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
var dirPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
?? Directory.GetCurrentDirectory(),
|
||||
"Temp"
|
||||
);
|
||||
|
||||
Directory.CreateDirectory(dirPath);
|
||||
|
||||
var filePath = PathEx.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
||||
var filePath = System.IO.Path.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
||||
|
||||
return new TempFile(filePath);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -- Build
|
||||
# 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.
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||
|
||||
# Expose the target architecture set by the `docker build --platform` option, so that
|
||||
# we can build the assembly for the correct platform.
|
||||
@@ -30,7 +30,7 @@ RUN dotnet publish DiscordChatExporter.Cli \
|
||||
|
||||
# -- Run
|
||||
# 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:10.0-alpine AS run
|
||||
|
||||
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."
|
||||
@@ -45,6 +45,9 @@ ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
ENV LC_ALL=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
|
||||
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851
|
||||
# https://github.com/Tyrrrz/DiscordChatExporter/issues/1174
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
@@ -27,8 +28,19 @@ public abstract class DiscordCommandBase : ICommand
|
||||
)]
|
||||
public bool IsBotToken { get; init; } = false;
|
||||
|
||||
private DiscordClient? _discordClient;
|
||||
protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token);
|
||||
[CommandOption(
|
||||
"respect-rate-limits",
|
||||
Description = "Whether to respect advisory rate limits. "
|
||||
+ "If disabled, only hard rate limits (i.e. 429 responses) will be respected."
|
||||
)]
|
||||
public bool ShouldRespectRateLimits { get; init; } = true;
|
||||
|
||||
[field: AllowNull, MaybeNull]
|
||||
protected DiscordClient Discord =>
|
||||
field ??= new DiscordClient(
|
||||
Token,
|
||||
ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
|
||||
);
|
||||
|
||||
public virtual ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@@ -8,6 +9,7 @@ using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
@@ -15,7 +17,6 @@ using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using Gress;
|
||||
using Spectre.Console;
|
||||
|
||||
@@ -23,8 +24,6 @@ namespace DiscordChatExporter.Cli.Commands.Base;
|
||||
|
||||
public abstract class ExportCommandBase : DiscordCommandBase
|
||||
{
|
||||
private readonly string _outputPath = Directory.GetCurrentDirectory();
|
||||
|
||||
[CommandOption(
|
||||
"output",
|
||||
'o',
|
||||
@@ -35,11 +34,11 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public string OutputPath
|
||||
{
|
||||
get => _outputPath;
|
||||
get;
|
||||
// Handle ~/ in paths on Unix systems
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||
init => _outputPath = Path.GetFullPath(value);
|
||||
}
|
||||
init => field = Path.GetFullPath(value);
|
||||
} = Directory.GetCurrentDirectory();
|
||||
|
||||
[CommandOption("format", 'f', Description = "Export format.")]
|
||||
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
||||
@@ -64,6 +63,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
||||
|
||||
[CommandOption(
|
||||
"include-threads",
|
||||
Description = "Which types of threads should be included.",
|
||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
||||
)]
|
||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
||||
|
||||
[CommandOption(
|
||||
"filter",
|
||||
Description = "Only include messages that satisfy this filter. "
|
||||
@@ -95,8 +101,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public bool ShouldReuseAssets { get; init; } = false;
|
||||
|
||||
private readonly string? _assetsDirPath;
|
||||
|
||||
[CommandOption(
|
||||
"media-dir",
|
||||
Description = "Download assets to this directory. "
|
||||
@@ -104,10 +108,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public string? AssetsDirPath
|
||||
{
|
||||
get => _assetsDirPath;
|
||||
get;
|
||||
// Handle ~/ in paths on Unix systems
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||
init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null;
|
||||
init => field = value is not null ? Path.GetFullPath(value) : null;
|
||||
}
|
||||
|
||||
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
||||
@@ -136,11 +140,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
|
||||
|
||||
private ChannelExporter? _channelExporter;
|
||||
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord);
|
||||
[field: AllowNull, MaybeNull]
|
||||
protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
|
||||
|
||||
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||
{
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
// Asset reuse can only be enabled if the download assets option is set
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
|
||||
if (ShouldReuseAssets && !ShouldDownloadAssets)
|
||||
@@ -154,13 +160,53 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
throw new CommandException("Option --media-dir cannot be used without --media.");
|
||||
}
|
||||
|
||||
var unwrappedChannels = new List<Channel>(channels);
|
||||
|
||||
// Unwrap threads
|
||||
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
||||
{
|
||||
await console.Output.WriteLineAsync("Fetching threads...");
|
||||
|
||||
var fetchedThreadsCount = 0;
|
||||
await console
|
||||
.CreateStatusTicker()
|
||||
.StartAsync(
|
||||
"...",
|
||||
async ctx =>
|
||||
{
|
||||
await foreach (
|
||||
var thread in Discord.GetChannelThreadsAsync(
|
||||
channels,
|
||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
||||
Before,
|
||||
After,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
unwrappedChannels.Add(thread);
|
||||
|
||||
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
|
||||
|
||||
fetchedThreadsCount++;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Remove forums, as they cannot be exported directly and their constituent threads
|
||||
// have already been fetched.
|
||||
unwrappedChannels.RemoveAll(channel => channel.Kind == ChannelKind.GuildForum);
|
||||
|
||||
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
||||
}
|
||||
|
||||
// Make sure the user does not try to export multiple channels into one file.
|
||||
// Output path must either be a directory or contain template tokens for this to work.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
||||
var isValidOutputPath =
|
||||
// Anything is valid when exporting a single channel
|
||||
channels.Count <= 1
|
||||
unwrappedChannels.Count <= 1
|
||||
// When using template tokens, assume the user knows what they're doing
|
||||
|| OutputPath.Contains('%')
|
||||
// Otherwise, require an existing directory or an unambiguous directory path
|
||||
@@ -177,10 +223,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
}
|
||||
|
||||
// Export
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
|
||||
var warningsByChannel = new ConcurrentDictionary<Channel, string>();
|
||||
|
||||
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
|
||||
await console.Output.WriteLineAsync($"Exporting {unwrappedChannels.Count} channel(s)...");
|
||||
await console
|
||||
.CreateProgressTicker()
|
||||
.HideCompleted(
|
||||
@@ -192,11 +238,11 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
.StartAsync(async ctx =>
|
||||
{
|
||||
await Parallel.ForEachAsync(
|
||||
channels,
|
||||
unwrappedChannels,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
|
||||
CancellationToken = cancellationToken
|
||||
CancellationToken = cancellationToken,
|
||||
},
|
||||
async (channel, innerCancellationToken) =>
|
||||
{
|
||||
@@ -236,6 +282,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
}
|
||||
);
|
||||
}
|
||||
catch (ChannelEmptyException ex)
|
||||
{
|
||||
warningsByChannel[channel] = ex.Message;
|
||||
}
|
||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||
{
|
||||
errorsByChannel[channel] = ex.Message;
|
||||
@@ -248,10 +298,32 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
{
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Successfully exported {channels.Count - errorsByChannel.Count} channel(s)."
|
||||
$"Successfully exported {unwrappedChannels.Count - errorsByChannel.Count} channel(s)."
|
||||
);
|
||||
}
|
||||
|
||||
// Print warnings
|
||||
if (warningsByChannel.Any())
|
||||
{
|
||||
await console.Output.WriteLineAsync();
|
||||
|
||||
using (console.WithForegroundColor(ConsoleColor.Yellow))
|
||||
{
|
||||
await console.Error.WriteLineAsync(
|
||||
"Warnings reported for the following channel(s):"
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var (channel, message) in warningsByChannel)
|
||||
{
|
||||
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
|
||||
using (console.WithForegroundColor(ConsoleColor.Yellow))
|
||||
await console.Error.WriteLineAsync(message);
|
||||
}
|
||||
|
||||
await console.Error.WriteLineAsync();
|
||||
}
|
||||
|
||||
// Print errors
|
||||
if (errorsByChannel.Any())
|
||||
{
|
||||
@@ -259,16 +331,14 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
|
||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||
{
|
||||
await console.Error.WriteLineAsync(
|
||||
$"Failed to export {errorsByChannel.Count} the following channel(s):"
|
||||
);
|
||||
await console.Error.WriteLineAsync("Failed to export the following channel(s):");
|
||||
}
|
||||
|
||||
foreach (var (channel, error) in errorsByChannel)
|
||||
foreach (var (channel, message) in errorsByChannel)
|
||||
{
|
||||
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
|
||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||
await console.Error.WriteLineAsync(error);
|
||||
await console.Error.WriteLineAsync(message);
|
||||
}
|
||||
|
||||
await console.Error.WriteLineAsync();
|
||||
@@ -276,48 +346,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
|
||||
// Fail the command only if ALL channels failed to export.
|
||||
// If only some channels failed to export, it's okay.
|
||||
if (errorsByChannel.Count >= channels.Count)
|
||||
if (errorsByChannel.Count >= unwrappedChannels.Count)
|
||||
throw new CommandException("Export failed.");
|
||||
}
|
||||
|
||||
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Snowflake> channelIds)
|
||||
{
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
await console.Output.WriteLineAsync("Resolving channel(s)...");
|
||||
|
||||
var channels = new List<Channel>();
|
||||
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
||||
|
||||
foreach (var channelId in channelIds)
|
||||
{
|
||||
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
|
||||
|
||||
// Unwrap categories
|
||||
if (channel.IsCategory)
|
||||
{
|
||||
var guildChannels =
|
||||
channelsByGuild.GetValueOrDefault(channel.GuildId)
|
||||
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
||||
|
||||
foreach (var guildChannel in guildChannels)
|
||||
{
|
||||
if (guildChannel.Parent?.Id == channel.Id)
|
||||
channels.Add(guildChannel);
|
||||
}
|
||||
|
||||
// Cache the guild channels to avoid redundant work
|
||||
channelsByGuild[channel.GuildId] = guildChannels;
|
||||
}
|
||||
else
|
||||
{
|
||||
channels.Add(channel);
|
||||
}
|
||||
}
|
||||
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
// Support Ukraine callout
|
||||
|
||||
@@ -5,8 +5,6 @@ using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Discord.Dump;
|
||||
@@ -27,13 +25,6 @@ public class ExportAllCommand : ExportCommandBase
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
public bool IncludeVoiceChannels { get; init; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"include-threads",
|
||||
Description = "Which types of threads should be included.",
|
||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
||||
)]
|
||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
||||
|
||||
[CommandOption(
|
||||
"data-package",
|
||||
Description = "Path to the personal data package (ZIP file) requested from Discord. "
|
||||
@@ -90,46 +81,6 @@ public class ExportAllCommand : ExportCommandBase
|
||||
);
|
||||
|
||||
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
||||
|
||||
// Threads
|
||||
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
||||
{
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Fetching threads for server '{guild.Name}'..."
|
||||
);
|
||||
|
||||
var fetchedThreadsCount = 0;
|
||||
await console
|
||||
.CreateStatusTicker()
|
||||
.StartAsync(
|
||||
"...",
|
||||
async ctx =>
|
||||
{
|
||||
await foreach (
|
||||
var thread in Discord.GetGuildThreadsAsync(
|
||||
guild.Id,
|
||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
||||
Before,
|
||||
After,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
channels.Add(thread);
|
||||
|
||||
ctx.Status(
|
||||
Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'.")
|
||||
);
|
||||
|
||||
fetchedThreadsCount++;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Fetched {fetchedThreadsCount} thread(s)."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pull from the data package
|
||||
@@ -199,10 +150,6 @@ public class ExportAllCommand : ExportCommandBase
|
||||
channels.RemoveAll(c => c.IsGuild);
|
||||
if (!IncludeVoiceChannels)
|
||||
channels.RemoveAll(c => c.IsVoice);
|
||||
if (ThreadInclusionMode == ThreadInclusionMode.None)
|
||||
channels.RemoveAll(c => c.IsThread);
|
||||
if (ThreadInclusionMode != ThreadInclusionMode.All)
|
||||
channels.RemoveAll(c => c is { IsThread: true, IsArchived: true });
|
||||
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
@@ -22,6 +24,40 @@ public class ExportChannelsCommand : ExportCommandBase
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
await base.ExecuteAsync(console);
|
||||
await ExportAsync(console, ChannelIds);
|
||||
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
await console.Output.WriteLineAsync("Resolving channel(s)...");
|
||||
|
||||
var channels = new List<Channel>();
|
||||
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
||||
|
||||
foreach (var channelId in ChannelIds)
|
||||
{
|
||||
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
|
||||
|
||||
// Unwrap categories
|
||||
if (channel.IsCategory)
|
||||
{
|
||||
var guildChannels =
|
||||
channelsByGuild.GetValueOrDefault(channel.GuildId)
|
||||
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
||||
|
||||
foreach (var guildChannel in guildChannels)
|
||||
{
|
||||
if (guildChannel.Parent?.Id == channel.Id)
|
||||
channels.Add(guildChannel);
|
||||
}
|
||||
|
||||
// Cache the guild channels to avoid redundant work
|
||||
channelsByGuild[channel.GuildId] = guildChannels;
|
||||
}
|
||||
else
|
||||
{
|
||||
channels.Add(channel);
|
||||
}
|
||||
}
|
||||
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
@@ -21,13 +19,6 @@ public class ExportGuildCommand : ExportCommandBase
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
public bool IncludeVoiceChannels { get; init; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"include-threads",
|
||||
Description = "Which types of threads should be included.",
|
||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
||||
)]
|
||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
await base.ExecuteAsync(console);
|
||||
@@ -35,7 +26,6 @@ public class ExportGuildCommand : ExportCommandBase
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
var channels = new List<Channel>();
|
||||
|
||||
// Regular channels
|
||||
await console.Output.WriteLineAsync("Fetching channels...");
|
||||
|
||||
var fetchedChannelsCount = 0;
|
||||
@@ -66,40 +56,6 @@ public class ExportGuildCommand : ExportCommandBase
|
||||
|
||||
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
||||
|
||||
// Threads
|
||||
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
||||
{
|
||||
await console.Output.WriteLineAsync("Fetching threads...");
|
||||
|
||||
var fetchedThreadsCount = 0;
|
||||
await console
|
||||
.CreateStatusTicker()
|
||||
.StartAsync(
|
||||
"...",
|
||||
async ctx =>
|
||||
{
|
||||
await foreach (
|
||||
var thread in Discord.GetGuildThreadsAsync(
|
||||
GuildId,
|
||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
||||
Before,
|
||||
After,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
channels.Add(thread);
|
||||
|
||||
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
|
||||
|
||||
fetchedThreadsCount++;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
||||
}
|
||||
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,17 @@ public class GuideCommand : ICommand
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
console.Output.WriteLine("To get the token for your bot:");
|
||||
|
||||
console.Output.WriteLine(
|
||||
" The token is generated during bot creation. If you lost it, generate a new one:"
|
||||
);
|
||||
console.Output.WriteLine(" 1. Go to Discord developer portal");
|
||||
console.Output.WriteLine(" 2. Open your application's settings");
|
||||
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
||||
console.Output.WriteLine(" 4. Under Token click Copy");
|
||||
console.Output.WriteLine(" 4. Under Token click Reset Token");
|
||||
console.Output.WriteLine(" 5. Click Yes, do it! and authenticate to confirm");
|
||||
console.Output.WriteLine(
|
||||
" * Integrations using the previous token will stop working until updated"
|
||||
);
|
||||
console.Output.WriteLine(
|
||||
" * Your bot needs to have the Message Content Intent enabled to read messages"
|
||||
);
|
||||
|
||||
@@ -4,5 +4,5 @@ public enum ThreadInclusionMode
|
||||
{
|
||||
None,
|
||||
Active,
|
||||
All
|
||||
All,
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<NoWarn>$(NoWarn);IL2104</NoWarn>
|
||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
|
||||
<PropertyGroup>
|
||||
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
|
||||
<EnableAotAnalyzer>false</EnableAotAnalyzer>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.3.5" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="CliFx" Version="2.3.6" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.54.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -3,6 +3,8 @@ 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;
|
||||
|
||||
namespace DiscordChatExporter.Cli;
|
||||
|
||||
@@ -22,6 +24,8 @@ public static class Program
|
||||
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>()
|
||||
|
||||
@@ -7,32 +7,35 @@ namespace DiscordChatExporter.Cli.Utils.Extensions;
|
||||
|
||||
internal static class ConsoleExtensions
|
||||
{
|
||||
public static IAnsiConsole CreateAnsiConsole(this IConsole console) =>
|
||||
AnsiConsole.Create(
|
||||
new AnsiConsoleSettings
|
||||
{
|
||||
Ansi = AnsiSupport.Detect,
|
||||
ColorSystem = ColorSystemSupport.Detect,
|
||||
Out = new AnsiConsoleOutput(console.Output)
|
||||
}
|
||||
);
|
||||
|
||||
public static Status CreateStatusTicker(this IConsole console) =>
|
||||
console.CreateAnsiConsole().Status().AutoRefresh(true);
|
||||
|
||||
public static Progress CreateProgressTicker(this IConsole console) =>
|
||||
console
|
||||
.CreateAnsiConsole()
|
||||
.Progress()
|
||||
.AutoClear(false)
|
||||
.AutoRefresh(true)
|
||||
.HideCompleted(false)
|
||||
.Columns(
|
||||
new TaskDescriptionColumn { Alignment = Justify.Left },
|
||||
new ProgressBarColumn(),
|
||||
new PercentageColumn()
|
||||
extension(IConsole console)
|
||||
{
|
||||
public IAnsiConsole CreateAnsiConsole() =>
|
||||
AnsiConsole.Create(
|
||||
new AnsiConsoleSettings
|
||||
{
|
||||
Ansi = AnsiSupport.Detect,
|
||||
ColorSystem = ColorSystemSupport.Detect,
|
||||
Out = new AnsiConsoleOutput(console.Output),
|
||||
}
|
||||
);
|
||||
|
||||
public Status CreateStatusTicker() =>
|
||||
console.CreateAnsiConsole().Status().AutoRefresh(true);
|
||||
|
||||
public Progress CreateProgressTicker() =>
|
||||
console
|
||||
.CreateAnsiConsole()
|
||||
.Progress()
|
||||
.AutoClear(false)
|
||||
.AutoRefresh(true)
|
||||
.HideCompleted(false)
|
||||
.Columns(
|
||||
new TaskDescriptionColumn { Alignment = Justify.Left },
|
||||
new ProgressBarColumn(),
|
||||
new PercentageColumn()
|
||||
);
|
||||
}
|
||||
|
||||
public static async ValueTask StartTaskAsync(
|
||||
this ProgressContext context,
|
||||
string description,
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
||||
// https://discord.com/developers/docs/resources/application#application-object
|
||||
public partial record Application(Snowflake Id, string Name, ApplicationFlags Flags)
|
||||
{
|
||||
public bool IsMessageContentIntentEnabled =>
|
||||
public bool IsMessageContentIntentEnabled { get; } =
|
||||
Flags.HasFlag(ApplicationFlags.GatewayMessageContent)
|
||||
|| Flags.HasFlag(ApplicationFlags.GatewayMessageContentLimited);
|
||||
}
|
||||
|
||||
@@ -16,5 +16,5 @@ public enum ApplicationFlags
|
||||
Embedded = 131072,
|
||||
GatewayMessageContent = 262144,
|
||||
GatewayMessageContentLimited = 524288,
|
||||
ApplicationCommandBadge = 8388608
|
||||
ApplicationCommandBadge = 8388608,
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public partial record Attachment(
|
||||
FileSize FileSize
|
||||
) : IHasId
|
||||
{
|
||||
public string FileExtension => Path.GetExtension(FileName);
|
||||
public string FileExtension { get; } = Path.GetExtension(FileName);
|
||||
|
||||
public bool IsImage =>
|
||||
string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase)
|
||||
@@ -41,7 +41,7 @@ public partial record Attachment(
|
||||
|| string.Equals(FileExtension, ".flac", 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
|
||||
|
||||
@@ -21,21 +21,23 @@ public partial record Channel(
|
||||
Snowflake? LastMessageId
|
||||
) : 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 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
|
||||
is ChannelKind.GuildNewsThread
|
||||
or ChannelKind.GuildPublicThread
|
||||
or ChannelKind.GuildPrivateThread;
|
||||
is ChannelKind.GuildNewsThread
|
||||
or ChannelKind.GuildPublicThread
|
||||
or ChannelKind.GuildPrivateThread;
|
||||
|
||||
public bool IsEmpty => LastMessageId is null;
|
||||
public bool IsEmpty { get; } = LastMessageId is null;
|
||||
|
||||
public IEnumerable<Channel> GetParents()
|
||||
{
|
||||
@@ -67,7 +69,8 @@ public partial record Channel
|
||||
var guildId =
|
||||
json.GetPropertyOrNull("guild_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse) ?? Guild.DirectMessages.Id;
|
||||
?.Pipe(Snowflake.Parse)
|
||||
?? Guild.DirectMessages.Id;
|
||||
|
||||
var name =
|
||||
// Guild channel
|
||||
@@ -76,6 +79,7 @@ public partial record Channel
|
||||
?? json.GetPropertyOrNull("recipients")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(User.Parse)
|
||||
.OrderBy(u => u.Id)
|
||||
.Select(u => u.DisplayName)
|
||||
.Pipe(s => string.Join(", ", s))
|
||||
// Fallback
|
||||
@@ -93,7 +97,8 @@ public partial record Channel
|
||||
var isArchived =
|
||||
json.GetPropertyOrNull("thread_metadata")
|
||||
?.GetPropertyOrNull("archived")
|
||||
?.GetBooleanOrNull() ?? false;
|
||||
?.GetBooleanOrNull()
|
||||
?? false;
|
||||
|
||||
var lastMessageId = json.GetPropertyOrNull("last_message_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
|
||||
@@ -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,
|
||||
GuildStageVoice = 13,
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,11 @@ public partial record Embed
|
||||
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
||||
|
||||
var kind =
|
||||
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>()
|
||||
json.GetPropertyOrNull("type")
|
||||
?.GetStringOrNull()
|
||||
?.Pipe(s =>
|
||||
Enum.TryParse<EmbedKind>(s, true, out var result) ? result : (EmbedKind?)null
|
||||
)
|
||||
?? EmbedKind.Rich;
|
||||
|
||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||
@@ -63,7 +67,8 @@ public partial record Embed
|
||||
json.GetPropertyOrNull("fields")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(EmbedField.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||
|
||||
@@ -78,7 +83,8 @@ public partial record Embed
|
||||
json.GetPropertyOrNull("image")
|
||||
?.Pipe(EmbedImage.Parse)
|
||||
.ToSingletonEnumerable()
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@ public enum EmbedKind
|
||||
Image,
|
||||
Video,
|
||||
Gifv,
|
||||
Link
|
||||
Link,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
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
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
||||
{
|
||||
public string Url => $"https://www.youtube.com/embed/{VideoId}";
|
||||
public string Url { get; } = $"https://www.youtube.com/watch?v={VideoId}";
|
||||
|
||||
// Using hqdefault.jpg which is guaranteed to exist for all YouTube videos
|
||||
public string ThumbnailUrl { get; } = $"https://i.ytimg.com/vi/{VideoId}/hqdefault.jpg";
|
||||
}
|
||||
|
||||
public partial record YouTubeVideoEmbedProjection
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
@@ -13,29 +12,22 @@ public partial record Emoji(
|
||||
Snowflake? Id,
|
||||
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
|
||||
string Name,
|
||||
bool IsAnimated,
|
||||
string ImageUrl
|
||||
bool IsAnimated
|
||||
)
|
||||
{
|
||||
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)
|
||||
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 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)
|
||||
{
|
||||
var id = json.GetPropertyOrNull("id")
|
||||
@@ -47,8 +39,7 @@ public partial record Emoji
|
||||
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji";
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
||||
// https://discord.com/developers/docs/resources/guild#guild-object
|
||||
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
|
||||
@@ -25,7 +25,8 @@ public partial record Guild
|
||||
var iconUrl =
|
||||
json.GetPropertyOrNull("icon")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? ImageCdn.GetFallbackUserAvatarUrl();
|
||||
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h))
|
||||
?? ImageCdn.GetFallbackUserAvatarUrl();
|
||||
|
||||
return new Guild(id, name, iconUrl);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public partial record Member(
|
||||
IReadOnlyList<Snowflake> RoleIds
|
||||
) : IHasId
|
||||
{
|
||||
public Snowflake Id => User.Id;
|
||||
public Snowflake Id { get; } = User.Id;
|
||||
}
|
||||
|
||||
public partial record Member
|
||||
@@ -32,7 +32,8 @@ public partial record Member
|
||||
?.EnumerateArray()
|
||||
.Select(j => j.GetNonWhiteSpaceString())
|
||||
.Select(Snowflake.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var avatarUrl = guildId is not null
|
||||
? json.GetPropertyOrNull("avatar")
|
||||
|
||||
@@ -30,15 +30,15 @@ public partial record Message(
|
||||
Interaction? Interaction
|
||||
) : IHasId
|
||||
{
|
||||
public bool IsSystemNotification =>
|
||||
public bool IsSystemNotification { get; } =
|
||||
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
|
||||
public bool IsReplyLike => IsReply || Interaction is not null;
|
||||
|
||||
public bool IsEmpty =>
|
||||
public bool IsEmpty { get; } =
|
||||
string.IsNullOrWhiteSpace(Content)
|
||||
&& !Attachments.Any()
|
||||
&& !Embeds.Any()
|
||||
@@ -143,7 +143,8 @@ public partial record Message
|
||||
json.GetPropertyOrNull("attachments")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Attachment.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var embeds = NormalizeEmbeds(
|
||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||
@@ -154,13 +155,15 @@ public partial record Message
|
||||
json.GetPropertyOrNull("sticker_items")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Sticker.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var reactions =
|
||||
json.GetPropertyOrNull("reactions")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Reaction.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var mentionedUsers =
|
||||
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
||||
|
||||
@@ -14,5 +14,5 @@ public enum MessageFlags
|
||||
Urgent = 16,
|
||||
HasThread = 32,
|
||||
Ephemeral = 64,
|
||||
Loading = 128
|
||||
Loading = 128,
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@ public enum MessageKind
|
||||
ChannelPinnedMessage = 6,
|
||||
GuildMemberJoin = 7,
|
||||
ThreadCreated = 18,
|
||||
Reply = 19
|
||||
Reply = 19,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
||||
// https://discord.com/developers/docs/resources/sticker#sticker-resource
|
||||
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
|
||||
@@ -28,7 +28,7 @@ public partial record Sticker
|
||||
StickerFormat.Apng => "png",
|
||||
StickerFormat.Lottie => "json",
|
||||
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,
|
||||
Apng = 2,
|
||||
Lottie = 3,
|
||||
Gif = 4
|
||||
Gif = 4,
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public partial record User(
|
||||
string AvatarUrl
|
||||
) : IHasId
|
||||
{
|
||||
public string DiscriminatorFormatted =>
|
||||
public string DiscriminatorFormatted { get; } =
|
||||
Discriminator is not null ? $"{Discriminator:0000}" : "0000";
|
||||
|
||||
// This effectively represents the user's true identity.
|
||||
|
||||
@@ -18,7 +18,10 @@ using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord;
|
||||
|
||||
public class DiscordClient(string token)
|
||||
public class DiscordClient(
|
||||
string token,
|
||||
RateLimitPreference rateLimitPreference = RateLimitPreference.RespectAll
|
||||
)
|
||||
{
|
||||
private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute);
|
||||
private TokenKind? _resolvedTokenKind;
|
||||
@@ -47,33 +50,41 @@ public class DiscordClient(string token)
|
||||
innerCancellationToken
|
||||
);
|
||||
|
||||
// If this was the last request available before hitting the rate limit,
|
||||
// wait out the reset time so that future requests can succeed.
|
||||
// This may add an unnecessary delay in case the user doesn't intend to
|
||||
// make any more requests, but implementing a smarter solution would
|
||||
// require properly keeping track of Discord's global/per-route/per-resource
|
||||
// rate limits and that's just way too much effort.
|
||||
// https://discord.com/developers/docs/topics/rate-limits
|
||||
var remainingRequestCount = response
|
||||
.Headers.TryGetValue("X-RateLimit-Remaining")
|
||||
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
|
||||
|
||||
var resetAfterDelay = response
|
||||
.Headers.TryGetValue("X-RateLimit-Reset-After")
|
||||
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
|
||||
.Pipe(TimeSpan.FromSeconds);
|
||||
|
||||
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
|
||||
// Discord has advisory rate limits (communicated via response headers), but they are typically
|
||||
// way stricter than the actual rate limits enforced by the server.
|
||||
// The user may choose to ignore the advisory rate limits and only retry on hard rate limits,
|
||||
// if they want to prioritize speed over compliance (and safety of their account/bot).
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1021
|
||||
if (rateLimitPreference.IsRespectedFor(tokenKind))
|
||||
{
|
||||
var delay =
|
||||
// Adding a small buffer to the reset time reduces the chance of getting
|
||||
// rate limited again, because it allows for more requests to be released.
|
||||
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
|
||||
// Sometimes Discord returns an absurdly high value for the reset time, which
|
||||
// is not actually enforced by the server. So we cap it at a reasonable value.
|
||||
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
|
||||
var remainingRequestCount = response
|
||||
.Headers.TryGetValue("X-RateLimit-Remaining")
|
||||
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
|
||||
|
||||
await Task.Delay(delay, innerCancellationToken);
|
||||
var resetAfterDelay = response
|
||||
.Headers.TryGetValue("X-RateLimit-Reset-After")
|
||||
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
|
||||
.Pipe(TimeSpan.FromSeconds);
|
||||
|
||||
// If this was the last request available before hitting the rate limit,
|
||||
// wait out the reset time so that future requests can succeed.
|
||||
// This may add an unnecessary delay in case the user doesn't intend to
|
||||
// make any more requests, but implementing a smarter solution would
|
||||
// require properly keeping track of Discord's global/per-route/per-resource
|
||||
// rate limits and that's just way too much effort.
|
||||
// https://discord.com/developers/docs/topics/rate-limits
|
||||
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
|
||||
{
|
||||
var delay =
|
||||
// Adding a small buffer to the reset time reduces the chance of getting
|
||||
// rate limited again, because it allows for more requests to be released.
|
||||
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
|
||||
// Sometimes Discord returns an absurdly high value for the reset time, which
|
||||
// is not actually enforced by the server. So we cap it at a reasonable value.
|
||||
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
|
||||
|
||||
await Task.Delay(delay, innerCancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -133,35 +144,31 @@ public class DiscordClient(string token)
|
||||
{
|
||||
throw response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized
|
||||
=> throw new DiscordChatExporterException(
|
||||
"Authentication token is invalid.",
|
||||
true
|
||||
),
|
||||
HttpStatusCode.Unauthorized => throw new DiscordChatExporterException(
|
||||
"Authentication token is invalid.",
|
||||
true
|
||||
),
|
||||
|
||||
HttpStatusCode.Forbidden
|
||||
=> throw new DiscordChatExporterException(
|
||||
$"Request to '{url}' failed: forbidden."
|
||||
),
|
||||
HttpStatusCode.Forbidden => throw new DiscordChatExporterException(
|
||||
$"Request to '{url}' failed: forbidden."
|
||||
),
|
||||
|
||||
HttpStatusCode.NotFound
|
||||
=> throw new DiscordChatExporterException(
|
||||
$"Request to '{url}' failed: not found."
|
||||
),
|
||||
HttpStatusCode.NotFound => throw new DiscordChatExporterException(
|
||||
$"Request to '{url}' failed: not found."
|
||||
),
|
||||
|
||||
_
|
||||
=> throw new DiscordChatExporterException(
|
||||
$"""
|
||||
Request to '{url}' failed: {response
|
||||
.StatusCode.ToString()
|
||||
.ToSpaceSeparatedWords()
|
||||
.ToLowerInvariant()}.
|
||||
Response content: {await response.Content.ReadAsStringAsync(
|
||||
cancellationToken
|
||||
)}
|
||||
""",
|
||||
true
|
||||
)
|
||||
_ => throw new DiscordChatExporterException(
|
||||
$"""
|
||||
Request to '{url}' failed: {response
|
||||
.StatusCode.ToString()
|
||||
.ToSpaceSeparatedWords()
|
||||
.ToLowerInvariant()}.
|
||||
Response content: {await response.Content.ReadAsStringAsync(
|
||||
cancellationToken
|
||||
)}
|
||||
""",
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -298,184 +305,19 @@ public class DiscordClient(string token)
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
yield break;
|
||||
|
||||
var channels = (await GetGuildChannelsAsync(guildId, cancellationToken))
|
||||
// Categories cannot have threads
|
||||
.Where(c => !c.IsCategory)
|
||||
// Voice channels cannot have threads
|
||||
.Where(c => !c.IsVoice)
|
||||
// Empty channels cannot have threads
|
||||
.Where(c => !c.IsEmpty)
|
||||
// If the 'before' boundary is specified, skip channels that don't have messages
|
||||
// for that range, because thread-start event should always be accompanied by a message.
|
||||
// Note that we don't perform a similar check for the 'after' boundary, because
|
||||
// threads may have messages in range, even if the parent channel doesn't.
|
||||
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
||||
.ToArray();
|
||||
var channels = await GetGuildChannelsAsync(guildId, cancellationToken);
|
||||
|
||||
// User accounts can only fetch threads using the search endpoint
|
||||
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
||||
foreach (
|
||||
var channel in await GetChannelThreadsAsync(
|
||||
channels,
|
||||
includeArchived,
|
||||
before,
|
||||
after,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
// Active threads
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
var currentOffset = 0;
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channel.Id}/threads/search")
|
||||
.SetQueryParameter("sort_by", "last_message_time")
|
||||
.SetQueryParameter("sort_order", "desc")
|
||||
.SetQueryParameter("archived", "false")
|
||||
.SetQueryParameter("offset", currentOffset.ToString())
|
||||
.Build();
|
||||
|
||||
// Can be null on channels that the user cannot access or channels without threads
|
||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||
if (response is null)
|
||||
break;
|
||||
|
||||
var breakOuter = false;
|
||||
|
||||
foreach (
|
||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
||||
)
|
||||
{
|
||||
var thread = Channel.Parse(threadJson, channel);
|
||||
|
||||
// If the 'after' boundary is specified, we can break early,
|
||||
// because threads are sorted by last message time.
|
||||
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
||||
{
|
||||
breakOuter = true;
|
||||
break;
|
||||
}
|
||||
|
||||
yield return thread;
|
||||
currentOffset++;
|
||||
}
|
||||
|
||||
if (breakOuter)
|
||||
break;
|
||||
|
||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Archived threads
|
||||
if (includeArchived)
|
||||
{
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
var currentOffset = 0;
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channel.Id}/threads/search")
|
||||
.SetQueryParameter("sort_by", "last_message_time")
|
||||
.SetQueryParameter("sort_order", "desc")
|
||||
.SetQueryParameter("archived", "true")
|
||||
.SetQueryParameter("offset", currentOffset.ToString())
|
||||
.Build();
|
||||
|
||||
// Can be null on channels that the user cannot access or channels without threads
|
||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||
if (response is null)
|
||||
break;
|
||||
|
||||
var breakOuter = false;
|
||||
|
||||
foreach (
|
||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
||||
)
|
||||
{
|
||||
var thread = Channel.Parse(threadJson, channel);
|
||||
|
||||
// If the 'after' boundary is specified, we can break early,
|
||||
// because threads are sorted by last message time.
|
||||
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
||||
{
|
||||
breakOuter = true;
|
||||
break;
|
||||
}
|
||||
|
||||
yield return thread;
|
||||
currentOffset++;
|
||||
}
|
||||
|
||||
if (breakOuter)
|
||||
break;
|
||||
|
||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bot accounts can only fetch threads using the threads endpoint
|
||||
else
|
||||
{
|
||||
// Active threads
|
||||
{
|
||||
var parentsById = channels.ToDictionary(c => c.Id);
|
||||
|
||||
var response = await GetJsonResponseAsync(
|
||||
$"guilds/{guildId}/threads/active",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
||||
{
|
||||
var parent = threadJson
|
||||
.GetPropertyOrNull("parent_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse)
|
||||
.Pipe(parentsById.GetValueOrDefault);
|
||||
|
||||
yield return Channel.Parse(threadJson, parent);
|
||||
}
|
||||
}
|
||||
|
||||
// Archived threads
|
||||
if (includeArchived)
|
||||
{
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
// Public archived threads
|
||||
{
|
||||
// Can be null on certain channels
|
||||
var response = await TryGetJsonResponseAsync(
|
||||
$"channels/{channel.Id}/threads/archived/public",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (response is null)
|
||||
continue;
|
||||
|
||||
foreach (
|
||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
||||
)
|
||||
yield return Channel.Parse(threadJson, channel);
|
||||
}
|
||||
|
||||
// Private archived threads
|
||||
{
|
||||
// Can be null on certain channels
|
||||
var response = await TryGetJsonResponseAsync(
|
||||
$"channels/{channel.Id}/threads/archived/private",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (response is null)
|
||||
continue;
|
||||
|
||||
foreach (
|
||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
||||
)
|
||||
yield return Channel.Parse(threadJson, channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
yield return channel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,6 +388,163 @@ public class DiscordClient(string token)
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Channel> GetChannelThreadsAsync(
|
||||
IReadOnlyList<Channel> channels,
|
||||
bool includeArchived = false,
|
||||
Snowflake? before = null,
|
||||
Snowflake? after = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var filteredChannels = channels
|
||||
// Categories cannot have threads
|
||||
.Where(c => !c.IsCategory)
|
||||
// Voice channels cannot have threads
|
||||
.Where(c => !c.IsVoice)
|
||||
// Empty channels cannot have threads
|
||||
.Where(c => !c.IsEmpty)
|
||||
// If the 'before' boundary is specified, skip channels that don't have messages
|
||||
// for that range, because thread-start event should always be accompanied by a message.
|
||||
// Note that we don't perform a similar check for the 'after' boundary, because
|
||||
// threads may have messages in range, even if the parent channel doesn't.
|
||||
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
||||
.ToArray();
|
||||
|
||||
// User accounts can only fetch threads using the search endpoint
|
||||
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
||||
{
|
||||
foreach (var channel in filteredChannels)
|
||||
{
|
||||
// Either include both active and archived threads, or only active threads
|
||||
foreach (
|
||||
var isArchived in includeArchived ? new[] { false, true } : new[] { false }
|
||||
)
|
||||
{
|
||||
// Offset is just the index of the last thread in the previous batch
|
||||
var currentOffset = 0;
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channel.Id}/threads/search")
|
||||
.SetQueryParameter("sort_by", "last_message_time")
|
||||
.SetQueryParameter("sort_order", "desc")
|
||||
.SetQueryParameter("archived", isArchived.ToString().ToLowerInvariant())
|
||||
.SetQueryParameter("offset", currentOffset.ToString())
|
||||
.Build();
|
||||
|
||||
// Can be null on channels that the user cannot access or channels without threads
|
||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||
if (response is null)
|
||||
break;
|
||||
|
||||
var breakOuter = false;
|
||||
|
||||
foreach (
|
||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
||||
)
|
||||
{
|
||||
var thread = Channel.Parse(threadJson, channel);
|
||||
|
||||
// If the 'after' boundary is specified, we can break early,
|
||||
// because threads are sorted by last message timestamp.
|
||||
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
||||
{
|
||||
breakOuter = true;
|
||||
break;
|
||||
}
|
||||
|
||||
yield return thread;
|
||||
currentOffset++;
|
||||
}
|
||||
|
||||
if (breakOuter)
|
||||
break;
|
||||
|
||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bot accounts can only fetch threads using the threads endpoint
|
||||
else
|
||||
{
|
||||
var guilds = new HashSet<Snowflake>();
|
||||
foreach (var channel in filteredChannels)
|
||||
guilds.Add(channel.GuildId);
|
||||
|
||||
// Active threads
|
||||
foreach (var guildId in guilds)
|
||||
{
|
||||
var parentsById = filteredChannels.ToDictionary(c => c.Id);
|
||||
|
||||
var response = await GetJsonResponseAsync(
|
||||
$"guilds/{guildId}/threads/active",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
||||
{
|
||||
var parent = threadJson
|
||||
.GetPropertyOrNull("parent_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse)
|
||||
.Pipe(parentsById.GetValueOrDefault);
|
||||
|
||||
if (filteredChannels.Contains(parent))
|
||||
yield return Channel.Parse(threadJson, parent);
|
||||
}
|
||||
}
|
||||
|
||||
// Archived threads
|
||||
if (includeArchived)
|
||||
{
|
||||
foreach (var channel in filteredChannels)
|
||||
{
|
||||
foreach (var archiveType in new[] { "public", "private" })
|
||||
{
|
||||
// This endpoint parameter expects an ISO8601 timestamp, not a snowflake
|
||||
var currentBefore = before
|
||||
?.ToDate()
|
||||
.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
while (true)
|
||||
{
|
||||
// Threads are sorted by archive timestamp, not by last message timestamp
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channel.Id}/threads/archived/{archiveType}")
|
||||
.SetQueryParameter("before", currentBefore)
|
||||
.Build();
|
||||
|
||||
// Can be null on certain channels
|
||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||
if (response is null)
|
||||
break;
|
||||
|
||||
foreach (
|
||||
var threadJson in response
|
||||
.Value.GetProperty("threads")
|
||||
.EnumerateArray()
|
||||
)
|
||||
{
|
||||
var thread = Channel.Parse(threadJson, channel);
|
||||
yield return thread;
|
||||
|
||||
currentBefore = threadJson
|
||||
.GetProperty("thread_metadata")
|
||||
.GetProperty("archive_timestamp")
|
||||
.GetString();
|
||||
}
|
||||
|
||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? before = null,
|
||||
@@ -692,9 +691,7 @@ public class DiscordClient(string token)
|
||||
count++;
|
||||
}
|
||||
|
||||
// Each batch can contain up to 100 users.
|
||||
// If we got fewer, then it's definitely the last batch.
|
||||
if (count < 100)
|
||||
if (count <= 0)
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -40,17 +41,19 @@ public partial class DataDump
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(zipFilePath);
|
||||
await using var archive = await ZipFile.OpenReadAsync(zipFilePath, cancellationToken);
|
||||
|
||||
var entry = archive.GetEntry("messages/index.json");
|
||||
if (entry is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
// Use case-insensitive search to accommodate for different data dump versions
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1459
|
||||
var entry =
|
||||
archive.Entries.FirstOrDefault(e =>
|
||||
e.FullName.Equals("messages/index.json", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
?? throw new InvalidOperationException(
|
||||
"Failed to locate the channel index inside the data package."
|
||||
);
|
||||
}
|
||||
|
||||
await using var stream = entry.Open();
|
||||
await using var stream = await entry.OpenAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
|
||||
|
||||
return Parse(document.RootElement);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord;
|
||||
|
||||
[Flags]
|
||||
public enum RateLimitPreference
|
||||
{
|
||||
IgnoreAll = 0,
|
||||
RespectForUserTokens = 0b1,
|
||||
RespectForBotTokens = 0b10,
|
||||
RespectAll = RespectForUserTokens | RespectForBotTokens,
|
||||
}
|
||||
|
||||
public static class RateLimitPreferenceExtensions
|
||||
{
|
||||
extension(RateLimitPreference rateLimitPreference)
|
||||
{
|
||||
internal bool IsRespectedFor(TokenKind tokenKind) =>
|
||||
tokenKind switch
|
||||
{
|
||||
TokenKind.User => (rateLimitPreference & RateLimitPreference.RespectForUserTokens)
|
||||
!= 0,
|
||||
TokenKind.Bot => (rateLimitPreference & RateLimitPreference.RespectForBotTokens)
|
||||
!= 0,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(tokenKind)),
|
||||
};
|
||||
|
||||
public string GetDisplayName() =>
|
||||
rateLimitPreference switch
|
||||
{
|
||||
RateLimitPreference.IgnoreAll => "Always ignore",
|
||||
RateLimitPreference.RespectForUserTokens => "Respect for user tokens",
|
||||
RateLimitPreference.RespectForBotTokens => "Respect for bot tokens",
|
||||
RateLimitPreference.RespectAll => "Always respect",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(rateLimitPreference)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,5 @@
|
||||
public enum TokenKind
|
||||
{
|
||||
User,
|
||||
Bot
|
||||
Bot,
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncKeyedLock" Version="6.4.2" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageReference Include="AsyncKeyedLock" Version="8.0.1" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Polly" Version="8.4.0" />
|
||||
<PackageReference Include="RazorBlade" Version="0.6.0" />
|
||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.16.0" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.3.16" />
|
||||
<PackageReference Include="Polly" Version="8.6.5" />
|
||||
<PackageReference Include="RazorBlade" Version="0.11.0" />
|
||||
<PackageReference Include="Superpower" Version="3.1.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.5.6" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace DiscordChatExporter.Core.Exceptions;
|
||||
|
||||
public class ChannelEmptyException(string message) : DiscordChatExporterException(message);
|
||||
@@ -27,45 +27,43 @@ public class ChannelExporter(DiscordClient discord)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the channel is empty
|
||||
if (request.Channel.IsEmpty)
|
||||
{
|
||||
throw new DiscordChatExporterException(
|
||||
$"Channel '{request.Channel.Name}' "
|
||||
+ $"of guild '{request.Guild.Name}' "
|
||||
+ $"does not contain any messages."
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the 'after' boundary is valid
|
||||
if (request.After is not null && !request.Channel.MayHaveMessagesAfter(request.After.Value))
|
||||
{
|
||||
throw new DiscordChatExporterException(
|
||||
$"Channel '{request.Channel.Name}' "
|
||||
+ $"of guild '{request.Guild.Name}' "
|
||||
+ $"does not contain any messages within the specified period."
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the 'before' boundary is valid
|
||||
if (
|
||||
request.Before is not null
|
||||
&& !request.Channel.MayHaveMessagesBefore(request.Before.Value)
|
||||
)
|
||||
{
|
||||
throw new DiscordChatExporterException(
|
||||
$"Channel '{request.Channel.Name}' "
|
||||
+ $"of guild '{request.Guild.Name}' "
|
||||
+ $"does not contain any messages within the specified period."
|
||||
);
|
||||
}
|
||||
|
||||
// Build context
|
||||
var context = new ExportContext(discord, request);
|
||||
await context.PopulateChannelsAndRolesAsync(cancellationToken);
|
||||
|
||||
// Export messages
|
||||
// Initialize the exporter before further checks to ensure the file is created even if
|
||||
// an exception is thrown after this point.
|
||||
await using var messageExporter = new MessageExporter(context);
|
||||
|
||||
// Check if the channel is empty
|
||||
if (request.Channel.IsEmpty)
|
||||
{
|
||||
throw new ChannelEmptyException(
|
||||
$"Channel '{request.Channel.Name}' "
|
||||
+ $"of guild '{request.Guild.Name}' "
|
||||
+ $"does not contain any messages; an empty file will be created."
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the 'before' and 'after' boundaries are valid
|
||||
if (
|
||||
(
|
||||
request.Before is not null
|
||||
&& !request.Channel.MayHaveMessagesBefore(request.Before.Value)
|
||||
)
|
||||
|| (
|
||||
request.After is not null
|
||||
&& !request.Channel.MayHaveMessagesAfter(request.After.Value)
|
||||
)
|
||||
)
|
||||
{
|
||||
throw new ChannelEmptyException(
|
||||
$"Channel '{request.Channel.Name}' "
|
||||
+ $"of guild '{request.Guild.Name}' "
|
||||
+ $"does not contain any messages within the specified period; an empty file will be created."
|
||||
);
|
||||
}
|
||||
|
||||
await foreach (
|
||||
var message in discord.GetMessagesAsync(
|
||||
request.Channel.Id,
|
||||
@@ -98,15 +96,5 @@ public class ChannelExporter(DiscordClient discord)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Throw if no messages were exported
|
||||
if (messageExporter.MessagesExported <= 0)
|
||||
{
|
||||
throw new DiscordChatExporterException(
|
||||
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
|
||||
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
|
||||
+ $"does not contain any matching messages within the specified period."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -16,12 +15,7 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
|
||||
internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
{
|
||||
private static readonly AsyncKeyedLocker<string> Locker =
|
||||
new(o =>
|
||||
{
|
||||
o.PoolSize = 20;
|
||||
o.PoolInitialFill = 1;
|
||||
});
|
||||
private static readonly AsyncKeyedLocker<string> Locker = new();
|
||||
|
||||
// File paths of the previously downloaded assets
|
||||
private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal);
|
||||
@@ -50,38 +44,8 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
{
|
||||
// Download the file
|
||||
using var response = await Http.Client.GetAsync(url, innerCancellationToken);
|
||||
await using (var output = File.Create(filePath))
|
||||
await response.Content.CopyToAsync(output, innerCancellationToken);
|
||||
|
||||
// Try to set the file date according to the last-modified header
|
||||
try
|
||||
{
|
||||
var lastModified = response
|
||||
.Content.Headers.TryGetValue("Last-Modified")
|
||||
?.Pipe(s =>
|
||||
DateTimeOffset.TryParse(
|
||||
s,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var instant
|
||||
)
|
||||
? instant
|
||||
: (DateTimeOffset?)null
|
||||
);
|
||||
|
||||
if (lastModified is not null)
|
||||
{
|
||||
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// This can apparently fail for some reason.
|
||||
// Updating the file date is not a critical task, so we'll just ignore exceptions thrown here.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
||||
}
|
||||
await using var output = File.Create(filePath);
|
||||
await response.Content.CopyToAsync(output, innerCancellationToken);
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
@@ -111,7 +75,7 @@ internal partial class ExportAssetDownloader
|
||||
|
||||
return SHA256
|
||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||
.ToHex()
|
||||
.Pipe(Convert.ToHexStringLower)
|
||||
// 5 chars ought to be enough for anybody
|
||||
.Truncate(5);
|
||||
}
|
||||
@@ -139,7 +103,7 @@ internal partial class ExportAssetDownloader
|
||||
fileExtension = "";
|
||||
}
|
||||
|
||||
return PathEx.EscapeFileName(
|
||||
return Path.EscapeFileName(
|
||||
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting;
|
||||
@@ -18,8 +19,10 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
private readonly Dictionary<Snowflake, Channel> _channelsById = new();
|
||||
private readonly Dictionary<Snowflake, Role> _rolesById = new();
|
||||
|
||||
private readonly ExportAssetDownloader _assetDownloader =
|
||||
new(request.AssetsDirPath, request.ShouldReuseAssets);
|
||||
private readonly ExportAssetDownloader _assetDownloader = new(
|
||||
request.AssetsDirPath,
|
||||
request.ShouldReuseAssets
|
||||
);
|
||||
|
||||
public DiscordClient Discord { get; } = discord;
|
||||
|
||||
@@ -96,7 +99,8 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
?.RoleIds.Select(TryGetRole)
|
||||
.WhereNotNull()
|
||||
.OrderByDescending(r => r.Position)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
public Color? TryGetUserColor(Snowflake id) =>
|
||||
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
|
||||
@@ -115,7 +119,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
|
||||
|
||||
// Prefer the relative path so that the export package can be copied around without breaking references.
|
||||
// However, if the assets directory lies outside of the export directory, use the absolute path instead.
|
||||
// However, if the assets directory lies outside the export directory, use the absolute path instead.
|
||||
var shouldUseAbsoluteFilePath =
|
||||
relativeFilePath.StartsWith(
|
||||
".." + Path.DirectorySeparatorChar,
|
||||
@@ -130,14 +134,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
|
||||
// For HTML, the path needs to be properly formatted
|
||||
if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
|
||||
{
|
||||
// Format the path into a valid file URI
|
||||
var href = new Uri(new Uri("file:///"), optimalFilePath).ToString();
|
||||
|
||||
// File schema does not support relative paths, so strip it if that's the case
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1155
|
||||
return shouldUseAbsoluteFilePath ? href : href[8..];
|
||||
}
|
||||
return Url.EncodeFilePath(optimalFilePath);
|
||||
|
||||
return optimalFilePath;
|
||||
}
|
||||
|
||||
@@ -8,30 +8,33 @@ public enum ExportFormat
|
||||
HtmlDark,
|
||||
HtmlLight,
|
||||
Csv,
|
||||
Json
|
||||
Json,
|
||||
}
|
||||
|
||||
public static class ExportFormatExtensions
|
||||
{
|
||||
public static string GetFileExtension(this ExportFormat format) =>
|
||||
format switch
|
||||
{
|
||||
ExportFormat.PlainText => "txt",
|
||||
ExportFormat.HtmlDark => "html",
|
||||
ExportFormat.HtmlLight => "html",
|
||||
ExportFormat.Csv => "csv",
|
||||
ExportFormat.Json => "json",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
||||
};
|
||||
extension(ExportFormat format)
|
||||
{
|
||||
public string GetFileExtension() =>
|
||||
format switch
|
||||
{
|
||||
ExportFormat.PlainText => "txt",
|
||||
ExportFormat.HtmlDark => "html",
|
||||
ExportFormat.HtmlLight => "html",
|
||||
ExportFormat.Csv => "csv",
|
||||
ExportFormat.Json => "json",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
|
||||
public static string GetDisplayName(this ExportFormat format) =>
|
||||
format switch
|
||||
{
|
||||
ExportFormat.PlainText => "TXT",
|
||||
ExportFormat.HtmlDark => "HTML (Dark)",
|
||||
ExportFormat.HtmlLight => "HTML (Light)",
|
||||
ExportFormat.Csv => "CSV",
|
||||
ExportFormat.Json => "JSON",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
||||
};
|
||||
public string GetDisplayName() =>
|
||||
format switch
|
||||
{
|
||||
ExportFormat.PlainText => "TXT",
|
||||
ExportFormat.HtmlDark => "HTML (Dark)",
|
||||
ExportFormat.HtmlLight => "HTML (Light)",
|
||||
ExportFormat.Csv => "CSV",
|
||||
ExportFormat.Json => "JSON",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting;
|
||||
@@ -145,7 +144,7 @@ public partial class ExportRequest
|
||||
// File extension
|
||||
buffer.Append('.').Append(format.GetFileExtension());
|
||||
|
||||
return PathEx.EscapeFileName(buffer.ToString());
|
||||
return Path.EscapeFileName(buffer.ToString());
|
||||
}
|
||||
|
||||
private static string FormatPath(
|
||||
@@ -159,7 +158,7 @@ public partial class ExportRequest
|
||||
path,
|
||||
"%.",
|
||||
m =>
|
||||
PathEx.EscapeFileName(
|
||||
Path.EscapeFileName(
|
||||
m.Value switch
|
||||
{
|
||||
"%g" => guild.Id.ToString(),
|
||||
@@ -172,24 +171,22 @@ public partial class ExportRequest
|
||||
"%C" => channel.Name,
|
||||
|
||||
"%p" => channel.Position?.ToString(CultureInfo.InvariantCulture) ?? "0",
|
||||
"%P"
|
||||
=> channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture)
|
||||
?? "0",
|
||||
"%P" => channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture)
|
||||
?? "0",
|
||||
|
||||
"%a"
|
||||
=> after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
?? "",
|
||||
"%b"
|
||||
=> before?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
?? "",
|
||||
"%d"
|
||||
=> DateTimeOffset.Now.ToString(
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture
|
||||
),
|
||||
"%a" => after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
?? "",
|
||||
"%b" => before
|
||||
?.ToDate()
|
||||
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
?? "",
|
||||
"%d" => DateTimeOffset.Now.ToString(
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture
|
||||
),
|
||||
|
||||
"%%" => "%",
|
||||
_ => m.Value
|
||||
_ => m.Value,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
internal enum BinaryExpressionKind
|
||||
{
|
||||
Or,
|
||||
And
|
||||
And,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@ internal class BinaryExpressionMessageFilter(
|
||||
{
|
||||
BinaryExpressionKind.Or => 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.Sound => message.Attachments.Any(file => file.IsAudio),
|
||||
MessageContentMatchKind.Pin => message.IsPinned,
|
||||
MessageContentMatchKind.Invite
|
||||
=> MarkdownParser
|
||||
.ExtractLinks(message.Content)
|
||||
.Select(l => l.Url)
|
||||
.Select(Invite.TryGetCodeFromUrl)
|
||||
.Any(c => !string.IsNullOrWhiteSpace(c)),
|
||||
_
|
||||
=> throw new InvalidOperationException(
|
||||
$"Unknown message content match kind '{kind}'."
|
||||
)
|
||||
MessageContentMatchKind.Invite => MarkdownParser
|
||||
.ExtractLinks(message.Content)
|
||||
.Select(l => l.Url)
|
||||
.Select(Invite.TryGetCodeFromUrl)
|
||||
.Any(c => !string.IsNullOrWhiteSpace(c)),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unknown message content match kind '{kind}'."
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@ internal enum MessageContentMatchKind
|
||||
Image,
|
||||
Sound,
|
||||
Pin,
|
||||
Invite
|
||||
Invite,
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ internal static class FilterGrammar
|
||||
op switch
|
||||
{
|
||||
'|' => 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.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Markdown;
|
||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
@@ -34,58 +33,51 @@ internal partial class HtmlMarkdownVisitor(
|
||||
{
|
||||
var (openingTag, closingTag) = formatting.Kind switch
|
||||
{
|
||||
FormattingKind.Bold
|
||||
=> (
|
||||
// lang=html
|
||||
"<strong>",
|
||||
// lang=html
|
||||
"</strong>"
|
||||
),
|
||||
FormattingKind.Bold => (
|
||||
// lang=html
|
||||
"<strong>",
|
||||
// lang=html
|
||||
"</strong>"
|
||||
),
|
||||
|
||||
FormattingKind.Italic
|
||||
=> (
|
||||
// lang=html
|
||||
"<em>",
|
||||
// lang=html
|
||||
"</em>"
|
||||
),
|
||||
FormattingKind.Italic => (
|
||||
// lang=html
|
||||
"<em>",
|
||||
// lang=html
|
||||
"</em>"
|
||||
),
|
||||
|
||||
FormattingKind.Underline
|
||||
=> (
|
||||
// lang=html
|
||||
"<u>",
|
||||
// lang=html
|
||||
"</u>"
|
||||
),
|
||||
FormattingKind.Underline => (
|
||||
// lang=html
|
||||
"<u>",
|
||||
// lang=html
|
||||
"</u>"
|
||||
),
|
||||
|
||||
FormattingKind.Strikethrough
|
||||
=> (
|
||||
// lang=html
|
||||
"<s>",
|
||||
// lang=html
|
||||
"</s>"
|
||||
),
|
||||
FormattingKind.Strikethrough => (
|
||||
// lang=html
|
||||
"<s>",
|
||||
// lang=html
|
||||
"</s>"
|
||||
),
|
||||
|
||||
FormattingKind.Spoiler
|
||||
=> (
|
||||
// lang=html
|
||||
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
|
||||
// lang=html
|
||||
"""</span>"""
|
||||
),
|
||||
FormattingKind.Spoiler => (
|
||||
// lang=html
|
||||
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
|
||||
// lang=html
|
||||
"""</span>"""
|
||||
),
|
||||
|
||||
FormattingKind.Quote
|
||||
=> (
|
||||
// lang=html
|
||||
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
|
||||
// lang=html
|
||||
"""</div></div>"""
|
||||
),
|
||||
FormattingKind.Quote => (
|
||||
// lang=html
|
||||
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
|
||||
// lang=html
|
||||
"""</div></div>"""
|
||||
),
|
||||
|
||||
_
|
||||
=> throw new InvalidOperationException(
|
||||
$"Unknown formatting kind '{formatting.Kind}'."
|
||||
)
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unknown formatting kind '{formatting.Kind}'."
|
||||
),
|
||||
};
|
||||
|
||||
buffer.Append(openingTag);
|
||||
@@ -217,7 +209,6 @@ internal partial class HtmlMarkdownVisitor(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
||||
var jumboClass = isJumbo ? "chatlog__emoji--large" : "";
|
||||
|
||||
buffer.Append(
|
||||
@@ -228,7 +219,7 @@ internal partial class HtmlMarkdownVisitor(
|
||||
class="chatlog__emoji {jumboClass}"
|
||||
alt="{emoji.Name}"
|
||||
title="{emoji.Code}"
|
||||
src="{await context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}">
|
||||
src="{await context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)}">
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,20 @@ internal static class HtmlMessageExtensions
|
||||
{
|
||||
// Message content is hidden if it's a link to an embedded media
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
|
||||
public static bool IsContentHidden(this Message message)
|
||||
extension(Message message)
|
||||
{
|
||||
if (message.Embeds.Count != 1)
|
||||
return false;
|
||||
public bool IsContentHidden()
|
||||
{
|
||||
if (message.Embeds.Count != 1)
|
||||
return false;
|
||||
|
||||
var embed = message.Embeds[0];
|
||||
var embed = message.Embeds[0];
|
||||
|
||||
return string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase)
|
||||
&& embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
||||
return string.Equals(
|
||||
message.Content.Trim(),
|
||||
embed.Url,
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
) && embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
||||
private readonly TextWriter _writer = new StreamWriter(stream);
|
||||
|
||||
private readonly HtmlMinifier _minifier = new();
|
||||
private readonly List<Message> _messageGroup = new();
|
||||
private readonly List<Message> _messageGroup = [];
|
||||
|
||||
private bool CanJoinGroup(Message message)
|
||||
{
|
||||
@@ -90,7 +90,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
||||
await new MessageGroupTemplate
|
||||
{
|
||||
Context = Context,
|
||||
Messages = messages
|
||||
Messages = messages,
|
||||
}.RenderAsync(cancellationToken)
|
||||
)
|
||||
);
|
||||
@@ -131,7 +131,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
||||
await new PostambleTemplate
|
||||
{
|
||||
Context = Context,
|
||||
MessagesWritten = MessagesWritten
|
||||
MessagesWritten = MessagesWritten,
|
||||
}.RenderAsync(cancellationToken)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Writing;
|
||||
|
||||
@@ -15,19 +17,18 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
: MessageWriter(stream, context)
|
||||
{
|
||||
private readonly Utf8JsonWriter _writer =
|
||||
new(
|
||||
stream,
|
||||
new JsonWriterOptions
|
||||
{
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = true,
|
||||
// Validation errors may mask actual failures
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
||||
SkipValidation = true
|
||||
}
|
||||
);
|
||||
private readonly Utf8JsonWriter _writer = new(
|
||||
stream,
|
||||
new JsonWriterOptions
|
||||
{
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = true,
|
||||
// Validation errors may mask actual failures
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
||||
SkipValidation = true,
|
||||
}
|
||||
);
|
||||
|
||||
private async ValueTask<string> FormatMarkdownAsync(
|
||||
string markdown,
|
||||
@@ -37,22 +38,31 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
|
||||
: 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.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.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
|
||||
_writer.WriteBoolean("isBot", user.IsBot);
|
||||
|
||||
_writer.WritePropertyName("roles");
|
||||
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
|
||||
if (includeRoles)
|
||||
{
|
||||
_writer.WritePropertyName("roles");
|
||||
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
|
||||
}
|
||||
|
||||
_writer.WriteString(
|
||||
"avatarUrl",
|
||||
@@ -66,6 +76,26 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
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(
|
||||
IReadOnlyList<Role> roles,
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -108,6 +138,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteString("iconCanonicalUrl", embedAuthor.IconUrl);
|
||||
}
|
||||
|
||||
_writer.WriteEndObject();
|
||||
@@ -130,6 +162,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteString("canonicalUrl", embedImage.Url);
|
||||
}
|
||||
|
||||
_writer.WriteNumber("width", embedImage.Width);
|
||||
@@ -155,6 +189,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteString("canonicalUrl", embedVideo.Url);
|
||||
}
|
||||
|
||||
_writer.WriteNumber("width", embedVideo.Width);
|
||||
@@ -182,6 +218,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteString("iconCanonicalUrl", embedFooter.IconUrl);
|
||||
}
|
||||
|
||||
_writer.WriteEndObject();
|
||||
@@ -273,6 +311,26 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
_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();
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
@@ -373,7 +431,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
// Author
|
||||
_writer.WritePropertyName("author");
|
||||
await WriteUserAsync(message.Author, cancellationToken);
|
||||
await WriteUserAsync(message.Author, true, cancellationToken);
|
||||
|
||||
// Attachments
|
||||
_writer.WriteStartArray("attachments");
|
||||
@@ -431,20 +489,14 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
_writer.WriteStartObject();
|
||||
|
||||
// Emoji
|
||||
_writer.WriteStartObject("emoji");
|
||||
_writer.WriteString("id", reaction.Emoji.Id.ToString());
|
||||
_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.WritePropertyName("emoji");
|
||||
await WriteEmojiAsync(reaction.Emoji, cancellationToken);
|
||||
|
||||
_writer.WriteNumber("count", reaction.Count);
|
||||
|
||||
// Reaction authors
|
||||
_writer.WriteStartArray("users");
|
||||
|
||||
await foreach (
|
||||
var user in Context.Discord.GetMessageReactionsAsync(
|
||||
Context.Request.Channel.Id,
|
||||
@@ -454,28 +506,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
)
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
// 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();
|
||||
await WriteUserAsync(user, false, cancellationToken);
|
||||
}
|
||||
|
||||
_writer.WriteEndArray();
|
||||
@@ -487,9 +518,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
// Mentions
|
||||
_writer.WriteStartArray("mentions");
|
||||
|
||||
foreach (var user in message.MentionedUsers)
|
||||
await WriteUserAsync(user, cancellationToken);
|
||||
await WriteUserAsync(user, true, cancellationToken);
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
@@ -512,11 +542,28 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
_writer.WriteString("name", message.Interaction.Name);
|
||||
|
||||
_writer.WritePropertyName("user");
|
||||
await WriteUserAsync(message.Interaction.User, cancellationToken);
|
||||
await WriteUserAsync(message.Interaction.User, true, cancellationToken);
|
||||
|
||||
_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();
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -13,24 +13,7 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
||||
|
||||
public long MessagesExported { get; private set; }
|
||||
|
||||
private async ValueTask ResetWriterAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_writer is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _writer.WritePostambleAsync(cancellationToken);
|
||||
}
|
||||
// Writer must be disposed, even if it fails to write the postamble
|
||||
finally
|
||||
{
|
||||
await _writer.DisposeAsync();
|
||||
_writer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<MessageWriter> GetWriterAsync(
|
||||
private async ValueTask<MessageWriter> InitializeWriterAsync(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
@@ -43,7 +26,7 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
||||
)
|
||||
)
|
||||
{
|
||||
await ResetWriterAsync(cancellationToken);
|
||||
await UninitializeWriterAsync(cancellationToken);
|
||||
_partitionIndex++;
|
||||
}
|
||||
|
||||
@@ -60,17 +43,41 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
||||
return _writer = writer;
|
||||
}
|
||||
|
||||
private async ValueTask UninitializeWriterAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_writer is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _writer.WritePostambleAsync(cancellationToken);
|
||||
}
|
||||
// Writer must be disposed, even if it fails to write the postamble
|
||||
finally
|
||||
{
|
||||
await _writer.DisposeAsync();
|
||||
_writer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask ExportMessageAsync(
|
||||
Message message,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var writer = await GetWriterAsync(cancellationToken);
|
||||
var writer = await InitializeWriterAsync(cancellationToken);
|
||||
await writer.WriteMessageAsync(message, cancellationToken);
|
||||
MessagesExported++;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync() => await ResetWriterAsync();
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// If not messages were written, force the creation of an empty file
|
||||
if (MessagesExported <= 0)
|
||||
_ = await InitializeWriterAsync();
|
||||
|
||||
await UninitializeWriterAsync();
|
||||
}
|
||||
}
|
||||
|
||||
internal partial class MessageExporter
|
||||
@@ -100,13 +107,15 @@ internal partial class MessageExporter
|
||||
ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context),
|
||||
ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context),
|
||||
ExportFormat.HtmlDark => new HtmlMessageWriter(File.Create(filePath), context, "Dark"),
|
||||
ExportFormat.HtmlLight
|
||||
=> new HtmlMessageWriter(File.Create(filePath), context, "Light"),
|
||||
ExportFormat.HtmlLight => new HtmlMessageWriter(
|
||||
File.Create(filePath),
|
||||
context,
|
||||
"Light"
|
||||
),
|
||||
ExportFormat.Json => new JsonMessageWriter(File.Create(filePath), context),
|
||||
_
|
||||
=> throw new ArgumentOutOfRangeException(
|
||||
nameof(format),
|
||||
$"Unknown export format '{format}'."
|
||||
)
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(format),
|
||||
$"Unknown export format '{format}'."
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -388,7 +388,7 @@
|
||||
<div class="chatlog__embed-author-container">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
@@ -424,9 +424,11 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Video player *@
|
||||
@* Video thumbnail *@
|
||||
<div class="chatlog__embed-youtube-container">
|
||||
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
|
||||
<a href="@youTubeVideoEmbed.Url">
|
||||
<img class="chatlog__embed-youtube-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)" alt="YouTube video thumbnail" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@(embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -441,9 +443,12 @@
|
||||
embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ??
|
||||
embed.Url;
|
||||
|
||||
var embedImageCanonicalUrl =
|
||||
embed.Image?.Url ?? embed.Thumbnail?.Url ?? embed.Url;
|
||||
|
||||
<div class="chatlog__embed">
|
||||
<a href="@await ResolveAssetUrlAsync(embedImageUrl)">
|
||||
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy">
|
||||
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy" data-canonical-url="@embedImageCanonicalUrl">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@@ -457,8 +462,10 @@
|
||||
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
||||
embed.Url;
|
||||
|
||||
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
|
||||
|
||||
<div class="chatlog__embed">
|
||||
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls>
|
||||
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls data-canonical-url="@embedVideoCanonicalUrl">
|
||||
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
|
||||
</video>
|
||||
</div>
|
||||
@@ -470,8 +477,10 @@
|
||||
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
||||
embed.Url;
|
||||
|
||||
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
|
||||
|
||||
<div class="chatlog__embed">
|
||||
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()">
|
||||
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()" data-canonical-url="@embedVideoCanonicalUrl">
|
||||
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv">
|
||||
</video>
|
||||
</div>
|
||||
@@ -499,7 +508,7 @@
|
||||
<div class="chatlog__embed-author-container">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
@@ -574,7 +583,7 @@
|
||||
{
|
||||
<div class="chatlog__embed-thumbnail-container">
|
||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
||||
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
|
||||
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy" data-canonical-url="@embed.Thumbnail.Url">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@@ -590,7 +599,7 @@
|
||||
{
|
||||
<div class="chatlog__embed-image-container">
|
||||
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
|
||||
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
|
||||
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy" data-canonical-url="@image.Url">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@@ -605,7 +614,7 @@
|
||||
@* Footer icon *@
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy" data-canonical-url="@embed.Footer.IconUrl">
|
||||
}
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
|
||||
@@ -37,7 +37,7 @@ public partial class PartitionLimit
|
||||
"M" => 1_000_000,
|
||||
"K" => 1_000,
|
||||
"" => 1,
|
||||
_ => -1
|
||||
_ => -1,
|
||||
};
|
||||
|
||||
if (magnitude < 0)
|
||||
|
||||
@@ -7,40 +7,40 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
|
||||
internal static class PlainTextMessageExtensions
|
||||
{
|
||||
public static string GetFallbackContent(this Message message) =>
|
||||
message.Kind switch
|
||||
{
|
||||
MessageKind.RecipientAdd
|
||||
=> message.MentionedUsers.Any()
|
||||
extension(Message message)
|
||||
{
|
||||
public string GetFallbackContent() =>
|
||||
message.Kind switch
|
||||
{
|
||||
MessageKind.RecipientAdd => message.MentionedUsers.Any()
|
||||
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
||||
: "Added a recipient.",
|
||||
|
||||
MessageKind.RecipientRemove
|
||||
=> message.MentionedUsers.Any()
|
||||
MessageKind.RecipientRemove => message.MentionedUsers.Any()
|
||||
? message.Author.Id == message.MentionedUsers.First().Id
|
||||
? "Left the group."
|
||||
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
|
||||
: "Removed a recipient.",
|
||||
|
||||
MessageKind.Call
|
||||
=> $"Started a call that lasted {
|
||||
message
|
||||
.CallEndedTimestamp?
|
||||
.Pipe(t => t - message.Timestamp)
|
||||
.Pipe(t => t.TotalMinutes)
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||
} minutes.",
|
||||
MessageKind.Call =>
|
||||
$"Started a call that lasted {
|
||||
message
|
||||
.CallEndedTimestamp?
|
||||
.Pipe(t => t - message.Timestamp)
|
||||
.Pipe(t => t.TotalMinutes)
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||
} minutes.",
|
||||
|
||||
MessageKind.ChannelNameChange
|
||||
=> !string.IsNullOrWhiteSpace(message.Content)
|
||||
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
|
||||
? $"Changed the channel name: {message.Content}"
|
||||
: "Changed the channel name.",
|
||||
|
||||
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
||||
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
||||
MessageKind.ThreadCreated => "Started a thread.",
|
||||
MessageKind.GuildMemberJoin => "Joined the server.",
|
||||
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
||||
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
||||
MessageKind.ThreadCreated => "Started a thread.",
|
||||
MessageKind.GuildMemberJoin => "Joined the server.",
|
||||
|
||||
_ => message.Content
|
||||
};
|
||||
_ => message.Content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,9 +711,11 @@
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.chatlog__embed-youtube {
|
||||
border: 0;
|
||||
.chatlog__embed-youtube-thumbnail {
|
||||
max-width: 400px;
|
||||
max-height: 225px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chatlog__sticker {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown;
|
||||
|
||||
@@ -11,11 +11,17 @@ internal record EmojiNode(
|
||||
bool IsAnimated
|
||||
) : MarkdownNode
|
||||
{
|
||||
public bool IsCustomEmoji => Id is not null;
|
||||
|
||||
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
|
||||
public string Code => IsCustomEmoji ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
|
||||
// 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.
|
||||
private readonly Emoji _emoji = new(Id, Name, IsAnimated);
|
||||
|
||||
public EmojiNode(string name)
|
||||
: 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,
|
||||
Strikethrough,
|
||||
Spoiler,
|
||||
Quote
|
||||
Quote,
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@ internal enum MentionKind
|
||||
Here,
|
||||
User,
|
||||
Channel,
|
||||
Role
|
||||
Role,
|
||||
}
|
||||
|
||||
@@ -2,20 +2,19 @@
|
||||
|
||||
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)
|
||||
: this((IReadOnlyList<IMatcher<T>>)matchers) { }
|
||||
|
||||
public ParsedMatch<T>? TryMatch(StringSegment segment)
|
||||
public ParsedMatch<TValue>? TryMatch(TContext context, 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
|
||||
foreach (var matcher in matchers)
|
||||
{
|
||||
// Try to match
|
||||
var match = matcher.TryMatch(segment);
|
||||
var match = matcher.TryMatch(context, segment);
|
||||
|
||||
// If there's no match - continue
|
||||
if (match is null)
|
||||
@@ -26,7 +25,9 @@ internal class AggregateMatcher<T>(IReadOnlyList<IMatcher<T>> matchers) : IMatch
|
||||
earliestMatch is null
|
||||
|| match.Segment.StartIndex < earliestMatch.Segment.StartIndex
|
||||
)
|
||||
{
|
||||
earliestMatch = match;
|
||||
}
|
||||
|
||||
// If the earliest match starts at the very beginning - break,
|
||||
// because it's impossible to find a match earlier than that
|
||||
|
||||
@@ -3,17 +3,18 @@ using System.Collections.Generic;
|
||||
|
||||
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
|
||||
{
|
||||
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(
|
||||
this IMatcher<T> matcher,
|
||||
public static IEnumerable<ParsedMatch<TValue>> MatchAll<TContext, TValue>(
|
||||
this IMatcher<TContext, TValue> matcher,
|
||||
TContext context,
|
||||
StringSegment segment,
|
||||
Func<StringSegment, T> transformFallback
|
||||
Func<TContext, StringSegment, TValue> transformFallback
|
||||
)
|
||||
{
|
||||
// Loop through segments divided by individual matches
|
||||
@@ -22,6 +23,7 @@ internal static class MatcherExtensions
|
||||
{
|
||||
// Find a match within this segment
|
||||
var match = matcher.TryMatch(
|
||||
context,
|
||||
segment.Relocate(currentIndex, segment.EndIndex - currentIndex)
|
||||
);
|
||||
|
||||
@@ -36,9 +38,9 @@ internal static class MatcherExtensions
|
||||
match.Segment.StartIndex - currentIndex
|
||||
);
|
||||
|
||||
yield return new ParsedMatch<T>(
|
||||
yield return new ParsedMatch<TValue>(
|
||||
fallbackSegment,
|
||||
transformFallback(fallbackSegment)
|
||||
transformFallback(context, fallbackSegment)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +55,10 @@ internal static class MatcherExtensions
|
||||
{
|
||||
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 */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> BoldFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// There must be exactly two closing asterisks.
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> ItalicFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// There must be exactly one closing asterisk.
|
||||
// Opening asterisk must not be followed by whitespace.
|
||||
// Closing asterisk must not be preceded by whitespace.
|
||||
@@ -39,156 +39,174 @@ internal static partial class MarkdownParser
|
||||
@"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)",
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// There must be exactly three closing asterisks.
|
||||
new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) =>
|
||||
new FormattingNode(
|
||||
FormattingKind.Italic,
|
||||
Parse(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()
|
||||
private static readonly IMatcher<
|
||||
MarkdownContext,
|
||||
MarkdownNode
|
||||
> ItalicBoldFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// There must be exactly three closing asterisks.
|
||||
new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(c, s, m) =>
|
||||
new FormattingNode(
|
||||
FormattingKind.Italic,
|
||||
Parse(c, s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher)
|
||||
)
|
||||
);
|
||||
|
||||
/* Code blocks */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher =
|
||||
new RegexMatcher<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> ItalicAltFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Closing underscore must not be followed by a word character.
|
||||
new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(c, s, m) =>
|
||||
new FormattingNode(FormattingKind.Italic, Parse(c, s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> UnderlineFormattingNodeMatcher =
|
||||
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.
|
||||
// Blank lines at the beginning and at the end of content are trimmed.
|
||||
new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(_, m) =>
|
||||
(_, _, m) =>
|
||||
new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
|
||||
);
|
||||
|
||||
/* Mentions */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher =
|
||||
new StringMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> EveryoneMentionNodeMatcher =
|
||||
new StringMatcher<MarkdownContext, MarkdownNode>(
|
||||
"@everyone",
|
||||
_ => new MentionNode(null, MentionKind.Everyone)
|
||||
(_, _) => new MentionNode(null, MentionKind.Everyone)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher =
|
||||
new StringMatcher<MarkdownNode>("@here", _ => new MentionNode(null, MentionKind.Here));
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> HereMentionNodeMatcher =
|
||||
new StringMatcher<MarkdownContext, MarkdownNode>(
|
||||
"@here",
|
||||
(_, _) => new MentionNode(null, MentionKind.Here)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> UserMentionNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture <@123456> or <@!123456>
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> ChannelMentionNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture <#123456>
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> RoleMentionNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture <@&123456>
|
||||
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 */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
new Regex(
|
||||
@"("
|
||||
+
|
||||
@@ -239,21 +257,21 @@ internal static partial class MarkdownParser
|
||||
+ @")",
|
||||
DefaultRegexOptions
|
||||
),
|
||||
(_, m) => new EmojiNode(m.Groups[1].Value)
|
||||
(_, _, m) => new EmojiNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> CodedStandardEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> CodedStandardEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture :thinking:
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> CustomEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture <:lul:123456> or <a:lul:123456>
|
||||
new Regex(@"<(a)?:(.+?):(\d+?)>", DefaultRegexOptions),
|
||||
(_, m) =>
|
||||
(_, _, m) =>
|
||||
new EmojiNode(
|
||||
Snowflake.TryParse(m.Groups[3].Value),
|
||||
m.Groups[2].Value,
|
||||
@@ -263,70 +281,72 @@ internal static partial class MarkdownParser
|
||||
|
||||
/* Links */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> AutoLinkNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Any non-whitespace character after http:// or https://
|
||||
// until the last punctuation character or whitespace.
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> HiddenLinkNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Same as auto link but also surrounded by angular brackets
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> MaskedLinkNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture [title](link)
|
||||
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 */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher =
|
||||
new StringMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> ShrugTextNodeMatcher =
|
||||
new StringMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture the shrug kaomoji.
|
||||
// This escapes it from matching for formatting.
|
||||
@"¯\_(ツ)_/¯",
|
||||
s => new TextNode(s.ToString())
|
||||
(s, _) => new TextNode(s.ToString())
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture some specific emoji that don't get rendered.
|
||||
// This escapes them from matching for emoji.
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> EscapedSymbolTextNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture any "symbol/other" character or surrogate pair preceded by a backslash.
|
||||
// This escapes them from matching for emoji.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/230
|
||||
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 =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
|
||||
// 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)
|
||||
);
|
||||
private static readonly IMatcher<
|
||||
MarkdownContext,
|
||||
MarkdownNode
|
||||
> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
|
||||
// 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 */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> TimestampNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> TimestampNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture <t:12345678> or <t:12345678:R>
|
||||
new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions),
|
||||
(_, m) =>
|
||||
(_, _, m) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -357,10 +377,9 @@ internal static partial class MarkdownParser
|
||||
null => null,
|
||||
// Unknown format: throw an exception to consider this timestamp invalid
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1156
|
||||
var f
|
||||
=> throw new InvalidOperationException(
|
||||
$"Unknown timestamp format '{f}'."
|
||||
)
|
||||
var f => throw new InvalidOperationException(
|
||||
$"Unknown timestamp format '{f}'."
|
||||
),
|
||||
};
|
||||
|
||||
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
|
||||
private static readonly IMatcher<MarkdownNode> NodeMatcher = new AggregateMatcher<MarkdownNode>(
|
||||
// Escaped text
|
||||
ShrugTextNodeMatcher,
|
||||
IgnoredEmojiTextNodeMatcher,
|
||||
EscapedSymbolTextNodeMatcher,
|
||||
EscapedCharacterTextNodeMatcher,
|
||||
// Formatting
|
||||
ItalicBoldFormattingNodeMatcher,
|
||||
ItalicUnderlineFormattingNodeMatcher,
|
||||
BoldFormattingNodeMatcher,
|
||||
ItalicFormattingNodeMatcher,
|
||||
UnderlineFormattingNodeMatcher,
|
||||
ItalicAltFormattingNodeMatcher,
|
||||
StrikethroughFormattingNodeMatcher,
|
||||
SpoilerFormattingNodeMatcher,
|
||||
MultiLineQuoteNodeMatcher,
|
||||
RepeatedSingleLineQuoteNodeMatcher,
|
||||
SingleLineQuoteNodeMatcher,
|
||||
HeadingNodeMatcher,
|
||||
ListNodeMatcher,
|
||||
// Code blocks
|
||||
MultiLineCodeBlockNodeMatcher,
|
||||
InlineCodeBlockNodeMatcher,
|
||||
// Mentions
|
||||
EveryoneMentionNodeMatcher,
|
||||
HereMentionNodeMatcher,
|
||||
UserMentionNodeMatcher,
|
||||
ChannelMentionNodeMatcher,
|
||||
RoleMentionNodeMatcher,
|
||||
// Links
|
||||
MaskedLinkNodeMatcher,
|
||||
AutoLinkNodeMatcher,
|
||||
HiddenLinkNodeMatcher,
|
||||
// Emoji
|
||||
StandardEmojiNodeMatcher,
|
||||
CustomEmojiNodeMatcher,
|
||||
CodedStandardEmojiNodeMatcher,
|
||||
// Misc
|
||||
TimestampNodeMatcher
|
||||
);
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> NodeMatcher =
|
||||
new AggregateMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Escaped text
|
||||
ShrugTextNodeMatcher,
|
||||
IgnoredEmojiTextNodeMatcher,
|
||||
EscapedSymbolTextNodeMatcher,
|
||||
EscapedCharacterTextNodeMatcher,
|
||||
// Formatting
|
||||
ItalicBoldFormattingNodeMatcher,
|
||||
ItalicUnderlineFormattingNodeMatcher,
|
||||
BoldFormattingNodeMatcher,
|
||||
ItalicFormattingNodeMatcher,
|
||||
UnderlineFormattingNodeMatcher,
|
||||
ItalicAltFormattingNodeMatcher,
|
||||
StrikethroughFormattingNodeMatcher,
|
||||
SpoilerFormattingNodeMatcher,
|
||||
MultiLineQuoteNodeMatcher,
|
||||
RepeatedSingleLineQuoteNodeMatcher,
|
||||
SingleLineQuoteNodeMatcher,
|
||||
HeadingNodeMatcher,
|
||||
ListNodeMatcher,
|
||||
// Code blocks
|
||||
MultiLineCodeBlockNodeMatcher,
|
||||
InlineCodeBlockNodeMatcher,
|
||||
// Mentions
|
||||
EveryoneMentionNodeMatcher,
|
||||
HereMentionNodeMatcher,
|
||||
UserMentionNodeMatcher,
|
||||
ChannelMentionNodeMatcher,
|
||||
RoleMentionNodeMatcher,
|
||||
// Links
|
||||
MaskedLinkNodeMatcher,
|
||||
AutoLinkNodeMatcher,
|
||||
HiddenLinkNodeMatcher,
|
||||
// Emoji
|
||||
StandardEmojiNodeMatcher,
|
||||
CustomEmojiNodeMatcher,
|
||||
CodedStandardEmojiNodeMatcher,
|
||||
// Misc
|
||||
TimestampNodeMatcher
|
||||
);
|
||||
|
||||
// Minimal set of matchers for non-multimedia formats (e.g. plain text)
|
||||
private static readonly IMatcher<MarkdownNode> MinimalNodeMatcher =
|
||||
new AggregateMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> MinimalNodeMatcher =
|
||||
new AggregateMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Mentions
|
||||
EveryoneMentionNodeMatcher,
|
||||
HereMentionNodeMatcher,
|
||||
@@ -439,42 +459,75 @@ internal static partial class MarkdownParser
|
||||
);
|
||||
|
||||
private static IReadOnlyList<MarkdownNode> Parse(
|
||||
MarkdownContext context,
|
||||
StringSegment segment,
|
||||
IMatcher<MarkdownNode> matcher
|
||||
) => matcher.MatchAll(segment, s => new TextNode(s.ToString())).Select(r => r.Value).ToArray();
|
||||
IMatcher<MarkdownContext, MarkdownNode> matcher
|
||||
)
|
||||
{
|
||||
// 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
|
||||
{
|
||||
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment) =>
|
||||
Parse(segment, NodeMatcher);
|
||||
|
||||
public static IReadOnlyList<MarkdownNode> Parse(string markdown) =>
|
||||
Parse(new StringSegment(markdown));
|
||||
|
||||
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)
|
||||
private static void Extract<TNode>(
|
||||
IEnumerable<MarkdownNode> nodes,
|
||||
ICollection<TNode> extractedNodes
|
||||
)
|
||||
where TNode : MarkdownNode
|
||||
{
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (node is LinkNode linkNode)
|
||||
links.Add(linkNode);
|
||||
if (node is TNode extractedNode)
|
||||
extractedNodes.Add(extractedNode);
|
||||
|
||||
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>();
|
||||
ExtractLinks(Parse(markdown), links);
|
||||
var extractedNodes = new List<TNode>();
|
||||
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;
|
||||
|
||||
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);
|
||||
if (!match.Success)
|
||||
@@ -20,8 +23,8 @@ internal class RegexMatcher<T>(Regex regex, Func<StringSegment, Match, T?> trans
|
||||
return null;
|
||||
|
||||
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;
|
||||
|
||||
internal class StringMatcher<T>(
|
||||
internal class StringMatcher<TContext, TValue>(
|
||||
string needle,
|
||||
StringComparison comparison,
|
||||
Func<StringSegment, T?> transform
|
||||
) : IMatcher<T>
|
||||
Func<TContext, StringSegment, TValue?> transform
|
||||
) : 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) { }
|
||||
|
||||
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);
|
||||
|
||||
@@ -19,8 +19,8 @@ internal class StringMatcher<T>(
|
||||
return null;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,19 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class AsyncCollectionExtensions
|
||||
{
|
||||
private static async ValueTask<IReadOnlyList<T>> CollectAsync<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable
|
||||
)
|
||||
extension<T>(IAsyncEnumerable<T> asyncEnumerable)
|
||||
{
|
||||
var list = new List<T>();
|
||||
private async ValueTask<IReadOnlyList<T>> CollectAsync()
|
||||
{
|
||||
var list = new List<T>();
|
||||
|
||||
await foreach (var i in asyncEnumerable)
|
||||
list.Add(i);
|
||||
await foreach (var i in asyncEnumerable)
|
||||
list.Add(i);
|
||||
|
||||
return list;
|
||||
return list;
|
||||
}
|
||||
|
||||
public ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter() =>
|
||||
asyncEnumerable.CollectAsync().GetAwaiter();
|
||||
}
|
||||
|
||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable
|
||||
) => asyncEnumerable.CollectAsync().GetAwaiter();
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class BinaryExtensions
|
||||
{
|
||||
public static string ToHex(this byte[] data, bool isUpperCase = true)
|
||||
{
|
||||
var buffer = new StringBuilder(2 * data.Length);
|
||||
|
||||
foreach (var b in data)
|
||||
{
|
||||
buffer.Append(b.ToString(isUpperCase ? "X2" : "x2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
@@ -4,25 +4,34 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class CollectionExtensions
|
||||
{
|
||||
public static IEnumerable<T> ToSingletonEnumerable<T>(this T obj)
|
||||
extension<T>(T obj)
|
||||
{
|
||||
yield return obj;
|
||||
public IEnumerable<T> ToSingletonEnumerable()
|
||||
{
|
||||
yield return obj;
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<(T value, int index)> WithIndex<T>(this IEnumerable<T> source)
|
||||
extension<T>(IEnumerable<T> source)
|
||||
{
|
||||
var i = 0;
|
||||
foreach (var o in source)
|
||||
yield return (o, i++);
|
||||
public IEnumerable<(T value, int index)> WithIndex()
|
||||
{
|
||||
var i = 0;
|
||||
foreach (var o in source)
|
||||
yield return (o, i++);
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source)
|
||||
extension<T>(IEnumerable<T?> source)
|
||||
where T : class
|
||||
{
|
||||
foreach (var o in source)
|
||||
public IEnumerable<T> WhereNotNull()
|
||||
{
|
||||
if (o is not null)
|
||||
yield return o;
|
||||
foreach (var o in source)
|
||||
{
|
||||
if (o is not null)
|
||||
yield return o;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class ColorExtensions
|
||||
{
|
||||
public static Color WithAlpha(this Color color, int alpha) => Color.FromArgb(alpha, color);
|
||||
extension(Color color)
|
||||
{
|
||||
public Color WithAlpha(int alpha) => Color.FromArgb(alpha, color);
|
||||
|
||||
public static Color ResetAlpha(this Color color) => color.WithAlpha(255);
|
||||
public Color ResetAlpha() => color.WithAlpha(255);
|
||||
|
||||
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
|
||||
public int ToRgb() => color.ToArgb() & 0xffffff;
|
||||
|
||||
public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
||||
public string ToHex() => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,27 +5,30 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class ExceptionExtensions
|
||||
{
|
||||
private static void PopulateChildren(this Exception exception, ICollection<Exception> children)
|
||||
extension(Exception exception)
|
||||
{
|
||||
if (exception is AggregateException aggregateException)
|
||||
private void PopulateChildren(ICollection<Exception> children)
|
||||
{
|
||||
foreach (var innerException in aggregateException.InnerExceptions)
|
||||
if (exception is AggregateException aggregateException)
|
||||
{
|
||||
children.Add(innerException);
|
||||
PopulateChildren(innerException, children);
|
||||
foreach (var innerException in aggregateException.InnerExceptions)
|
||||
{
|
||||
children.Add(innerException);
|
||||
PopulateChildren(innerException, children);
|
||||
}
|
||||
}
|
||||
else if (exception.InnerException is not null)
|
||||
{
|
||||
children.Add(exception.InnerException);
|
||||
PopulateChildren(exception.InnerException, children);
|
||||
}
|
||||
}
|
||||
else if (exception.InnerException is not null)
|
||||
|
||||
public IReadOnlyList<Exception> GetSelfAndChildren()
|
||||
{
|
||||
children.Add(exception.InnerException);
|
||||
PopulateChildren(exception.InnerException, children);
|
||||
var children = new List<Exception> { exception };
|
||||
PopulateChildren(exception, children);
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<Exception> GetSelfAndChildren(this Exception exception)
|
||||
{
|
||||
var children = new List<Exception> { exception };
|
||||
PopulateChildren(exception, children);
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,17 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class GenericExtensions
|
||||
{
|
||||
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) =>
|
||||
transform(input);
|
||||
extension<TIn>(TIn input)
|
||||
{
|
||||
public TOut Pipe<TOut>(Func<TIn, TOut> transform) => transform(input);
|
||||
}
|
||||
|
||||
public static T? NullIf<T>(this T value, Func<T, bool> predicate)
|
||||
where T : struct => !predicate(value) ? value : null;
|
||||
extension<T>(T value)
|
||||
where T : struct
|
||||
{
|
||||
public T? NullIf(Func<T, bool> predicate) => !predicate(value) ? value : null;
|
||||
|
||||
public static T? NullIfDefault<T>(this T value)
|
||||
where T : struct => value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
|
||||
public T? NullIfDefault() =>
|
||||
value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class HttpExtensions
|
||||
{
|
||||
public static string? TryGetValue(this HttpHeaders headers, string name) =>
|
||||
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
|
||||
extension(HttpHeaders headers)
|
||||
{
|
||||
public string? TryGetValue(string name) =>
|
||||
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class PathExtensions
|
||||
{
|
||||
// This is a union of invalid characters from Windows (NTFS/FAT32), Linux (ext4/XFS), and macOS (HFS+/APFS).
|
||||
// We use this instead of Path.GetInvalidFileNameChars() because that only returns OS-specific characters,
|
||||
// not filesystem-specific characters. It's possible to use, for example, an NTFS drive on Linux,
|
||||
// which would make some additional characters invalid that are otherwise valid on Linux.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1452
|
||||
private static readonly char[] InvalidFileNameChars =
|
||||
[
|
||||
'\0', // Null character - invalid on all filesystems
|
||||
'/', // Path separator on Unix and Windows
|
||||
'\\', // Path separator on Windows
|
||||
':', // Reserved on Windows (drive letters, NTFS streams)
|
||||
'*', // Wildcard on Windows
|
||||
'?', // Wildcard on Windows
|
||||
'"', // Reserved on Windows
|
||||
'<', // Redirection on Windows
|
||||
'>', // Redirection on Windows
|
||||
'|', // Pipe on Windows
|
||||
];
|
||||
|
||||
extension(Path)
|
||||
{
|
||||
public static string EscapeFileName(string path)
|
||||
{
|
||||
var buffer = new StringBuilder(path.Length);
|
||||
|
||||
foreach (var c in path)
|
||||
buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_');
|
||||
|
||||
// File names cannot end with a dot on Windows
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
while (buffer.Length > 0 && buffer[^1] == '.')
|
||||
buffer.Remove(buffer.Length - 1, 1);
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user