mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 04:39:33 +00:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
<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**:
|
||||
> Your bot needs to have **Message Content Intent** enabled for it to be able to read messages!
|
||||
> 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).
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -10,7 +10,22 @@
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
32
.github/dependabot.yml
vendored
Normal file
32
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: monthly
|
||||
labels:
|
||||
- enhancement
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: monthly
|
||||
labels:
|
||||
- enhancement
|
||||
groups:
|
||||
docker:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: nuget
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: monthly
|
||||
labels:
|
||||
- enhancement
|
||||
groups:
|
||||
nuget:
|
||||
patterns:
|
||||
- "*"
|
||||
10
.github/workflows/docker.yml
vendored
10
.github/workflows/docker.yml
vendored
@@ -23,10 +23,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
- name: Build image
|
||||
run: >
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
run: >
|
||||
|
||||
49
.github/workflows/main.yml
vendored
49
.github/workflows/main.yml
vendored
@@ -26,18 +26,31 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
# 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 +64,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -72,7 +85,7 @@ jobs:
|
||||
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@f30e4959ba63075080d4f7f90cacc18d9f3fafd7 # v4.0.0
|
||||
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -108,25 +121,26 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- 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
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/bin/publish/
|
||||
@@ -191,14 +205,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.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 }}
|
||||
run: |
|
||||
if [ -f ${{ matrix.app }}/${{ matrix.asset }} ]; then
|
||||
chmod +x ${{ matrix.app }}/${{ matrix.asset }}
|
||||
fi
|
||||
|
||||
if [ -f ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} ]; then
|
||||
chmod +x ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }}
|
||||
fi
|
||||
|
||||
- name: Create package
|
||||
# Change into the artifacts directory to avoid including the directory itself in the zip archive
|
||||
@@ -223,7 +244,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,7 +1,7 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version>999.9.9-dev</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<UserSecretsId>d1fe5ae2-2a19-404d-a36e-81ba9eada1c1</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</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.3.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.3.0" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.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="9.0.5" />
|
||||
<PackageReference
|
||||
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
||||
Version="9.0.5"
|
||||
/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.5" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0" 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
69
DiscordChatExporter.Cli.Tests/Specs/JsonEmojiSpecs.cs
Normal file
69
DiscordChatExporter.Cli.Tests/Specs/JsonEmojiSpecs.cs
Normal file
@@ -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,16 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||
|
||||
internal static class StringExtensions
|
||||
{
|
||||
public static string ReplaceWhiteSpace(this string str, 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,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:9.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:9.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,21 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||
</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.0.2" 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.50.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -13,7 +13,7 @@ internal static class ConsoleExtensions
|
||||
{
|
||||
Ansi = AnsiSupport.Detect,
|
||||
ColorSystem = ColorSystemSupport.Detect,
|
||||
Out = new AnsiConsoleOutput(console.Output)
|
||||
Out = new AnsiConsoleOutput(console.Output),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
||||
// https://discord.com/developers/docs/resources/application#application-object
|
||||
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;
|
||||
|
||||
public bool IsEmpty => LastMessageId is null;
|
||||
public bool IsEmpty { get; } = LastMessageId is null;
|
||||
|
||||
public IEnumerable<Channel> GetParents()
|
||||
{
|
||||
@@ -76,6 +78,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
|
||||
|
||||
21
DiscordChatExporter.Core/Discord/Data/ChannelConnection.cs
Normal file
21
DiscordChatExporter.Core/Discord/Data/ChannelConnection.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,7 @@
|
||||
|
||||
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
||||
{
|
||||
public string Url => $"https://www.youtube.com/embed/{VideoId}";
|
||||
public string Url { get; } = $"https://www.youtube.com/embed/{VideoId}";
|
||||
}
|
||||
|
||||
public partial record YouTubeVideoEmbedProjection
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
36
DiscordChatExporter.Core/Discord/RateLimitPreference.cs
Normal file
36
DiscordChatExporter.Core/Discord/RateLimitPreference.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord;
|
||||
|
||||
[Flags]
|
||||
public enum RateLimitPreference
|
||||
{
|
||||
IgnoreAll = 0,
|
||||
RespectForUserTokens = 0b1,
|
||||
RespectForBotTokens = 0b10,
|
||||
RespectAll = RespectForUserTokens | RespectForBotTokens,
|
||||
}
|
||||
|
||||
public static class RateLimitPreferenceExtensions
|
||||
{
|
||||
internal static bool IsRespectedFor(
|
||||
this RateLimitPreference rateLimitPreference,
|
||||
TokenKind tokenKind
|
||||
) =>
|
||||
tokenKind switch
|
||||
{
|
||||
TokenKind.User => (rateLimitPreference & RateLimitPreference.RespectForUserTokens) != 0,
|
||||
TokenKind.Bot => (rateLimitPreference & RateLimitPreference.RespectForBotTokens) != 0,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(tokenKind)),
|
||||
};
|
||||
|
||||
public static string GetDisplayName(this RateLimitPreference rateLimitPreference) =>
|
||||
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,13 @@
|
||||
<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="AsyncKeyedLock" Version="7.1.6" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.0.2" 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="Polly" Version="8.5.2" />
|
||||
<PackageReference Include="RazorBlade" Version="0.9.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="WebMarkupMin.Core" Version="2.19.0" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.5.4" />
|
||||
</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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -115,7 +118,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 +133,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,7 +8,7 @@ public enum ExportFormat
|
||||
HtmlDark,
|
||||
HtmlLight,
|
||||
Csv,
|
||||
Json
|
||||
Json,
|
||||
}
|
||||
|
||||
public static class ExportFormatExtensions
|
||||
@@ -21,7 +21,7 @@ public static class ExportFormatExtensions
|
||||
ExportFormat.HtmlLight => "html",
|
||||
ExportFormat.Csv => "csv",
|
||||
ExportFormat.Json => "json",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
|
||||
public static string GetDisplayName(this ExportFormat format) =>
|
||||
@@ -32,6 +32,6 @@ public static class ExportFormatExtensions
|
||||
ExportFormat.HtmlLight => "HTML (Light)",
|
||||
ExportFormat.Csv => "CSV",
|
||||
ExportFormat.Json => "JSON",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -172,24 +172,21 @@ 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)}">
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -273,6 +303,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 +423,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 +481,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 +498,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 +510,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 +534,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}'."
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public partial class PartitionLimit
|
||||
"M" => 1_000_000,
|
||||
"K" => 1_000,
|
||||
"" => 1,
|
||||
_ => -1
|
||||
_ => -1,
|
||||
};
|
||||
|
||||
if (magnitude < 0)
|
||||
|
||||
@@ -10,20 +10,18 @@ internal static class PlainTextMessageExtensions
|
||||
public static string GetFallbackContent(this Message message) =>
|
||||
message.Kind switch
|
||||
{
|
||||
MessageKind.RecipientAdd
|
||||
=> message.MentionedUsers.Any()
|
||||
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
||||
: "Added a recipient.",
|
||||
MessageKind.RecipientAdd => message.MentionedUsers.Any()
|
||||
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
||||
: "Added a recipient.",
|
||||
|
||||
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.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 {
|
||||
MessageKind.Call =>
|
||||
$"Started a call that lasted {
|
||||
message
|
||||
.CallEndedTimestamp?
|
||||
.Pipe(t => t - message.Timestamp)
|
||||
@@ -31,16 +29,15 @@ internal static class PlainTextMessageExtensions
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||
} minutes.",
|
||||
|
||||
MessageKind.ChannelNameChange
|
||||
=> !string.IsNullOrWhiteSpace(message.Content)
|
||||
? $"Changed the channel name: {message.Content}"
|
||||
: "Changed the channel name.",
|
||||
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.",
|
||||
|
||||
_ => message.Content
|
||||
_ => message.Content,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public static class Http
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(IsRetryableException),
|
||||
MaxRetryAttempts = 4,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
Delay = TimeSpan.FromSeconds(1)
|
||||
Delay = TimeSpan.FromSeconds(1),
|
||||
}
|
||||
)
|
||||
.Build();
|
||||
@@ -68,7 +68,7 @@ public static class Http
|
||||
return ValueTask.FromResult<TimeSpan?>(
|
||||
TimeSpan.FromSeconds(Math.Pow(2, args.AttemptNumber) + 1)
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
.Build();
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Frozen;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils;
|
||||
|
||||
public static class PathEx
|
||||
{
|
||||
private static readonly HashSet<char> InvalidFileNameChars =
|
||||
new(Path.GetInvalidFileNameChars());
|
||||
private static readonly FrozenSet<char> InvalidFileNameChars =
|
||||
[
|
||||
.. Path.GetInvalidFileNameChars(),
|
||||
];
|
||||
|
||||
public static string EscapeFileName(string path)
|
||||
{
|
||||
@@ -19,7 +21,7 @@ public static class PathEx
|
||||
|
||||
// File names cannot end with a dot on Windows
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
while (buffer.Length > 0 && buffer[^1] == '.')
|
||||
buffer.Remove(buffer.Length - 1, 1);
|
||||
|
||||
43
DiscordChatExporter.Core/Utils/Url.cs
Normal file
43
DiscordChatExporter.Core/Utils/Url.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils;
|
||||
|
||||
public static class Url
|
||||
{
|
||||
public static string EncodeFilePath(string filePath)
|
||||
{
|
||||
var buffer = new StringBuilder();
|
||||
var position = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (position >= filePath.Length)
|
||||
break;
|
||||
|
||||
var separatorIndex = filePath.IndexOfAny([':', '/', '\\'], position);
|
||||
if (separatorIndex < 0)
|
||||
{
|
||||
buffer.Append(Uri.EscapeDataString(filePath[position..]));
|
||||
break;
|
||||
}
|
||||
|
||||
// Append the segment
|
||||
buffer.Append(Uri.EscapeDataString(filePath[position..separatorIndex]));
|
||||
|
||||
// Append the separator
|
||||
buffer.Append(
|
||||
filePath[separatorIndex] switch
|
||||
{
|
||||
// Normalize slashes
|
||||
'\\' => '/',
|
||||
var c => c,
|
||||
}
|
||||
);
|
||||
|
||||
position = separatorIndex + 1;
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils;
|
||||
|
||||
@@ -10,8 +10,9 @@ public class UrlBuilder
|
||||
{
|
||||
private string _path = "";
|
||||
|
||||
private readonly Dictionary<string, string?> _queryParameters =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string?> _queryParameters = new(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
public UrlBuilder SetPath(string path)
|
||||
{
|
||||
@@ -24,8 +25,8 @@ public class UrlBuilder
|
||||
if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value))
|
||||
return this;
|
||||
|
||||
var keyEncoded = WebUtility.UrlEncode(key);
|
||||
var valueEncoded = WebUtility.UrlEncode(value);
|
||||
var keyEncoded = Uri.EscapeDataString(key);
|
||||
var valueEncoded = value?.Pipe(Uri.EscapeDataString);
|
||||
_queryParameters[keyEncoded] = valueEncoded;
|
||||
|
||||
return this;
|
||||
@@ -38,9 +39,11 @@ public class UrlBuilder
|
||||
buffer.Append(_path);
|
||||
|
||||
if (_queryParameters.Any())
|
||||
{
|
||||
buffer
|
||||
.Append('?')
|
||||
.AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
|
||||
<Style Selector="dialogHostAvalonia|DialogOverlayPopupHost">
|
||||
<Setter Property="Margin" Value="48" />
|
||||
<Setter Property="Background" Value="{DynamicResource MaterialPaperBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Snack bar host -->
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
@@ -61,12 +60,11 @@ public class App : Application, IDisposable
|
||||
{
|
||||
ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light,
|
||||
ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark,
|
||||
_ => Avalonia.Styling.ThemeVariant.Default
|
||||
_ => Avalonia.Styling.ThemeVariant.Default,
|
||||
};
|
||||
|
||||
InitializeTheme();
|
||||
},
|
||||
false
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -75,9 +73,6 @@ public class App : Application, IDisposable
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
// Increase maximum concurrent connections
|
||||
ServicePointManager.DefaultConnectionLimit = 20;
|
||||
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
@@ -87,7 +82,7 @@ public class App : Application, IDisposable
|
||||
{
|
||||
"Light" => PlatformThemeVariant.Light,
|
||||
"Dark" => PlatformThemeVariant.Dark,
|
||||
_ => PlatformSettings?.GetColorValues().ThemeVariant ?? PlatformThemeVariant.Light
|
||||
_ => PlatformSettings?.GetColorValues().ThemeVariant ?? PlatformThemeVariant.Light,
|
||||
};
|
||||
|
||||
this.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme =
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Converters;
|
||||
|
||||
public class RateLimitPreferenceToStringConverter : IValueConverter
|
||||
{
|
||||
public static RateLimitPreferenceToStringConverter Instance { get; } = new();
|
||||
|
||||
public object? Convert(
|
||||
object? value,
|
||||
Type targetType,
|
||||
object? parameter,
|
||||
CultureInfo culture
|
||||
) =>
|
||||
value is RateLimitPreference rateLimitPreference
|
||||
? rateLimitPreference.GetDisplayName()
|
||||
: default;
|
||||
|
||||
public object ConvertBack(
|
||||
object? value,
|
||||
Type targetType,
|
||||
object? parameter,
|
||||
CultureInfo culture
|
||||
) => throw new NotSupportedException();
|
||||
}
|
||||
@@ -1,36 +1,42 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<AssemblyName>DiscordChatExporter</AssemblyName>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.2.1" />
|
||||
<PackageReference Include="Avalonia" Version="11.0.10" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.10" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.10" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.3.0" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
||||
<PackageReference
|
||||
Include="Avalonia.Diagnostics"
|
||||
Version="11.3.0"
|
||||
Condition="'$(Configuration)' == 'Debug'"
|
||||
/>
|
||||
<PackageReference Include="Cogwheel" Version="2.1.0" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="DialogHost.Avalonia" Version="0.7.7" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||
<PackageReference Include="DialogHost.Avalonia" Version="0.9.2" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="Material.Avalonia" Version="3.6.0" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.1.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
<PackageReference Include="Onova" Version="2.6.12" />
|
||||
<PackageReference Include="Material.Avalonia" Version="3.9.2" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.5" />
|
||||
<PackageReference Include="Onova" Version="2.6.13" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<Target Name="PublishMacOSBundle" AfterTargets="Publish" Condition="$(PublishMacOSBundle)">
|
||||
<Exec
|
||||
Command="pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Publish-MacOSBundle.ps1 -PublishDirPath $(PublishDir) -IconsFilePath $(ProjectDir)/../favicon.icns -FullVersion $(Version) -ShortVersion $(AssemblyVersion)"
|
||||
LogStandardErrorAsError="true"
|
||||
/>
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -56,7 +56,7 @@ public class DialogManager : IDisposable
|
||||
{
|
||||
FileTypeChoices = fileTypes,
|
||||
SuggestedFileName = defaultFilePath,
|
||||
DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.')
|
||||
DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.'),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -77,7 +77,7 @@ public class DialogManager : IDisposable
|
||||
new FolderPickerOpenOptions
|
||||
{
|
||||
AllowMultiple = false,
|
||||
SuggestedStartLocation = startLocation
|
||||
SuggestedStartLocation = startLocation,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@ namespace DiscordChatExporter.Gui.Framework;
|
||||
|
||||
public abstract partial class DialogViewModelBase<T> : ViewModelBase
|
||||
{
|
||||
private readonly TaskCompletionSource<T> _closeTcs =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource<T> _closeTcs = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously
|
||||
);
|
||||
|
||||
[ObservableProperty]
|
||||
private T? _dialogResult;
|
||||
public partial T? DialogResult { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
protected void Close(T dialogResult)
|
||||
|
||||
@@ -4,5 +4,5 @@ public enum ThemeVariant
|
||||
{
|
||||
System,
|
||||
Light,
|
||||
Dark
|
||||
Dark,
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public partial class ViewManager
|
||||
ExportSetupViewModel => new ExportSetupView(),
|
||||
MessageBoxViewModel => new MessageBoxView(),
|
||||
SettingsViewModel => new SettingsView(),
|
||||
_ => null
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public Control? TryBindView(ViewModelBase viewModel)
|
||||
|
||||
@@ -4,5 +4,5 @@ public enum ThreadInclusionMode
|
||||
{
|
||||
None,
|
||||
Active,
|
||||
All
|
||||
All,
|
||||
}
|
||||
|
||||
@@ -7,17 +7,21 @@ namespace DiscordChatExporter.Gui;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
private static Assembly Assembly { get; } = typeof(App).Assembly;
|
||||
private static Assembly Assembly { get; } = Assembly.GetExecutingAssembly();
|
||||
|
||||
public static string Name { get; } = Assembly.GetName().Name!;
|
||||
public static string Name { get; } = Assembly.GetName().Name ?? "DiscordChatExporter";
|
||||
|
||||
public static Version Version { get; } = Assembly.GetName().Version!;
|
||||
public static Version Version { get; } = Assembly.GetName().Version ?? new Version(0, 0, 0);
|
||||
|
||||
public static string VersionString { get; } = Version.ToString(3);
|
||||
|
||||
public static bool IsDevelopmentBuild { get; } = Version.Major is <= 0 or >= 999;
|
||||
|
||||
public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter";
|
||||
|
||||
public static string DocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
|
||||
public static string ProjectReleasesUrl { get; } = $"{ProjectUrl}/releases";
|
||||
|
||||
public static string ProjectDocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>().UsePlatformDetect().LogToTrace();
|
||||
|
||||
87
DiscordChatExporter.Gui/Publish-MacOSBundle.ps1
Normal file
87
DiscordChatExporter.Gui/Publish-MacOSBundle.ps1
Normal file
@@ -0,0 +1,87 @@
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$PublishDirPath,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$IconsFilePath,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$FullVersion,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$ShortVersion
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Setup paths
|
||||
$tempDirPath = Join-Path $PublishDirPath "../publish-macos-app-temp"
|
||||
$bundleName = "DiscordChatExporter.app"
|
||||
$bundleDirPath = Join-Path $tempDirPath $bundleName
|
||||
$contentsDirPath = Join-Path $bundleDirPath "Contents"
|
||||
$macosDirPath = Join-Path $contentsDirPath "MacOS"
|
||||
$resourcesDirPath = Join-Path $contentsDirPath "Resources"
|
||||
|
||||
try {
|
||||
# Initialize the bundle's directory structure
|
||||
New-Item -Path $bundleDirPath -ItemType Directory -Force
|
||||
New-Item -Path $contentsDirPath -ItemType Directory -Force
|
||||
New-Item -Path $macosDirPath -ItemType Directory -Force
|
||||
New-Item -Path $resourcesDirPath -ItemType Directory -Force
|
||||
|
||||
# Copy icons into the .app's Resources folder
|
||||
Copy-Item -Path $IconsFilePath -Destination (Join-Path $resourcesDirPath "AppIcon.icns") -Force
|
||||
|
||||
# Generate the Info.plist metadata file with the app information
|
||||
$plistContent = @"
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>DiscordChatExporter</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>DiscordChatExporter</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>DiscordChatExporter</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© Oleksii Holub</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>me.Tyrrrz.DiscordChatExporter</string>
|
||||
<key>CFBundleSpokenName</key>
|
||||
<string>Discord Chat Exporter</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$FullVersion</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$ShortVersion</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true />
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
</dict>
|
||||
</plist>
|
||||
"@
|
||||
|
||||
Set-Content -Path (Join-Path $contentsDirPath "Info.plist") -Value $plistContent
|
||||
|
||||
# Delete the previous bundle if it exists
|
||||
if (Test-Path (Join-Path $PublishDirPath $bundleName)) {
|
||||
Remove-Item -Path (Join-Path $PublishDirPath $bundleName) -Recurse -Force
|
||||
}
|
||||
|
||||
# Move all files from the publish directory into the MacOS directory
|
||||
Get-ChildItem -Path $PublishDirPath | ForEach-Object {
|
||||
Move-Item -Path $_.FullName -Destination $macosDirPath -Force
|
||||
}
|
||||
|
||||
# Move the final bundle into the publish directory for upload
|
||||
Move-Item -Path $bundleDirPath -Destination $PublishDirPath -Force
|
||||
}
|
||||
finally {
|
||||
# Clean up the temporary directory
|
||||
Remove-Item -Path $tempDirPath -Recurse -Force
|
||||
}
|
||||
@@ -3,132 +3,71 @@ using System.IO;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cogwheel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using DiscordChatExporter.Gui.Framework;
|
||||
using DiscordChatExporter.Gui.Models;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Services;
|
||||
|
||||
// Can't use [ObservableProperty] here because System.Text.Json's source generator doesn't see
|
||||
// the generated properties.
|
||||
[INotifyPropertyChanged]
|
||||
[ObservableObject]
|
||||
public partial class SettingsService()
|
||||
: SettingsBase(
|
||||
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.dat"),
|
||||
Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
|
||||
SerializerContext.Default
|
||||
)
|
||||
{
|
||||
private bool _isUkraineSupportMessageEnabled = true;
|
||||
public bool IsUkraineSupportMessageEnabled
|
||||
{
|
||||
get => _isUkraineSupportMessageEnabled;
|
||||
set => SetProperty(ref _isUkraineSupportMessageEnabled, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial bool IsUkraineSupportMessageEnabled { get; set; } = true;
|
||||
|
||||
private ThemeVariant _theme;
|
||||
public ThemeVariant Theme
|
||||
{
|
||||
get => _theme;
|
||||
set => SetProperty(ref _theme, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial ThemeVariant Theme { get; set; }
|
||||
|
||||
private bool _isAutoUpdateEnabled = true;
|
||||
public bool IsAutoUpdateEnabled
|
||||
{
|
||||
get => _isAutoUpdateEnabled;
|
||||
set => SetProperty(ref _isAutoUpdateEnabled, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial bool IsAutoUpdateEnabled { get; set; } = true;
|
||||
|
||||
private bool _isTokenPersisted = true;
|
||||
public bool IsTokenPersisted
|
||||
{
|
||||
get => _isTokenPersisted;
|
||||
set => SetProperty(ref _isTokenPersisted, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial bool IsTokenPersisted { get; set; } = true;
|
||||
|
||||
private ThreadInclusionMode _threadInclusionMode;
|
||||
public ThreadInclusionMode ThreadInclusionMode
|
||||
{
|
||||
get => _threadInclusionMode;
|
||||
set => SetProperty(ref _threadInclusionMode, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial RateLimitPreference RateLimitPreference { get; set; } =
|
||||
RateLimitPreference.RespectAll;
|
||||
|
||||
private string? _locale;
|
||||
public string? Locale
|
||||
{
|
||||
get => _locale;
|
||||
set => SetProperty(ref _locale, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial ThreadInclusionMode ThreadInclusionMode { get; set; }
|
||||
|
||||
private bool _isUtcNormalizationEnabled;
|
||||
public bool IsUtcNormalizationEnabled
|
||||
{
|
||||
get => _isUtcNormalizationEnabled;
|
||||
set => SetProperty(ref _isUtcNormalizationEnabled, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial string? Locale { get; set; }
|
||||
|
||||
private int _parallelLimit = 1;
|
||||
public int ParallelLimit
|
||||
{
|
||||
get => _parallelLimit;
|
||||
set => SetProperty(ref _parallelLimit, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial bool IsUtcNormalizationEnabled { get; set; }
|
||||
|
||||
private string? _lastToken;
|
||||
public string? LastToken
|
||||
{
|
||||
get => _lastToken;
|
||||
set => SetProperty(ref _lastToken, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial int ParallelLimit { get; set; } = 1;
|
||||
|
||||
private ExportFormat _lastExportFormat = ExportFormat.HtmlDark;
|
||||
public ExportFormat LastExportFormat
|
||||
{
|
||||
get => _lastExportFormat;
|
||||
set => SetProperty(ref _lastExportFormat, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial string? LastToken { get; set; }
|
||||
|
||||
private string? _lastPartitionLimitValue;
|
||||
public string? LastPartitionLimitValue
|
||||
{
|
||||
get => _lastPartitionLimitValue;
|
||||
set => SetProperty(ref _lastPartitionLimitValue, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||
|
||||
private string? _lastMessageFilterValue;
|
||||
public string? LastMessageFilterValue
|
||||
{
|
||||
get => _lastMessageFilterValue;
|
||||
set => SetProperty(ref _lastMessageFilterValue, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial string? LastPartitionLimitValue { get; set; }
|
||||
|
||||
private bool _lastShouldFormatMarkdown = true;
|
||||
public bool LastShouldFormatMarkdown
|
||||
{
|
||||
get => _lastShouldFormatMarkdown;
|
||||
set => SetProperty(ref _lastShouldFormatMarkdown, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial string? LastMessageFilterValue { get; set; }
|
||||
|
||||
private bool _lastShouldDownloadAssets;
|
||||
public bool LastShouldDownloadAssets
|
||||
{
|
||||
get => _lastShouldDownloadAssets;
|
||||
set => SetProperty(ref _lastShouldDownloadAssets, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial bool LastShouldFormatMarkdown { get; set; } = true;
|
||||
|
||||
private bool _lastShouldReuseAssets;
|
||||
public bool LastShouldReuseAssets
|
||||
{
|
||||
get => _lastShouldReuseAssets;
|
||||
set => SetProperty(ref _lastShouldReuseAssets, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial bool LastShouldDownloadAssets { get; set; }
|
||||
|
||||
private string? _lastAssetsDirPath;
|
||||
public string? LastAssetsDirPath
|
||||
{
|
||||
get => _lastAssetsDirPath;
|
||||
set => SetProperty(ref _lastAssetsDirPath, value);
|
||||
}
|
||||
[ObservableProperty]
|
||||
public partial bool LastShouldReuseAssets { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial string? LastAssetsDirPath { get; set; }
|
||||
|
||||
public override void Save()
|
||||
{
|
||||
|
||||
@@ -11,15 +11,11 @@ internal static class NotifyPropertyChangedExtensions
|
||||
this TOwner owner,
|
||||
Expression<Func<TOwner, TProperty>> propertyExpression,
|
||||
Action callback,
|
||||
bool watchInitialValue = true
|
||||
bool watchInitialValue = false
|
||||
)
|
||||
where TOwner : INotifyPropertyChanged
|
||||
{
|
||||
var memberExpression =
|
||||
propertyExpression.Body as MemberExpression
|
||||
// Property value might be boxed inside a conversion expression, if the types don't match
|
||||
?? (propertyExpression.Body as UnaryExpression)?.Operand as MemberExpression;
|
||||
|
||||
var memberExpression = propertyExpression.Body as MemberExpression;
|
||||
if (memberExpression?.Member is not PropertyInfo property)
|
||||
throw new ArgumentException("Provided expression must reference a property.");
|
||||
|
||||
@@ -45,7 +41,7 @@ internal static class NotifyPropertyChangedExtensions
|
||||
public static IDisposable WatchAllProperties<TOwner>(
|
||||
this TOwner owner,
|
||||
Action callback,
|
||||
bool watchInitialValues = true
|
||||
bool watchInitialValues = false
|
||||
)
|
||||
where TOwner : INotifyPropertyChanged
|
||||
{
|
||||
|
||||
@@ -33,28 +33,6 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
|
||||
private DiscordClient? _discord;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsProgressIndeterminate))]
|
||||
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
||||
private bool _isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
|
||||
private string? _token;
|
||||
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<Guild>? _availableGuilds;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
||||
private Guild? _selectedGuild;
|
||||
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<ChannelNode>? _availableChannels;
|
||||
|
||||
public DashboardViewModel(
|
||||
ViewModelManager viewModelManager,
|
||||
DialogManager dialogManager,
|
||||
@@ -84,11 +62,33 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsProgressIndeterminate))]
|
||||
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
||||
public ProgressContainer<Percentage> Progress { get; } = new();
|
||||
|
||||
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
|
||||
|
||||
public ObservableCollection<ChannelNode> SelectedChannels { get; } = [];
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
|
||||
public partial string? Token { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial IReadOnlyList<Guild>? AvailableGuilds { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
||||
public partial Guild? SelectedGuild { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial IReadOnlyList<ChannelConnection>? AvailableChannels { get; set; }
|
||||
|
||||
public ObservableCollection<ChannelConnection> SelectedChannels { get; } = [];
|
||||
|
||||
[RelayCommand]
|
||||
private void Initialize()
|
||||
@@ -102,7 +102,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowHelp() => ProcessEx.StartShellExecute(Program.DocumentationUrl);
|
||||
private void ShowHelp() => ProcessEx.StartShellExecute(Program.ProjectDocumentationUrl);
|
||||
|
||||
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
|
||||
|
||||
@@ -123,7 +123,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
AvailableChannels = null;
|
||||
SelectedChannels.Clear();
|
||||
|
||||
_discord = new DiscordClient(token);
|
||||
_discord = new DiscordClient(token, _settingsService.RateLimitPreference);
|
||||
_settingsService.LastToken = token;
|
||||
|
||||
var guilds = await _discord.GetUserGuildsAsync();
|
||||
@@ -190,7 +190,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
// Build a hierarchy of channels
|
||||
var channelTree = ChannelNode.BuildTree(
|
||||
var channelTree = ChannelConnection.BuildTree(
|
||||
channels
|
||||
.OrderByDescending(c => c.IsDirect ? c.LastMessageId : null)
|
||||
.ThenBy(c => c.Position)
|
||||
@@ -253,7 +253,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
channelProgressPairs,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit)
|
||||
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit),
|
||||
},
|
||||
async (pair, cancellationToken) =>
|
||||
{
|
||||
@@ -283,6 +283,10 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
|
||||
Interlocked.Increment(ref successfulExportCount);
|
||||
}
|
||||
catch (ChannelEmptyException ex)
|
||||
{
|
||||
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
|
||||
}
|
||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||
{
|
||||
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
|
||||
|
||||
@@ -22,56 +22,56 @@ public partial class ExportSetupViewModel(
|
||||
) : DialogViewModelBase
|
||||
{
|
||||
[ObservableProperty]
|
||||
private Guild? _guild;
|
||||
public partial Guild? Guild { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsSingleChannel))]
|
||||
private IReadOnlyList<Channel>? _channels;
|
||||
public partial IReadOnlyList<Channel>? Channels { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _outputPath;
|
||||
public partial string? OutputPath { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
private ExportFormat _selectedFormat;
|
||||
public partial ExportFormat SelectedFormat { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsAfterDateSet))]
|
||||
[NotifyPropertyChangedFor(nameof(After))]
|
||||
private DateTimeOffset? _afterDate;
|
||||
public partial DateTimeOffset? AfterDate { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
private TimeSpan? _afterTime;
|
||||
public partial TimeSpan? AfterTime { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsBeforeDateSet))]
|
||||
[NotifyPropertyChangedFor(nameof(Before))]
|
||||
private DateTimeOffset? _beforeDate;
|
||||
public partial DateTimeOffset? BeforeDate { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
private TimeSpan? _beforeTime;
|
||||
public partial TimeSpan? BeforeTime { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(PartitionLimit))]
|
||||
private string? _partitionLimitValue;
|
||||
public partial string? PartitionLimitValue { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(MessageFilter))]
|
||||
private string? _messageFilterValue;
|
||||
public partial string? MessageFilterValue { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _shouldFormatMarkdown;
|
||||
public partial bool ShouldFormatMarkdown { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _shouldDownloadAssets;
|
||||
public partial bool ShouldDownloadAssets { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _shouldReuseAssets;
|
||||
public partial bool ShouldReuseAssets { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _assetsDirPath;
|
||||
public partial string? AssetsDirPath { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isAdvancedSectionDisplayed;
|
||||
public partial bool IsAdvancedSectionDisplayed { get; set; }
|
||||
|
||||
public bool IsSingleChannel => Channels?.Count == 1;
|
||||
|
||||
@@ -138,8 +138,8 @@ public partial class ExportSetupViewModel(
|
||||
[
|
||||
new FilePickerFileType($"{extension.ToUpperInvariant()} file")
|
||||
{
|
||||
Patterns = [$"*.{extension}"]
|
||||
}
|
||||
Patterns = [$"*.{extension}"],
|
||||
},
|
||||
],
|
||||
defaultFileName
|
||||
);
|
||||
|
||||
@@ -6,20 +6,20 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||
public partial class MessageBoxViewModel : DialogViewModelBase
|
||||
{
|
||||
[ObservableProperty]
|
||||
private string? _title = "Title";
|
||||
public partial string? Title { get; set; } = "Title";
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _message = "Message";
|
||||
public partial string? Message { get; set; } = "Message";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsDefaultButtonVisible))]
|
||||
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
|
||||
private string? _defaultButtonText = "OK";
|
||||
public partial string? DefaultButtonText { get; set; } = "OK";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsCancelButtonVisible))]
|
||||
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
|
||||
private string? _cancelButtonText = "Cancel";
|
||||
public partial string? CancelButtonText { get; set; } = "Cancel";
|
||||
|
||||
public bool IsDefaultButtonVisible => !string.IsNullOrWhiteSpace(DefaultButtonText);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user