mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-09 07:30:28 +02:00
Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea0b47f26d | |||
| f6e710909c | |||
| 1835af12d5 | |||
| f8ab926074 | |||
| 7f08641974 | |||
| 948ceba8cc | |||
| b0ee4ba646 | |||
| 89407c121f | |||
| 4ec8faef93 | |||
| 245b368396 | |||
| cbf74ba6a8 | |||
| f656234c38 | |||
| 316e8c6ea8 | |||
| 1087173fef | |||
| a31629f722 | |||
| 5c86beb809 | |||
| 55c06ef40e | |||
| f3638ecbdd | |||
| 0e3b455655 | |||
| 16ecb2802a | |||
| 6750195a35 | |||
| efb371f093 | |||
| 716ea79f60 | |||
| af3ec8c299 | |||
| f1673cde77 | |||
| ed33d19bc0 | |||
| 80e9ad75ba | |||
| 295f4cf9a2 | |||
| 1f189e5206 | |||
| df683b3b41 | |||
| d467948d0b | |||
| f6166764e9 | |||
| 7ee2763d4b | |||
| eb99ab29ed | |||
| 5c61306231 | |||
| 2e4601d378 | |||
| cc703cc860 | |||
| 661f82c478 | |||
| b9f19f7003 | |||
| e794d7c833 | |||
| 5a26ba0b5a | |||
| 1181a35f08 | |||
| 4a62f9c34f | |||
| b6e1f92989 | |||
| 0d3d2ddcf0 | |||
| 9afecd47bd | |||
| 5cec162b4e | |||
| 6647f90ced | |||
| b9a3001c11 | |||
| dadfe5b8ef | |||
| 4f29fa63d0 | |||
| 54b0174bde | |||
| 3be5bc6a21 | |||
| c9f152773a | |||
| 5e39611395 | |||
| 40491bdc0f | |||
| 03dd272eaf | |||
| 98e294f7e4 | |||
| b22e786449 | |||
| 951c1e4613 | |||
| 675d910ea3 | |||
| 28f26e45fb | |||
| 4674c517e3 | |||
| b660edfe78 | |||
| 532470e3db | |||
| b533e08d74 | |||
| d490fd0a38 | |||
| 21c2398e2a | |||
| 7ff2347df1 | |||
| 3391e755fc | |||
| f0e9d3be1b | |||
| eef0fc742d | |||
| 2e47c73388 | |||
| 2d49146c74 | |||
| c59374598a | |||
| 01d0203445 | |||
| c4bfb3424e | |||
| 522caba420 | |||
| ad1170b42e | |||
| d2c0e2169b | |||
| e6562de1f5 | |||
| 5f8054e2f0 | |||
| 12d98e9ab0 | |||
| 18086aa209 | |||
| dd7196b6a5 | |||
| 72f9e981de | |||
| 81e286751f | |||
| 225230a20f | |||
| dea412ff25 | |||
| 95953f8cf0 | |||
| 6191855e33 | |||
| 33a975b907 | |||
| 3d353bc1e6 | |||
| f555ab3f00 | |||
| a14a17db83 | |||
| 1f63d4fa26 | |||
| d3d078735a | |||
| 58dd40bf3e | |||
| 7c79e0639b | |||
| a74ba4da0c | |||
| 8bbb8fa5ee | |||
| a91337ccac | |||
| 303f5667bf | |||
| 934cb526e9 | |||
| c33b742dce | |||
| f0a74fbc6e | |||
| 768e124370 | |||
| 8dc7eb2937 | |||
| 8fff0f4445 | |||
| 0fc8124aa0 | |||
| 64ed6acf34 | |||
| e958600073 | |||
| 714d2e6806 | |||
| fbbac2afaa | |||
| 380dd6d511 | |||
| 607ec7442f | |||
| e6f675bcaa | |||
| 42ebb9928e | |||
| 7e64aa5a8e | |||
| a383722469 | |||
| 8f3fe7e501 | |||
| 27a434a663 | |||
| a966bee140 | |||
| fb17e86146 | |||
| 8fcf5e5377 | |||
| 845e2a52fe | |||
| 187befcf11 | |||
| 9e685a0375 | |||
| 0d98e1084d | |||
| f149fad406 | |||
| 5405e619a9 | |||
| adafaaa49d | |||
| d071459a5b |
+2
-2
@@ -17,7 +17,7 @@ Note the `:stable` tag. DiscordChatExporter images are tagged according to the f
|
||||
|
||||
- `stable` — latest stable version release. This tag is updated with each release of a new project version. Recommended for personal use.
|
||||
- `x.y.z` (e.g. `2.30.1`) — specific stable version release. This tag is pushed when the corresponding version is released and never updated thereafter. Recommended for use in automation scenarios.
|
||||
- `latest` — latest (potentially unstable) build. This tag is updated with each new commit to the `master` branch. Not recommended, unless you want to test a new feature that has not been released in a stable version yet.
|
||||
- `latest` — latest (potentially unstable) build. This tag is updated with each new commit to the `prime` branch. Not recommended, unless you want to test a new feature that has not been released in a stable version yet.
|
||||
|
||||
You can see all available tags [here](https://hub.docker.com/r/tyrrrz/discordchatexporter/tags?ordering=name).
|
||||
|
||||
@@ -57,7 +57,7 @@ You can also use the current working directory as the output directory by specif
|
||||
- `-v $PWD:/out` in Bash
|
||||
- `-v $pwd.Path:/out` in PowerShell
|
||||
|
||||
For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/DiscordChatExporter.Cli.dockerfile) and [Docker documentation](https://docs.docker.com/engine/reference/run).
|
||||
For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/DiscordChatExporter.Cli.dockerfile) and [Docker documentation](https://docs.docker.com/engine/reference/run).
|
||||
|
||||
To get your Token and Channel IDs, please refer to [this page](Token-and-IDs.md).
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ The HTML format replicates Discord's interface, making it the most user-friendly
|
||||
It's the best format for attachment preview and sharing.
|
||||
You can open `.html` files with a web browser, such as Google Chrome.
|
||||
|
||||
> **Warning**:
|
||||
> [!WARNING]
|
||||
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed.
|
||||
> Export using the "Download assets" (`--media`) option to avoid this.
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||
|
||||
TOKEN=tokenhere
|
||||
CHANNELID=channelhere
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||
|
||||
TOKEN=tokenhere
|
||||
CHANNELID=channelhere
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
1. Open a text editor such as Notepad and paste:
|
||||
|
||||
```console
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||
|
||||
$TOKEN = "tokenhere"
|
||||
$CHANNEL = "channelhere"
|
||||
|
||||
@@ -27,7 +27,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
3. Type
|
||||
|
||||
```js
|
||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
||||
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.getToken){m=x;break}}}]);m&&console.log("Token:",m.exports.getToken());
|
||||
```
|
||||
|
||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||
@@ -120,7 +120,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
1. Type
|
||||
|
||||
```js
|
||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
||||
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.getToken){m=x;break}}}]);m&&console.log("Token:",m.exports.getToken());
|
||||
```
|
||||
|
||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||
@@ -306,7 +306,7 @@ If you don't have a bot token yet or if you've lost it, follow these steps to re
|
||||
> **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**:
|
||||
> [!WARNING]
|
||||
> Resetting the token will invalidate the old one. Any integrations relying on the old token will cease to function until they are updated.
|
||||
|
||||

|
||||
|
||||
+116
-61
@@ -23,85 +23,104 @@ 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
|
||||
./dce
|
||||
```
|
||||
|
||||
> **Note**:
|
||||
> On Windows, if you're using the default Command Prompt (`cmd`), omit the leading `./` at the start of the command.
|
||||
|
||||
> On Windows, use `dce` instead of `./dce`.
|
||||
|
||||
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
|
||||
|
||||
## CLI commands
|
||||
|
||||
| Command | Description |
|
||||
|-------------------------|------------------------------------------------------|
|
||||
| export | Exports a channel |
|
||||
| exportdm | Exports all direct message channels |
|
||||
| exportguild | Exports all channels within the specified server |
|
||||
| exportall | Exports all accessible channels |
|
||||
| channels | Outputs the list of channels in the given server |
|
||||
| dm | Outputs the list of direct message channels |
|
||||
| guilds | Outputs the list of accessible servers |
|
||||
| guide | Explains how to obtain token, server, and channel ID |
|
||||
| Command | Description |
|
||||
| ----------------- | -------------------------------------------------------------------- |
|
||||
| export | Exports one or more channels |
|
||||
| list channels | Outputs the list of channels in the given server(s) |
|
||||
| list channels dm | Outputs the list of direct message channels |
|
||||
| list servers | Outputs the list of accessible servers |
|
||||
| list unwrap | Resolves categories and forums in a channel list to their child channels and threads |
|
||||
| guide | Explains how to obtain token, server, and channel ID |
|
||||
|
||||
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `./DiscordChatExporter.Cli guide`.
|
||||
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `./dce guide`.
|
||||
|
||||
To pass the token, use the `-t|--token` option:
|
||||
|
||||
```console
|
||||
./dce export 53555 -t "mfa.Ifrn"
|
||||
```
|
||||
|
||||
Alternatively, you can set the `DISCORD_TOKEN` environment variable and omit `-t`:
|
||||
|
||||
**Linux/macOS:**
|
||||
|
||||
```console
|
||||
export DISCORD_TOKEN="mfa.Ifrn"
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
|
||||
```console
|
||||
set DISCORD_TOKEN=mfa.Ifrn
|
||||
```
|
||||
|
||||
The pipeline examples in this guide assume `DISCORD_TOKEN` is already set.
|
||||
|
||||
To get help with a specific command, run:
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli command --help
|
||||
./dce command --help
|
||||
```
|
||||
|
||||
For example, to figure out how to use the `export` command, run:
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli export --help
|
||||
./dce export --help
|
||||
```
|
||||
|
||||
## Export a specific channel
|
||||
|
||||
You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`.
|
||||
You can quickly export with DCE's default settings by providing the channel ID as a positional argument and `-t|--token`.
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
|
||||
./dce export 53555 -t "mfa.Ifrn"
|
||||
```
|
||||
|
||||
#### Changing the format
|
||||
|
||||
You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json` or `Csv` with `-f format`. The default
|
||||
You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json` or `Csv` with `-f|--format`. The default
|
||||
format is `HtmlDark`.
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json
|
||||
./dce export 53555 -t "mfa.Ifrn" -f Json
|
||||
```
|
||||
|
||||
#### Changing the output filename
|
||||
|
||||
You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:
|
||||
You can change the filename by using `-o|--output`. e.g. for the `HTML` format:
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
|
||||
./dce export 53555 -t "mfa.Ifrn" -o myserver.html
|
||||
```
|
||||
|
||||
#### Changing the output directory
|
||||
|
||||
You can change the export directory by using `-o` and providing a path that ends with a slash or does not have a file
|
||||
You can change the export directory by using `-o|--output` and providing a path that ends with a slash or does not have a file
|
||||
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"
|
||||
./dce export 53555 -t "mfa.Ifrn" -o "C:\Discord Exports"
|
||||
```
|
||||
|
||||
#### Changing the filename and output directory
|
||||
|
||||
You can change both the filename and export directory by using `-o directory\name.ext`.
|
||||
You can change both the filename and export directory by using `-o|--output`.
|
||||
Note that the filename must have an extension, otherwise it will be considered a directory name.
|
||||
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"
|
||||
./dce export 53555 -t "mfa.Ifrn" -o "C:\Discord Exports\myserver.html"
|
||||
```
|
||||
|
||||
#### Generating the filename and output directory dynamically
|
||||
@@ -109,7 +128,7 @@ If any of the folders in the path have a space in its name, escape them with quo
|
||||
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"
|
||||
./dce export 53555 -t "mfa.Ifrn" -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
|
||||
@@ -137,13 +156,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
|
||||
./dce export 53555 -t "mfa.Ifrn" -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
|
||||
./dce export 53555 -t "mfa.Ifrn" -p 20mb
|
||||
```
|
||||
|
||||
#### Downloading assets
|
||||
@@ -154,7 +173,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
|
||||
./dce export 53555 -t "mfa.Ifrn" --media
|
||||
```
|
||||
|
||||
#### Reusing assets
|
||||
@@ -163,7 +182,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
|
||||
./dce export 53555 -t "mfa.Ifrn" --media --reuse-media
|
||||
```
|
||||
|
||||
#### Changing the media directory
|
||||
@@ -172,7 +191,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"
|
||||
./dce export 53555 -t "mfa.Ifrn" --media --media-dir "C:\Discord Media"
|
||||
```
|
||||
|
||||
#### Changing the date format
|
||||
@@ -181,7 +200,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"
|
||||
./dce export 53555 -t "mfa.Ifrn" --locale "de-DE"
|
||||
```
|
||||
|
||||
#### Date ranges
|
||||
@@ -190,14 +209,14 @@ locales. The default locale is `en-US`.
|
||||
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
|
||||
./dce export 53555 -t "mfa.Ifrn" --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"
|
||||
./dce export 53555 -t "mfa.Ifrn" --after "2019-09-17 23:34"
|
||||
```
|
||||
|
||||
**Messages sent in a date range**
|
||||
@@ -205,7 +224,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"
|
||||
./dce export 53555 -t "mfa.Ifrn" --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
|
||||
@@ -219,75 +238,111 @@ formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custo
|
||||
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"
|
||||
./dce export 53555 -t "mfa.Ifrn" --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).
|
||||
Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.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:
|
||||
> [!IMPORTANT]
|
||||
> The following examples assume `DISCORD_TOKEN` is already set. See [CLI commands](#cli-commands) for instructions.
|
||||
|
||||
To export all channels in a specific server, use `list channels` to list channels and pipe the result to `export`.
|
||||
|
||||
**Linux/macOS:**
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
|
||||
./dce list channels 21814 | ./dce export
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
|
||||
```console
|
||||
dce list channels 21814 | dce export
|
||||
```
|
||||
|
||||
You can also list channels for multiple servers at once:
|
||||
|
||||
```console
|
||||
./dce list channels 21814 35930 | ./dce export
|
||||
```
|
||||
|
||||
#### Including threads
|
||||
|
||||
By default, threads are not included in the export. You can change this behavior by using `--include-threads` and
|
||||
specifying which threads should be included. It has possible values of `none`, `active`, or `all`, indicating which
|
||||
threads should be included. To include both active and archived threads, use `--include-threads all`.
|
||||
By default, threads are not included. You can change this behavior by passing `--include-threads` to the `list channels` command. It has possible values of `none`, `active`, or `all`, indicating which 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
|
||||
./dce list channels 21814 --include-threads all | ./dce export
|
||||
```
|
||||
|
||||
#### Including voice channels
|
||||
|
||||
By default, voice channels are included in the export. You can change this behavior by using `--include-vc` and
|
||||
specifying whether to include voice channels in the export. It has possible values of `true` or `false`, to exclude
|
||||
voice channels, use `--include-vc false`.
|
||||
By default, voice channels are included. You can change this behavior by passing `--include-vc false` to the `list channels` command.
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
|
||||
./dce list channels 21814 --include-vc false | ./dce export
|
||||
```
|
||||
|
||||
### Export all channels
|
||||
### Export all DMs
|
||||
|
||||
To export all accessible channels, use the `exportall` command:
|
||||
> [!IMPORTANT]
|
||||
> The following examples assume `DISCORD_TOKEN` is already set. See [CLI commands](#cli-commands) for instructions.
|
||||
|
||||
To export all DMs:
|
||||
|
||||
**Linux/macOS:**
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
|
||||
./dce list channels dm | ./dce export
|
||||
```
|
||||
|
||||
#### Excluding DMs
|
||||
|
||||
To exclude DMs, add the `--include-dm false` option.
|
||||
**Windows:**
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
|
||||
dce list channels dm | dce export
|
||||
```
|
||||
|
||||
### List channels in a server
|
||||
|
||||
To list the channels available in a specific server, use the `channels` command and provide the server ID through the `-g|--guild` option:
|
||||
To list the channels available in a specific server, use the `list channels` command and provide the server ID as an argument:
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
|
||||
./dce list channels 21814 -t "mfa.Ifrn"
|
||||
```
|
||||
|
||||
The `list channels` command outputs a JSON array of channel objects. You can pipe this directly to the `export` command:
|
||||
|
||||
```console
|
||||
./dce list channels 21814 | ./dce export
|
||||
```
|
||||
|
||||
### List direct message channels
|
||||
|
||||
To list all DM channels accessible to the current account, use the `dm` command:
|
||||
To list all DM channels accessible to the current account, use the `list channels dm` command:
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
|
||||
./dce list channels dm -t "mfa.Ifrn"
|
||||
```
|
||||
|
||||
The `list channels dm` command outputs a JSON array of channel objects. You can pipe this directly to the `export` command:
|
||||
|
||||
```console
|
||||
./dce list channels dm | ./dce export
|
||||
```
|
||||
|
||||
### Unwrap categories and forums
|
||||
|
||||
To resolve category and forum channels in a list to their child channels and thread posts, use the `list unwrap` command:
|
||||
|
||||
```console
|
||||
./dce list channels 21814 | ./dce list unwrap | ./dce export
|
||||
```
|
||||
|
||||
### List servers
|
||||
|
||||
To list all servers accessible by the current account, use the `guilds` command:
|
||||
To list all servers accessible by the current account, use the `list servers` command:
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
|
||||
./dce list servers -t "mfa.Ifrn" > C:\path\to\output.txt
|
||||
```
|
||||
|
||||
@@ -31,7 +31,7 @@ If you skip these steps, the "DiscordChatExporter is damaged and can’t be open
|
||||
|
||||
Please refer to the on-screen instructions to get your token, then paste your token in the upper text box and hit ENTER or click the arrow (→).
|
||||
|
||||
> **Warning**:
|
||||
> [!WARNING]
|
||||
> **Never share your token!**
|
||||
> A token gives full access to an account, treat it like a password.
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ⚠ Feature request
|
||||
url: https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md
|
||||
url: https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md
|
||||
about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.
|
||||
- name: 📖 Documentation
|
||||
url: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
||||
url: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||
about: Find usage guides and frequently asked questions.
|
||||
- name: 🗨 Discussions
|
||||
url: https://github.com/Tyrrrz/DiscordChatExporter/discussions/new
|
||||
|
||||
@@ -4,12 +4,12 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- prime
|
||||
tags:
|
||||
- "*"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- prime
|
||||
|
||||
jobs:
|
||||
# Outputs from this job aren't really used, but it's here to verify that the Dockerfile builds correctly
|
||||
@@ -23,10 +23,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build image
|
||||
run: >
|
||||
@@ -36,16 +36,16 @@ jobs:
|
||||
--build-arg VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
- name: Upload image
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: DiscordChatExporter.Cli.Docker
|
||||
path: DiscordChatExporter.Cli.Docker.tar
|
||||
if-no-files-found: error
|
||||
|
||||
deploy:
|
||||
# Deploy to DockerHub only on tag push or master branch push
|
||||
if: ${{ github.ref_type == 'tag' || github.ref_type == 'branch' && github.ref_name == 'master' }}
|
||||
# Deploy to DockerHub only on tag push or prime branch push
|
||||
if: ${{ github.ref_type == 'tag' || github.ref_type == 'branch' && github.ref_name == 'prime' }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
@@ -55,10 +55,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
run: >
|
||||
|
||||
+19
-27
@@ -4,12 +4,12 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- prime
|
||||
tags:
|
||||
- "*"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- prime
|
||||
|
||||
env:
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
|
||||
@@ -26,12 +26,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
|
||||
# Build the project separately to discern between build and format errors
|
||||
- name: Build
|
||||
@@ -64,12 +62,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -85,7 +81,7 @@ jobs:
|
||||
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -121,26 +117,25 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
|
||||
- name: Publish app
|
||||
run: >
|
||||
dotnet publish ${{ matrix.app }}
|
||||
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||
-p:CSharpier_Bypass=true
|
||||
-p:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }}
|
||||
-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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
- name: Upload app binaries
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/bin/publish/
|
||||
@@ -204,8 +199,8 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
- name: Download app binaries
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/
|
||||
@@ -213,13 +208,10 @@ jobs:
|
||||
- name: Set permissions
|
||||
if: ${{ !startsWith(matrix.rid, 'win-') }}
|
||||
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
|
||||
[ -f ${{ matrix.app }}/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }} || true
|
||||
|
||||
# macOS bundle
|
||||
[ -f ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} || true
|
||||
|
||||
- name: Create package
|
||||
# Change into the artifacts directory to avoid including the directory itself in the zip archive
|
||||
@@ -244,7 +236,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Notify Discord
|
||||
uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3
|
||||
uses: tyrrrz/action-http-request@25f132e48dea89c0f6b7955398270b506e1d51cb # 1.1.4
|
||||
with:
|
||||
url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
method: POST
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@ bin/
|
||||
obj/
|
||||
|
||||
# Test results
|
||||
TestResults/
|
||||
TestResults/
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>999.9.9-dev</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.7.0" />
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
|
||||
<PackageVersion Include="Avalonia" Version="11.3.13" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.13" />
|
||||
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.13" />
|
||||
<PackageVersion Include="CliFx" Version="3.0.0" />
|
||||
<PackageVersion Include="Cogwheel" Version="2.1.1" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageVersion Include="coverlet.collector" Version="8.0.1" />
|
||||
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.6" />
|
||||
<PackageVersion Include="Deorcify" Version="1.1.0" />
|
||||
<PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
|
||||
<PackageVersion Include="DialogHost.Avalonia" Version="0.11.0" />
|
||||
<PackageVersion Include="FluentAssertions" Version="8.9.0" />
|
||||
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.3" />
|
||||
<PackageVersion Include="Gress" Version="2.1.1" />
|
||||
<PackageVersion Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageVersion Include="Markdig" Version="1.1.2" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.9.2" />
|
||||
<PackageVersion Include="Material.Icons.Avalonia" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.5" />
|
||||
<PackageVersion
|
||||
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
||||
Version="10.0.5"
|
||||
/>
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||
<PackageVersion Include="Onova" Version="2.6.13" />
|
||||
<PackageVersion Include="Polly" Version="8.6.6" />
|
||||
<PackageVersion Include="RazorBlade" Version="0.11.0" />
|
||||
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
|
||||
<PackageVersion Include="Superpower" Version="3.1.0" />
|
||||
<PackageVersion Include="WebMarkupMin.Core" Version="2.21.0" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
<PackageVersion Include="YoutubeExplode" Version="6.5.7" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -4,26 +4,26 @@
|
||||
<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.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="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" />
|
||||
<PackageReference Include="AngleSharp" />
|
||||
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -16,6 +16,8 @@ public static class ChannelIds
|
||||
|
||||
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
||||
|
||||
public static Snowflake ForwardTestCases { get; } = Snowflake.Parse("1455202357204877477");
|
||||
|
||||
public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521");
|
||||
|
||||
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
||||
|
||||
@@ -8,5 +8,5 @@ In order to run these tests locally, you need to join the test server and config
|
||||
3. Add your token to user secrets: `dotnet user-secrets set DISCORD_TOKEN <token>`
|
||||
4. Run the tests: `dotnet test`
|
||||
|
||||
> **Note**:
|
||||
> [!NOTE]
|
||||
> If you want to add a new test case, please let me know and I will give you the required permissions on the server.
|
||||
@@ -1,7 +1,12 @@
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
@@ -44,4 +49,41 @@ public class HtmlContentSpecs
|
||||
"Yeet"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_in_the_HTML_format_in_the_reverse_order()
|
||||
{
|
||||
// Arrange
|
||||
using var file = TempFile.Create();
|
||||
|
||||
// Act
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = file.Path,
|
||||
Locale = "en-US",
|
||||
IsUtcNormalizationEnabled = true,
|
||||
IsReverseMessageOrder = true,
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
var document = Html.Parse(await File.ReadAllTextAsync(file.Path));
|
||||
var messages = document.QuerySelectorAll("[data-message-id]").ToArray();
|
||||
|
||||
// Assert
|
||||
messages
|
||||
.Select(e => e.GetAttribute("data-message-id"))
|
||||
.Should()
|
||||
.Equal(
|
||||
"885169254029213696",
|
||||
"868505973294268457",
|
||||
"868505969821364245",
|
||||
"868505966528835604",
|
||||
"868490009366396958",
|
||||
"866732113319428096",
|
||||
"866710679758045195",
|
||||
"866674314627121232"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,8 +181,14 @@ public class HtmlEmbedSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
|
||||
iframeUrl.Should().StartWith("https://www.youtube.com/embed/qOWW4OlgbvE");
|
||||
// Check that the YouTube video thumbnail image exists with the correct video ID
|
||||
var youtubeThumbnailSrc = message
|
||||
.QuerySelectorAll("img")
|
||||
.Select(e => e.GetAttribute("src"))
|
||||
.WhereNotNull()
|
||||
.FirstOrDefault(s => s.Contains("qOWW4OlgbvE", StringComparison.Ordinal));
|
||||
|
||||
youtubeThumbnailSrc.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
|
||||
public class HtmlForwardSpecs
|
||||
{
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||
{
|
||||
// Act
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
ChannelIds.ForwardTestCases,
|
||||
Snowflake.Parse("1455202427115536514")
|
||||
);
|
||||
|
||||
// Assert
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.ContainAll("Forwarded", @"¯\_(ツ)_/¯", "12/29/2025 2:14 PM");
|
||||
}
|
||||
}
|
||||
@@ -61,4 +61,17 @@ public class HtmlMentionSpecs
|
||||
// Assert
|
||||
message.Text().Should().Contain("Role mention: @Role 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_message_with_a_thread_mention()
|
||||
{
|
||||
// Act
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
ChannelIds.MentionTestCases,
|
||||
Snowflake.Parse("1474874276828938290")
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Thread mention: #Thread starting message");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using JsonExtensions;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
@@ -43,4 +49,43 @@ public class JsonContentSpecs
|
||||
"Yeet"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_in_the_JSON_format_in_the_reverse_order()
|
||||
{
|
||||
// Arrange
|
||||
using var file = TempFile.Create();
|
||||
|
||||
// Act
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
Locale = "en-US",
|
||||
IsUtcNormalizationEnabled = true,
|
||||
IsReverseMessageOrder = true,
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
var messages = Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.ToArray();
|
||||
|
||||
// Assert
|
||||
messages
|
||||
.Select(j => j.GetProperty("id").GetString())
|
||||
.Should()
|
||||
.Equal(
|
||||
"885169254029213696",
|
||||
"868505973294268457",
|
||||
"868505969821364245",
|
||||
"868505966528835604",
|
||||
"868490009366396958",
|
||||
"866732113319428096",
|
||||
"866710679758045195",
|
||||
"866674314627121232"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
|
||||
public class JsonForwardSpecs
|
||||
{
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||
{
|
||||
// Act
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
ChannelIds.ForwardTestCases,
|
||||
Snowflake.Parse("1455202427115536514")
|
||||
);
|
||||
|
||||
// Assert
|
||||
var reference = message.GetProperty("reference");
|
||||
reference.GetProperty("type").GetString().Should().Be("Forward");
|
||||
reference.GetProperty("guildId").GetString().Should().Be("869237470565392384");
|
||||
|
||||
var forwardedMessage = message.GetProperty("forwardedMessage");
|
||||
forwardedMessage.GetProperty("content").GetString().Should().Contain(@"¯\_(ツ)_/¯");
|
||||
forwardedMessage
|
||||
.GetProperty("timestamp")
|
||||
.GetString()
|
||||
.Should()
|
||||
.StartWith("2025-12-28T22:52:42.175+00:00");
|
||||
}
|
||||
}
|
||||
@@ -75,4 +75,21 @@ public class JsonMentionSpecs
|
||||
// Assert
|
||||
message.GetProperty("content").GetString().Should().Be("Role mention: @Role 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_message_with_a_thread_mention()
|
||||
{
|
||||
// Act
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
ChannelIds.MentionTestCases,
|
||||
Snowflake.Parse("1474874276828938290")
|
||||
);
|
||||
|
||||
// Assert
|
||||
message
|
||||
.GetProperty("content")
|
||||
.GetString()
|
||||
.Should()
|
||||
.Be("Thread mention: #Thread starting message");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
|
||||
public class PlainTextForwardSpecs
|
||||
{
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||
{
|
||||
// Act
|
||||
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.ForwardTestCases);
|
||||
|
||||
// Assert
|
||||
document
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.ContainAll("{Forwarded Message}", @"¯\_(ツ)_/¯", "12/28/2025 10:52 PM");
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,16 @@ namespace DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||
|
||||
internal static class StringExtensions
|
||||
{
|
||||
public static string ReplaceWhiteSpace(this string str, string replacement = " ")
|
||||
extension(string str)
|
||||
{
|
||||
var buffer = new StringBuilder(str.Length);
|
||||
public string ReplaceWhiteSpace(string replacement = " ")
|
||||
{
|
||||
var buffer = new StringBuilder(str.Length);
|
||||
|
||||
foreach (var ch in str)
|
||||
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
|
||||
foreach (var ch in str)
|
||||
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
|
||||
|
||||
return buffer.ToString();
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using PathEx = System.IO.Path;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||
|
||||
@@ -23,8 +22,8 @@ internal partial class TempDir
|
||||
{
|
||||
public static TempDir Create()
|
||||
{
|
||||
var dirPath = PathEx.Combine(
|
||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
var dirPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
?? Directory.GetCurrentDirectory(),
|
||||
"Temp",
|
||||
Guid.NewGuid().ToString()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using PathEx = System.IO.Path;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||
|
||||
@@ -23,15 +22,15 @@ internal partial class TempFile
|
||||
{
|
||||
public static TempFile Create()
|
||||
{
|
||||
var dirPath = PathEx.Combine(
|
||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
var dirPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
?? Directory.GetCurrentDirectory(),
|
||||
"Temp"
|
||||
);
|
||||
|
||||
Directory.CreateDirectory(dirPath);
|
||||
|
||||
var filePath = PathEx.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
||||
var filePath = System.IO.Path.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
||||
|
||||
return new TempFile(filePath);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -- Build
|
||||
# Specify the platform here so that we pull the SDK image matching the host platform,
|
||||
# instead of the target platform specified during build by the `--platform` option.
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||
|
||||
# Expose the target architecture set by the `docker build --platform` option, so that
|
||||
# we can build the assembly for the correct platform.
|
||||
@@ -15,6 +15,7 @@ WORKDIR /tmp/app
|
||||
COPY favicon.ico .
|
||||
COPY NuGet.config .
|
||||
COPY Directory.Build.props .
|
||||
COPY Directory.Packages.props .
|
||||
COPY DiscordChatExporter.Core DiscordChatExporter.Core
|
||||
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
||||
|
||||
@@ -30,7 +31,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:9.0-alpine AS run
|
||||
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS run
|
||||
|
||||
LABEL org.opencontainers.image.title="DiscordChatExporter.Cli"
|
||||
LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
@@ -17,23 +17,22 @@ public abstract class DiscordCommandBase : ICommand
|
||||
EnvironmentVariable = "DISCORD_TOKEN",
|
||||
Description = "Authentication token."
|
||||
)]
|
||||
public required string Token { get; init; }
|
||||
public required string Token { get; set; }
|
||||
|
||||
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
||||
[CommandOption(
|
||||
"bot",
|
||||
'b',
|
||||
EnvironmentVariable = "DISCORD_TOKEN_BOT",
|
||||
Description = "This option doesn't do anything. Kept for backwards compatibility."
|
||||
)]
|
||||
public bool IsBotToken { get; init; } = false;
|
||||
public bool IsBotToken { get; set; } = false;
|
||||
|
||||
[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;
|
||||
public bool ShouldRespectRateLimits { get; set; } = true;
|
||||
|
||||
[field: AllowNull, MaybeNull]
|
||||
protected DiscordClient Discord =>
|
||||
|
||||
@@ -5,8 +5,8 @@ using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
@@ -37,23 +37,23 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
get;
|
||||
// Handle ~/ in paths on Unix systems
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||
init => field = Path.GetFullPath(value);
|
||||
set => field = Path.GetFullPath(value);
|
||||
} = Directory.GetCurrentDirectory();
|
||||
|
||||
[CommandOption("format", 'f', Description = "Export format.")]
|
||||
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
||||
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||
|
||||
[CommandOption(
|
||||
"after",
|
||||
Description = "Only include messages sent after this date or message ID."
|
||||
)]
|
||||
public Snowflake? After { get; init; }
|
||||
public Snowflake? After { get; set; }
|
||||
|
||||
[CommandOption(
|
||||
"before",
|
||||
Description = "Only include messages sent before this date or message ID."
|
||||
)]
|
||||
public Snowflake? Before { get; init; }
|
||||
public Snowflake? Before { get; set; }
|
||||
|
||||
[CommandOption(
|
||||
"partition",
|
||||
@@ -61,45 +61,51 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
Description = "Split the output into partitions, each limited to the specified "
|
||||
+ "number of messages (e.g. '100') or file size (e.g. '10mb')."
|
||||
)]
|
||||
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
||||
public PartitionLimit PartitionLimit { get; set; } = PartitionLimit.Null;
|
||||
|
||||
[CommandOption(
|
||||
"include-threads",
|
||||
Description = "Which types of threads should be included.",
|
||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
||||
Converter = typeof(ThreadInclusionModeInputConverter)
|
||||
)]
|
||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
||||
public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None;
|
||||
|
||||
[CommandOption(
|
||||
"filter",
|
||||
Description = "Only include messages that satisfy this filter. "
|
||||
+ "See the documentation for more info."
|
||||
)]
|
||||
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
|
||||
public MessageFilter MessageFilter { get; set; } = MessageFilter.Null;
|
||||
|
||||
[CommandOption(
|
||||
"parallel",
|
||||
Description = "Limits how many channels can be exported in parallel."
|
||||
)]
|
||||
public int ParallelLimit { get; init; } = 1;
|
||||
public int ParallelLimit { get; set; } = 1;
|
||||
|
||||
[CommandOption(
|
||||
"reverse",
|
||||
Description = "Export messages in reverse chronological order (newest first)."
|
||||
)]
|
||||
public bool IsReverseMessageOrder { get; set; }
|
||||
|
||||
[CommandOption(
|
||||
"markdown",
|
||||
Description = "Process markdown, mentions, and other special tokens."
|
||||
)]
|
||||
public bool ShouldFormatMarkdown { get; init; } = true;
|
||||
public bool ShouldFormatMarkdown { get; set; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"media",
|
||||
Description = "Download assets referenced by the export (user avatars, attached files, embedded images, etc.)."
|
||||
)]
|
||||
public bool ShouldDownloadAssets { get; init; }
|
||||
public bool ShouldDownloadAssets { get; set; }
|
||||
|
||||
[CommandOption(
|
||||
"reuse-media",
|
||||
Description = "Reuse previously downloaded assets to avoid redundant requests."
|
||||
)]
|
||||
public bool ShouldReuseAssets { get; init; } = false;
|
||||
public bool ShouldReuseAssets { get; set; } = false;
|
||||
|
||||
[CommandOption(
|
||||
"media-dir",
|
||||
@@ -111,34 +117,33 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
get;
|
||||
// Handle ~/ in paths on Unix systems
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||
init => field = value is not null ? Path.GetFullPath(value) : null;
|
||||
set => field = value is not null ? Path.GetFullPath(value) : null;
|
||||
}
|
||||
|
||||
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
||||
[CommandOption(
|
||||
"dateformat",
|
||||
Description = "This option doesn't do anything. Kept for backwards compatibility."
|
||||
)]
|
||||
public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt";
|
||||
public string DateFormat { get; set; } = "MM/dd/yyyy h:mm tt";
|
||||
|
||||
[CommandOption(
|
||||
"locale",
|
||||
Description = "Locale to use when formatting dates and numbers. "
|
||||
+ "If not specified, the default system locale will be used."
|
||||
)]
|
||||
public string? Locale { get; init; }
|
||||
public string? Locale { get; set; }
|
||||
|
||||
[CommandOption("utc", Description = "Normalize all timestamps to UTC+0.")]
|
||||
public bool IsUtcNormalizationEnabled { get; init; } = false;
|
||||
public bool IsUtcNormalizationEnabled { get; set; } = false;
|
||||
|
||||
[CommandOption(
|
||||
"fuck-russia",
|
||||
EnvironmentVariable = "FUCK_RUSSIA",
|
||||
Description = "Don't print the Support Ukraine message to the console.",
|
||||
// Use a converter to accept '1' as 'true' to reuse the existing environment variable
|
||||
Converter = typeof(TruthyBooleanBindingConverter)
|
||||
Converter = typeof(TruthyBooleanInputConverter)
|
||||
)]
|
||||
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
|
||||
public bool IsUkraineSupportMessageDisabled { get; set; } = false;
|
||||
|
||||
[field: AllowNull, MaybeNull]
|
||||
protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
|
||||
@@ -267,6 +272,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
Before,
|
||||
PartitionLimit,
|
||||
MessageFilter,
|
||||
IsReverseMessageOrder,
|
||||
ShouldFormatMarkdown,
|
||||
ShouldDownloadAssets,
|
||||
ShouldReuseAssets,
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
using System;
|
||||
using CliFx.Extensibility;
|
||||
using CliFx.Activation;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Converters;
|
||||
|
||||
internal class ThreadInclusionModeBindingConverter : BindingConverter<ThreadInclusionMode>
|
||||
internal class ThreadInclusionModeInputConverter : ScalarInputConverter<ThreadInclusionMode>
|
||||
{
|
||||
public override ThreadInclusionMode Convert(string? rawValue)
|
||||
{
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
using System.Globalization;
|
||||
using CliFx.Extensibility;
|
||||
using CliFx.Activation;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Converters;
|
||||
|
||||
internal class TruthyBooleanBindingConverter : BindingConverter<bool>
|
||||
internal class TruthyBooleanInputConverter : ScalarInputConverter<bool>
|
||||
{
|
||||
public override bool Convert(string? rawValue)
|
||||
{
|
||||
@@ -1,156 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Discord.Dump;
|
||||
using DiscordChatExporter.Core.Exceptions;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("exportall", Description = "Exports all accessible channels.")]
|
||||
public class ExportAllCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption("include-dm", Description = "Include direct message channels.")]
|
||||
public bool IncludeDirectChannels { get; init; } = true;
|
||||
|
||||
[CommandOption("include-guilds", Description = "Include server channels.")]
|
||||
public bool IncludeGuildChannels { get; init; } = true;
|
||||
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
public bool IncludeVoiceChannels { get; init; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"data-package",
|
||||
Description = "Path to the personal data package (ZIP file) requested from Discord. "
|
||||
+ "If provided, only channels referenced in the dump will be exported."
|
||||
)]
|
||||
public string? DataPackageFilePath { get; init; }
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
await base.ExecuteAsync(console);
|
||||
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
var channels = new List<Channel>();
|
||||
|
||||
// Pull from the API
|
||||
if (string.IsNullOrWhiteSpace(DataPackageFilePath))
|
||||
{
|
||||
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
|
||||
{
|
||||
// Regular channels
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Fetching channels for server '{guild.Name}'..."
|
||||
);
|
||||
|
||||
var fetchedChannelsCount = 0;
|
||||
await console
|
||||
.CreateStatusTicker()
|
||||
.StartAsync(
|
||||
"...",
|
||||
async ctx =>
|
||||
{
|
||||
await foreach (
|
||||
var channel in Discord.GetGuildChannelsAsync(
|
||||
guild.Id,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
if (channel.IsCategory)
|
||||
continue;
|
||||
|
||||
if (!IncludeVoiceChannels && channel.IsVoice)
|
||||
continue;
|
||||
|
||||
channels.Add(channel);
|
||||
|
||||
ctx.Status(
|
||||
Markup.Escape($"Fetched '{channel.GetHierarchicalName()}'.")
|
||||
);
|
||||
|
||||
fetchedChannelsCount++;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
||||
}
|
||||
}
|
||||
// Pull from the data package
|
||||
else
|
||||
{
|
||||
await console.Output.WriteLineAsync("Extracting channels...");
|
||||
|
||||
var dump = await DataDump.LoadAsync(DataPackageFilePath, cancellationToken);
|
||||
var inaccessibleChannels = new List<DataDumpChannel>();
|
||||
|
||||
await console
|
||||
.CreateStatusTicker()
|
||||
.StartAsync(
|
||||
"...",
|
||||
async ctx =>
|
||||
{
|
||||
foreach (var dumpChannel in dump.Channels)
|
||||
{
|
||||
ctx.Status(
|
||||
Markup.Escape(
|
||||
$"Fetching '{dumpChannel.Name}' ({dumpChannel.Id})..."
|
||||
)
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
var channel = await Discord.GetChannelAsync(
|
||||
dumpChannel.Id,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
channels.Add(channel);
|
||||
}
|
||||
catch (DiscordChatExporterException)
|
||||
{
|
||||
inaccessibleChannels.Add(dumpChannel);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await console.Output.WriteLineAsync($"Fetched {channels} channel(s).");
|
||||
|
||||
// Print inaccessible channels
|
||||
if (inaccessibleChannels.Any())
|
||||
{
|
||||
await console.Output.WriteLineAsync();
|
||||
|
||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||
{
|
||||
await console.Error.WriteLineAsync(
|
||||
"Failed to access the following channel(s):"
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var dumpChannel in inaccessibleChannels)
|
||||
await console.Error.WriteLineAsync($"{dumpChannel.Name} ({dumpChannel.Id})");
|
||||
|
||||
await console.Error.WriteLineAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out unwanted channels
|
||||
if (!IncludeDirectChannels)
|
||||
channels.RemoveAll(c => c.IsDirect);
|
||||
if (!IncludeGuildChannels)
|
||||
channels.RemoveAll(c => c.IsGuild);
|
||||
if (!IncludeVoiceChannels)
|
||||
channels.RemoveAll(c => c.IsVoice);
|
||||
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("export", Description = "Exports one or multiple channels.")]
|
||||
public class ExportChannelsCommand : ExportCommandBase
|
||||
public partial class ExportChannelsCommand : ExportCommandBase
|
||||
{
|
||||
// TODO: change this to plural (breaking change)
|
||||
[CommandOption(
|
||||
"channel",
|
||||
'c',
|
||||
[CommandParameter(
|
||||
0,
|
||||
Name = "channel-ids",
|
||||
Description = "Channel ID(s). "
|
||||
+ "If provided with category ID(s), all channels inside those categories will be exported."
|
||||
+ "If not provided, channel IDs are read from standard input (one per line or as a JSON array), "
|
||||
+ "enabling piping from the 'list channels' or 'list channels dm' commands."
|
||||
)]
|
||||
public required IReadOnlyList<Snowflake> ChannelIds { get; init; }
|
||||
public IReadOnlyList<Snowflake> ChannelIds { get; set; } = [];
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
@@ -27,35 +29,46 @@ public class ExportChannelsCommand : ExportCommandBase
|
||||
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
// If no channel IDs were specified, read them from stdin
|
||||
var channelIds = new List<Snowflake>(ChannelIds);
|
||||
if (channelIds.Count == 0 && console.IsInputRedirected)
|
||||
{
|
||||
await foreach (var line in console.Input.ReadLinesAsync(cancellationToken))
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
continue;
|
||||
|
||||
// Snowflake IDs are numeric; non-numeric input is treated as a JSON array
|
||||
if (!char.IsAsciiDigit(trimmed[0]))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(trimmed);
|
||||
foreach (var element in doc.RootElement.EnumerateArray())
|
||||
channelIds.Add(Snowflake.Parse(element.GetProperty("id").GetString()!));
|
||||
}
|
||||
else
|
||||
{
|
||||
channelIds.Add(Snowflake.Parse(trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (channelIds.Count == 0)
|
||||
{
|
||||
throw new CommandException(
|
||||
"No channel IDs provided. "
|
||||
+ "Specify channel IDs as arguments or pipe them from the 'list channels' or 'list channels dm' commands."
|
||||
);
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync("Resolving channel(s)...");
|
||||
|
||||
var channels = new List<Channel>();
|
||||
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
||||
|
||||
foreach (var channelId in ChannelIds)
|
||||
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);
|
||||
}
|
||||
channels.Add(channel);
|
||||
}
|
||||
|
||||
await ExportAsync(console, channels);
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("exportdm", Description = "Exports all direct message channels.")]
|
||||
public class ExportDirectMessagesCommand : ExportCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
await base.ExecuteAsync(console);
|
||||
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
await console.Output.WriteLineAsync("Fetching channels...");
|
||||
var channels = await Discord.GetGuildChannelsAsync(
|
||||
Guild.DirectMessages.Id,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("exportguild", Description = "Exports all channels within the specified server.")]
|
||||
public class ExportGuildCommand : ExportCommandBase
|
||||
{
|
||||
[CommandOption("guild", 'g', Description = "Server ID.")]
|
||||
public required Snowflake GuildId { get; init; }
|
||||
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
public bool IncludeVoiceChannels { get; init; } = true;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
await base.ExecuteAsync(console);
|
||||
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
var channels = new List<Channel>();
|
||||
|
||||
await console.Output.WriteLineAsync("Fetching channels...");
|
||||
|
||||
var fetchedChannelsCount = 0;
|
||||
await console
|
||||
.CreateStatusTicker()
|
||||
.StartAsync(
|
||||
"...",
|
||||
async ctx =>
|
||||
{
|
||||
await foreach (
|
||||
var channel in Discord.GetGuildChannelsAsync(GuildId, cancellationToken)
|
||||
)
|
||||
{
|
||||
if (channel.IsCategory)
|
||||
continue;
|
||||
|
||||
if (!IncludeVoiceChannels && channel.IsVoice)
|
||||
continue;
|
||||
|
||||
channels.Add(channel);
|
||||
|
||||
ctx.Status(Markup.Escape($"Fetched '{channel.GetHierarchicalName()}'."));
|
||||
|
||||
fetchedChannelsCount++;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
||||
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,34 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
using DiscordChatExporter.Cli.Utils.Json;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("channels", Description = "Get the list of channels in a server.")]
|
||||
public class GetChannelsCommand : DiscordCommandBase
|
||||
[Command("list channels", Description = "Gets the list of channels in one or more servers.")]
|
||||
public partial class GetChannelsCommand : DiscordCommandBase
|
||||
{
|
||||
[CommandOption("guild", 'g', Description = "Server ID.")]
|
||||
public required Snowflake GuildId { get; init; }
|
||||
[CommandParameter(0, Name = "server-ids", Description = "Server ID(s).")]
|
||||
public required IReadOnlyList<Snowflake> ServerIds { get; set; }
|
||||
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
public bool IncludeVoiceChannels { get; init; } = true;
|
||||
public bool IncludeVoiceChannels { get; set; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"include-threads",
|
||||
Description = "Which types of threads should be included.",
|
||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
||||
Converter = typeof(ThreadInclusionModeInputConverter)
|
||||
)]
|
||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
||||
public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
@@ -33,82 +36,44 @@ public class GetChannelsCommand : DiscordCommandBase
|
||||
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
|
||||
.Where(c => !c.IsCategory)
|
||||
.Where(c => IncludeVoiceChannels || !c.IsVoice)
|
||||
.OrderBy(c => c.Parent?.Position)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToArray();
|
||||
var allChannels = new List<Channel>();
|
||||
|
||||
var channelIdMaxLength = channels
|
||||
.Select(c => c.Id.ToString().Length)
|
||||
.OrderDescending()
|
||||
.FirstOrDefault();
|
||||
|
||||
var threads =
|
||||
ThreadInclusionMode != ThreadInclusionMode.None
|
||||
? (
|
||||
await Discord.GetGuildThreadsAsync(
|
||||
GuildId,
|
||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
||||
null,
|
||||
null,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToArray()
|
||||
: [];
|
||||
|
||||
foreach (var channel in channels)
|
||||
foreach (var serverId in ServerIds)
|
||||
{
|
||||
// Channel ID
|
||||
await console.Output.WriteAsync(
|
||||
channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
|
||||
);
|
||||
var channels = (await Discord.GetGuildChannelsAsync(serverId, cancellationToken))
|
||||
.Where(c => !c.IsCategory)
|
||||
.Where(c => IncludeVoiceChannels || !c.IsVoice)
|
||||
.OrderBy(c => c.Parent?.Position)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToArray();
|
||||
|
||||
// Separator
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||
await console.Output.WriteAsync(" | ");
|
||||
var threads =
|
||||
ThreadInclusionMode != ThreadInclusionMode.None
|
||||
? (
|
||||
await Discord.GetGuildThreadsAsync(
|
||||
serverId,
|
||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
||||
null,
|
||||
null,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToArray()
|
||||
: [];
|
||||
|
||||
// Channel name
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
await console.Output.WriteLineAsync(channel.GetHierarchicalName());
|
||||
|
||||
var channelThreads = threads.Where(t => t.Parent?.Id == channel.Id).ToArray();
|
||||
var channelThreadIdMaxLength = channelThreads
|
||||
.Select(t => t.Id.ToString().Length)
|
||||
.OrderDescending()
|
||||
.FirstOrDefault();
|
||||
|
||||
foreach (var channelThread in channelThreads)
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
// Indent
|
||||
await console.Output.WriteAsync(" * ");
|
||||
|
||||
// Thread ID
|
||||
await console.Output.WriteAsync(
|
||||
channelThread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
|
||||
);
|
||||
|
||||
// Separator
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||
await console.Output.WriteAsync(" | ");
|
||||
|
||||
// Thread name
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
await console.Output.WriteAsync($"Thread / {channelThread.Name}");
|
||||
|
||||
// Separator
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||
await console.Output.WriteAsync(" | ");
|
||||
|
||||
// Thread status
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
await console.Output.WriteLineAsync(
|
||||
channelThread.IsArchived ? "Archived" : "Active"
|
||||
);
|
||||
allChannels.Add(channel);
|
||||
allChannels.AddRange(threads.Where(t => t.Parent?.Id == channel.Id));
|
||||
}
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync(
|
||||
JsonSerializer.Serialize(
|
||||
allChannels.ToArray(),
|
||||
CliJsonSerializerContext.Instance.ChannelArray
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Utils.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("dm", Description = "Gets the list of all direct message channels.")]
|
||||
public class GetDirectChannelsCommand : DiscordCommandBase
|
||||
[Command("list channels dm", Description = "Gets the list of direct message channels.")]
|
||||
public partial class GetDirectChannelsCommand : DiscordCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
@@ -25,25 +26,8 @@ public class GetDirectChannelsCommand : DiscordCommandBase
|
||||
.ThenBy(c => c.Name)
|
||||
.ToArray();
|
||||
|
||||
var channelIdMaxLength = channels
|
||||
.Select(c => c.Id.ToString().Length)
|
||||
.OrderDescending()
|
||||
.FirstOrDefault();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
// Channel ID
|
||||
await console.Output.WriteAsync(
|
||||
channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
|
||||
);
|
||||
|
||||
// Separator
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||
await console.Output.WriteAsync(" | ");
|
||||
|
||||
// Channel name
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
await console.Output.WriteLineAsync(channel.GetHierarchicalName());
|
||||
}
|
||||
await console.Output.WriteLineAsync(
|
||||
JsonSerializer.Serialize(channels, CliJsonSerializerContext.Instance.ChannelArray)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Utils.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("guilds", Description = "Gets the list of accessible servers.")]
|
||||
public class GetGuildsCommand : DiscordCommandBase
|
||||
[Command("list servers", Description = "Gets the list of accessible servers.")]
|
||||
public partial class GetGuildsCommand : DiscordCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
@@ -24,23 +25,8 @@ public class GetGuildsCommand : DiscordCommandBase
|
||||
.ThenBy(g => g.Name)
|
||||
.ToArray();
|
||||
|
||||
var guildIdMaxLength = guilds
|
||||
.Select(g => g.Id.ToString().Length)
|
||||
.OrderDescending()
|
||||
.FirstOrDefault();
|
||||
|
||||
foreach (var guild in guilds)
|
||||
{
|
||||
// Guild ID
|
||||
await console.Output.WriteAsync(guild.Id.ToString().PadRight(guildIdMaxLength, ' '));
|
||||
|
||||
// Separator
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||
await console.Output.WriteAsync(" | ");
|
||||
|
||||
// Guild name
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
await console.Output.WriteLineAsync(guild.Name);
|
||||
}
|
||||
await console.Output.WriteLineAsync(
|
||||
JsonSerializer.Serialize(guilds, CliJsonSerializerContext.Instance.GuildArray)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("guide", Description = "Explains how to obtain the token, server or channel ID.")]
|
||||
public class GuideCommand : ICommand
|
||||
public partial class GuideCommand : ICommand
|
||||
{
|
||||
public ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
@@ -74,7 +74,7 @@ public class GuideCommand : ICommand
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
|
||||
{
|
||||
console.Output.WriteLine(
|
||||
"https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"
|
||||
"https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("list", Description = "Lists channels, DMs, or servers.")]
|
||||
public partial class ListCommand : ICommand
|
||||
{
|
||||
public ValueTask ExecuteAsync(IConsole console) =>
|
||||
throw new CommandException("Use one of the named commands listed below.", showHelp: true);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Cli.Utils.Json;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command(
|
||||
"list unwrap",
|
||||
Description = "Resolves categories and forums in a channel list to their child channels and threads."
|
||||
)]
|
||||
public partial class UnwrapChannelsCommand : DiscordCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
await base.ExecuteAsync(console);
|
||||
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
// Read all JSON from stdin (produced by 'list channels' or 'list channels dm')
|
||||
var sb = new StringBuilder();
|
||||
await foreach (var line in console.Input.ReadLinesAsync(cancellationToken))
|
||||
sb.Append(line);
|
||||
|
||||
Channel[] channels;
|
||||
try
|
||||
{
|
||||
channels =
|
||||
JsonSerializer.Deserialize(
|
||||
sb.ToString().Trim(),
|
||||
CliJsonSerializerContext.Instance.ChannelArray
|
||||
) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new CommandException(
|
||||
"Failed to parse input as a JSON channel array. "
|
||||
+ "Pipe the output of 'list channels' or 'list channels dm' to this command."
|
||||
);
|
||||
}
|
||||
|
||||
var result = new List<Channel>();
|
||||
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
if (channel.IsCategory)
|
||||
{
|
||||
// Expand category to its child channels
|
||||
var guildChannels =
|
||||
channelsByGuild.GetValueOrDefault(channel.GuildId)
|
||||
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
||||
|
||||
foreach (var guildChannel in guildChannels)
|
||||
{
|
||||
if (guildChannel.Parent?.Id == channel.Id)
|
||||
result.Add(guildChannel);
|
||||
}
|
||||
|
||||
channelsByGuild[channel.GuildId] = guildChannels;
|
||||
}
|
||||
else if (channel.Kind == ChannelKind.GuildForum)
|
||||
{
|
||||
// Expand forum to its thread posts
|
||||
await foreach (
|
||||
var thread in Discord.GetChannelThreadsAsync(
|
||||
[channel],
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
)
|
||||
result.Add(thread);
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Add(channel);
|
||||
}
|
||||
}
|
||||
|
||||
await console.Output.WriteLineAsync(
|
||||
JsonSerializer.Serialize(
|
||||
result.ToArray(),
|
||||
CliJsonSerializerContext.Instance.ChannelArray
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,29 @@
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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.50.0" />
|
||||
<PackageReference Include="CliFx" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" />
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None
|
||||
Include="dce"
|
||||
CopyToOutputDirectory="PreserveNewest"
|
||||
CopyToPublishDirectory="PreserveNewest"
|
||||
/>
|
||||
<None
|
||||
Include="dce.bat"
|
||||
CopyToOutputDirectory="PreserveNewest"
|
||||
CopyToPublishDirectory="PreserveNewest"
|
||||
/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,41 +1,13 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||
|
||||
namespace DiscordChatExporter.Cli;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
// Explicit references because CliFx relies on reflection and we're publishing with trimming enabled
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportAllCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportChannelsCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportDirectMessagesCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportGuildCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetChannelsCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetDirectChannelsCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetGuildsCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GuideCommand))]
|
||||
[DynamicDependency(
|
||||
DynamicallyAccessedMemberTypes.All,
|
||||
typeof(ThreadInclusionModeBindingConverter)
|
||||
)]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TruthyBooleanBindingConverter))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PartitionLimit))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageFilter))]
|
||||
public static async Task<int> Main(string[] args) =>
|
||||
await new CliApplicationBuilder()
|
||||
.AddCommand<ExportAllCommand>()
|
||||
.AddCommand<ExportChannelsCommand>()
|
||||
.AddCommand<ExportDirectMessagesCommand>()
|
||||
.AddCommand<ExportGuildCommand>()
|
||||
.AddCommand<GetChannelsCommand>()
|
||||
.AddCommand<GetDirectChannelsCommand>()
|
||||
.AddCommand<GetGuildsCommand>()
|
||||
.AddCommand<GuideCommand>()
|
||||
await new CommandLineApplicationBuilder()
|
||||
.AddCommandsFromThisAssembly()
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Infrastructure;
|
||||
using Spectre.Console;
|
||||
@@ -7,32 +11,35 @@ namespace DiscordChatExporter.Cli.Utils.Extensions;
|
||||
|
||||
internal static class ConsoleExtensions
|
||||
{
|
||||
public static IAnsiConsole CreateAnsiConsole(this IConsole console) =>
|
||||
AnsiConsole.Create(
|
||||
new AnsiConsoleSettings
|
||||
{
|
||||
Ansi = AnsiSupport.Detect,
|
||||
ColorSystem = ColorSystemSupport.Detect,
|
||||
Out = new AnsiConsoleOutput(console.Output),
|
||||
}
|
||||
);
|
||||
|
||||
public static Status CreateStatusTicker(this IConsole console) =>
|
||||
console.CreateAnsiConsole().Status().AutoRefresh(true);
|
||||
|
||||
public static Progress CreateProgressTicker(this IConsole console) =>
|
||||
console
|
||||
.CreateAnsiConsole()
|
||||
.Progress()
|
||||
.AutoClear(false)
|
||||
.AutoRefresh(true)
|
||||
.HideCompleted(false)
|
||||
.Columns(
|
||||
new TaskDescriptionColumn { Alignment = Justify.Left },
|
||||
new ProgressBarColumn(),
|
||||
new PercentageColumn()
|
||||
extension(IConsole console)
|
||||
{
|
||||
public IAnsiConsole CreateAnsiConsole() =>
|
||||
AnsiConsole.Create(
|
||||
new AnsiConsoleSettings
|
||||
{
|
||||
Ansi = AnsiSupport.Detect,
|
||||
ColorSystem = ColorSystemSupport.Detect,
|
||||
Out = new AnsiConsoleOutput(console.Output),
|
||||
}
|
||||
);
|
||||
|
||||
public Status CreateStatusTicker() =>
|
||||
console.CreateAnsiConsole().Status().AutoRefresh(true);
|
||||
|
||||
public Progress CreateProgressTicker() =>
|
||||
console
|
||||
.CreateAnsiConsole()
|
||||
.Progress()
|
||||
.AutoClear(false)
|
||||
.AutoRefresh(true)
|
||||
.HideCompleted(false)
|
||||
.Columns(
|
||||
new TaskDescriptionColumn { Alignment = Justify.Left },
|
||||
new ProgressBarColumn(),
|
||||
new PercentageColumn()
|
||||
);
|
||||
}
|
||||
|
||||
public static async ValueTask StartTaskAsync(
|
||||
this ProgressContext context,
|
||||
string description,
|
||||
@@ -58,4 +65,15 @@ internal static class ConsoleExtensions
|
||||
progressTask.StopTask();
|
||||
}
|
||||
}
|
||||
|
||||
public static async IAsyncEnumerable<string> ReadLinesAsync(
|
||||
this TextReader reader,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line)
|
||||
{
|
||||
yield return line;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Utils.Json;
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
GenerationMode = JsonSourceGenerationMode.Metadata
|
||||
)]
|
||||
[JsonSerializable(typeof(Channel[]))]
|
||||
[JsonSerializable(typeof(Guild[]))]
|
||||
internal partial class CliJsonSerializerContext : JsonSerializerContext
|
||||
{
|
||||
// Instance pre-configured with converters for Snowflake (serialised as a string)
|
||||
// and all enum types (serialised as their name). Defined here so the Core types
|
||||
// are never touched.
|
||||
public static CliJsonSerializerContext Instance { get; } =
|
||||
new(
|
||||
new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
Converters = { new SnowflakeJsonConverter(), new JsonStringEnumConverter() },
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Utils.Json;
|
||||
|
||||
internal class SnowflakeJsonConverter : JsonConverter<Snowflake>
|
||||
{
|
||||
public override Snowflake Read(
|
||||
ref Utf8JsonReader reader,
|
||||
Type typeToConvert,
|
||||
JsonSerializerOptions options
|
||||
) => Snowflake.Parse(reader.GetString()!);
|
||||
|
||||
public override void Write(
|
||||
Utf8JsonWriter writer,
|
||||
Snowflake value,
|
||||
JsonSerializerOptions options
|
||||
) => writer.WriteStringValue(value.ToString());
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env sh
|
||||
exec "$(dirname "$0")/DiscordChatExporter.Cli" "$@"
|
||||
@@ -0,0 +1,2 @@
|
||||
@echo off
|
||||
"%~dp0DiscordChatExporter.Cli.exe" %*
|
||||
@@ -33,9 +33,9 @@ public partial record Channel(
|
||||
|
||||
public bool IsThread { get; } =
|
||||
Kind
|
||||
is ChannelKind.GuildNewsThread
|
||||
or ChannelKind.GuildPublicThread
|
||||
or ChannelKind.GuildPrivateThread;
|
||||
is ChannelKind.GuildNewsThread
|
||||
or ChannelKind.GuildPublicThread
|
||||
or ChannelKind.GuildPrivateThread;
|
||||
|
||||
public bool IsEmpty { get; } = LastMessageId is null;
|
||||
|
||||
@@ -69,7 +69,8 @@ public partial record Channel
|
||||
var guildId =
|
||||
json.GetPropertyOrNull("guild_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse) ?? Guild.DirectMessages.Id;
|
||||
?.Pipe(Snowflake.Parse)
|
||||
?? Guild.DirectMessages.Id;
|
||||
|
||||
var name =
|
||||
// Guild channel
|
||||
@@ -96,7 +97,8 @@ public partial record Channel
|
||||
var isArchived =
|
||||
json.GetPropertyOrNull("thread_metadata")
|
||||
?.GetPropertyOrNull("archived")
|
||||
?.GetBooleanOrNull() ?? false;
|
||||
?.GetBooleanOrNull()
|
||||
?? false;
|
||||
|
||||
var lastMessageId = json.GetPropertyOrNull("last_message_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
|
||||
@@ -45,7 +45,11 @@ public partial record Embed
|
||||
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
||||
|
||||
var kind =
|
||||
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>()
|
||||
json.GetPropertyOrNull("type")
|
||||
?.GetStringOrNull()
|
||||
?.Pipe(s =>
|
||||
Enum.TryParse<EmbedKind>(s, true, out var result) ? result : (EmbedKind?)null
|
||||
)
|
||||
?? EmbedKind.Rich;
|
||||
|
||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||
@@ -63,7 +67,8 @@ public partial record Embed
|
||||
json.GetPropertyOrNull("fields")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(EmbedField.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||
|
||||
@@ -78,7 +83,8 @@ public partial record Embed
|
||||
json.GetPropertyOrNull("image")
|
||||
?.Pipe(EmbedImage.Parse)
|
||||
.ToSingletonEnumerable()
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
||||
{
|
||||
public string Url { get; } = $"https://www.youtube.com/embed/{VideoId}";
|
||||
public string Url { get; } = $"https://www.youtube.com/watch?v={VideoId}";
|
||||
|
||||
// Using hqdefault.jpg which is guaranteed to exist for all YouTube videos
|
||||
public string ThumbnailUrl { get; } = $"https://i.ytimg.com/vi/{VideoId}/hqdefault.jpg";
|
||||
}
|
||||
|
||||
public partial record YouTubeVideoEmbedProjection
|
||||
|
||||
@@ -8855,6 +8855,8 @@ internal static class EmojiIndex
|
||||
["united_nations"] = "🇺🇳",
|
||||
};
|
||||
|
||||
public static IReadOnlyCollection<string> GetAllNames() => _toCodes.Keys;
|
||||
|
||||
public static string? TryGetCode(string name) => _toCodes.GetValueOrDefault(name);
|
||||
|
||||
public static string? TryGetName(string code) => _fromCodes.GetValueOrDefault(code);
|
||||
|
||||
@@ -25,7 +25,8 @@ public partial record Guild
|
||||
var iconUrl =
|
||||
json.GetPropertyOrNull("icon")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? ImageCdn.GetFallbackUserAvatarUrl();
|
||||
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h))
|
||||
?? ImageCdn.GetFallbackUserAvatarUrl();
|
||||
|
||||
return new Guild(id, name, iconUrl);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ public partial record Member
|
||||
?.EnumerateArray()
|
||||
.Select(j => j.GetNonWhiteSpaceString())
|
||||
.Select(Snowflake.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var avatarUrl = guildId is not null
|
||||
? json.GetPropertyOrNull("avatar")
|
||||
|
||||
@@ -27,9 +27,16 @@ public partial record Message(
|
||||
IReadOnlyList<User> MentionedUsers,
|
||||
MessageReference? Reference,
|
||||
Message? ReferencedMessage,
|
||||
MessageSnapshot? ForwardedMessage,
|
||||
Interaction? Interaction
|
||||
) : IHasId
|
||||
{
|
||||
public bool IsEmpty { get; } =
|
||||
string.IsNullOrWhiteSpace(Content)
|
||||
&& !Attachments.Any()
|
||||
&& !Embeds.Any()
|
||||
&& !Stickers.Any();
|
||||
|
||||
public bool IsSystemNotification { get; } =
|
||||
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
||||
|
||||
@@ -38,11 +45,7 @@ public partial record Message(
|
||||
// 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 { get; } =
|
||||
string.IsNullOrWhiteSpace(Content)
|
||||
&& !Attachments.Any()
|
||||
&& !Embeds.Any()
|
||||
&& !Stickers.Any();
|
||||
public bool IsForwarded { get; } = Reference?.Kind == MessageReferenceKind.Forward;
|
||||
|
||||
public IEnumerable<User> GetReferencedUsers()
|
||||
{
|
||||
@@ -143,7 +146,8 @@ public partial record Message
|
||||
json.GetPropertyOrNull("attachments")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Attachment.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var embeds = NormalizeEmbeds(
|
||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||
@@ -154,13 +158,15 @@ public partial record Message
|
||||
json.GetPropertyOrNull("sticker_items")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Sticker.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var reactions =
|
||||
json.GetPropertyOrNull("reactions")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Reaction.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var mentionedUsers =
|
||||
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
||||
@@ -168,7 +174,17 @@ public partial record Message
|
||||
|
||||
var messageReference = json.GetPropertyOrNull("message_reference")
|
||||
?.Pipe(MessageReference.Parse);
|
||||
|
||||
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
||||
|
||||
// Currently Discord only supports 1 snapshot per forward
|
||||
var forwardedMessage = json.GetPropertyOrNull("message_snapshots")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(j => j.GetPropertyOrNull("message"))
|
||||
.WhereNotNull()
|
||||
.Select(MessageSnapshot.Parse)
|
||||
.FirstOrDefault();
|
||||
|
||||
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
|
||||
|
||||
return new Message(
|
||||
@@ -188,6 +204,7 @@ public partial record Message
|
||||
mentionedUsers,
|
||||
messageReference,
|
||||
referencedMessage,
|
||||
forwardedMessage,
|
||||
interaction
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,19 @@ using JsonExtensions.Reading;
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
|
||||
public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowflake? GuildId)
|
||||
public record MessageReference(
|
||||
MessageReferenceKind Kind,
|
||||
Snowflake? MessageId,
|
||||
Snowflake? ChannelId,
|
||||
Snowflake? GuildId
|
||||
)
|
||||
{
|
||||
public static MessageReference Parse(JsonElement json)
|
||||
{
|
||||
var kind =
|
||||
json.GetPropertyOrNull("type")?.GetInt32OrNull()?.Pipe(t => (MessageReferenceKind)t)
|
||||
?? MessageReferenceKind.Default;
|
||||
|
||||
var messageId = json.GetPropertyOrNull("message_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
@@ -21,6 +30,6 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
|
||||
return new MessageReference(messageId, channelId, guildId);
|
||||
return new MessageReference(kind, messageId, channelId, guildId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#message-reference-types
|
||||
public enum MessageReferenceKind
|
||||
{
|
||||
Default = 0,
|
||||
Forward = 1,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
// https://docs.discord.com/developers/resources/message#message-snapshot-object
|
||||
public record MessageSnapshot(
|
||||
DateTimeOffset Timestamp,
|
||||
DateTimeOffset? EditedTimestamp,
|
||||
string Content,
|
||||
IReadOnlyList<Attachment> Attachments,
|
||||
IReadOnlyList<Embed> Embeds,
|
||||
IReadOnlyList<Sticker> Stickers
|
||||
)
|
||||
{
|
||||
public static MessageSnapshot Parse(JsonElement json)
|
||||
{
|
||||
var timestamp =
|
||||
json.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull()
|
||||
?? DateTimeOffset.MinValue;
|
||||
|
||||
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffsetOrNull();
|
||||
|
||||
var content = json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "";
|
||||
|
||||
var attachments =
|
||||
json.GetPropertyOrNull("attachments")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Attachment.Parse)
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var embeds =
|
||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||
?? [];
|
||||
|
||||
var stickers =
|
||||
json.GetPropertyOrNull("sticker_items")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Sticker.Parse)
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
return new MessageSnapshot(
|
||||
timestamp,
|
||||
editedTimestamp,
|
||||
content,
|
||||
attachments,
|
||||
embeds,
|
||||
stickers
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,23 @@ public class DiscordClient(
|
||||
return Application.Parse(response);
|
||||
}
|
||||
|
||||
private async ValueTask EnsureMessageContentIntentAsync(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (await ResolveTokenKindAsync(cancellationToken) != TokenKind.Bot)
|
||||
return;
|
||||
|
||||
var application = await GetApplicationAsync(cancellationToken);
|
||||
if (application.IsMessageContentIntentEnabled)
|
||||
return;
|
||||
|
||||
throw new DiscordChatExporterException(
|
||||
"Provided bot account is missing the MESSAGE_CONTENT privileged intent.",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
public async ValueTask<User?> TryGetUserAsync(
|
||||
Snowflake userId,
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -371,21 +388,40 @@ public class DiscordClient(
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
|
||||
try
|
||||
{
|
||||
var parent = parentId is not null
|
||||
? await GetChannelAsync(parentId.Value, cancellationToken)
|
||||
: null;
|
||||
|
||||
return Channel.Parse(response, parent);
|
||||
}
|
||||
// It's possible for the parent channel to be inaccessible, despite the
|
||||
// child channel being accessible.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
||||
catch (DiscordChatExporterException)
|
||||
var parent = parentId is not null
|
||||
? await TryGetChannelAsync(parentId.Value, cancellationToken)
|
||||
: null;
|
||||
|
||||
return Channel.Parse(response, parent);
|
||||
}
|
||||
|
||||
public async ValueTask<Channel?> TryGetChannelAsync(
|
||||
Snowflake channelId,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var response = await TryGetJsonResponseAsync($"channels/{channelId}", cancellationToken);
|
||||
if (response is null)
|
||||
return null;
|
||||
|
||||
var parentId = response
|
||||
.Value.GetPropertyOrNull("parent_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
|
||||
Channel? parent = null;
|
||||
if (parentId is not null)
|
||||
{
|
||||
return Channel.Parse(response);
|
||||
// It's possible for the parent channel to be inaccessible, despite the
|
||||
// child channel being accessible.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
||||
parent = await TryGetChannelAsync(parentId.Value, cancellationToken);
|
||||
}
|
||||
|
||||
return Channel.Parse(response.Value, parent);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Channel> GetChannelThreadsAsync(
|
||||
@@ -410,6 +446,11 @@ public class DiscordClient(
|
||||
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
||||
.ToArray();
|
||||
|
||||
// Track yielded thread IDs to avoid duplicates that can occur when a thread transitions
|
||||
// from active to archived between the two separate API calls used to fetch threads.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1433
|
||||
var seenThreadIds = new HashSet<Snowflake>();
|
||||
|
||||
// User accounts can only fetch threads using the search endpoint
|
||||
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
||||
{
|
||||
@@ -453,7 +494,9 @@ public class DiscordClient(
|
||||
break;
|
||||
}
|
||||
|
||||
yield return thread;
|
||||
if (seenThreadIds.Add(thread.Id))
|
||||
yield return thread;
|
||||
|
||||
currentOffset++;
|
||||
}
|
||||
|
||||
@@ -492,7 +535,12 @@ public class DiscordClient(
|
||||
.Pipe(parentsById.GetValueOrDefault);
|
||||
|
||||
if (filteredChannels.Contains(parent))
|
||||
yield return Channel.Parse(threadJson, parent);
|
||||
{
|
||||
var thread = Channel.Parse(threadJson, parent);
|
||||
|
||||
if (seenThreadIds.Add(thread.Id))
|
||||
yield return thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,12 +576,14 @@ public class DiscordClient(
|
||||
)
|
||||
{
|
||||
var thread = Channel.Parse(threadJson, channel);
|
||||
yield return thread;
|
||||
|
||||
currentBefore = threadJson
|
||||
.GetProperty("thread_metadata")
|
||||
.GetProperty("archive_timestamp")
|
||||
.GetString();
|
||||
|
||||
if (seenThreadIds.Add(thread.Id))
|
||||
yield return thread;
|
||||
}
|
||||
|
||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||
@@ -545,6 +595,24 @@ public class DiscordClient(
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<Message?> TryGetFirstMessageAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? after = null,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
.SetQueryParameter("limit", "1")
|
||||
.SetQueryParameter("after", (after ?? Snowflake.Zero).ToString())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||
var message = response.EnumerateArray().Select(Message.Parse).FirstOrDefault();
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? before = null,
|
||||
@@ -603,22 +671,10 @@ public class DiscordClient(
|
||||
yield break;
|
||||
|
||||
// If all messages are empty, make sure that it's not because the bot account doesn't
|
||||
// have the Message Content Intent enabled.
|
||||
// have the MESSAGE_CONTENT intent enabled.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||
if (
|
||||
messages.All(m => m.IsEmpty)
|
||||
&& await ResolveTokenKindAsync(cancellationToken) == TokenKind.Bot
|
||||
)
|
||||
{
|
||||
var application = await GetApplicationAsync(cancellationToken);
|
||||
if (!application.IsMessageContentIntentEnabled)
|
||||
{
|
||||
throw new DiscordChatExporterException(
|
||||
"Provided bot account does not have the Message Content Intent enabled.",
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
if (messages.All(m => m.IsEmpty))
|
||||
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
@@ -651,6 +707,75 @@ public class DiscordClient(
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Message> GetMessagesInReverseAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? after = null,
|
||||
Snowflake? before = null,
|
||||
IProgress<Percentage>? progress = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
// Get the first message in the specified range, so we can later calculate the
|
||||
// progress based on the difference between message timestamps.
|
||||
// Snapshotting is not necessary here because new messages can't appear in the past.
|
||||
var firstMessage = await TryGetFirstMessageAsync(channelId, after, cancellationToken);
|
||||
if (firstMessage is null || firstMessage.Timestamp > before?.ToDate())
|
||||
yield break;
|
||||
|
||||
// Keep track of the last message in range in order to calculate the progress
|
||||
var lastMessage = default(Message);
|
||||
|
||||
var currentBefore = before;
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
.SetQueryParameter("limit", "100")
|
||||
.SetQueryParameter("before", currentBefore?.ToString())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||
|
||||
var messages = response.EnumerateArray().Select(Message.Parse).ToArray();
|
||||
|
||||
// Break if there are no messages (can happen if messages are deleted during execution)
|
||||
if (!messages.Any())
|
||||
yield break;
|
||||
|
||||
// If all messages are empty, make sure that it's not because the bot account doesn't
|
||||
// have the MESSAGE_CONTENT intent enabled.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||
if (messages.All(m => m.IsEmpty))
|
||||
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
lastMessage ??= message;
|
||||
|
||||
// Report progress based on timestamps
|
||||
if (progress is not null)
|
||||
{
|
||||
var exportedDuration = (lastMessage.Timestamp - message.Timestamp).Duration();
|
||||
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
||||
|
||||
progress.Report(
|
||||
Percentage.FromFraction(
|
||||
// Avoid division by zero if all messages have the exact same timestamp
|
||||
// (which happens when there's only one message in the channel)
|
||||
totalDuration > TimeSpan.Zero
|
||||
? exportedDuration / totalDuration
|
||||
: 1
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
yield return message;
|
||||
}
|
||||
|
||||
currentBefore = messages.Last().Id;
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<User> GetMessageReactionsAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake messageId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -40,17 +41,19 @@ public partial class DataDump
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(zipFilePath);
|
||||
await using var archive = await ZipFile.OpenReadAsync(zipFilePath, cancellationToken);
|
||||
|
||||
var entry = archive.GetEntry("messages/index.json");
|
||||
if (entry is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
// Use case-insensitive search to accommodate for different data dump versions
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1459
|
||||
var entry =
|
||||
archive.Entries.FirstOrDefault(e =>
|
||||
e.FullName.Equals("messages/index.json", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
?? throw new InvalidOperationException(
|
||||
"Failed to locate the channel index inside the data package."
|
||||
);
|
||||
}
|
||||
|
||||
await using var stream = entry.Open();
|
||||
await using var stream = await entry.OpenAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
|
||||
|
||||
return Parse(document.RootElement);
|
||||
|
||||
@@ -13,24 +13,26 @@ public enum RateLimitPreference
|
||||
|
||||
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)),
|
||||
};
|
||||
extension(RateLimitPreference rateLimitPreference)
|
||||
{
|
||||
internal bool IsRespectedFor(TokenKind tokenKind) =>
|
||||
tokenKind switch
|
||||
{
|
||||
TokenKind.User => (rateLimitPreference & RateLimitPreference.RespectForUserTokens)
|
||||
!= 0,
|
||||
TokenKind.Bot => (rateLimitPreference & RateLimitPreference.RespectForBotTokens)
|
||||
!= 0,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(tokenKind)),
|
||||
};
|
||||
|
||||
public 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)),
|
||||
};
|
||||
public string GetDisplayName() =>
|
||||
rateLimitPreference switch
|
||||
{
|
||||
RateLimitPreference.IgnoreAll => "Always ignore",
|
||||
RateLimitPreference.RespectForUserTokens => "Respect for user tokens",
|
||||
RateLimitPreference.RespectForBotTokens => "Respect for bot tokens",
|
||||
RateLimitPreference.RespectAll => "Always respect",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(rateLimitPreference)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<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.5.2" />
|
||||
<PackageReference Include="RazorBlade" Version="0.9.0" />
|
||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.19.0" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.5.4" />
|
||||
<PackageReference Include="AngleSharp" />
|
||||
<PackageReference Include="AsyncKeyedLock" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" />
|
||||
<PackageReference Include="JsonExtensions" />
|
||||
<PackageReference Include="Polly" />
|
||||
<PackageReference Include="RazorBlade" />
|
||||
<PackageReference Include="Superpower" />
|
||||
<PackageReference Include="WebMarkupMin.Core" />
|
||||
<PackageReference Include="YoutubeExplode" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -64,15 +64,23 @@ public class ChannelExporter(DiscordClient discord)
|
||||
);
|
||||
}
|
||||
|
||||
await foreach (
|
||||
var message in discord.GetMessagesAsync(
|
||||
var messages = !request.IsReverseMessageOrder
|
||||
? discord.GetMessagesAsync(
|
||||
request.Channel.Id,
|
||||
request.After,
|
||||
request.Before,
|
||||
progress,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
: discord.GetMessagesInReverseAsync(
|
||||
request.Channel.Id,
|
||||
request.After,
|
||||
request.Before,
|
||||
progress,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await foreach (var message in messages)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -37,6 +37,28 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
if (reuse && File.Exists(filePath))
|
||||
return _previousPathsByUrl[url] = filePath;
|
||||
|
||||
// Check for a file cached by the legacy naming scheme (5-char hash) and rename it
|
||||
// to the new naming scheme to preserve backwards compatibility with existing exports
|
||||
if (reuse)
|
||||
{
|
||||
var legacyFilePath = Path.Combine(workingDirPath, GetLegacyFileNameFromUrl(url));
|
||||
if (File.Exists(legacyFilePath))
|
||||
{
|
||||
// Overwrite in case the destination file was created concurrently between our
|
||||
// earlier existence check and this move operation
|
||||
try
|
||||
{
|
||||
File.Move(legacyFilePath, filePath, overwrite: true);
|
||||
return _previousPathsByUrl[url] = filePath;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// The legacy file was moved or deleted concurrently or something else happened.
|
||||
// Upgrading old files is not crucial, so we can just move on.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(workingDirPath);
|
||||
|
||||
await Http.ResiliencePipeline.ExecuteAsync(
|
||||
@@ -56,34 +78,23 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
|
||||
internal partial class ExportAssetDownloader
|
||||
{
|
||||
private static string GetUrlHash(string url)
|
||||
private static string NormalizeUrl(string url)
|
||||
{
|
||||
// Remove signature parameters from Discord CDN URLs to normalize them
|
||||
static string NormalizeUrl(string url)
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
||||
return url;
|
||||
var uri = new Uri(url);
|
||||
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
||||
return url;
|
||||
|
||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||
query.Remove("ex");
|
||||
query.Remove("is");
|
||||
query.Remove("hm");
|
||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||
query.Remove("ex");
|
||||
query.Remove("is");
|
||||
query.Remove("hm");
|
||||
|
||||
return uri.GetLeftPart(UriPartial.Path) + query;
|
||||
}
|
||||
|
||||
return SHA256
|
||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||
.ToHex()
|
||||
// 5 chars ought to be enough for anybody
|
||||
.Truncate(5);
|
||||
return uri.GetLeftPart(UriPartial.Path) + query;
|
||||
}
|
||||
|
||||
private static string GetFileNameFromUrl(string url)
|
||||
private static string GetFileNameFromUrl(string url, string urlHash)
|
||||
{
|
||||
var urlHash = GetUrlHash(url);
|
||||
|
||||
// Try to extract the file name from URL
|
||||
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
|
||||
@@ -103,8 +114,29 @@ internal partial class ExportAssetDownloader
|
||||
fileExtension = "";
|
||||
}
|
||||
|
||||
return PathEx.EscapeFileName(
|
||||
return Path.EscapeFileName(
|
||||
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
|
||||
);
|
||||
}
|
||||
|
||||
private static string GetFileNameFromUrl(string url) =>
|
||||
GetFileNameFromUrl(
|
||||
url,
|
||||
// 16 chars = 64 bits, reaches 1% collision probability at ~609 million files
|
||||
SHA256
|
||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||
.Pipe(Convert.ToHexStringLower)
|
||||
.Truncate(16)
|
||||
);
|
||||
|
||||
// Legacy naming used a 5-char hash, kept for backwards compatibility with existing exports
|
||||
private static string GetLegacyFileNameFromUrl(string url) =>
|
||||
GetFileNameFromUrl(
|
||||
url,
|
||||
SHA256
|
||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||
.Pipe(Convert.ToHexStringLower)
|
||||
// 5 chars = 20 bits, reaches 1% collision probability at ~145 files
|
||||
.Truncate(5)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
{
|
||||
private readonly Dictionary<Snowflake, Member?> _membersById = new();
|
||||
private readonly Dictionary<Snowflake, Channel> _channelsById = new();
|
||||
private readonly Dictionary<Snowflake, Channel?> _channelsById = new();
|
||||
private readonly Dictionary<Snowflake, Role> _rolesById = new();
|
||||
|
||||
private readonly ExportAssetDownloader _assetDownloader = new(
|
||||
@@ -51,6 +51,21 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
}
|
||||
}
|
||||
|
||||
// Threads are not preloaded, so we resolve them on demand
|
||||
public async ValueTask PopulateChannelAsync(
|
||||
Snowflake id,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (_channelsById.ContainsKey(id))
|
||||
return;
|
||||
|
||||
var channel = await Discord.TryGetChannelAsync(id, cancellationToken);
|
||||
|
||||
// Store the result even if it's null, to avoid re-fetching non-existing channels
|
||||
_channelsById[id] = channel;
|
||||
}
|
||||
|
||||
// Because members cannot be pulled in bulk, we need to populate them on demand
|
||||
private async ValueTask PopulateMemberAsync(
|
||||
Snowflake id,
|
||||
@@ -99,7 +114,8 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
?.RoleIds.Select(TryGetRole)
|
||||
.WhereNotNull()
|
||||
.OrderByDescending(r => r.Position)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
public Color? TryGetUserColor(Snowflake id) =>
|
||||
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
|
||||
|
||||
@@ -13,25 +13,28 @@ public enum ExportFormat
|
||||
|
||||
public static class ExportFormatExtensions
|
||||
{
|
||||
public static string GetFileExtension(this ExportFormat format) =>
|
||||
format switch
|
||||
{
|
||||
ExportFormat.PlainText => "txt",
|
||||
ExportFormat.HtmlDark => "html",
|
||||
ExportFormat.HtmlLight => "html",
|
||||
ExportFormat.Csv => "csv",
|
||||
ExportFormat.Json => "json",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
extension(ExportFormat format)
|
||||
{
|
||||
public string GetFileExtension() =>
|
||||
format switch
|
||||
{
|
||||
ExportFormat.PlainText => "txt",
|
||||
ExportFormat.HtmlDark => "html",
|
||||
ExportFormat.HtmlLight => "html",
|
||||
ExportFormat.Csv => "csv",
|
||||
ExportFormat.Json => "json",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
|
||||
public static string GetDisplayName(this ExportFormat format) =>
|
||||
format switch
|
||||
{
|
||||
ExportFormat.PlainText => "TXT",
|
||||
ExportFormat.HtmlDark => "HTML (Dark)",
|
||||
ExportFormat.HtmlLight => "HTML (Light)",
|
||||
ExportFormat.Csv => "CSV",
|
||||
ExportFormat.Json => "JSON",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
public string GetDisplayName() =>
|
||||
format switch
|
||||
{
|
||||
ExportFormat.PlainText => "TXT",
|
||||
ExportFormat.HtmlDark => "HTML (Dark)",
|
||||
ExportFormat.HtmlLight => "HTML (Light)",
|
||||
ExportFormat.Csv => "CSV",
|
||||
ExportFormat.Json => "JSON",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting;
|
||||
@@ -34,6 +33,8 @@ public partial class ExportRequest
|
||||
|
||||
public MessageFilter MessageFilter { get; }
|
||||
|
||||
public bool IsReverseMessageOrder { get; }
|
||||
|
||||
public bool ShouldFormatMarkdown { get; }
|
||||
|
||||
public bool ShouldDownloadAssets { get; }
|
||||
@@ -56,6 +57,7 @@ public partial class ExportRequest
|
||||
Snowflake? before,
|
||||
PartitionLimit partitionLimit,
|
||||
MessageFilter messageFilter,
|
||||
bool isReverseMessageOrder,
|
||||
bool shouldFormatMarkdown,
|
||||
bool shouldDownloadAssets,
|
||||
bool shouldReuseAssets,
|
||||
@@ -70,6 +72,7 @@ public partial class ExportRequest
|
||||
Before = before;
|
||||
PartitionLimit = partitionLimit;
|
||||
MessageFilter = messageFilter;
|
||||
IsReverseMessageOrder = isReverseMessageOrder;
|
||||
ShouldFormatMarkdown = shouldFormatMarkdown;
|
||||
ShouldDownloadAssets = shouldDownloadAssets;
|
||||
ShouldReuseAssets = shouldReuseAssets;
|
||||
@@ -145,7 +148,7 @@ public partial class ExportRequest
|
||||
// File extension
|
||||
buffer.Append('.').Append(format.GetFileExtension());
|
||||
|
||||
return PathEx.EscapeFileName(buffer.ToString());
|
||||
return Path.EscapeFileName(buffer.ToString());
|
||||
}
|
||||
|
||||
private static string FormatPath(
|
||||
@@ -159,7 +162,7 @@ public partial class ExportRequest
|
||||
path,
|
||||
"%.",
|
||||
m =>
|
||||
PathEx.EscapeFileName(
|
||||
Path.EscapeFileName(
|
||||
m.Value switch
|
||||
{
|
||||
"%g" => guild.Id.ToString(),
|
||||
@@ -179,7 +182,8 @@ public partial class ExportRequest
|
||||
?? "",
|
||||
"%b" => before
|
||||
?.ToDate()
|
||||
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "",
|
||||
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
?? "",
|
||||
"%d" => DateTimeOffset.Now.ToString(
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture
|
||||
|
||||
@@ -270,6 +270,12 @@ internal partial class HtmlMarkdownVisitor(
|
||||
}
|
||||
else if (mention.Kind == MentionKind.Channel)
|
||||
{
|
||||
// Channel/thread mentions may reference threads that are not preloaded,
|
||||
// so we resolve them on demand.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1261
|
||||
if (mention.TargetId is not null)
|
||||
await context.PopulateChannelAsync(mention.TargetId.Value, cancellationToken);
|
||||
|
||||
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
||||
var symbol = channel?.IsVoice == true ? "🔊" : "#";
|
||||
var name = channel?.Name ?? "deleted-channel";
|
||||
|
||||
@@ -8,14 +8,20 @@ internal static class HtmlMessageExtensions
|
||||
{
|
||||
// Message content is hidden if it's a link to an embedded media
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
|
||||
public static bool IsContentHidden(this Message message)
|
||||
extension(Message message)
|
||||
{
|
||||
if (message.Embeds.Count != 1)
|
||||
return false;
|
||||
public bool IsContentHidden()
|
||||
{
|
||||
if (message.Embeds.Count != 1)
|
||||
return false;
|
||||
|
||||
var embed = message.Embeds[0];
|
||||
var embed = message.Embeds[0];
|
||||
|
||||
return string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase)
|
||||
&& embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
||||
return string.Equals(
|
||||
message.Content.Trim(),
|
||||
embed.Url,
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
) && embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
||||
private readonly HtmlMinifier _minifier = new();
|
||||
private readonly List<Message> _messageGroup = [];
|
||||
|
||||
// Note: in reverse order, last message appears earlier than the first message
|
||||
private bool CanJoinGroup(Message message)
|
||||
{
|
||||
// If the group is empty, any message can join it
|
||||
|
||||
@@ -119,6 +119,24 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async ValueTask WriteAttachmentAsync(
|
||||
Attachment attachment,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", attachment.Id.ToString());
|
||||
_writer.WriteString(
|
||||
"url",
|
||||
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
|
||||
);
|
||||
_writer.WriteString("fileName", attachment.FileName);
|
||||
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private async ValueTask WriteEmbedAuthorAsync(
|
||||
EmbedAuthor embedAuthor,
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -138,6 +156,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteString("iconCanonicalUrl", embedAuthor.IconUrl);
|
||||
}
|
||||
|
||||
_writer.WriteEndObject();
|
||||
@@ -160,6 +180,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteString("canonicalUrl", embedImage.Url);
|
||||
}
|
||||
|
||||
_writer.WriteNumber("width", embedImage.Width);
|
||||
@@ -185,6 +207,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteString("canonicalUrl", embedVideo.Url);
|
||||
}
|
||||
|
||||
_writer.WriteNumber("width", embedVideo.Width);
|
||||
@@ -212,6 +236,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteString("iconCanonicalUrl", embedFooter.IconUrl);
|
||||
}
|
||||
|
||||
_writer.WriteEndObject();
|
||||
@@ -327,6 +353,24 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async ValueTask WriteStickerAsync(
|
||||
Sticker sticker,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", sticker.Id.ToString());
|
||||
_writer.WriteString("name", sticker.Name);
|
||||
_writer.WriteString("format", sticker.Format.ToString());
|
||||
_writer.WriteString(
|
||||
"sourceUrl",
|
||||
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
|
||||
);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
|
||||
public override async ValueTask WritePreambleAsync(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
@@ -429,19 +473,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
_writer.WriteStartArray("attachments");
|
||||
|
||||
foreach (var attachment in message.Attachments)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", attachment.Id.ToString());
|
||||
_writer.WriteString(
|
||||
"url",
|
||||
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
|
||||
);
|
||||
_writer.WriteString("fileName", attachment.FileName);
|
||||
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
await WriteAttachmentAsync(attachment, cancellationToken);
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
@@ -457,19 +489,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
_writer.WriteStartArray("stickers");
|
||||
|
||||
foreach (var sticker in message.Stickers)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", sticker.Id.ToString());
|
||||
_writer.WriteString("name", sticker.Name);
|
||||
_writer.WriteString("format", sticker.Format.ToString());
|
||||
_writer.WriteString(
|
||||
"sourceUrl",
|
||||
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
|
||||
);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
await WriteStickerAsync(sticker, cancellationToken);
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
@@ -519,12 +539,58 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
if (message.Reference is not null)
|
||||
{
|
||||
_writer.WriteStartObject("reference");
|
||||
_writer.WriteString("type", message.Reference.Kind.ToString());
|
||||
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
|
||||
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
|
||||
_writer.WriteString("guildId", message.Reference.GuildId?.ToString());
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
|
||||
// Forwarded message
|
||||
if (message.ForwardedMessage is not null)
|
||||
{
|
||||
_writer.WriteStartObject("forwardedMessage");
|
||||
|
||||
_writer.WriteString(
|
||||
"timestamp",
|
||||
Context.NormalizeDate(message.ForwardedMessage.Timestamp)
|
||||
);
|
||||
|
||||
_writer.WriteString(
|
||||
"timestampEdited",
|
||||
message.ForwardedMessage.EditedTimestamp?.Pipe(Context.NormalizeDate)
|
||||
);
|
||||
|
||||
_writer.WriteString(
|
||||
"content",
|
||||
await FormatMarkdownAsync(message.ForwardedMessage.Content, cancellationToken)
|
||||
);
|
||||
|
||||
// Forwarded attachments
|
||||
_writer.WriteStartArray("attachments");
|
||||
|
||||
foreach (var attachment in message.ForwardedMessage.Attachments)
|
||||
await WriteAttachmentAsync(attachment, cancellationToken);
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
// Forwarded embeds
|
||||
_writer.WriteStartArray("embeds");
|
||||
foreach (var embed in message.ForwardedMessage.Embeds)
|
||||
await WriteEmbedAsync(embed, cancellationToken);
|
||||
_writer.WriteEndArray();
|
||||
|
||||
// Forwarded stickers
|
||||
_writer.WriteStartArray("stickers");
|
||||
|
||||
foreach (var sticker in message.ForwardedMessage.Stickers)
|
||||
await WriteStickerAsync(sticker, cancellationToken);
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
|
||||
// Interaction
|
||||
if (message.Interaction is not null)
|
||||
{
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
<div class="chatlog__message-group">
|
||||
@foreach (var (message, i) in Messages.WithIndex())
|
||||
@foreach (var (i, message) in Messages.Index())
|
||||
{
|
||||
var isFirst = i == 0;
|
||||
|
||||
@@ -262,6 +262,95 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Forwarded message *@
|
||||
@if (message is { IsForwarded: true, ForwardedMessage: not null })
|
||||
{
|
||||
<div class="chatlog__forwarded">
|
||||
<div class="chatlog__forwarded-header">
|
||||
<svg class="chatlog__forwarded-icon">
|
||||
<use href="#forward-icon"></use>
|
||||
</svg>
|
||||
<em>Forwarded</em>
|
||||
</div>
|
||||
|
||||
@* Forwarded content *@
|
||||
@if (!string.IsNullOrWhiteSpace(message.ForwardedMessage.Content))
|
||||
{
|
||||
<div class="chatlog__forwarded-content chatlog__markdown">
|
||||
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.ForwardedMessage.Content))<!--/wmm:ignore--></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Forwarded attachments *@
|
||||
@if (message.ForwardedMessage.Attachments.Any())
|
||||
{
|
||||
<div class="chatlog__forwarded-attachments">
|
||||
@foreach (var attachment in message.ForwardedMessage.Attachments)
|
||||
{
|
||||
@if (attachment.IsImage)
|
||||
{
|
||||
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||
<img class="chatlog__forwarded-attachment" src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
|
||||
</a>
|
||||
}
|
||||
else if (attachment.IsVideo)
|
||||
{
|
||||
<video class="chatlog__forwarded-attachment" controls>
|
||||
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
|
||||
</video>
|
||||
}
|
||||
else if (attachment.IsAudio)
|
||||
{
|
||||
<audio class="chatlog__forwarded-attachment" controls>
|
||||
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
|
||||
</audio>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__attachment-generic">
|
||||
<svg class="chatlog__attachment-generic-icon">
|
||||
<use href="#attachment-icon"/>
|
||||
</svg>
|
||||
<div class="chatlog__attachment-generic-name">
|
||||
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||
@attachment.FileName
|
||||
</a>
|
||||
</div>
|
||||
<div class="chatlog__attachment-generic-size">
|
||||
@attachment.FileSize
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Forwarded stickers *@
|
||||
@foreach (var sticker in message.ForwardedMessage.Stickers)
|
||||
{
|
||||
<div class="chatlog__sticker" title="@sticker.Name">
|
||||
@if (sticker.IsImage)
|
||||
{
|
||||
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
|
||||
}
|
||||
else if (sticker.Format == StickerFormat.Lottie)
|
||||
{
|
||||
<div class="chatlog__sticker--media" data-source="@await ResolveAssetUrlAsync(sticker.SourceUrl)"></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Forwarded timestamp *@
|
||||
<div class="chatlog__forwarded-timestamp">
|
||||
<span title="@FormatDate(message.ForwardedMessage.Timestamp, "f")">Originally sent: @FormatDate(message.ForwardedMessage.Timestamp)</span>
|
||||
@if (message.ForwardedMessage.EditedTimestamp is not null)
|
||||
{
|
||||
<span title="@FormatDate(message.ForwardedMessage.EditedTimestamp.Value, "f")"> (edited)</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Attachments *@
|
||||
@foreach (var attachment in message.Attachments)
|
||||
{
|
||||
@@ -388,7 +477,7 @@
|
||||
<div class="chatlog__embed-author-container">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
@@ -424,9 +513,11 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Video player *@
|
||||
@* Video thumbnail *@
|
||||
<div class="chatlog__embed-youtube-container">
|
||||
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
|
||||
<a href="@youTubeVideoEmbed.Url">
|
||||
<img class="chatlog__embed-youtube-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)" alt="YouTube video thumbnail" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@(embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -441,9 +532,12 @@
|
||||
embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ??
|
||||
embed.Url;
|
||||
|
||||
var embedImageCanonicalUrl =
|
||||
embed.Image?.Url ?? embed.Thumbnail?.Url ?? embed.Url;
|
||||
|
||||
<div class="chatlog__embed">
|
||||
<a href="@await ResolveAssetUrlAsync(embedImageUrl)">
|
||||
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy">
|
||||
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy" data-canonical-url="@embedImageCanonicalUrl">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@@ -457,8 +551,10 @@
|
||||
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
||||
embed.Url;
|
||||
|
||||
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
|
||||
|
||||
<div class="chatlog__embed">
|
||||
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls>
|
||||
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls data-canonical-url="@embedVideoCanonicalUrl">
|
||||
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
|
||||
</video>
|
||||
</div>
|
||||
@@ -470,8 +566,10 @@
|
||||
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
||||
embed.Url;
|
||||
|
||||
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
|
||||
|
||||
<div class="chatlog__embed">
|
||||
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()">
|
||||
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()" data-canonical-url="@embedVideoCanonicalUrl">
|
||||
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv">
|
||||
</video>
|
||||
</div>
|
||||
@@ -499,7 +597,7 @@
|
||||
<div class="chatlog__embed-author-container">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
@@ -574,7 +672,7 @@
|
||||
{
|
||||
<div class="chatlog__embed-thumbnail-container">
|
||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
||||
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
|
||||
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy" data-canonical-url="@embed.Thumbnail.Url">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@@ -590,7 +688,7 @@
|
||||
{
|
||||
<div class="chatlog__embed-image-container">
|
||||
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
|
||||
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
|
||||
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy" data-canonical-url="@image.Url">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@@ -605,7 +703,7 @@
|
||||
@* Footer icon *@
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy" data-canonical-url="@embed.Footer.IconUrl">
|
||||
}
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
|
||||
@@ -57,6 +57,12 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
|
||||
}
|
||||
else if (mention.Kind == MentionKind.Channel)
|
||||
{
|
||||
// Channel/thread mentions may reference threads that are not preloaded,
|
||||
// so we resolve them on demand.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1261
|
||||
if (mention.TargetId is not null)
|
||||
await context.PopulateChannelAsync(mention.TargetId.Value, cancellationToken);
|
||||
|
||||
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
||||
var name = channel?.Name ?? "deleted-channel";
|
||||
|
||||
|
||||
@@ -7,37 +7,38 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
|
||||
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.",
|
||||
extension(Message message)
|
||||
{
|
||||
public string GetFallbackContent() =>
|
||||
message.Kind switch
|
||||
{
|
||||
MessageKind.RecipientAdd => message.MentionedUsers.Any()
|
||||
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
||||
: "Added a recipient.",
|
||||
|
||||
MessageKind.RecipientRemove => message.MentionedUsers.Any()
|
||||
? 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 {
|
||||
message
|
||||
.CallEndedTimestamp?
|
||||
.Pipe(t => t - message.Timestamp)
|
||||
.Pipe(t => t.TotalMinutes)
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||
} minutes.",
|
||||
MessageKind.Call =>
|
||||
$"Started a call that lasted {message
|
||||
.CallEndedTimestamp?
|
||||
.Pipe(t => t - message.Timestamp)
|
||||
.Pipe(t => t.TotalMinutes)
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"} minutes.",
|
||||
|
||||
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
|
||||
? $"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.",
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting;
|
||||
|
||||
@@ -173,7 +172,7 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
await _writer.WriteLineAsync("{Reactions}");
|
||||
|
||||
foreach (var (reaction, i) in reactions.WithIndex())
|
||||
foreach (var (i, reaction) in reactions.Index())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -224,6 +223,31 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
||||
await _writer.WriteLineAsync();
|
||||
}
|
||||
|
||||
private async ValueTask WriteForwardedMessageAsync(
|
||||
MessageSnapshot forwardedMessage,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
await _writer.WriteLineAsync("{Forwarded Message}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(forwardedMessage.Content))
|
||||
{
|
||||
await _writer.WriteLineAsync(
|
||||
await FormatMarkdownAsync(forwardedMessage.Content, cancellationToken)
|
||||
);
|
||||
}
|
||||
|
||||
await _writer.WriteLineAsync(
|
||||
$"Originally sent: {Context.FormatDate(forwardedMessage.Timestamp)}"
|
||||
);
|
||||
|
||||
await WriteAttachmentsAsync(forwardedMessage.Attachments, cancellationToken);
|
||||
await WriteEmbedsAsync(forwardedMessage.Embeds, cancellationToken);
|
||||
await WriteStickersAsync(forwardedMessage.Stickers, cancellationToken);
|
||||
|
||||
await _writer.WriteLineAsync();
|
||||
}
|
||||
|
||||
public override async ValueTask WriteMessageAsync(
|
||||
Message message,
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -248,6 +272,12 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
await _writer.WriteLineAsync();
|
||||
|
||||
// Forwarded message content
|
||||
if (message.ForwardedMessage is not null)
|
||||
{
|
||||
await WriteForwardedMessageAsync(message.ForwardedMessage, cancellationToken);
|
||||
}
|
||||
|
||||
// Attachments, embeds, reactions, etc.
|
||||
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
||||
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
||||
|
||||
@@ -304,6 +304,52 @@
|
||||
unicode-bidi: bidi-override;
|
||||
}
|
||||
|
||||
.chatlog__forwarded {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.15rem;
|
||||
padding: 0.5rem;
|
||||
border-left: 4px solid @Themed("#4f545c", "#c7ccd1");
|
||||
border-radius: 4px;
|
||||
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
|
||||
}
|
||||
|
||||
.chatlog__forwarded-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
color: @Themed("#b5b6b8", "#5f5f60");
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chatlog__forwarded-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.chatlog__forwarded-content {
|
||||
color: @Themed("#dcddde", "#2e3338");
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.chatlog__forwarded-attachments {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.chatlog__forwarded-attachment {
|
||||
max-width: 300px;
|
||||
max-height: 200px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chatlog__forwarded-timestamp {
|
||||
margin-top: 0.25rem;
|
||||
color: @Themed("#a3a6aa", "#5e6772");
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.chatlog__system-notification-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -711,9 +757,11 @@
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.chatlog__embed-youtube {
|
||||
border: 0;
|
||||
.chatlog__embed-youtube-thumbnail {
|
||||
max-width: 400px;
|
||||
max-height: 225px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chatlog__sticker {
|
||||
@@ -997,6 +1045,9 @@
|
||||
<path fill="#b9bbbe" d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.40991 9H3.55274C3.47819 9 3.42168 8.93274 3.43453 8.85931L3.74253 7.09931C3.75258 7.04189 3.80244 7 3.86074 7H7.75991L8.45234 3.09903C8.46251 3.04174 8.51231 3 8.57049 3H10.3267C10.4014 3 10.4579 3.06746 10.4449 3.14097L9.75991 7H15.7599L16.4523 3.09903C16.4625 3.04174 16.5123 3 16.5705 3H18.3267C18.4014 3 18.4579 3.06746 18.4449 3.14097L17.7599 7H21.6171C21.6916 7 21.7481 7.06725 21.7353 7.14069L21.4273 8.90069C21.4172 8.95811 21.3674 9 21.3091 9H17.4099L17.0495 11.04H15.05L15.4104 9H9.41035L8.35035 15H10.5599V17H7.99991L7.30749 20.901C7.29732 20.9583 7.24752 21 7.18934 21H5.43309Z" />
|
||||
<path fill="#b9bbbe" d="M13.4399 12.96C12.9097 12.96 12.4799 13.3898 12.4799 13.92V20.2213C12.4799 20.7515 12.9097 21.1813 13.4399 21.1813H14.3999C14.5325 21.1813 14.6399 21.2887 14.6399 21.4213V23.4597C14.6399 23.6677 14.8865 23.7773 15.0408 23.6378L17.4858 21.4289C17.6622 21.2695 17.8916 21.1813 18.1294 21.1813H22.5599C23.0901 21.1813 23.5199 20.7515 23.5199 20.2213V13.92C23.5199 13.3898 23.0901 12.96 22.5599 12.96H13.4399Z" />
|
||||
</symbol>
|
||||
<symbol id="forward-icon" viewBox="0 0 24 24">
|
||||
<path fill="#b9bbbe" d="M13 4L21 12L13 20V15C8 15 4 17 1 22C2 16 6 10 13 9V4Z" />
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
</head>
|
||||
|
||||
@@ -14,7 +14,7 @@ internal static class MatcherExtensions
|
||||
this IMatcher<TContext, TValue> matcher,
|
||||
TContext context,
|
||||
StringSegment segment,
|
||||
Func<TContext, StringSegment, TValue> transformFallback
|
||||
Func<TContext, StringSegment, TValue> fallbackTransform
|
||||
)
|
||||
{
|
||||
// Loop through segments divided by individual matches
|
||||
@@ -40,7 +40,7 @@ internal static class MatcherExtensions
|
||||
|
||||
yield return new ParsedMatch<TValue>(
|
||||
fallbackSegment,
|
||||
transformFallback(context, fallbackSegment)
|
||||
fallbackTransform(context, fallbackSegment)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ internal static class MatcherExtensions
|
||||
|
||||
yield return new ParsedMatch<TValue>(
|
||||
fallbackSegment,
|
||||
transformFallback(context, fallbackSegment)
|
||||
fallbackTransform(context, fallbackSegment)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,53 +208,18 @@ internal static partial class MarkdownParser
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
new Regex(
|
||||
@"("
|
||||
+
|
||||
// Country flag emoji (two regional indicator surrogate pairs)
|
||||
@"(?:\uD83C[\uDDE6-\uDDFF]){2}|"
|
||||
+
|
||||
// Digit emoji (digit followed by enclosing mark)
|
||||
@"\d\p{Me}|"
|
||||
+
|
||||
// Surrogate pair
|
||||
@"\p{Cs}{2}|"
|
||||
+
|
||||
// Miscellaneous characters
|
||||
@"["
|
||||
+ @"\u2600-\u2604"
|
||||
+ @"\u260E\u2611"
|
||||
+ @"\u2614-\u2615"
|
||||
+ @"\u2618\u261D\u2620"
|
||||
+ @"\u2622-\u2623"
|
||||
+ @"\u2626\u262A"
|
||||
+ @"\u262E-\u262F"
|
||||
+ @"\u2638-\u263A"
|
||||
+ @"\u2640\u2642"
|
||||
+ @"\u2648-\u2653"
|
||||
+ @"\u265F-\u2660"
|
||||
+ @"\u2663"
|
||||
+ @"\u2665-\u2666"
|
||||
+ @"\u2668\u267B"
|
||||
+ @"\u267E-\u267F"
|
||||
+ @"\u2692-\u2697"
|
||||
+ @"\u2699"
|
||||
+ @"\u269B-\u269C"
|
||||
+ @"\u26A0-\u26A1"
|
||||
+ @"\u26A7"
|
||||
+ @"\u26AA-\u26AB"
|
||||
+ @"\u26B0-\u26B1"
|
||||
+ @"\u26BD-\u26BE"
|
||||
+ @"\u26C4-\u26C5"
|
||||
+ @"\u26C8"
|
||||
+ @"\u26CE-\u26CF"
|
||||
+ @"\u26D1"
|
||||
+ @"\u26D3-\u26D4"
|
||||
+ @"\u26E9-\u26EA"
|
||||
+ @"\u26F0-\u26F5"
|
||||
+ @"\u26F7-\u26FA"
|
||||
+ @"\u26FD"
|
||||
+ @"]"
|
||||
+ @")",
|
||||
// Build a pattern from all known emoji, sorted longest-first so that compound
|
||||
// emoji (e.g. sequences with ZWJ or skin-tone modifiers) are matched before
|
||||
// their individual components.
|
||||
"("
|
||||
+ string.Join(
|
||||
"|",
|
||||
EmojiIndex
|
||||
.GetAllNames()
|
||||
.OrderByDescending(e => e.Length)
|
||||
.Select(Regex.Escape)
|
||||
)
|
||||
+ ")",
|
||||
DefaultRegexOptions
|
||||
),
|
||||
(_, _, m) => new EmojiNode(m.Groups[1].Value)
|
||||
@@ -310,7 +275,7 @@ internal static partial class MarkdownParser
|
||||
// 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<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
|
||||
|
||||
@@ -14,5 +14,5 @@ internal readonly record struct StringSegment(string Source, int StartIndex, int
|
||||
|
||||
public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length);
|
||||
|
||||
public override string ToString() => Source.Substring(StartIndex, Length);
|
||||
public override string ToString() => Source[StartIndex..EndIndex];
|
||||
}
|
||||
|
||||
@@ -6,19 +6,19 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class AsyncCollectionExtensions
|
||||
{
|
||||
private static async ValueTask<IReadOnlyList<T>> CollectAsync<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable
|
||||
)
|
||||
extension<T>(IAsyncEnumerable<T> asyncEnumerable)
|
||||
{
|
||||
var list = new List<T>();
|
||||
private async ValueTask<IReadOnlyList<T>> CollectAsync()
|
||||
{
|
||||
var list = new List<T>();
|
||||
|
||||
await foreach (var i in asyncEnumerable)
|
||||
list.Add(i);
|
||||
await foreach (var i in asyncEnumerable)
|
||||
list.Add(i);
|
||||
|
||||
return list;
|
||||
return list;
|
||||
}
|
||||
|
||||
public ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter() =>
|
||||
asyncEnumerable.CollectAsync().GetAwaiter();
|
||||
}
|
||||
|
||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable
|
||||
) => asyncEnumerable.CollectAsync().GetAwaiter();
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class BinaryExtensions
|
||||
{
|
||||
public static string ToHex(this byte[] data, bool isUpperCase = true)
|
||||
{
|
||||
var buffer = new StringBuilder(2 * data.Length);
|
||||
|
||||
foreach (var b in data)
|
||||
{
|
||||
buffer.Append(b.ToString(isUpperCase ? "X2" : "x2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
@@ -4,25 +4,37 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class CollectionExtensions
|
||||
{
|
||||
public static IEnumerable<T> ToSingletonEnumerable<T>(this T obj)
|
||||
extension<T>(T obj)
|
||||
{
|
||||
yield return obj;
|
||||
public IEnumerable<T> ToSingletonEnumerable()
|
||||
{
|
||||
yield return obj;
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<(T value, int index)> WithIndex<T>(this IEnumerable<T> source)
|
||||
{
|
||||
var i = 0;
|
||||
foreach (var o in source)
|
||||
yield return (o, i++);
|
||||
}
|
||||
|
||||
public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source)
|
||||
extension<T>(IEnumerable<T?> source)
|
||||
where T : class
|
||||
{
|
||||
foreach (var o in source)
|
||||
public IEnumerable<T> WhereNotNull()
|
||||
{
|
||||
if (o is not null)
|
||||
yield return o;
|
||||
foreach (var o in source)
|
||||
{
|
||||
if (o is not null)
|
||||
yield return o;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension<T>(IEnumerable<T?> source)
|
||||
where T : struct
|
||||
{
|
||||
public IEnumerable<T> WhereNotNull()
|
||||
{
|
||||
foreach (var o in source)
|
||||
{
|
||||
if (o is not null)
|
||||
yield return o.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class ColorExtensions
|
||||
{
|
||||
public static Color WithAlpha(this Color color, int alpha) => Color.FromArgb(alpha, color);
|
||||
extension(Color color)
|
||||
{
|
||||
public Color WithAlpha(int alpha) => Color.FromArgb(alpha, color);
|
||||
|
||||
public static Color ResetAlpha(this Color color) => color.WithAlpha(255);
|
||||
public Color ResetAlpha() => color.WithAlpha(255);
|
||||
|
||||
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
|
||||
public int ToRgb() => color.ToArgb() & 0xffffff;
|
||||
|
||||
public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
||||
public string ToHex() => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,27 +5,30 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class ExceptionExtensions
|
||||
{
|
||||
private static void PopulateChildren(this Exception exception, ICollection<Exception> children)
|
||||
extension(Exception exception)
|
||||
{
|
||||
if (exception is AggregateException aggregateException)
|
||||
private void PopulateChildren(ICollection<Exception> children)
|
||||
{
|
||||
foreach (var innerException in aggregateException.InnerExceptions)
|
||||
if (exception is AggregateException aggregateException)
|
||||
{
|
||||
children.Add(innerException);
|
||||
PopulateChildren(innerException, children);
|
||||
foreach (var innerException in aggregateException.InnerExceptions)
|
||||
{
|
||||
children.Add(innerException);
|
||||
PopulateChildren(innerException, children);
|
||||
}
|
||||
}
|
||||
else if (exception.InnerException is not null)
|
||||
{
|
||||
children.Add(exception.InnerException);
|
||||
PopulateChildren(exception.InnerException, children);
|
||||
}
|
||||
}
|
||||
else if (exception.InnerException is not null)
|
||||
|
||||
public IReadOnlyList<Exception> GetSelfAndChildren()
|
||||
{
|
||||
children.Add(exception.InnerException);
|
||||
PopulateChildren(exception.InnerException, children);
|
||||
var children = new List<Exception> { exception };
|
||||
PopulateChildren(exception, children);
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<Exception> GetSelfAndChildren(this Exception exception)
|
||||
{
|
||||
var children = new List<Exception> { exception };
|
||||
PopulateChildren(exception, children);
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,17 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class GenericExtensions
|
||||
{
|
||||
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) =>
|
||||
transform(input);
|
||||
extension<TIn>(TIn input)
|
||||
{
|
||||
public TOut Pipe<TOut>(Func<TIn, TOut> transform) => transform(input);
|
||||
}
|
||||
|
||||
public static T? NullIf<T>(this T value, Func<T, bool> predicate)
|
||||
where T : struct => !predicate(value) ? value : null;
|
||||
extension<T>(T value)
|
||||
where T : struct
|
||||
{
|
||||
public T? NullIf(Func<T, bool> predicate) => !predicate(value) ? value : null;
|
||||
|
||||
public static T? NullIfDefault<T>(this T value)
|
||||
where T : struct => value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
|
||||
public T? NullIfDefault() =>
|
||||
value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class HttpExtensions
|
||||
{
|
||||
public static string? TryGetValue(this HttpHeaders headers, string name) =>
|
||||
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
|
||||
extension(HttpHeaders headers)
|
||||
{
|
||||
public string? TryGetValue(string name) =>
|
||||
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class PathExtensions
|
||||
{
|
||||
// This is a union of invalid characters from Windows (NTFS/FAT32), Linux (ext4/XFS), and macOS (HFS+/APFS).
|
||||
// We use this instead of Path.GetInvalidFileNameChars() because that only returns OS-specific characters,
|
||||
// not filesystem-specific characters. It's possible to use, for example, an NTFS drive on Linux,
|
||||
// which would make some additional characters invalid that are otherwise valid on Linux.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1452
|
||||
private static readonly char[] InvalidFileNameChars =
|
||||
[
|
||||
'\0', // Null character - invalid on all filesystems
|
||||
'/', // Path separator on Unix and Windows
|
||||
'\\', // Path separator on Windows
|
||||
':', // Reserved on Windows (drive letters, NTFS streams)
|
||||
'*', // Wildcard on Windows
|
||||
'?', // Wildcard on Windows
|
||||
'"', // Reserved on Windows
|
||||
'<', // Redirection on Windows
|
||||
'>', // Redirection on Windows
|
||||
'|', // Pipe on Windows
|
||||
];
|
||||
|
||||
extension(Path)
|
||||
{
|
||||
public static string EscapeFileName(string path)
|
||||
{
|
||||
var buffer = new StringBuilder(path.Length);
|
||||
|
||||
foreach (var c in path)
|
||||
buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_');
|
||||
|
||||
// File names cannot end with a dot on Windows
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
while (buffer.Length > 0 && buffer[^1] == '.')
|
||||
buffer.Remove(buffer.Length - 1, 1);
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,34 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class StringExtensions
|
||||
{
|
||||
public static string? NullIfWhiteSpace(this string str) =>
|
||||
!string.IsNullOrWhiteSpace(str) ? str : null;
|
||||
|
||||
public static string Truncate(this string str, int charCount) =>
|
||||
str.Length > charCount ? str[..charCount] : str;
|
||||
|
||||
public static string ToSpaceSeparatedWords(this string str)
|
||||
extension(string str)
|
||||
{
|
||||
var builder = new StringBuilder(str.Length * 2);
|
||||
public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(str) ? str : null;
|
||||
|
||||
foreach (var c in str)
|
||||
public string Truncate(int charCount) => str.Length > charCount ? str[..charCount] : str;
|
||||
|
||||
public string ToSpaceSeparatedWords()
|
||||
{
|
||||
if (char.IsUpper(c) && builder.Length > 0)
|
||||
builder.Append(' ');
|
||||
var builder = new StringBuilder(str.Length * 2);
|
||||
|
||||
builder.Append(c);
|
||||
foreach (var c in str)
|
||||
{
|
||||
if (char.IsUpper(c) && builder.Length > 0)
|
||||
builder.Append(' ');
|
||||
|
||||
builder.Append(c);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true)
|
||||
where T : struct, Enum => Enum.TryParse<T>(str, ignoreCase, out var result) ? result : null;
|
||||
|
||||
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
|
||||
builder.Length > 0 ? builder.Append(value) : builder;
|
||||
extension(StringBuilder builder)
|
||||
{
|
||||
public StringBuilder AppendIfNotEmpty(char value) =>
|
||||
builder.Length > 0 ? builder.Append(value) : builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,21 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class SuperpowerExtensions
|
||||
{
|
||||
public static TextParser<string> Text(this TextParser<char[]> parser) =>
|
||||
parser.Select(chars => new string(chars));
|
||||
extension<T>(TextParser<T> parser)
|
||||
{
|
||||
public TextParser<T> Token() =>
|
||||
parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany());
|
||||
|
||||
public static TextParser<T> Token<T>(this TextParser<T> parser) =>
|
||||
parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany());
|
||||
|
||||
// Only used for debugging while writing Superpower parsers.
|
||||
// From https://twitter.com/nblumhardt/status/1389349059786264578
|
||||
[ExcludeFromCodeCoverage]
|
||||
public static TextParser<T> Log<T>(this TextParser<T> parser, string description) =>
|
||||
i =>
|
||||
{
|
||||
Console.WriteLine($"Trying {description} ->");
|
||||
var r = parser(i);
|
||||
Console.WriteLine($"Result was {r}");
|
||||
return r;
|
||||
};
|
||||
// Only used for debugging while writing Superpower parsers.
|
||||
// From https://twitter.com/nblumhardt/status/1389349059786264578
|
||||
[ExcludeFromCodeCoverage]
|
||||
public TextParser<T> Log(string description) =>
|
||||
i =>
|
||||
{
|
||||
Console.WriteLine($"Trying {description} ->");
|
||||
var r = parser(i);
|
||||
Console.WriteLine($"Result was {r}");
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class TimeSpanExtensions
|
||||
{
|
||||
public static TimeSpan Clamp(this TimeSpan value, TimeSpan min, TimeSpan max)
|
||||
extension(TimeSpan value)
|
||||
{
|
||||
if (value < min)
|
||||
return min;
|
||||
public TimeSpan Clamp(TimeSpan min, TimeSpan max)
|
||||
{
|
||||
if (value < min)
|
||||
return min;
|
||||
|
||||
if (value > max)
|
||||
return max;
|
||||
if (value > max)
|
||||
return max;
|
||||
|
||||
return value;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Frozen;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils;
|
||||
|
||||
public static class PathEx
|
||||
{
|
||||
private static readonly FrozenSet<char> InvalidFileNameChars =
|
||||
[
|
||||
.. Path.GetInvalidFileNameChars(),
|
||||
];
|
||||
|
||||
public static string EscapeFileName(string path)
|
||||
{
|
||||
var buffer = new StringBuilder(path.Length);
|
||||
|
||||
foreach (var c in path)
|
||||
buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_');
|
||||
|
||||
// File names cannot end with a dot on Windows
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
while (buffer.Length > 0 && buffer[^1] == '.')
|
||||
buffer.Remove(buffer.Length - 1, 1);
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils;
|
||||
@@ -10,6 +11,19 @@ public static class Url
|
||||
var buffer = new StringBuilder();
|
||||
var position = 0;
|
||||
|
||||
// For absolute paths, prepend file:// protocol for proper browser handling
|
||||
if (Path.IsPathFullyQualified(filePath))
|
||||
{
|
||||
buffer.Append("file://");
|
||||
|
||||
// On Windows, we need to add an extra slash before the drive letter
|
||||
// e.g., file:///C:/path instead of file://C:/path
|
||||
if (!filePath.StartsWith('/') && !filePath.StartsWith('\\'))
|
||||
{
|
||||
buffer.Append('/');
|
||||
}
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (position >= filePath.Length)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
xmlns:materialControls="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
||||
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
|
||||
Name="DiscordChatExporter"
|
||||
ActualThemeVariantChanged="Application_OnActualThemeVariantChanged">
|
||||
<Application.DataTemplates>
|
||||
<framework:ViewManager />
|
||||
@@ -115,6 +116,11 @@
|
||||
</Style>
|
||||
</Style>
|
||||
|
||||
<!-- Run -->
|
||||
<Style Selector="Run">
|
||||
<Setter Property="BaselineAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!-- Text box -->
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
|
||||
@@ -5,13 +5,13 @@ using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using DiscordChatExporter.Gui.Framework;
|
||||
using DiscordChatExporter.Gui.Localization;
|
||||
using DiscordChatExporter.Gui.Services;
|
||||
using DiscordChatExporter.Gui.Utils;
|
||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||
using DiscordChatExporter.Gui.ViewModels;
|
||||
using DiscordChatExporter.Gui.ViewModels.Components;
|
||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||
using DiscordChatExporter.Gui.Views;
|
||||
using Material.Styles.Themes;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -19,11 +19,12 @@ namespace DiscordChatExporter.Gui;
|
||||
|
||||
public class App : Application, IDisposable
|
||||
{
|
||||
private readonly DisposableCollector _eventRoot = new();
|
||||
|
||||
private readonly ServiceProvider _services;
|
||||
private readonly SettingsService _settingsService;
|
||||
private readonly MainViewModel _mainViewModel;
|
||||
|
||||
private readonly DisposableCollector _eventRoot = new();
|
||||
|
||||
private bool _isDisposed;
|
||||
|
||||
public App()
|
||||
{
|
||||
@@ -39,6 +40,9 @@ public class App : Application, IDisposable
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton<UpdateService>();
|
||||
|
||||
// Localization
|
||||
services.AddSingleton<LocalizationManager>();
|
||||
|
||||
// View models
|
||||
services.AddTransient<MainViewModel>();
|
||||
services.AddTransient<DashboardViewModel>();
|
||||
@@ -48,15 +52,14 @@ public class App : Application, IDisposable
|
||||
|
||||
_services = services.BuildServiceProvider(true);
|
||||
_settingsService = _services.GetRequiredService<SettingsService>();
|
||||
_mainViewModel = _services.GetRequiredService<ViewModelManager>().CreateMainViewModel();
|
||||
|
||||
// Re-initialize the theme when the user changes it
|
||||
_eventRoot.Add(
|
||||
_settingsService.WatchProperty(
|
||||
o => o.Theme,
|
||||
() =>
|
||||
v =>
|
||||
{
|
||||
RequestedThemeVariant = _settingsService.Theme switch
|
||||
RequestedThemeVariant = v switch
|
||||
{
|
||||
ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light,
|
||||
ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark,
|
||||
@@ -69,13 +72,6 @@ public class App : Application, IDisposable
|
||||
);
|
||||
}
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
private void InitializeTheme()
|
||||
{
|
||||
var actualTheme = RequestedThemeVariant?.Key switch
|
||||
@@ -91,18 +87,37 @@ public class App : Application, IDisposable
|
||||
: Theme.Create(Theme.Dark, Color.Parse("#E8E8E8"), Color.Parse("#F9A825"));
|
||||
}
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
desktop.MainWindow = new MainView { DataContext = _mainViewModel };
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
|
||||
// Set up custom theme colors
|
||||
// Initialize the default theme, before a custom one (if any) is applied by loading settings
|
||||
InitializeTheme();
|
||||
|
||||
// Load settings
|
||||
_settingsService.Load();
|
||||
|
||||
// Initialize and configure the main window
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var viewManager = _services.GetRequiredService<ViewManager>();
|
||||
var viewModelManager = _services.GetRequiredService<ViewModelManager>();
|
||||
|
||||
desktop.MainWindow = viewManager.TryBindWindow(viewModelManager.GetMainViewModel());
|
||||
|
||||
// Although `App.Dispose()` is invoked from `Program.Main(...)`, on some platforms
|
||||
// it may be called too late in the shutdown lifecycle. Attach an exit
|
||||
// handler to ensure timely disposal as a safeguard.
|
||||
// https://github.com/Tyrrrz/YoutubeDownloader/issues/795
|
||||
desktop.Exit += (_, _) => Dispose();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void Application_OnActualThemeVariantChanged(object? sender, EventArgs args) =>
|
||||
@@ -111,6 +126,11 @@ public class App : Application, IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed)
|
||||
return;
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
_eventRoot.Dispose();
|
||||
_services.Dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||
using DiscordChatExporter.Gui.Views.Controls;
|
||||
using Markdig;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
using MarkdownInline = Markdig.Syntax.Inlines.Inline;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Converters;
|
||||
|
||||
public class MarkdownToInlinesConverter : IValueConverter
|
||||
{
|
||||
public static readonly MarkdownToInlinesConverter Instance = new();
|
||||
|
||||
private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder()
|
||||
.UseEmphasisExtras()
|
||||
.Build();
|
||||
|
||||
private static void ProcessInline(
|
||||
InlineCollection inlines,
|
||||
MarkdownInline markdownInline,
|
||||
FontWeight? fontWeight = null,
|
||||
FontStyle? fontStyle = null,
|
||||
TextDecorationCollection? textDecorations = null
|
||||
)
|
||||
{
|
||||
switch (markdownInline)
|
||||
{
|
||||
case LiteralInline literal:
|
||||
{
|
||||
var run = new Run(literal.Content.ToString());
|
||||
|
||||
if (fontWeight is not null)
|
||||
run.FontWeight = fontWeight.Value;
|
||||
if (fontStyle is not null)
|
||||
run.FontStyle = fontStyle.Value;
|
||||
if (textDecorations is not null)
|
||||
run.TextDecorations = textDecorations;
|
||||
|
||||
inlines.Add(run);
|
||||
break;
|
||||
}
|
||||
|
||||
case LineBreakInline:
|
||||
{
|
||||
inlines.Add(new LineBreak());
|
||||
break;
|
||||
}
|
||||
|
||||
case EmphasisInline emphasis:
|
||||
{
|
||||
var newWeight = fontWeight;
|
||||
var newStyle = fontStyle;
|
||||
var newDecorations = textDecorations;
|
||||
|
||||
switch (emphasis.DelimiterChar)
|
||||
{
|
||||
case '*' or '_' when emphasis.DelimiterCount == 2:
|
||||
newWeight = FontWeight.SemiBold;
|
||||
break;
|
||||
case '*' or '_':
|
||||
newStyle = FontStyle.Italic;
|
||||
break;
|
||||
case '~':
|
||||
newDecorations = TextDecorations.Strikethrough;
|
||||
break;
|
||||
case '+':
|
||||
newDecorations = TextDecorations.Underline;
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var child in emphasis)
|
||||
ProcessInline(inlines, child, newWeight, newStyle, newDecorations);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case LinkInline link:
|
||||
{
|
||||
inlines.Add(new HyperLink { Text = link.GetInnerText(), Url = link.Url });
|
||||
break;
|
||||
}
|
||||
|
||||
case ContainerInline container:
|
||||
{
|
||||
foreach (var child in container)
|
||||
ProcessInline(inlines, child, fontWeight, fontStyle, textDecorations);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var inlines = new InlineCollection();
|
||||
if (value is not string { Length: > 0 } text)
|
||||
return inlines;
|
||||
|
||||
var isFirst = true;
|
||||
|
||||
foreach (var block in Markdown.Parse(text, MarkdownPipeline))
|
||||
{
|
||||
switch (block)
|
||||
{
|
||||
case ParagraphBlock { Inline: not null } paragraph:
|
||||
{
|
||||
if (!isFirst)
|
||||
{
|
||||
// Insert a blank line between paragraphs
|
||||
inlines.Add(new LineBreak());
|
||||
inlines.Add(new LineBreak());
|
||||
}
|
||||
|
||||
isFirst = false;
|
||||
|
||||
foreach (var markdownInline in paragraph.Inline!)
|
||||
ProcessInline(inlines, markdownInline);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case ListBlock list:
|
||||
{
|
||||
var itemOrder = 1;
|
||||
if (list.IsOrdered && int.TryParse(list.OrderedStart, out var startNum))
|
||||
itemOrder = startNum;
|
||||
|
||||
foreach (var listItem in list.OfType<ListItemBlock>())
|
||||
{
|
||||
if (!isFirst)
|
||||
inlines.Add(new LineBreak());
|
||||
isFirst = false;
|
||||
|
||||
var prefix = list.IsOrdered ? $"{itemOrder++}. " : $"{list.BulletType} ";
|
||||
|
||||
inlines.Add(new Run(prefix));
|
||||
|
||||
foreach (var subBlock in listItem.OfType<ParagraphBlock>())
|
||||
{
|
||||
if (subBlock is { Inline: not null })
|
||||
{
|
||||
foreach (var markdownInline in subBlock.Inline)
|
||||
ProcessInline(inlines, markdownInline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inlines;
|
||||
}
|
||||
|
||||
public object? ConvertBack(
|
||||
object? value,
|
||||
Type targetType,
|
||||
object? parameter,
|
||||
CultureInfo culture
|
||||
) => throw new NotSupportedException();
|
||||
}
|
||||
@@ -7,35 +7,50 @@
|
||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<EncryptionSalt>HimalayanPinkSalt</EncryptionSalt>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Expose this property in code -->
|
||||
<ProjectProperty Include="EncryptionSalt" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<PublishMacOSBundle>false</PublishMacOSBundle>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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.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.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" />
|
||||
<PackageReference Include="AsyncImageLoader.Avalonia" />
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Cogwheel" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||
<PackageReference Include="DialogHost.Avalonia" />
|
||||
<PackageReference Include="Gress" />
|
||||
<PackageReference Include="Markdig" />
|
||||
<PackageReference Include="Material.Avalonia" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Onova" />
|
||||
<PackageReference Include="ThisAssembly.Project" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<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)"
|
||||
Command="dotnet run --file "$(ProjectDir)PublishMacOSBundle.csx" -- --publish-dir "$(PublishDir)" --icons-file "$(ProjectDir)../favicon.icns" --full-version $(Version) --short-version $(AssemblyVersion)"
|
||||
LogStandardErrorAsError="true"
|
||||
/>
|
||||
</Target>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using Avalonia;
|
||||
using Avalonia.Platform.Storage;
|
||||
using DialogHostAvalonia;
|
||||
@@ -13,11 +13,12 @@ namespace DiscordChatExporter.Gui.Framework;
|
||||
|
||||
public class DialogManager : IDisposable
|
||||
{
|
||||
private readonly AsyncNonKeyedLocker _dialogLock = new();
|
||||
private readonly SemaphoreSlim _dialogLock = new(1, 1);
|
||||
|
||||
public async Task<T?> ShowDialogAsync<T>(DialogViewModelBase<T> dialog)
|
||||
{
|
||||
using (await _dialogLock.LockAsync())
|
||||
await _dialogLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await DialogHost.Show(
|
||||
dialog,
|
||||
@@ -38,8 +39,16 @@ public class DialogManager : IDisposable
|
||||
}
|
||||
);
|
||||
|
||||
// Yield to allow DialogHost to fully reset its state before
|
||||
// another dialog is shown (e.g. when dialogs are shown sequentially).
|
||||
await Task.Yield();
|
||||
|
||||
return dialog.DialogResult;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dialogLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> PromptSaveFilePathAsync(
|
||||
@@ -60,7 +69,7 @@ public class DialogManager : IDisposable
|
||||
}
|
||||
);
|
||||
|
||||
return file?.Path.LocalPath;
|
||||
return file?.TryGetLocalPath() ?? file?.Path.ToString();
|
||||
}
|
||||
|
||||
public async Task<string?> PromptDirectoryPathAsync(string defaultDirPath = "")
|
||||
@@ -69,19 +78,21 @@ public class DialogManager : IDisposable
|
||||
Application.Current?.ApplicationLifetime?.TryGetTopLevel()
|
||||
?? throw new ApplicationException("Could not find the top-level visual element.");
|
||||
|
||||
var startLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(
|
||||
defaultDirPath
|
||||
);
|
||||
|
||||
var folderPickResult = await topLevel.StorageProvider.OpenFolderPickerAsync(
|
||||
var result = await topLevel.StorageProvider.OpenFolderPickerAsync(
|
||||
new FolderPickerOpenOptions
|
||||
{
|
||||
AllowMultiple = false,
|
||||
SuggestedStartLocation = startLocation,
|
||||
SuggestedStartLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(
|
||||
defaultDirPath
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
return folderPickResult.FirstOrDefault()?.Path.LocalPath;
|
||||
var directory = result.FirstOrDefault();
|
||||
if (directory is null)
|
||||
return null;
|
||||
|
||||
return directory.TryGetLocalPath() ?? directory.Path.ToString();
|
||||
}
|
||||
|
||||
public void Dispose() => _dialogLock.Dispose();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user