mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f877cf543 | |||
| 62babc0de5 | |||
| 9e115562dc | |||
| db50a2bb96 | |||
| b39d015133 | |||
| b4c3dce760 | |||
| f31e73bb7a | |||
| 75ff5c2f7e | |||
| 7663f8ed31 | |||
| c0e7334084 | |||
| a9eca1ceb2 | |||
| 45ff1daff3 | |||
| 5299c2ebc4 | |||
| f6c189f57c | |||
| a9acf17375 | |||
| bf417db80c | |||
| d78d22d066 | |||
| 0fa5e93f0d | |||
| 7b892c8e5b | |||
| 692d886442 | |||
| e664eab5fb | |||
| 1fb6156187 | |||
| 789e5af8ba | |||
| 9c15baf799 | |||
| 56fa6ee150 | |||
| 09e0b3f133 | |||
| e8192b2b53 | |||
| 34e9b0bfcc | |||
| abd7adc9e0 | |||
| f5cdd5c8be | |||
| 9fb78509b4 | |||
| 161e91ccf4 | |||
| 8519ec0fff | |||
| 9f8f718c94 | |||
| bb14e3431d | |||
| aa377f3131 | |||
| 522789e01d | |||
| 10adba3a4d | |||
| 73e2e0624b | |||
| c978a4cf60 | |||
| 6d82930505 | |||
| 4e98c79313 | |||
| 42e6de359e | |||
| 281e0f608a | |||
| c63313e328 | |||
| 767ca1b177 | |||
| 429801183c | |||
| 520c06dceb | |||
| 46d8deba87 | |||
| 0e13be2fdf | |||
| 15f8a13f02 | |||
| c8ea365c04 | |||
| 949abc2582 | |||
| 3b3423dd9b | |||
| 8380e88744 | |||
| f1bc5a2266 | |||
| dc35f25293 | |||
| 0e3969ca2d | |||
| 91b7486f45 |
+44
-30
@@ -2,7 +2,7 @@
|
||||
|
||||
## Step 1
|
||||
|
||||
After extracting the `.zip`, open Command Prompt, aka `cmd` (`Terminal` on **macOS** and **Linux**).
|
||||
After extracting the `.zip` archive, open your preferred terminal.
|
||||
|
||||
## Step 2
|
||||
|
||||
@@ -18,15 +18,18 @@ You can also drag and drop the folder on **every platform**.
|
||||
|
||||
## Step 3
|
||||
|
||||
Now we're ready to run the commands. The examples on this page follow the Windows file path format, change the file
|
||||
paths according to your system.
|
||||
Now we're ready to run the commands.
|
||||
|
||||
Type the following command in your terminal of choice, then press ENTER to run it. This will list all available subcommands and options.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli
|
||||
./DiscordChatExporter.Cli
|
||||
```
|
||||
|
||||
> **Note**:
|
||||
> On Windows, if you're using the default Command Prompt (`cmd`), omit the leading `./` at the start of the command.
|
||||
|
||||
|
||||
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
|
||||
|
||||
## CLI commands
|
||||
@@ -47,13 +50,13 @@ To use the commands, you'll need a token. For the instructions on how to get a t
|
||||
To get help with a specific command, run:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli command --help
|
||||
./DiscordChatExporter.Cli command --help
|
||||
```
|
||||
|
||||
For example, to figure out how to use the `export` command, run:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export --help
|
||||
./DiscordChatExporter.Cli export --help
|
||||
```
|
||||
|
||||
## Export a specific channel
|
||||
@@ -61,7 +64,7 @@ $ ./DiscordChatExporter.Cli export --help
|
||||
You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
|
||||
```
|
||||
|
||||
#### Changing the format
|
||||
@@ -70,7 +73,7 @@ You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json`
|
||||
format is `HtmlDark`.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json
|
||||
```
|
||||
|
||||
#### Changing the output filename
|
||||
@@ -78,7 +81,7 @@ $ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json
|
||||
You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
|
||||
```
|
||||
|
||||
#### Changing the output directory
|
||||
@@ -88,7 +91,7 @@ extension.
|
||||
If any of the folders in the path have a space in its name, escape them with quotes (").
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports"
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports"
|
||||
```
|
||||
|
||||
#### Changing the filename and output directory
|
||||
@@ -98,7 +101,7 @@ Note that the filename must have an extension, otherwise it will be considered a
|
||||
If any of the folders in the path have a space in its name, escape them with quotes (").
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\myserver.html"
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\myserver.html"
|
||||
```
|
||||
|
||||
#### Generating the filename and output directory dynamically
|
||||
@@ -106,7 +109,7 @@ $ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports
|
||||
You can use template tokens to generate the output file path based on the server and channel metadata.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html"
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html"
|
||||
```
|
||||
|
||||
Assuming you are exporting a channel named `"my-channel"` in the `"Text channels"` category from a server
|
||||
@@ -125,6 +128,7 @@ Here is the full list of supported template tokens:
|
||||
- `%P` - category position
|
||||
- `%a` - the "after" date
|
||||
- `%b` - the "before" date
|
||||
- `%d` - the current date
|
||||
- `%%` - escapes `%`
|
||||
|
||||
#### Partitioning
|
||||
@@ -133,13 +137,13 @@ You can use partitioning to split files after a given number of messages or file
|
||||
For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 10
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 10
|
||||
```
|
||||
|
||||
A 45 MB channel set to be partitioned every 20 MB will output 3 files.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb
|
||||
```
|
||||
|
||||
#### Downloading assets
|
||||
@@ -150,7 +154,7 @@ downloaded when using the plain text (TXT) export format.
|
||||
A folder containing the assets will be created along with the exported chat. They must be kept together.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
|
||||
```
|
||||
|
||||
#### Reusing assets
|
||||
@@ -159,7 +163,7 @@ Previously downloaded assets can be reused to skip redundant downloads as long a
|
||||
same folder. Using this option can speed up future exports. This option requires the `--media` option.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --reuse-media
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --reuse-media
|
||||
```
|
||||
|
||||
#### Changing the media directory
|
||||
@@ -168,7 +172,7 @@ By default, the media directory is created alongside the exported chat. You can
|
||||
providing a path that ends with a slash. All of the exported media will be stored in this directory.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --media-dir "C:\Discord Media"
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --media-dir "C:\Discord Media"
|
||||
```
|
||||
|
||||
#### Changing the date format
|
||||
@@ -177,7 +181,7 @@ You can customize how dates are formatted in the exported files by using `--loca
|
||||
locales. The default locale is `en-US`.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
|
||||
```
|
||||
|
||||
#### Date ranges
|
||||
@@ -186,14 +190,14 @@ $ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
|
||||
Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
|
||||
```
|
||||
|
||||
**Messages sent after a date**
|
||||
Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
|
||||
```
|
||||
|
||||
**Messages sent in a date range**
|
||||
@@ -201,7 +205,7 @@ Use `--before` and `--after` to export messages sent during the provided date ra
|
||||
September 17th, 2019 11:34 PM and September 18th:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" --before "2019-09-18"
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" --before "2019-09-18"
|
||||
```
|
||||
|
||||
You can try different formats like `17-SEP-2019 11:34 PM` or even refine your ranges down to
|
||||
@@ -210,12 +214,22 @@ Don't forget to quote (") the date if it has spaces!
|
||||
More info about .NET date
|
||||
formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
|
||||
|
||||
#### Filtering messages
|
||||
|
||||
Use `--filter` to filter what messages are included in the export.
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --filter "from:Tyrrrz has:image"
|
||||
```
|
||||
|
||||
Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Message-filters.md).
|
||||
|
||||
### Export channels from a specific server
|
||||
|
||||
To export all channels in a specific server, use the `exportguild` command and provide the server ID through the `-g|--guild` option:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
|
||||
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
|
||||
```
|
||||
|
||||
#### Including threads
|
||||
@@ -225,7 +239,7 @@ specifying which threads should be included. It has possible values of `none`, `
|
||||
threads should be included. To include both active and archived threads, use `--include-threads all`.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-threads all
|
||||
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-threads all
|
||||
```
|
||||
|
||||
#### Including voice channels
|
||||
@@ -235,7 +249,7 @@ specifying whether to include voice channels in the export. It has possible valu
|
||||
voice channels, use `--include-vc false`.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
|
||||
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
|
||||
```
|
||||
|
||||
### Export all channels
|
||||
@@ -243,7 +257,7 @@ $ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc fals
|
||||
To export all accessible channels, use the `exportall` command:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
|
||||
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
|
||||
```
|
||||
|
||||
#### Excluding DMs
|
||||
@@ -251,7 +265,7 @@ $ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
|
||||
To exclude DMs, add the `--include-dm false` option.
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
|
||||
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
|
||||
```
|
||||
|
||||
### List channels in a server
|
||||
@@ -259,7 +273,7 @@ $ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
|
||||
To list the channels available in a specific server, use the `channels` command and provide the server ID through the `-g|--guild` option:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
|
||||
./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
|
||||
```
|
||||
|
||||
### List direct message channels
|
||||
@@ -267,7 +281,7 @@ $ ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
|
||||
To list all DM channels accessible to the current account, use the `dm` command:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
|
||||
./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
|
||||
```
|
||||
|
||||
### List servers
|
||||
@@ -275,5 +289,5 @@ $ ./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
|
||||
To list all servers accessible by the current account, use the `guilds` command:
|
||||
|
||||
```console
|
||||
$ ./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
|
||||
```
|
||||
./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
|
||||
```
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: monthly
|
||||
labels:
|
||||
- enhancement
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: monthly
|
||||
labels:
|
||||
- enhancement
|
||||
groups:
|
||||
docker:
|
||||
patterns:
|
||||
- "*"
|
||||
- package-ecosystem: nuget
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: monthly
|
||||
labels:
|
||||
- enhancement
|
||||
groups:
|
||||
nuget:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -23,10 +23,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
- name: Build image
|
||||
run: >
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
|
||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||
with:
|
||||
name: DiscordChatExporter.Cli.Docker
|
||||
path: DiscordChatExporter.Cli.Docker.tar
|
||||
@@ -55,10 +55,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
run: >
|
||||
|
||||
+28
-15
@@ -26,18 +26,31 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
# Build the project separately to discern between build and format errors
|
||||
- name: Build
|
||||
run: >
|
||||
dotnet build
|
||||
-p:CSharpier_Bypass=true
|
||||
--configuration Release
|
||||
|
||||
- name: Verify formatting
|
||||
id: verify
|
||||
run: >
|
||||
dotnet build
|
||||
-t:CSharpierFormat
|
||||
--configuration Release
|
||||
--no-restore
|
||||
|
||||
- name: Report issues
|
||||
if: ${{ failure() && steps.verify.outcome == 'failure' }}
|
||||
run: echo "::error title=Bad formatting::Formatting issues detected. Please build the solution locally to fix them."
|
||||
|
||||
test:
|
||||
# Tests need access to secrets, so we can't run them against PRs because of limited trust
|
||||
@@ -51,12 +64,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -72,7 +85,7 @@ jobs:
|
||||
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@f30e4959ba63075080d4f7f90cacc18d9f3fafd7 # v4.0.0
|
||||
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -99,7 +112,7 @@ jobs:
|
||||
# GUI assets aren't suffixed, unlike the CLI assets
|
||||
asset: DiscordChatExporter
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ startsWith(matrix.rid, 'win-') && 'windows-latest' || startsWith(matrix.rid, 'osx-') && 'macos-latest' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 10
|
||||
|
||||
permissions:
|
||||
@@ -108,12 +121,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Publish app
|
||||
run: >
|
||||
@@ -126,7 +139,7 @@ jobs:
|
||||
--self-contained
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
|
||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||
with:
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/bin/publish/
|
||||
@@ -191,13 +204,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1
|
||||
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
|
||||
with:
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/
|
||||
|
||||
- name: Set permissions
|
||||
if: ${{ !startsWith(matrix.rid, 'win') }}
|
||||
if: ${{ !startsWith(matrix.rid, 'win-') }}
|
||||
run: chmod +x ${{ matrix.app }}/${{ matrix.asset }}
|
||||
|
||||
- name: Create package
|
||||
@@ -223,7 +236,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Notify Discord
|
||||
uses: tyrrrz/action-http-request@64c70c67f5ebc54d4c7ea09cbe3553322778afd5 # 1.1.2
|
||||
uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3
|
||||
with:
|
||||
url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
method: POST
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Version>999.9.9-dev</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -11,18 +11,18 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.1.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" />
|
||||
<PackageReference Include="AngleSharp" Version="1.2.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.1.1" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.8.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,6 +10,8 @@ public static class ChannelIds
|
||||
|
||||
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
|
||||
|
||||
public static Snowflake EmojiTestCases { get; } = Snowflake.Parse("866768438290415636");
|
||||
|
||||
public static Snowflake GroupingTestCases { get; } = Snowflake.Parse("992092091545034842");
|
||||
|
||||
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
||||
|
||||
@@ -19,12 +19,7 @@ namespace DiscordChatExporter.Cli.Tests.Infra;
|
||||
|
||||
public static class ExportWrapper
|
||||
{
|
||||
private static readonly AsyncKeyedLocker<string> Locker =
|
||||
new(o =>
|
||||
{
|
||||
o.PoolSize = 20;
|
||||
o.PoolInitialFill = 1;
|
||||
});
|
||||
private static readonly AsyncKeyedLocker<string> Locker = new();
|
||||
|
||||
private static readonly string DirPath = Path.Combine(
|
||||
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
@@ -61,7 +56,7 @@ public static class ExportWrapper
|
||||
ExportFormat = format,
|
||||
OutputPath = filePath,
|
||||
Locale = "en-US",
|
||||
IsUtcNormalizationEnabled = true
|
||||
IsUtcNormalizationEnabled = true,
|
||||
}.ExecuteAsync(console);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ public class DateRangeSpecs
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
After = Snowflake.FromDate(after)
|
||||
After = Snowflake.FromDate(after),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -50,7 +50,7 @@ public class DateRangeSpecs
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero)
|
||||
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero),
|
||||
],
|
||||
o =>
|
||||
o.Using<DateTimeOffset>(ctx =>
|
||||
@@ -74,7 +74,7 @@ public class DateRangeSpecs
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
Before = Snowflake.FromDate(before)
|
||||
Before = Snowflake.FromDate(before),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -92,7 +92,7 @@ public class DateRangeSpecs
|
||||
[
|
||||
new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero)
|
||||
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero),
|
||||
],
|
||||
o =>
|
||||
o.Using<DateTimeOffset>(ctx =>
|
||||
@@ -118,7 +118,7 @@ public class DateRangeSpecs
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
Before = Snowflake.FromDate(before),
|
||||
After = Snowflake.FromDate(after)
|
||||
After = Snowflake.FromDate(after),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -137,7 +137,7 @@ public class DateRangeSpecs
|
||||
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero)
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
|
||||
],
|
||||
o =>
|
||||
o.Using<DateTimeOffset>(ctx =>
|
||||
|
||||
@@ -29,7 +29,7 @@ public class FilterSpecs
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("some text")
|
||||
MessageFilter = MessageFilter.Parse("some text"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -54,7 +54,7 @@ public class FilterSpecs
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("from:Tyrrrz")
|
||||
MessageFilter = MessageFilter.Parse("from:Tyrrrz"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -79,7 +79,7 @@ public class FilterSpecs
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("has:image")
|
||||
MessageFilter = MessageFilter.Parse("has:image"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -104,7 +104,7 @@ public class FilterSpecs
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("has:pin")
|
||||
MessageFilter = MessageFilter.Parse("has:pin"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -129,7 +129,7 @@ public class FilterSpecs
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("has:invite")
|
||||
MessageFilter = MessageFilter.Parse("has:invite"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -154,7 +154,7 @@ public class FilterSpecs
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz")
|
||||
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -28,7 +28,7 @@ public class HtmlGroupingSpecs
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = [ChannelIds.GroupingTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = file.Path
|
||||
OutputPath = file.Path,
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
@@ -19,8 +20,13 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Default timestamp: 2/12/2023 1:36 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.Contain("Default timestamp: 2/12/2023 1:36 PM");
|
||||
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -33,8 +39,8 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Short time timestamp: 1:36 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message.Text().ReplaceWhiteSpace().Should().Contain("Short time timestamp: 1:36 PM");
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -47,8 +53,8 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Long time timestamp: 1:36:12 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message.Text().ReplaceWhiteSpace().Should().Contain("Long time timestamp: 1:36:12 PM");
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -61,8 +67,8 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Short date timestamp: 2/12/2023");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message.Text().ReplaceWhiteSpace().Should().Contain("Short date timestamp: 2/12/2023");
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -75,8 +81,13 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Long date timestamp: Sunday, February 12, 2023");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.Contain("Long date timestamp: Sunday, February 12, 2023");
|
||||
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -89,8 +100,13 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
|
||||
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -105,9 +121,11 @@ public class HtmlMarkdownSpecs
|
||||
// Assert
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.Contain("Full long timestamp: Sunday, February 12, 2023 1:36:12 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -120,8 +138,13 @@ public class HtmlMarkdownSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Relative timestamp: 2/12/2023 1:36 PM");
|
||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.Contain("Relative timestamp: 2/12/2023 1:36 PM");
|
||||
|
||||
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
|
||||
public class JsonEmojiSpecs
|
||||
{
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_message_with_inline_emoji_and_have_them_listed_separately()
|
||||
{
|
||||
// Act
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
ChannelIds.EmojiTestCases,
|
||||
Snowflake.Parse("866768521052553216")
|
||||
);
|
||||
|
||||
// Assert
|
||||
var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
|
||||
inlineEmojis.Should().HaveCount(4);
|
||||
|
||||
inlineEmojis[0].GetProperty("id").GetString().Should().BeNullOrEmpty();
|
||||
inlineEmojis[0].GetProperty("name").GetString().Should().Be("🙂");
|
||||
inlineEmojis[0].GetProperty("code").GetString().Should().Be("slight_smile");
|
||||
inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
|
||||
inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
inlineEmojis[1].GetProperty("id").GetString().Should().BeNullOrEmpty();
|
||||
inlineEmojis[1].GetProperty("name").GetString().Should().Be("😦");
|
||||
inlineEmojis[1].GetProperty("code").GetString().Should().Be("frowning");
|
||||
inlineEmojis[1].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
|
||||
inlineEmojis[1].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
inlineEmojis[2].GetProperty("id").GetString().Should().BeNullOrEmpty();
|
||||
inlineEmojis[2].GetProperty("name").GetString().Should().Be("😔");
|
||||
inlineEmojis[2].GetProperty("code").GetString().Should().Be("pensive");
|
||||
inlineEmojis[2].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
|
||||
inlineEmojis[2].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||
|
||||
inlineEmojis[3].GetProperty("id").GetString().Should().BeNullOrEmpty();
|
||||
inlineEmojis[3].GetProperty("name").GetString().Should().Be("😂");
|
||||
inlineEmojis[3].GetProperty("code").GetString().Should().Be("joy");
|
||||
inlineEmojis[3].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
|
||||
inlineEmojis[3].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_message_with_custom_inline_emoji_and_have_them_listed_separately()
|
||||
{
|
||||
// Act
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
ChannelIds.EmojiTestCases,
|
||||
Snowflake.Parse("1299804867447230594")
|
||||
);
|
||||
|
||||
// Assert
|
||||
var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
|
||||
inlineEmojis.Should().HaveCount(1);
|
||||
|
||||
inlineEmojis[0].GetProperty("id").GetString().Should().Be("754441880066064584");
|
||||
inlineEmojis[0].GetProperty("name").GetString().Should().Be("lemon_blush");
|
||||
inlineEmojis[0].GetProperty("code").GetString().Should().Be("lemon_blush");
|
||||
inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
|
||||
inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ public class PartitioningSpecs
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = filePath,
|
||||
PartitionLimit = PartitionLimit.Parse("3")
|
||||
PartitionLimit = PartitionLimit.Parse("3"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
@@ -48,7 +48,7 @@ public class PartitioningSpecs
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = filePath,
|
||||
PartitionLimit = PartitionLimit.Parse("1kb")
|
||||
PartitionLimit = PartitionLimit.Parse("1kb"),
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -27,7 +27,7 @@ public class SelfContainedSpecs
|
||||
ChannelIds = [ChannelIds.SelfContainedTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = filePath,
|
||||
ShouldDownloadAssets = true
|
||||
ShouldDownloadAssets = true,
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||
|
||||
internal static class StringExtensions
|
||||
{
|
||||
public static string ReplaceWhiteSpace(this string str, string replacement = " ")
|
||||
{
|
||||
var buffer = new StringBuilder(str.Length);
|
||||
|
||||
foreach (var ch in str)
|
||||
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# -- Build
|
||||
# Specify the platform here so that we pull the SDK image matching the host platform,
|
||||
# instead of the target platform specified during build by the `--platform` option.
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
|
||||
|
||||
# Expose the target architecture set by the `docker build --platform` option, so that
|
||||
# we can build the assembly for the correct platform.
|
||||
@@ -30,7 +30,7 @@ RUN dotnet publish DiscordChatExporter.Cli \
|
||||
|
||||
# -- Run
|
||||
# Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
|
||||
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine AS run
|
||||
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:9.0-alpine AS run
|
||||
|
||||
LABEL org.opencontainers.image.title="DiscordChatExporter.Cli"
|
||||
LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
|
||||
@@ -45,6 +45,9 @@ ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
ENV LANG=en_US.UTF-8
|
||||
|
||||
# Alpine is missing tzdata, which we need to support timezones
|
||||
RUN apk add --no-cache tzdata
|
||||
|
||||
# Use a non-root user to ensure that the files shared with the host are accessible by the host user
|
||||
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851
|
||||
# https://github.com/Tyrrrz/DiscordChatExporter/issues/1174
|
||||
|
||||
@@ -196,7 +196,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
|
||||
CancellationToken = cancellationToken
|
||||
CancellationToken = cancellationToken,
|
||||
},
|
||||
async (channel, innerCancellationToken) =>
|
||||
{
|
||||
|
||||
@@ -4,5 +4,5 @@ public enum ThreadInclusionMode
|
||||
{
|
||||
None,
|
||||
Active,
|
||||
All
|
||||
All,
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.3.5" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,3 +1,41 @@
|
||||
using CliFx;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||
|
||||
return await new CliApplicationBuilder().AddCommandsFromThisAssembly().Build().RunAsync(args);
|
||||
namespace DiscordChatExporter.Cli;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
// Explicit references because CliFx relies on reflection and we're publishing with trimming enabled
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportAllCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportChannelsCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportDirectMessagesCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportGuildCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetChannelsCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetDirectChannelsCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetGuildsCommand))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GuideCommand))]
|
||||
[DynamicDependency(
|
||||
DynamicallyAccessedMemberTypes.All,
|
||||
typeof(ThreadInclusionModeBindingConverter)
|
||||
)]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TruthyBooleanBindingConverter))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PartitionLimit))]
|
||||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageFilter))]
|
||||
public static async Task<int> Main(string[] args) =>
|
||||
await new CliApplicationBuilder()
|
||||
.AddCommand<ExportAllCommand>()
|
||||
.AddCommand<ExportChannelsCommand>()
|
||||
.AddCommand<ExportDirectMessagesCommand>()
|
||||
.AddCommand<ExportGuildCommand>()
|
||||
.AddCommand<GetChannelsCommand>()
|
||||
.AddCommand<GetDirectChannelsCommand>()
|
||||
.AddCommand<GetGuildsCommand>()
|
||||
.AddCommand<GuideCommand>()
|
||||
.Build()
|
||||
.RunAsync(args);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ internal static class ConsoleExtensions
|
||||
{
|
||||
Ansi = AnsiSupport.Detect,
|
||||
ColorSystem = ColorSystemSupport.Detect,
|
||||
Out = new AnsiConsoleOutput(console.Output)
|
||||
Out = new AnsiConsoleOutput(console.Output),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
||||
// https://discord.com/developers/docs/resources/application#application-object
|
||||
public partial record Application(Snowflake Id, string Name, ApplicationFlags Flags)
|
||||
{
|
||||
public bool IsMessageContentIntentEnabled =>
|
||||
public bool IsMessageContentIntentEnabled { get; } =
|
||||
Flags.HasFlag(ApplicationFlags.GatewayMessageContent)
|
||||
|| Flags.HasFlag(ApplicationFlags.GatewayMessageContentLimited);
|
||||
}
|
||||
|
||||
@@ -16,5 +16,5 @@ public enum ApplicationFlags
|
||||
Embedded = 131072,
|
||||
GatewayMessageContent = 262144,
|
||||
GatewayMessageContentLimited = 524288,
|
||||
ApplicationCommandBadge = 8388608
|
||||
ApplicationCommandBadge = 8388608,
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public partial record Attachment(
|
||||
FileSize FileSize
|
||||
) : IHasId
|
||||
{
|
||||
public string FileExtension => Path.GetExtension(FileName);
|
||||
public string FileExtension { get; } = Path.GetExtension(FileName);
|
||||
|
||||
public bool IsImage =>
|
||||
string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase)
|
||||
@@ -41,7 +41,7 @@ public partial record Attachment(
|
||||
|| string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
||||
public bool IsSpoiler { get; } = FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public partial record Attachment
|
||||
|
||||
@@ -21,21 +21,23 @@ public partial record Channel(
|
||||
Snowflake? LastMessageId
|
||||
) : IHasId
|
||||
{
|
||||
public bool IsDirect => Kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat;
|
||||
public bool IsDirect { get; } =
|
||||
Kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat;
|
||||
|
||||
public bool IsGuild => !IsDirect;
|
||||
|
||||
public bool IsCategory => Kind == ChannelKind.GuildCategory;
|
||||
public bool IsCategory { get; } = Kind == ChannelKind.GuildCategory;
|
||||
|
||||
public bool IsVoice => Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
|
||||
public bool IsVoice { get; } =
|
||||
Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
|
||||
|
||||
public bool IsThread =>
|
||||
public bool IsThread { get; } =
|
||||
Kind
|
||||
is ChannelKind.GuildNewsThread
|
||||
or ChannelKind.GuildPublicThread
|
||||
or ChannelKind.GuildPrivateThread;
|
||||
|
||||
public bool IsEmpty => LastMessageId is null;
|
||||
public bool IsEmpty { get; } = LastMessageId is null;
|
||||
|
||||
public IEnumerable<Channel> GetParents()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
public record ChannelConnection(Channel Channel, IReadOnlyList<ChannelConnection> Children)
|
||||
{
|
||||
public static IReadOnlyList<ChannelConnection> BuildTree(IReadOnlyList<Channel> channels)
|
||||
{
|
||||
IReadOnlyList<ChannelConnection> GetChildren(Channel parent) =>
|
||||
channels
|
||||
.Where(c => c.Parent?.Id == parent.Id)
|
||||
.Select(c => new ChannelConnection(c, GetChildren(c)))
|
||||
.ToArray();
|
||||
|
||||
return channels
|
||||
.Where(c => c.Parent is null)
|
||||
.Select(c => new ChannelConnection(c, GetChildren(c)))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -14,5 +14,5 @@ public enum ChannelKind
|
||||
GuildPrivateThread = 12,
|
||||
GuildStageVoice = 13,
|
||||
GuildDirectory = 14,
|
||||
GuildForum = 15
|
||||
GuildForum = 15,
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
public record ChannelNode(Channel Channel, IReadOnlyList<ChannelNode> Children)
|
||||
{
|
||||
public static IReadOnlyList<ChannelNode> BuildTree(IReadOnlyList<Channel> channels)
|
||||
{
|
||||
IReadOnlyList<ChannelNode> GetChildren(Channel parent) =>
|
||||
channels
|
||||
.Where(c => c.Parent?.Id == parent.Id)
|
||||
.Select(c => new ChannelNode(c, GetChildren(c)))
|
||||
.ToArray();
|
||||
|
||||
return channels
|
||||
.Where(c => c.Parent is null)
|
||||
.Select(c => new ChannelNode(c, GetChildren(c)))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,5 @@ public enum EmbedKind
|
||||
Image,
|
||||
Video,
|
||||
Gifv,
|
||||
Link
|
||||
Link,
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
public partial record SpotifyTrackEmbedProjection(string TrackId)
|
||||
{
|
||||
public string Url => $"https://open.spotify.com/embed/track/{TrackId}";
|
||||
public string Url { get; } = $"https://open.spotify.com/embed/track/{TrackId}";
|
||||
}
|
||||
|
||||
public partial record SpotifyTrackEmbedProjection
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
public partial record TwitchClipEmbedProjection(string ClipId)
|
||||
{
|
||||
public string Url => $"https://clips.twitch.tv/embed?clip={ClipId}&parent=localhost";
|
||||
public string Url { get; } = $"https://clips.twitch.tv/embed?clip={ClipId}&parent=localhost";
|
||||
}
|
||||
|
||||
public partial record TwitchClipEmbedProjection
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
||||
{
|
||||
public string Url => $"https://www.youtube.com/embed/{VideoId}";
|
||||
public string Url { get; } = $"https://www.youtube.com/embed/{VideoId}";
|
||||
}
|
||||
|
||||
public partial record YouTubeVideoEmbedProjection
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
@@ -13,29 +12,22 @@ public partial record Emoji(
|
||||
Snowflake? Id,
|
||||
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
|
||||
string Name,
|
||||
bool IsAnimated,
|
||||
string ImageUrl
|
||||
bool IsAnimated
|
||||
)
|
||||
{
|
||||
public bool IsCustomEmoji { get; } = Id is not null;
|
||||
|
||||
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
|
||||
public string Code => Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
|
||||
public string Code { get; } = Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
|
||||
|
||||
public string ImageUrl { get; } =
|
||||
Id is not null
|
||||
? ImageCdn.GetCustomEmojiUrl(Id.Value, IsAnimated)
|
||||
: ImageCdn.GetStandardEmojiUrl(Name);
|
||||
}
|
||||
|
||||
public partial record Emoji
|
||||
{
|
||||
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
|
||||
{
|
||||
// Custom emoji
|
||||
if (id is not null)
|
||||
return ImageCdn.GetCustomEmojiUrl(id.Value, isAnimated);
|
||||
|
||||
// Standard emoji
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
return ImageCdn.GetStandardEmojiUrl(name);
|
||||
|
||||
throw new InvalidOperationException("Either the emoji ID or name should be provided.");
|
||||
}
|
||||
|
||||
public static Emoji Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetPropertyOrNull("id")
|
||||
@@ -47,8 +39,7 @@ public partial record Emoji
|
||||
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji";
|
||||
|
||||
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
|
||||
var imageUrl = GetImageUrl(id, name, isAnimated);
|
||||
|
||||
return new Emoji(id, name, isAnimated, imageUrl);
|
||||
return new Emoji(id, name, isAnimated);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
||||
// https://discord.com/developers/docs/resources/guild#guild-object
|
||||
public partial record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
|
||||
{
|
||||
public bool IsDirect => Id == DirectMessages.Id;
|
||||
public bool IsDirect { get; } = Id == Snowflake.Zero;
|
||||
}
|
||||
|
||||
public partial record Guild
|
||||
|
||||
@@ -15,7 +15,7 @@ public partial record Member(
|
||||
IReadOnlyList<Snowflake> RoleIds
|
||||
) : IHasId
|
||||
{
|
||||
public Snowflake Id => User.Id;
|
||||
public Snowflake Id { get; } = User.Id;
|
||||
}
|
||||
|
||||
public partial record Member
|
||||
|
||||
@@ -30,15 +30,15 @@ public partial record Message(
|
||||
Interaction? Interaction
|
||||
) : IHasId
|
||||
{
|
||||
public bool IsSystemNotification =>
|
||||
public bool IsSystemNotification { get; } =
|
||||
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
||||
|
||||
public bool IsReply => Kind == MessageKind.Reply;
|
||||
public bool IsReply { get; } = Kind == MessageKind.Reply;
|
||||
|
||||
// App interactions are rendered as replies in the Discord client, but they are not actually replies
|
||||
public bool IsReplyLike => IsReply || Interaction is not null;
|
||||
|
||||
public bool IsEmpty =>
|
||||
public bool IsEmpty { get; } =
|
||||
string.IsNullOrWhiteSpace(Content)
|
||||
&& !Attachments.Any()
|
||||
&& !Embeds.Any()
|
||||
|
||||
@@ -14,5 +14,5 @@ public enum MessageFlags
|
||||
Urgent = 16,
|
||||
HasThread = 32,
|
||||
Ephemeral = 64,
|
||||
Loading = 128
|
||||
Loading = 128,
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@ public enum MessageKind
|
||||
ChannelPinnedMessage = 6,
|
||||
GuildMemberJoin = 7,
|
||||
ThreadCreated = 18,
|
||||
Reply = 19
|
||||
Reply = 19,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
||||
// https://discord.com/developers/docs/resources/sticker#sticker-resource
|
||||
public partial record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl)
|
||||
{
|
||||
public bool IsImage => Format != StickerFormat.Lottie;
|
||||
public bool IsImage { get; } = Format != StickerFormat.Lottie;
|
||||
}
|
||||
|
||||
public partial record Sticker
|
||||
@@ -28,7 +28,7 @@ public partial record Sticker
|
||||
StickerFormat.Apng => "png",
|
||||
StickerFormat.Lottie => "json",
|
||||
StickerFormat.Gif => "gif",
|
||||
_ => throw new InvalidOperationException($"Unknown sticker format '{format}'.")
|
||||
_ => throw new InvalidOperationException($"Unknown sticker format '{format}'."),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@ public enum StickerFormat
|
||||
Png = 1,
|
||||
Apng = 2,
|
||||
Lottie = 3,
|
||||
Gif = 4
|
||||
Gif = 4,
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public partial record User(
|
||||
string AvatarUrl
|
||||
) : IHasId
|
||||
{
|
||||
public string DiscriminatorFormatted =>
|
||||
public string DiscriminatorFormatted { get; } =
|
||||
Discriminator is not null ? $"{Discriminator:0000}" : "0000";
|
||||
|
||||
// This effectively represents the user's true identity.
|
||||
|
||||
@@ -133,25 +133,21 @@ public class DiscordClient(string token)
|
||||
{
|
||||
throw response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized
|
||||
=> throw new DiscordChatExporterException(
|
||||
"Authentication token is invalid.",
|
||||
true
|
||||
),
|
||||
HttpStatusCode.Unauthorized => throw new DiscordChatExporterException(
|
||||
"Authentication token is invalid.",
|
||||
true
|
||||
),
|
||||
|
||||
HttpStatusCode.Forbidden
|
||||
=> throw new DiscordChatExporterException(
|
||||
$"Request to '{url}' failed: forbidden."
|
||||
),
|
||||
HttpStatusCode.Forbidden => throw new DiscordChatExporterException(
|
||||
$"Request to '{url}' failed: forbidden."
|
||||
),
|
||||
|
||||
HttpStatusCode.NotFound
|
||||
=> throw new DiscordChatExporterException(
|
||||
$"Request to '{url}' failed: not found."
|
||||
),
|
||||
HttpStatusCode.NotFound => throw new DiscordChatExporterException(
|
||||
$"Request to '{url}' failed: not found."
|
||||
),
|
||||
|
||||
_
|
||||
=> throw new DiscordChatExporterException(
|
||||
$"""
|
||||
_ => throw new DiscordChatExporterException(
|
||||
$"""
|
||||
Request to '{url}' failed: {response
|
||||
.StatusCode.ToString()
|
||||
.ToSpaceSeparatedWords()
|
||||
@@ -160,8 +156,8 @@ public class DiscordClient(string token)
|
||||
cancellationToken
|
||||
)}
|
||||
""",
|
||||
true
|
||||
)
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
public enum TokenKind
|
||||
{
|
||||
User,
|
||||
Bot
|
||||
Bot,
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncKeyedLock" Version="6.4.2" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
||||
<PackageReference Include="AsyncKeyedLock" Version="7.1.4" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Polly" Version="8.4.0" />
|
||||
<PackageReference Include="RazorBlade" Version="0.6.0" />
|
||||
<PackageReference Include="Polly" Version="8.5.2" />
|
||||
<PackageReference Include="RazorBlade" Version="0.8.0" />
|
||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.16.0" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.3.14" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.17.0" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.5.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -16,12 +15,7 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
|
||||
internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
{
|
||||
private static readonly AsyncKeyedLocker<string> Locker =
|
||||
new(o =>
|
||||
{
|
||||
o.PoolSize = 20;
|
||||
o.PoolInitialFill = 1;
|
||||
});
|
||||
private static readonly AsyncKeyedLocker<string> Locker = new();
|
||||
|
||||
// File paths of the previously downloaded assets
|
||||
private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal);
|
||||
@@ -50,38 +44,8 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
{
|
||||
// Download the file
|
||||
using var response = await Http.Client.GetAsync(url, innerCancellationToken);
|
||||
await using (var output = File.Create(filePath))
|
||||
await response.Content.CopyToAsync(output, innerCancellationToken);
|
||||
|
||||
// Try to set the file date according to the last-modified header
|
||||
try
|
||||
{
|
||||
var lastModified = response
|
||||
.Content.Headers.TryGetValue("Last-Modified")
|
||||
?.Pipe(s =>
|
||||
DateTimeOffset.TryParse(
|
||||
s,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var instant
|
||||
)
|
||||
? instant
|
||||
: (DateTimeOffset?)null
|
||||
);
|
||||
|
||||
if (lastModified is not null)
|
||||
{
|
||||
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// This can apparently fail for some reason.
|
||||
// Updating the file date is not a critical task, so we'll just ignore exceptions thrown here.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
||||
}
|
||||
await using var output = File.Create(filePath);
|
||||
await response.Content.CopyToAsync(output, innerCancellationToken);
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting;
|
||||
@@ -18,8 +19,10 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
private readonly Dictionary<Snowflake, Channel> _channelsById = new();
|
||||
private readonly Dictionary<Snowflake, Role> _rolesById = new();
|
||||
|
||||
private readonly ExportAssetDownloader _assetDownloader =
|
||||
new(request.AssetsDirPath, request.ShouldReuseAssets);
|
||||
private readonly ExportAssetDownloader _assetDownloader = new(
|
||||
request.AssetsDirPath,
|
||||
request.ShouldReuseAssets
|
||||
);
|
||||
|
||||
public DiscordClient Discord { get; } = discord;
|
||||
|
||||
@@ -115,7 +118,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
|
||||
|
||||
// Prefer the relative path so that the export package can be copied around without breaking references.
|
||||
// However, if the assets directory lies outside of the export directory, use the absolute path instead.
|
||||
// However, if the assets directory lies outside the export directory, use the absolute path instead.
|
||||
var shouldUseAbsoluteFilePath =
|
||||
relativeFilePath.StartsWith(
|
||||
".." + Path.DirectorySeparatorChar,
|
||||
@@ -130,14 +133,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
|
||||
// For HTML, the path needs to be properly formatted
|
||||
if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
|
||||
{
|
||||
// Format the path into a valid file URI
|
||||
var href = new Uri(new Uri("file:///"), optimalFilePath).ToString();
|
||||
|
||||
// File schema does not support relative paths, so strip it if that's the case
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1155
|
||||
return shouldUseAbsoluteFilePath ? href : href[8..];
|
||||
}
|
||||
return Url.EncodeFilePath(optimalFilePath);
|
||||
|
||||
return optimalFilePath;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ public enum ExportFormat
|
||||
HtmlDark,
|
||||
HtmlLight,
|
||||
Csv,
|
||||
Json
|
||||
Json,
|
||||
}
|
||||
|
||||
public static class ExportFormatExtensions
|
||||
@@ -21,7 +21,7 @@ public static class ExportFormatExtensions
|
||||
ExportFormat.HtmlLight => "html",
|
||||
ExportFormat.Csv => "csv",
|
||||
ExportFormat.Json => "json",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
|
||||
public static string GetDisplayName(this ExportFormat format) =>
|
||||
@@ -32,6 +32,6 @@ public static class ExportFormatExtensions
|
||||
ExportFormat.HtmlLight => "HTML (Light)",
|
||||
ExportFormat.Csv => "CSV",
|
||||
ExportFormat.Json => "JSON",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format))
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -172,24 +172,21 @@ public partial class ExportRequest
|
||||
"%C" => channel.Name,
|
||||
|
||||
"%p" => channel.Position?.ToString(CultureInfo.InvariantCulture) ?? "0",
|
||||
"%P"
|
||||
=> channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture)
|
||||
?? "0",
|
||||
"%P" => channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture)
|
||||
?? "0",
|
||||
|
||||
"%a"
|
||||
=> after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
?? "",
|
||||
"%b"
|
||||
=> before?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
?? "",
|
||||
"%d"
|
||||
=> DateTimeOffset.Now.ToString(
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture
|
||||
),
|
||||
"%a" => after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
?? "",
|
||||
"%b" => before
|
||||
?.ToDate()
|
||||
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "",
|
||||
"%d" => DateTimeOffset.Now.ToString(
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture
|
||||
),
|
||||
|
||||
"%%" => "%",
|
||||
_ => m.Value
|
||||
_ => m.Value,
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
internal enum BinaryExpressionKind
|
||||
{
|
||||
Or,
|
||||
And
|
||||
And,
|
||||
}
|
||||
|
||||
@@ -14,6 +14,6 @@ internal class BinaryExpressionMessageFilter(
|
||||
{
|
||||
BinaryExpressionKind.Or => first.IsMatch(message) || second.IsMatch(message),
|
||||
BinaryExpressionKind.And => first.IsMatch(message) && second.IsMatch(message),
|
||||
_ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'.")
|
||||
_ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,15 +17,13 @@ internal class HasMessageFilter(MessageContentMatchKind kind) : MessageFilter
|
||||
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
|
||||
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
|
||||
MessageContentMatchKind.Pin => message.IsPinned,
|
||||
MessageContentMatchKind.Invite
|
||||
=> MarkdownParser
|
||||
.ExtractLinks(message.Content)
|
||||
.Select(l => l.Url)
|
||||
.Select(Invite.TryGetCodeFromUrl)
|
||||
.Any(c => !string.IsNullOrWhiteSpace(c)),
|
||||
_
|
||||
=> throw new InvalidOperationException(
|
||||
$"Unknown message content match kind '{kind}'."
|
||||
)
|
||||
MessageContentMatchKind.Invite => MarkdownParser
|
||||
.ExtractLinks(message.Content)
|
||||
.Select(l => l.Url)
|
||||
.Select(Invite.TryGetCodeFromUrl)
|
||||
.Any(c => !string.IsNullOrWhiteSpace(c)),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unknown message content match kind '{kind}'."
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@ internal enum MessageContentMatchKind
|
||||
Image,
|
||||
Sound,
|
||||
Pin,
|
||||
Invite
|
||||
Invite,
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ internal static class FilterGrammar
|
||||
op switch
|
||||
{
|
||||
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
|
||||
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And)
|
||||
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Markdown;
|
||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
@@ -34,58 +33,51 @@ internal partial class HtmlMarkdownVisitor(
|
||||
{
|
||||
var (openingTag, closingTag) = formatting.Kind switch
|
||||
{
|
||||
FormattingKind.Bold
|
||||
=> (
|
||||
// lang=html
|
||||
"<strong>",
|
||||
// lang=html
|
||||
"</strong>"
|
||||
),
|
||||
FormattingKind.Bold => (
|
||||
// lang=html
|
||||
"<strong>",
|
||||
// lang=html
|
||||
"</strong>"
|
||||
),
|
||||
|
||||
FormattingKind.Italic
|
||||
=> (
|
||||
// lang=html
|
||||
"<em>",
|
||||
// lang=html
|
||||
"</em>"
|
||||
),
|
||||
FormattingKind.Italic => (
|
||||
// lang=html
|
||||
"<em>",
|
||||
// lang=html
|
||||
"</em>"
|
||||
),
|
||||
|
||||
FormattingKind.Underline
|
||||
=> (
|
||||
// lang=html
|
||||
"<u>",
|
||||
// lang=html
|
||||
"</u>"
|
||||
),
|
||||
FormattingKind.Underline => (
|
||||
// lang=html
|
||||
"<u>",
|
||||
// lang=html
|
||||
"</u>"
|
||||
),
|
||||
|
||||
FormattingKind.Strikethrough
|
||||
=> (
|
||||
// lang=html
|
||||
"<s>",
|
||||
// lang=html
|
||||
"</s>"
|
||||
),
|
||||
FormattingKind.Strikethrough => (
|
||||
// lang=html
|
||||
"<s>",
|
||||
// lang=html
|
||||
"</s>"
|
||||
),
|
||||
|
||||
FormattingKind.Spoiler
|
||||
=> (
|
||||
// lang=html
|
||||
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
|
||||
// lang=html
|
||||
"""</span>"""
|
||||
),
|
||||
FormattingKind.Spoiler => (
|
||||
// lang=html
|
||||
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
|
||||
// lang=html
|
||||
"""</span>"""
|
||||
),
|
||||
|
||||
FormattingKind.Quote
|
||||
=> (
|
||||
// lang=html
|
||||
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
|
||||
// lang=html
|
||||
"""</div></div>"""
|
||||
),
|
||||
FormattingKind.Quote => (
|
||||
// lang=html
|
||||
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
|
||||
// lang=html
|
||||
"""</div></div>"""
|
||||
),
|
||||
|
||||
_
|
||||
=> throw new InvalidOperationException(
|
||||
$"Unknown formatting kind '{formatting.Kind}'."
|
||||
)
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unknown formatting kind '{formatting.Kind}'."
|
||||
),
|
||||
};
|
||||
|
||||
buffer.Append(openingTag);
|
||||
@@ -217,7 +209,6 @@ internal partial class HtmlMarkdownVisitor(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
|
||||
var jumboClass = isJumbo ? "chatlog__emoji--large" : "";
|
||||
|
||||
buffer.Append(
|
||||
@@ -228,7 +219,7 @@ internal partial class HtmlMarkdownVisitor(
|
||||
class="chatlog__emoji {jumboClass}"
|
||||
alt="{emoji.Name}"
|
||||
title="{emoji.Code}"
|
||||
src="{await context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}">
|
||||
src="{await context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)}">
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
||||
private readonly TextWriter _writer = new StreamWriter(stream);
|
||||
|
||||
private readonly HtmlMinifier _minifier = new();
|
||||
private readonly List<Message> _messageGroup = new();
|
||||
private readonly List<Message> _messageGroup = [];
|
||||
|
||||
private bool CanJoinGroup(Message message)
|
||||
{
|
||||
@@ -90,7 +90,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
||||
await new MessageGroupTemplate
|
||||
{
|
||||
Context = Context,
|
||||
Messages = messages
|
||||
Messages = messages,
|
||||
}.RenderAsync(cancellationToken)
|
||||
)
|
||||
);
|
||||
@@ -131,7 +131,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
||||
await new PostambleTemplate
|
||||
{
|
||||
Context = Context,
|
||||
MessagesWritten = MessagesWritten
|
||||
MessagesWritten = MessagesWritten,
|
||||
}.RenderAsync(cancellationToken)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using JsonExtensions.Writing;
|
||||
|
||||
@@ -15,19 +17,18 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
: MessageWriter(stream, context)
|
||||
{
|
||||
private readonly Utf8JsonWriter _writer =
|
||||
new(
|
||||
stream,
|
||||
new JsonWriterOptions
|
||||
{
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = true,
|
||||
// Validation errors may mask actual failures
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
||||
SkipValidation = true
|
||||
}
|
||||
);
|
||||
private readonly Utf8JsonWriter _writer = new(
|
||||
stream,
|
||||
new JsonWriterOptions
|
||||
{
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = true,
|
||||
// Validation errors may mask actual failures
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
||||
SkipValidation = true,
|
||||
}
|
||||
);
|
||||
|
||||
private async ValueTask<string> FormatMarkdownAsync(
|
||||
string markdown,
|
||||
@@ -37,22 +38,31 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
|
||||
: markdown;
|
||||
|
||||
private async ValueTask WriteUserAsync(User user, CancellationToken cancellationToken = default)
|
||||
private async ValueTask WriteUserAsync(
|
||||
User user,
|
||||
bool includeRoles = true,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", user.Id.ToString());
|
||||
_writer.WriteString("name", user.Name);
|
||||
_writer.WriteString("discriminator", user.DiscriminatorFormatted);
|
||||
|
||||
_writer.WriteString(
|
||||
"nickname",
|
||||
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
|
||||
);
|
||||
|
||||
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
|
||||
_writer.WriteBoolean("isBot", user.IsBot);
|
||||
|
||||
_writer.WritePropertyName("roles");
|
||||
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
|
||||
if (includeRoles)
|
||||
{
|
||||
_writer.WritePropertyName("roles");
|
||||
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
|
||||
}
|
||||
|
||||
_writer.WriteString(
|
||||
"avatarUrl",
|
||||
@@ -66,6 +76,26 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async ValueTask WriteEmojiAsync(
|
||||
Emoji emoji,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", emoji.Id.ToString());
|
||||
_writer.WriteString("name", emoji.Name);
|
||||
_writer.WriteString("code", emoji.Code);
|
||||
_writer.WriteBoolean("isAnimated", emoji.IsAnimated);
|
||||
_writer.WriteString(
|
||||
"imageUrl",
|
||||
await Context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)
|
||||
);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async ValueTask WriteRolesAsync(
|
||||
IReadOnlyList<Role> roles,
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -273,6 +303,26 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
// Inline emoji
|
||||
_writer.WriteStartArray("inlineEmojis");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||
{
|
||||
foreach (
|
||||
var emoji in MarkdownParser
|
||||
.ExtractEmojis(embed.Description)
|
||||
.DistinctBy(e => e.Name, StringComparer.Ordinal)
|
||||
)
|
||||
{
|
||||
await WriteEmojiAsync(
|
||||
new Emoji(emoji.Id, emoji.Name, emoji.IsAnimated),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
_writer.WriteEndObject();
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
@@ -373,7 +423,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
// Author
|
||||
_writer.WritePropertyName("author");
|
||||
await WriteUserAsync(message.Author, cancellationToken);
|
||||
await WriteUserAsync(message.Author, true, cancellationToken);
|
||||
|
||||
// Attachments
|
||||
_writer.WriteStartArray("attachments");
|
||||
@@ -431,20 +481,14 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
_writer.WriteStartObject();
|
||||
|
||||
// Emoji
|
||||
_writer.WriteStartObject("emoji");
|
||||
_writer.WriteString("id", reaction.Emoji.Id.ToString());
|
||||
_writer.WriteString("name", reaction.Emoji.Name);
|
||||
_writer.WriteString("code", reaction.Emoji.Code);
|
||||
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
|
||||
_writer.WriteString(
|
||||
"imageUrl",
|
||||
await Context.ResolveAssetUrlAsync(reaction.Emoji.ImageUrl, cancellationToken)
|
||||
);
|
||||
_writer.WriteEndObject();
|
||||
_writer.WritePropertyName("emoji");
|
||||
await WriteEmojiAsync(reaction.Emoji, cancellationToken);
|
||||
|
||||
_writer.WriteNumber("count", reaction.Count);
|
||||
|
||||
// Reaction authors
|
||||
_writer.WriteStartArray("users");
|
||||
|
||||
await foreach (
|
||||
var user in Context.Discord.GetMessageReactionsAsync(
|
||||
Context.Request.Channel.Id,
|
||||
@@ -454,28 +498,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
)
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
// Write limited user information without color and roles,
|
||||
// so we can avoid fetching guild member information for each user.
|
||||
_writer.WriteString("id", user.Id.ToString());
|
||||
_writer.WriteString("name", user.Name);
|
||||
_writer.WriteString("discriminator", user.DiscriminatorFormatted);
|
||||
_writer.WriteString(
|
||||
"nickname",
|
||||
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
|
||||
);
|
||||
_writer.WriteBoolean("isBot", user.IsBot);
|
||||
|
||||
_writer.WriteString(
|
||||
"avatarUrl",
|
||||
await Context.ResolveAssetUrlAsync(
|
||||
Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl,
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
await WriteUserAsync(user, false, cancellationToken);
|
||||
}
|
||||
|
||||
_writer.WriteEndArray();
|
||||
@@ -487,9 +510,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
// Mentions
|
||||
_writer.WriteStartArray("mentions");
|
||||
|
||||
foreach (var user in message.MentionedUsers)
|
||||
await WriteUserAsync(user, cancellationToken);
|
||||
await WriteUserAsync(user, true, cancellationToken);
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
@@ -512,11 +534,28 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
_writer.WriteString("name", message.Interaction.Name);
|
||||
|
||||
_writer.WritePropertyName("user");
|
||||
await WriteUserAsync(message.Interaction.User, cancellationToken);
|
||||
await WriteUserAsync(message.Interaction.User, true, cancellationToken);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
|
||||
// Inline emoji
|
||||
_writer.WriteStartArray("inlineEmojis");
|
||||
|
||||
foreach (
|
||||
var emoji in MarkdownParser
|
||||
.ExtractEmojis(message.Content)
|
||||
.DistinctBy(e => e.Name, StringComparer.Ordinal)
|
||||
)
|
||||
{
|
||||
await WriteEmojiAsync(
|
||||
new Emoji(emoji.Id, emoji.Name, emoji.IsAnimated),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
_writer.WriteEndObject();
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@@ -100,13 +100,15 @@ internal partial class MessageExporter
|
||||
ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context),
|
||||
ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context),
|
||||
ExportFormat.HtmlDark => new HtmlMessageWriter(File.Create(filePath), context, "Dark"),
|
||||
ExportFormat.HtmlLight
|
||||
=> new HtmlMessageWriter(File.Create(filePath), context, "Light"),
|
||||
ExportFormat.HtmlLight => new HtmlMessageWriter(
|
||||
File.Create(filePath),
|
||||
context,
|
||||
"Light"
|
||||
),
|
||||
ExportFormat.Json => new JsonMessageWriter(File.Create(filePath), context),
|
||||
_
|
||||
=> throw new ArgumentOutOfRangeException(
|
||||
nameof(format),
|
||||
$"Unknown export format '{format}'."
|
||||
)
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(format),
|
||||
$"Unknown export format '{format}'."
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public partial class PartitionLimit
|
||||
"M" => 1_000_000,
|
||||
"K" => 1_000,
|
||||
"" => 1,
|
||||
_ => -1
|
||||
_ => -1,
|
||||
};
|
||||
|
||||
if (magnitude < 0)
|
||||
|
||||
@@ -10,20 +10,18 @@ internal static class PlainTextMessageExtensions
|
||||
public static string GetFallbackContent(this Message message) =>
|
||||
message.Kind switch
|
||||
{
|
||||
MessageKind.RecipientAdd
|
||||
=> message.MentionedUsers.Any()
|
||||
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
||||
: "Added a recipient.",
|
||||
MessageKind.RecipientAdd => message.MentionedUsers.Any()
|
||||
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
||||
: "Added a recipient.",
|
||||
|
||||
MessageKind.RecipientRemove
|
||||
=> message.MentionedUsers.Any()
|
||||
? message.Author.Id == message.MentionedUsers.First().Id
|
||||
? "Left the group."
|
||||
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
|
||||
: "Removed a recipient.",
|
||||
MessageKind.RecipientRemove => message.MentionedUsers.Any()
|
||||
? message.Author.Id == message.MentionedUsers.First().Id
|
||||
? "Left the group."
|
||||
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
|
||||
: "Removed a recipient.",
|
||||
|
||||
MessageKind.Call
|
||||
=> $"Started a call that lasted {
|
||||
MessageKind.Call =>
|
||||
$"Started a call that lasted {
|
||||
message
|
||||
.CallEndedTimestamp?
|
||||
.Pipe(t => t - message.Timestamp)
|
||||
@@ -31,16 +29,15 @@ internal static class PlainTextMessageExtensions
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||
} minutes.",
|
||||
|
||||
MessageKind.ChannelNameChange
|
||||
=> !string.IsNullOrWhiteSpace(message.Content)
|
||||
? $"Changed the channel name: {message.Content}"
|
||||
: "Changed the channel name.",
|
||||
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
|
||||
? $"Changed the channel name: {message.Content}"
|
||||
: "Changed the channel name.",
|
||||
|
||||
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
||||
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
||||
MessageKind.ThreadCreated => "Started a thread.",
|
||||
MessageKind.GuildMemberJoin => "Joined the server.",
|
||||
|
||||
_ => message.Content
|
||||
_ => message.Content,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown;
|
||||
|
||||
@@ -11,11 +11,17 @@ internal record EmojiNode(
|
||||
bool IsAnimated
|
||||
) : MarkdownNode
|
||||
{
|
||||
public bool IsCustomEmoji => Id is not null;
|
||||
|
||||
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
|
||||
public string Code => IsCustomEmoji ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
|
||||
// This coupling is unsound from the domain-design perspective, but it helps us reuse
|
||||
// some code for now. We can refactor this later, if the coupling becomes a problem.
|
||||
private readonly Emoji _emoji = new(Id, Name, IsAnimated);
|
||||
|
||||
public EmojiNode(string name)
|
||||
: this(null, name, false) { }
|
||||
|
||||
public bool IsCustomEmoji => _emoji.IsCustomEmoji;
|
||||
|
||||
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
|
||||
public string Code => _emoji.Code;
|
||||
|
||||
public string ImageUrl => _emoji.ImageUrl;
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@ internal enum FormattingKind
|
||||
Underline,
|
||||
Strikethrough,
|
||||
Spoiler,
|
||||
Quote
|
||||
Quote,
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@ internal enum MentionKind
|
||||
Here,
|
||||
User,
|
||||
Channel,
|
||||
Role
|
||||
Role,
|
||||
}
|
||||
|
||||
@@ -2,20 +2,19 @@
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||
|
||||
internal class AggregateMatcher<T>(IReadOnlyList<IMatcher<T>> matchers) : IMatcher<T>
|
||||
internal class AggregateMatcher<TContext, TValue>(
|
||||
params IReadOnlyList<IMatcher<TContext, TValue>> matchers
|
||||
) : IMatcher<TContext, TValue>
|
||||
{
|
||||
public AggregateMatcher(params IMatcher<T>[] matchers)
|
||||
: this((IReadOnlyList<IMatcher<T>>)matchers) { }
|
||||
|
||||
public ParsedMatch<T>? TryMatch(StringSegment segment)
|
||||
public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
|
||||
{
|
||||
ParsedMatch<T>? earliestMatch = null;
|
||||
ParsedMatch<TValue>? earliestMatch = null;
|
||||
|
||||
// Try to match the input with each matcher and get the match with the lowest start index
|
||||
foreach (var matcher in matchers)
|
||||
{
|
||||
// Try to match
|
||||
var match = matcher.TryMatch(segment);
|
||||
var match = matcher.TryMatch(context, segment);
|
||||
|
||||
// If there's no match - continue
|
||||
if (match is null)
|
||||
@@ -26,7 +25,9 @@ internal class AggregateMatcher<T>(IReadOnlyList<IMatcher<T>> matchers) : IMatch
|
||||
earliestMatch is null
|
||||
|| match.Segment.StartIndex < earliestMatch.Segment.StartIndex
|
||||
)
|
||||
{
|
||||
earliestMatch = match;
|
||||
}
|
||||
|
||||
// If the earliest match starts at the very beginning - break,
|
||||
// because it's impossible to find a match earlier than that
|
||||
|
||||
@@ -3,17 +3,18 @@ using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||
|
||||
internal interface IMatcher<T>
|
||||
internal interface IMatcher<in TContext, TValue>
|
||||
{
|
||||
ParsedMatch<T>? TryMatch(StringSegment segment);
|
||||
ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment);
|
||||
}
|
||||
|
||||
internal static class MatcherExtensions
|
||||
{
|
||||
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(
|
||||
this IMatcher<T> matcher,
|
||||
public static IEnumerable<ParsedMatch<TValue>> MatchAll<TContext, TValue>(
|
||||
this IMatcher<TContext, TValue> matcher,
|
||||
TContext context,
|
||||
StringSegment segment,
|
||||
Func<StringSegment, T> transformFallback
|
||||
Func<TContext, StringSegment, TValue> transformFallback
|
||||
)
|
||||
{
|
||||
// Loop through segments divided by individual matches
|
||||
@@ -22,6 +23,7 @@ internal static class MatcherExtensions
|
||||
{
|
||||
// Find a match within this segment
|
||||
var match = matcher.TryMatch(
|
||||
context,
|
||||
segment.Relocate(currentIndex, segment.EndIndex - currentIndex)
|
||||
);
|
||||
|
||||
@@ -36,9 +38,9 @@ internal static class MatcherExtensions
|
||||
match.Segment.StartIndex - currentIndex
|
||||
);
|
||||
|
||||
yield return new ParsedMatch<T>(
|
||||
yield return new ParsedMatch<TValue>(
|
||||
fallbackSegment,
|
||||
transformFallback(fallbackSegment)
|
||||
transformFallback(context, fallbackSegment)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,7 +55,10 @@ internal static class MatcherExtensions
|
||||
{
|
||||
var fallbackSegment = segment.Relocate(currentIndex, segment.EndIndex - currentIndex);
|
||||
|
||||
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment));
|
||||
yield return new ParsedMatch<TValue>(
|
||||
fallbackSegment,
|
||||
transformFallback(context, fallbackSegment)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||
|
||||
internal readonly record struct MarkdownContext(int Depth = 0);
|
||||
@@ -23,15 +23,15 @@ internal static partial class MarkdownParser
|
||||
|
||||
/* Formatting */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> BoldFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// There must be exactly two closing asterisks.
|
||||
new Regex(@"\*\*(.+?)\*\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1])))
|
||||
(c, s, m) => new FormattingNode(FormattingKind.Bold, Parse(c, s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> ItalicFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// There must be exactly one closing asterisk.
|
||||
// Opening asterisk must not be followed by whitespace.
|
||||
// Closing asterisk must not be preceded by whitespace.
|
||||
@@ -39,156 +39,174 @@ internal static partial class MarkdownParser
|
||||
@"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)",
|
||||
DefaultRegexOptions | RegexOptions.Singleline
|
||||
),
|
||||
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
|
||||
(c, s, m) =>
|
||||
new FormattingNode(FormattingKind.Italic, Parse(c, s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> ItalicBoldFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// There must be exactly three closing asterisks.
|
||||
new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) =>
|
||||
new FormattingNode(
|
||||
FormattingKind.Italic,
|
||||
Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher)
|
||||
)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> ItalicAltFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Closing underscore must not be followed by a word character.
|
||||
new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> UnderlineFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// There must be exactly two closing underscores.
|
||||
new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// There must be exactly three closing underscores.
|
||||
new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) =>
|
||||
new FormattingNode(
|
||||
FormattingKind.Italic,
|
||||
Parse(s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher)
|
||||
)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) =>
|
||||
new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> SpoilerFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Include the linebreak in the content so that the lines are preserved in quotes.
|
||||
new Regex(@"^>\s(.+\n?)", DefaultRegexOptions),
|
||||
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Include the linebreaks in the content, so that the lines are preserved in quotes.
|
||||
// Empty content is allowed within quotes.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1115
|
||||
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions),
|
||||
(s, m) =>
|
||||
new FormattingNode(
|
||||
FormattingKind.Quote,
|
||||
m.Groups[1].Captures.SelectMany(c => Parse(s.Relocate(c))).ToArray()
|
||||
)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> HeadingNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Consume the linebreak so that it's not attached to following nodes.
|
||||
new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions),
|
||||
(s, m) => new HeadingNode(m.Groups[1].Length, Parse(s.Relocate(m.Groups[2])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> ListNodeMatcher = new RegexMatcher<MarkdownNode>(
|
||||
// Can be preceded by whitespace, which specifies the list's nesting level.
|
||||
// Following lines that start with (level+1) whitespace are considered part of the list item.
|
||||
// Consume the linebreak so that it's not attached to following nodes.
|
||||
new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions),
|
||||
(s, m) =>
|
||||
new ListNode(
|
||||
m.Groups[2].Captures.Select(c => new ListItemNode(Parse(s.Relocate(c)))).ToArray()
|
||||
private static readonly IMatcher<
|
||||
MarkdownContext,
|
||||
MarkdownNode
|
||||
> ItalicBoldFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// There must be exactly three closing asterisks.
|
||||
new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(c, s, m) =>
|
||||
new FormattingNode(
|
||||
FormattingKind.Italic,
|
||||
Parse(c, s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher)
|
||||
)
|
||||
);
|
||||
|
||||
/* Code blocks */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// One or two backticks are allowed, but they must match on both sides.
|
||||
new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(_, m) => new InlineCodeBlockNode(m.Groups[2].Value)
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> ItalicAltFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Closing underscore must not be followed by a word character.
|
||||
new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(c, s, m) =>
|
||||
new FormattingNode(FormattingKind.Italic, Parse(c, s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> UnderlineFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// There must be exactly two closing underscores.
|
||||
new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(c, s, m) =>
|
||||
new FormattingNode(FormattingKind.Underline, Parse(c, s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<
|
||||
MarkdownContext,
|
||||
MarkdownNode
|
||||
> ItalicUnderlineFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// There must be exactly three closing underscores.
|
||||
new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(c, s, m) =>
|
||||
new FormattingNode(
|
||||
FormattingKind.Italic,
|
||||
Parse(c, s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher)
|
||||
)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<
|
||||
MarkdownContext,
|
||||
MarkdownNode
|
||||
> StrikethroughFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(c, s, m) =>
|
||||
new FormattingNode(FormattingKind.Strikethrough, Parse(c, s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> SpoilerFormattingNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(c, s, m) =>
|
||||
new FormattingNode(FormattingKind.Spoiler, Parse(c, s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> SingleLineQuoteNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Include the linebreak in the content so that the lines are preserved in quotes.
|
||||
new Regex(@"^>\s(.+\n?)", DefaultRegexOptions),
|
||||
(c, s, m) => new FormattingNode(FormattingKind.Quote, Parse(c, s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<
|
||||
MarkdownContext,
|
||||
MarkdownNode
|
||||
> RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Include the linebreaks in the content, so that the lines are preserved in quotes.
|
||||
// Empty content is allowed within quotes.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1115
|
||||
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions),
|
||||
(c, s, m) =>
|
||||
new FormattingNode(
|
||||
FormattingKind.Quote,
|
||||
m.Groups[1].Captures.SelectMany(r => Parse(c, s.Relocate(r))).ToArray()
|
||||
)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> MultiLineQuoteNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(c, s, m) => new FormattingNode(FormattingKind.Quote, Parse(c, s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> HeadingNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Consume the linebreak so that it's not attached to following nodes.
|
||||
new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions),
|
||||
(c, s, m) => new HeadingNode(m.Groups[1].Length, Parse(c, s.Relocate(m.Groups[2])))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> ListNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Can be preceded by whitespace, which specifies the list's nesting level.
|
||||
// Following lines that start with (level+1) whitespace are considered part of the list item.
|
||||
// Consume the linebreak so that it's not attached to following nodes.
|
||||
new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions),
|
||||
(c, s, m) =>
|
||||
new ListNode(
|
||||
m.Groups[2]
|
||||
.Captures.Select(x => new ListItemNode(Parse(c, s.Relocate(x))))
|
||||
.ToArray()
|
||||
)
|
||||
);
|
||||
|
||||
/* Code blocks */
|
||||
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> InlineCodeBlockNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// One or two backticks are allowed, but they must match on both sides.
|
||||
new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(_, _, m) => new InlineCodeBlockNode(m.Groups[2].Value)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> MultiLineCodeBlockNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Language identifier is one word immediately after opening backticks, followed immediately by a linebreak.
|
||||
// Blank lines at the beginning and at the end of content are trimmed.
|
||||
new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
|
||||
(_, m) =>
|
||||
(_, _, m) =>
|
||||
new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
|
||||
);
|
||||
|
||||
/* Mentions */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher =
|
||||
new StringMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> EveryoneMentionNodeMatcher =
|
||||
new StringMatcher<MarkdownContext, MarkdownNode>(
|
||||
"@everyone",
|
||||
_ => new MentionNode(null, MentionKind.Everyone)
|
||||
(_, _) => new MentionNode(null, MentionKind.Everyone)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher =
|
||||
new StringMatcher<MarkdownNode>("@here", _ => new MentionNode(null, MentionKind.Here));
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> HereMentionNodeMatcher =
|
||||
new StringMatcher<MarkdownContext, MarkdownNode>(
|
||||
"@here",
|
||||
(_, _) => new MentionNode(null, MentionKind.Here)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> UserMentionNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture <@123456> or <@!123456>
|
||||
new Regex(@"<@!?(\d+)>", DefaultRegexOptions),
|
||||
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User)
|
||||
(_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> ChannelMentionNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture <#123456>
|
||||
new Regex(@"<\#!?(\d+)>", DefaultRegexOptions),
|
||||
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel)
|
||||
(_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> RoleMentionNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture <@&123456>
|
||||
new Regex(@"<@&(\d+)>", DefaultRegexOptions),
|
||||
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role)
|
||||
(_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role)
|
||||
);
|
||||
|
||||
/* Emoji */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
new Regex(
|
||||
@"("
|
||||
+
|
||||
@@ -239,21 +257,21 @@ internal static partial class MarkdownParser
|
||||
+ @")",
|
||||
DefaultRegexOptions
|
||||
),
|
||||
(_, m) => new EmojiNode(m.Groups[1].Value)
|
||||
(_, _, m) => new EmojiNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> CodedStandardEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> CodedStandardEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture :thinking:
|
||||
new Regex(@":([\w_]+):", DefaultRegexOptions),
|
||||
(_, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n))
|
||||
(_, _, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n))
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> CustomEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture <:lul:123456> or <a:lul:123456>
|
||||
new Regex(@"<(a)?:(.+?):(\d+?)>", DefaultRegexOptions),
|
||||
(_, m) =>
|
||||
(_, _, m) =>
|
||||
new EmojiNode(
|
||||
Snowflake.TryParse(m.Groups[3].Value),
|
||||
m.Groups[2].Value,
|
||||
@@ -263,70 +281,72 @@ internal static partial class MarkdownParser
|
||||
|
||||
/* Links */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> AutoLinkNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Any non-whitespace character after http:// or https://
|
||||
// until the last punctuation character or whitespace.
|
||||
new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions),
|
||||
(_, m) => new LinkNode(m.Groups[1].Value)
|
||||
(_, _, m) => new LinkNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> HiddenLinkNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> HiddenLinkNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Same as auto link but also surrounded by angular brackets
|
||||
new Regex(@"<(https?://\S*[^\.,:;""'\s])>", DefaultRegexOptions),
|
||||
(_, m) => new LinkNode(m.Groups[1].Value)
|
||||
(_, _, m) => new LinkNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> MaskedLinkNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> MaskedLinkNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture [title](link)
|
||||
new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions),
|
||||
(s, m) => new LinkNode(m.Groups[2].Value, Parse(s.Relocate(m.Groups[1])))
|
||||
(c, s, m) => new LinkNode(m.Groups[2].Value, Parse(c, s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
/* Text */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher =
|
||||
new StringMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> ShrugTextNodeMatcher =
|
||||
new StringMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture the shrug kaomoji.
|
||||
// This escapes it from matching for formatting.
|
||||
@"¯\_(ツ)_/¯",
|
||||
s => new TextNode(s.ToString())
|
||||
(s, _) => new TextNode(s.ToString())
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture some specific emoji that don't get rendered.
|
||||
// This escapes them from matching for emoji.
|
||||
new Regex(@"([\u26A7\u2640\u2642\u2695\u267E\u00A9\u00AE\u2122])", DefaultRegexOptions),
|
||||
(_, m) => new TextNode(m.Groups[1].Value)
|
||||
(_, _, m) => new TextNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> EscapedSymbolTextNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture any "symbol/other" character or surrogate pair preceded by a backslash.
|
||||
// This escapes them from matching for emoji.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/230
|
||||
new Regex(@"\\(\p{So}|\p{Cs}{2})", DefaultRegexOptions),
|
||||
(_, m) => new TextNode(m.Groups[1].Value)
|
||||
(_, _, m) => new TextNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
|
||||
// This escapes them from matching for formatting or other tokens.
|
||||
new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions),
|
||||
(_, m) => new TextNode(m.Groups[1].Value)
|
||||
);
|
||||
private static readonly IMatcher<
|
||||
MarkdownContext,
|
||||
MarkdownNode
|
||||
> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
|
||||
// This escapes them from matching for formatting or other tokens.
|
||||
new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions),
|
||||
(_, _, m) => new TextNode(m.Groups[1].Value)
|
||||
);
|
||||
|
||||
/* Misc */
|
||||
|
||||
private static readonly IMatcher<MarkdownNode> TimestampNodeMatcher =
|
||||
new RegexMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> TimestampNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture <t:12345678> or <t:12345678:R>
|
||||
new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions),
|
||||
(_, m) =>
|
||||
(_, _, m) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -357,10 +377,9 @@ internal static partial class MarkdownParser
|
||||
null => null,
|
||||
// Unknown format: throw an exception to consider this timestamp invalid
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1156
|
||||
var f
|
||||
=> throw new InvalidOperationException(
|
||||
$"Unknown timestamp format '{f}'."
|
||||
)
|
||||
var f => throw new InvalidOperationException(
|
||||
$"Unknown timestamp format '{f}'."
|
||||
),
|
||||
};
|
||||
|
||||
return new TimestampNode(instant, format);
|
||||
@@ -382,50 +401,51 @@ internal static partial class MarkdownParser
|
||||
);
|
||||
|
||||
// Matchers that have similar patterns are ordered from most specific to least specific
|
||||
private static readonly IMatcher<MarkdownNode> NodeMatcher = new AggregateMatcher<MarkdownNode>(
|
||||
// Escaped text
|
||||
ShrugTextNodeMatcher,
|
||||
IgnoredEmojiTextNodeMatcher,
|
||||
EscapedSymbolTextNodeMatcher,
|
||||
EscapedCharacterTextNodeMatcher,
|
||||
// Formatting
|
||||
ItalicBoldFormattingNodeMatcher,
|
||||
ItalicUnderlineFormattingNodeMatcher,
|
||||
BoldFormattingNodeMatcher,
|
||||
ItalicFormattingNodeMatcher,
|
||||
UnderlineFormattingNodeMatcher,
|
||||
ItalicAltFormattingNodeMatcher,
|
||||
StrikethroughFormattingNodeMatcher,
|
||||
SpoilerFormattingNodeMatcher,
|
||||
MultiLineQuoteNodeMatcher,
|
||||
RepeatedSingleLineQuoteNodeMatcher,
|
||||
SingleLineQuoteNodeMatcher,
|
||||
HeadingNodeMatcher,
|
||||
ListNodeMatcher,
|
||||
// Code blocks
|
||||
MultiLineCodeBlockNodeMatcher,
|
||||
InlineCodeBlockNodeMatcher,
|
||||
// Mentions
|
||||
EveryoneMentionNodeMatcher,
|
||||
HereMentionNodeMatcher,
|
||||
UserMentionNodeMatcher,
|
||||
ChannelMentionNodeMatcher,
|
||||
RoleMentionNodeMatcher,
|
||||
// Links
|
||||
MaskedLinkNodeMatcher,
|
||||
AutoLinkNodeMatcher,
|
||||
HiddenLinkNodeMatcher,
|
||||
// Emoji
|
||||
StandardEmojiNodeMatcher,
|
||||
CustomEmojiNodeMatcher,
|
||||
CodedStandardEmojiNodeMatcher,
|
||||
// Misc
|
||||
TimestampNodeMatcher
|
||||
);
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> NodeMatcher =
|
||||
new AggregateMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Escaped text
|
||||
ShrugTextNodeMatcher,
|
||||
IgnoredEmojiTextNodeMatcher,
|
||||
EscapedSymbolTextNodeMatcher,
|
||||
EscapedCharacterTextNodeMatcher,
|
||||
// Formatting
|
||||
ItalicBoldFormattingNodeMatcher,
|
||||
ItalicUnderlineFormattingNodeMatcher,
|
||||
BoldFormattingNodeMatcher,
|
||||
ItalicFormattingNodeMatcher,
|
||||
UnderlineFormattingNodeMatcher,
|
||||
ItalicAltFormattingNodeMatcher,
|
||||
StrikethroughFormattingNodeMatcher,
|
||||
SpoilerFormattingNodeMatcher,
|
||||
MultiLineQuoteNodeMatcher,
|
||||
RepeatedSingleLineQuoteNodeMatcher,
|
||||
SingleLineQuoteNodeMatcher,
|
||||
HeadingNodeMatcher,
|
||||
ListNodeMatcher,
|
||||
// Code blocks
|
||||
MultiLineCodeBlockNodeMatcher,
|
||||
InlineCodeBlockNodeMatcher,
|
||||
// Mentions
|
||||
EveryoneMentionNodeMatcher,
|
||||
HereMentionNodeMatcher,
|
||||
UserMentionNodeMatcher,
|
||||
ChannelMentionNodeMatcher,
|
||||
RoleMentionNodeMatcher,
|
||||
// Links
|
||||
MaskedLinkNodeMatcher,
|
||||
AutoLinkNodeMatcher,
|
||||
HiddenLinkNodeMatcher,
|
||||
// Emoji
|
||||
StandardEmojiNodeMatcher,
|
||||
CustomEmojiNodeMatcher,
|
||||
CodedStandardEmojiNodeMatcher,
|
||||
// Misc
|
||||
TimestampNodeMatcher
|
||||
);
|
||||
|
||||
// Minimal set of matchers for non-multimedia formats (e.g. plain text)
|
||||
private static readonly IMatcher<MarkdownNode> MinimalNodeMatcher =
|
||||
new AggregateMatcher<MarkdownNode>(
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> MinimalNodeMatcher =
|
||||
new AggregateMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Mentions
|
||||
EveryoneMentionNodeMatcher,
|
||||
HereMentionNodeMatcher,
|
||||
@@ -439,42 +459,75 @@ internal static partial class MarkdownParser
|
||||
);
|
||||
|
||||
private static IReadOnlyList<MarkdownNode> Parse(
|
||||
MarkdownContext context,
|
||||
StringSegment segment,
|
||||
IMatcher<MarkdownNode> matcher
|
||||
) => matcher.MatchAll(segment, s => new TextNode(s.ToString())).Select(r => r.Value).ToArray();
|
||||
IMatcher<MarkdownContext, MarkdownNode> matcher
|
||||
)
|
||||
{
|
||||
// Limit recursion depth to a reasonable number to prevent
|
||||
// stack overflow on messages with inadvertently deep nesting.
|
||||
// Example: ********************************* (repeat ad nauseam)
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1214
|
||||
if (context.Depth >= 32)
|
||||
return [new TextNode(segment.ToString())];
|
||||
|
||||
return matcher
|
||||
.MatchAll(
|
||||
new MarkdownContext(context.Depth + 1),
|
||||
segment,
|
||||
(_, s) => new TextNode(s.ToString())
|
||||
)
|
||||
.Select(r => r.Value)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
internal static partial class MarkdownParser
|
||||
{
|
||||
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment) =>
|
||||
Parse(segment, NodeMatcher);
|
||||
|
||||
public static IReadOnlyList<MarkdownNode> Parse(string markdown) =>
|
||||
Parse(new StringSegment(markdown));
|
||||
|
||||
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringSegment segment) =>
|
||||
Parse(segment, MinimalNodeMatcher);
|
||||
|
||||
public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown) =>
|
||||
ParseMinimal(new StringSegment(markdown));
|
||||
|
||||
private static void ExtractLinks(IEnumerable<MarkdownNode> nodes, ICollection<LinkNode> links)
|
||||
private static void Extract<TNode>(
|
||||
IEnumerable<MarkdownNode> nodes,
|
||||
ICollection<TNode> extractedNodes
|
||||
)
|
||||
where TNode : MarkdownNode
|
||||
{
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
if (node is LinkNode linkNode)
|
||||
links.Add(linkNode);
|
||||
if (node is TNode extractedNode)
|
||||
extractedNodes.Add(extractedNode);
|
||||
|
||||
if (node is IContainerNode containerNode)
|
||||
ExtractLinks(containerNode.Children, links);
|
||||
Extract(containerNode.Children, extractedNodes);
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<LinkNode> ExtractLinks(string markdown)
|
||||
public static IReadOnlyList<TNode> Extract<TNode>(string markdown)
|
||||
where TNode : MarkdownNode
|
||||
{
|
||||
var links = new List<LinkNode>();
|
||||
ExtractLinks(Parse(markdown), links);
|
||||
var extractedNodes = new List<TNode>();
|
||||
Extract(Parse(markdown), extractedNodes);
|
||||
|
||||
return links;
|
||||
return extractedNodes;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<LinkNode> ExtractLinks(string markdown) =>
|
||||
Extract<LinkNode>(markdown);
|
||||
|
||||
public static IReadOnlyList<EmojiNode> ExtractEmojis(string markdown) =>
|
||||
Extract<EmojiNode>(markdown);
|
||||
|
||||
private static IReadOnlyList<MarkdownNode> Parse(
|
||||
MarkdownContext context,
|
||||
StringSegment segment
|
||||
) => Parse(context, segment, NodeMatcher);
|
||||
|
||||
public static IReadOnlyList<MarkdownNode> Parse(string markdown) =>
|
||||
Parse(new MarkdownContext(), new StringSegment(markdown));
|
||||
|
||||
private static IReadOnlyList<MarkdownNode> ParseMinimal(
|
||||
MarkdownContext context,
|
||||
StringSegment segment
|
||||
) => Parse(context, segment, MinimalNodeMatcher);
|
||||
|
||||
public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown) =>
|
||||
ParseMinimal(new MarkdownContext(), new StringSegment(markdown));
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ using System.Text.RegularExpressions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||
|
||||
internal class RegexMatcher<T>(Regex regex, Func<StringSegment, Match, T?> transform) : IMatcher<T>
|
||||
internal class RegexMatcher<TContext, TValue>(
|
||||
Regex regex,
|
||||
Func<TContext, StringSegment, Match, TValue?> transform
|
||||
) : IMatcher<TContext, TValue>
|
||||
{
|
||||
public ParsedMatch<T>? TryMatch(StringSegment segment)
|
||||
public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
|
||||
{
|
||||
var match = regex.Match(segment.Source, segment.StartIndex, segment.Length);
|
||||
if (!match.Success)
|
||||
@@ -20,8 +23,8 @@ internal class RegexMatcher<T>(Regex regex, Func<StringSegment, Match, T?> trans
|
||||
return null;
|
||||
|
||||
var segmentMatch = segment.Relocate(match);
|
||||
var value = transform(segmentMatch, match);
|
||||
var value = transform(context, segmentMatch, match);
|
||||
|
||||
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
|
||||
return value is not null ? new ParsedMatch<TValue>(segmentMatch, value) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown.Parsing;
|
||||
|
||||
internal class StringMatcher<T>(
|
||||
internal class StringMatcher<TContext, TValue>(
|
||||
string needle,
|
||||
StringComparison comparison,
|
||||
Func<StringSegment, T?> transform
|
||||
) : IMatcher<T>
|
||||
Func<TContext, StringSegment, TValue?> transform
|
||||
) : IMatcher<TContext, TValue>
|
||||
{
|
||||
public StringMatcher(string needle, Func<StringSegment, T> transform)
|
||||
public StringMatcher(string needle, Func<TContext, StringSegment, TValue> transform)
|
||||
: this(needle, StringComparison.Ordinal, transform) { }
|
||||
|
||||
public ParsedMatch<T>? TryMatch(StringSegment segment)
|
||||
public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
|
||||
{
|
||||
var index = segment.Source.IndexOf(needle, segment.StartIndex, segment.Length, comparison);
|
||||
|
||||
@@ -19,8 +19,8 @@ internal class StringMatcher<T>(
|
||||
return null;
|
||||
|
||||
var segmentMatch = segment.Relocate(index, needle.Length);
|
||||
var value = transform(segmentMatch);
|
||||
var value = transform(context, segmentMatch);
|
||||
|
||||
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
|
||||
return value is not null ? new ParsedMatch<TValue>(segmentMatch, value) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public static class Http
|
||||
ShouldHandle = new PredicateBuilder().Handle<Exception>(IsRetryableException),
|
||||
MaxRetryAttempts = 4,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
Delay = TimeSpan.FromSeconds(1)
|
||||
Delay = TimeSpan.FromSeconds(1),
|
||||
}
|
||||
)
|
||||
.Build();
|
||||
@@ -68,7 +68,7 @@ public static class Http
|
||||
return ValueTask.FromResult<TimeSpan?>(
|
||||
TimeSpan.FromSeconds(Math.Pow(2, args.AttemptNumber) + 1)
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
.Build();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils;
|
||||
@@ -8,7 +8,9 @@ namespace DiscordChatExporter.Core.Utils;
|
||||
public static class PathEx
|
||||
{
|
||||
private static readonly HashSet<char> InvalidFileNameChars =
|
||||
new(Path.GetInvalidFileNameChars());
|
||||
[
|
||||
.. Path.GetInvalidFileNameChars(),
|
||||
];
|
||||
|
||||
public static string EscapeFileName(string path)
|
||||
{
|
||||
@@ -19,7 +21,7 @@ public static class PathEx
|
||||
|
||||
// File names cannot end with a dot on Windows
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
while (buffer.Length > 0 && buffer[^1] == '.')
|
||||
buffer.Remove(buffer.Length - 1, 1);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils;
|
||||
|
||||
public static class Url
|
||||
{
|
||||
public static string EncodeFilePath(string filePath)
|
||||
{
|
||||
var buffer = new StringBuilder();
|
||||
var position = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (position >= filePath.Length)
|
||||
break;
|
||||
|
||||
var separatorIndex = filePath.IndexOfAny([':', '/', '\\'], position);
|
||||
if (separatorIndex < 0)
|
||||
{
|
||||
buffer.Append(Uri.EscapeDataString(filePath[position..]));
|
||||
break;
|
||||
}
|
||||
|
||||
// Append the segment
|
||||
buffer.Append(Uri.EscapeDataString(filePath[position..separatorIndex]));
|
||||
|
||||
// Append the separator
|
||||
buffer.Append(
|
||||
filePath[separatorIndex] switch
|
||||
{
|
||||
// Normalize slashes
|
||||
'\\' => '/',
|
||||
var c => c,
|
||||
}
|
||||
);
|
||||
|
||||
position = separatorIndex + 1;
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,9 @@ public class UrlBuilder
|
||||
{
|
||||
private string _path = "";
|
||||
|
||||
private readonly Dictionary<string, string?> _queryParameters =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string?> _queryParameters = new(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
public UrlBuilder SetPath(string path)
|
||||
{
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
|
||||
<Style Selector="dialogHostAvalonia|DialogOverlayPopupHost">
|
||||
<Setter Property="Margin" Value="48" />
|
||||
<Setter Property="Background" Value="{DynamicResource MaterialPaperBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Snack bar host -->
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
@@ -61,12 +60,11 @@ public class App : Application, IDisposable
|
||||
{
|
||||
ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light,
|
||||
ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark,
|
||||
_ => Avalonia.Styling.ThemeVariant.Default
|
||||
_ => Avalonia.Styling.ThemeVariant.Default,
|
||||
};
|
||||
|
||||
InitializeTheme();
|
||||
},
|
||||
false
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -75,9 +73,6 @@ public class App : Application, IDisposable
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
// Increase maximum concurrent connections
|
||||
ServicePointManager.DefaultConnectionLimit = 20;
|
||||
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
@@ -87,7 +82,7 @@ public class App : Application, IDisposable
|
||||
{
|
||||
"Light" => PlatformThemeVariant.Light,
|
||||
"Dark" => PlatformThemeVariant.Dark,
|
||||
_ => PlatformSettings?.GetColorValues().ThemeVariant ?? PlatformThemeVariant.Light
|
||||
_ => PlatformSettings?.GetColorValues().ThemeVariant ?? PlatformThemeVariant.Light,
|
||||
};
|
||||
|
||||
this.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme =
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<OutputType>WinExe</OutputType>
|
||||
<AssemblyName>DiscordChatExporter</AssemblyName>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
<PublishTrimmed>true</PublishTrimmed>
|
||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -11,20 +14,20 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.2.1" />
|
||||
<PackageReference Include="Avalonia" Version="11.0.10" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.10" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.10" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Cogwheel" Version="2.0.4" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="DialogHost.Avalonia" Version="0.7.7" />
|
||||
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.3.0" />
|
||||
<PackageReference Include="Avalonia" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.5" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Cogwheel" Version="2.1.0" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||
<PackageReference Include="DialogHost.Avalonia" Version="0.9.2" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="Material.Avalonia" Version="3.6.0" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.1.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
<PackageReference Include="Onova" Version="2.6.11" />
|
||||
<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.2" />
|
||||
<PackageReference Include="Onova" Version="2.6.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -56,7 +56,7 @@ public class DialogManager : IDisposable
|
||||
{
|
||||
FileTypeChoices = fileTypes,
|
||||
SuggestedFileName = defaultFilePath,
|
||||
DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.')
|
||||
DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.'),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -77,7 +77,7 @@ public class DialogManager : IDisposable
|
||||
new FolderPickerOpenOptions
|
||||
{
|
||||
AllowMultiple = false,
|
||||
SuggestedStartLocation = startLocation
|
||||
SuggestedStartLocation = startLocation,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ namespace DiscordChatExporter.Gui.Framework;
|
||||
|
||||
public abstract partial class DialogViewModelBase<T> : ViewModelBase
|
||||
{
|
||||
private readonly TaskCompletionSource<T> _closeTcs =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource<T> _closeTcs = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously
|
||||
);
|
||||
|
||||
[ObservableProperty]
|
||||
private T? _dialogResult;
|
||||
|
||||
@@ -4,5 +4,5 @@ public enum ThemeVariant
|
||||
{
|
||||
System,
|
||||
Light,
|
||||
Dark
|
||||
Dark,
|
||||
}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using DiscordChatExporter.Gui.ViewModels;
|
||||
using DiscordChatExporter.Gui.ViewModels.Components;
|
||||
using DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||
using DiscordChatExporter.Gui.Views;
|
||||
using DiscordChatExporter.Gui.Views.Components;
|
||||
using DiscordChatExporter.Gui.Views.Dialogs;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Framework;
|
||||
|
||||
public partial class ViewManager
|
||||
{
|
||||
private Control? TryCreateView(ViewModelBase viewModel) =>
|
||||
viewModel switch
|
||||
{
|
||||
MainViewModel => new MainView(),
|
||||
DashboardViewModel => new DashboardView(),
|
||||
ExportSetupViewModel => new ExportSetupView(),
|
||||
MessageBoxViewModel => new MessageBoxView(),
|
||||
SettingsViewModel => new SettingsView(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
public Control? TryBindView(ViewModelBase viewModel)
|
||||
{
|
||||
var name = viewModel
|
||||
.GetType()
|
||||
.FullName?.Replace("ViewModel", "View", StringComparison.Ordinal);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
var type = Type.GetType(name);
|
||||
if (type is null)
|
||||
return null;
|
||||
|
||||
if (Activator.CreateInstance(type) is not Control view)
|
||||
var view = TryCreateView(viewModel);
|
||||
if (view is null)
|
||||
return null;
|
||||
|
||||
view.DataContext ??= viewModel;
|
||||
|
||||
@@ -4,5 +4,5 @@ public enum ThreadInclusionMode
|
||||
{
|
||||
None,
|
||||
Active,
|
||||
All
|
||||
All,
|
||||
}
|
||||
|
||||
@@ -7,17 +7,21 @@ namespace DiscordChatExporter.Gui;
|
||||
|
||||
public static class Program
|
||||
{
|
||||
private static Assembly Assembly { get; } = typeof(App).Assembly;
|
||||
private static Assembly Assembly { get; } = Assembly.GetExecutingAssembly();
|
||||
|
||||
public static string Name { get; } = Assembly.GetName().Name!;
|
||||
public static string Name { get; } = Assembly.GetName().Name ?? "DiscordChatExporter";
|
||||
|
||||
public static Version Version { get; } = Assembly.GetName().Version!;
|
||||
public static Version Version { get; } = Assembly.GetName().Version ?? new Version(0, 0, 0);
|
||||
|
||||
public static string VersionString { get; } = Version.ToString(3);
|
||||
|
||||
public static bool IsDevelopmentBuild { get; } = Version.Major is <= 0 or >= 999;
|
||||
|
||||
public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter";
|
||||
|
||||
public static string DocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
|
||||
public static string ProjectReleasesUrl { get; } = $"{ProjectUrl}/releases";
|
||||
|
||||
public static string ProjectDocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>().UsePlatformDetect().LogToTrace();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cogwheel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
@@ -8,57 +9,126 @@ using DiscordChatExporter.Gui.Models;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Services;
|
||||
|
||||
// Can't use [ObservableProperty] here because System.Text.Json's source generator doesn't see
|
||||
// the generated properties.
|
||||
[INotifyPropertyChanged]
|
||||
public partial class SettingsService()
|
||||
: SettingsBase(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.dat"))
|
||||
: SettingsBase(
|
||||
Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
|
||||
SerializerContext.Default
|
||||
)
|
||||
{
|
||||
[ObservableProperty]
|
||||
private bool _isUkraineSupportMessageEnabled = true;
|
||||
public bool IsUkraineSupportMessageEnabled
|
||||
{
|
||||
get => _isUkraineSupportMessageEnabled;
|
||||
set => SetProperty(ref _isUkraineSupportMessageEnabled, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private ThemeVariant _theme;
|
||||
public ThemeVariant Theme
|
||||
{
|
||||
get => _theme;
|
||||
set => SetProperty(ref _theme, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isAutoUpdateEnabled = true;
|
||||
public bool IsAutoUpdateEnabled
|
||||
{
|
||||
get => _isAutoUpdateEnabled;
|
||||
set => SetProperty(ref _isAutoUpdateEnabled, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isTokenPersisted = true;
|
||||
public bool IsTokenPersisted
|
||||
{
|
||||
get => _isTokenPersisted;
|
||||
set => SetProperty(ref _isTokenPersisted, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private ThreadInclusionMode _threadInclusionMode;
|
||||
public ThreadInclusionMode ThreadInclusionMode
|
||||
{
|
||||
get => _threadInclusionMode;
|
||||
set => SetProperty(ref _threadInclusionMode, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _locale;
|
||||
public string? Locale
|
||||
{
|
||||
get => _locale;
|
||||
set => SetProperty(ref _locale, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isUtcNormalizationEnabled;
|
||||
public bool IsUtcNormalizationEnabled
|
||||
{
|
||||
get => _isUtcNormalizationEnabled;
|
||||
set => SetProperty(ref _isUtcNormalizationEnabled, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private int _parallelLimit = 1;
|
||||
public int ParallelLimit
|
||||
{
|
||||
get => _parallelLimit;
|
||||
set => SetProperty(ref _parallelLimit, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _lastToken;
|
||||
public string? LastToken
|
||||
{
|
||||
get => _lastToken;
|
||||
set => SetProperty(ref _lastToken, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private ExportFormat _lastExportFormat = ExportFormat.HtmlDark;
|
||||
public ExportFormat LastExportFormat
|
||||
{
|
||||
get => _lastExportFormat;
|
||||
set => SetProperty(ref _lastExportFormat, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _lastPartitionLimitValue;
|
||||
public string? LastPartitionLimitValue
|
||||
{
|
||||
get => _lastPartitionLimitValue;
|
||||
set => SetProperty(ref _lastPartitionLimitValue, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _lastMessageFilterValue;
|
||||
public string? LastMessageFilterValue
|
||||
{
|
||||
get => _lastMessageFilterValue;
|
||||
set => SetProperty(ref _lastMessageFilterValue, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _lastShouldFormatMarkdown = true;
|
||||
public bool LastShouldFormatMarkdown
|
||||
{
|
||||
get => _lastShouldFormatMarkdown;
|
||||
set => SetProperty(ref _lastShouldFormatMarkdown, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _lastShouldDownloadAssets;
|
||||
public bool LastShouldDownloadAssets
|
||||
{
|
||||
get => _lastShouldDownloadAssets;
|
||||
set => SetProperty(ref _lastShouldDownloadAssets, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _lastShouldReuseAssets;
|
||||
public bool LastShouldReuseAssets
|
||||
{
|
||||
get => _lastShouldReuseAssets;
|
||||
set => SetProperty(ref _lastShouldReuseAssets, value);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private string? _lastAssetsDirPath;
|
||||
public string? LastAssetsDirPath
|
||||
{
|
||||
get => _lastAssetsDirPath;
|
||||
set => SetProperty(ref _lastAssetsDirPath, value);
|
||||
}
|
||||
|
||||
public override void Save()
|
||||
{
|
||||
@@ -72,3 +142,9 @@ public partial class SettingsService()
|
||||
LastToken = lastToken;
|
||||
}
|
||||
}
|
||||
|
||||
public partial class SettingsService
|
||||
{
|
||||
[JsonSerializable(typeof(SettingsService))]
|
||||
private partial class SerializerContext : JsonSerializerContext;
|
||||
}
|
||||
|
||||
@@ -9,18 +9,20 @@ namespace DiscordChatExporter.Gui.Services;
|
||||
|
||||
public class UpdateService(SettingsService settingsService) : IDisposable
|
||||
{
|
||||
private readonly IUpdateManager _updateManager = new UpdateManager(
|
||||
new GithubPackageResolver(
|
||||
"Tyrrrz",
|
||||
"DiscordChatExporter",
|
||||
// Examples:
|
||||
// DiscordChatExporter.win-arm64.zip
|
||||
// DiscordChatExporter.win-x64.zip
|
||||
// DiscordChatExporter.linux-x64.zip
|
||||
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
|
||||
),
|
||||
new ZipPackageExtractor()
|
||||
);
|
||||
private readonly IUpdateManager? _updateManager = OperatingSystem.IsWindows()
|
||||
? new UpdateManager(
|
||||
new GithubPackageResolver(
|
||||
"Tyrrrz",
|
||||
"DiscordChatExporter",
|
||||
// Examples:
|
||||
// DiscordChatExporter.win-arm64.zip
|
||||
// DiscordChatExporter.win-x64.zip
|
||||
// DiscordChatExporter.linux-x64.zip
|
||||
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
|
||||
),
|
||||
new ZipPackageExtractor()
|
||||
)
|
||||
: null;
|
||||
|
||||
private Version? _updateVersion;
|
||||
private bool _updatePrepared;
|
||||
@@ -28,6 +30,9 @@ public class UpdateService(SettingsService settingsService) : IDisposable
|
||||
|
||||
public async ValueTask<Version?> CheckForUpdatesAsync()
|
||||
{
|
||||
if (_updateManager is null)
|
||||
return null;
|
||||
|
||||
if (!settingsService.IsAutoUpdateEnabled)
|
||||
return null;
|
||||
|
||||
@@ -37,6 +42,9 @@ public class UpdateService(SettingsService settingsService) : IDisposable
|
||||
|
||||
public async ValueTask PrepareUpdateAsync(Version version)
|
||||
{
|
||||
if (_updateManager is null)
|
||||
return;
|
||||
|
||||
if (!settingsService.IsAutoUpdateEnabled)
|
||||
return;
|
||||
|
||||
@@ -57,11 +65,10 @@ public class UpdateService(SettingsService settingsService) : IDisposable
|
||||
|
||||
public void FinalizeUpdate(bool needRestart)
|
||||
{
|
||||
if (!settingsService.IsAutoUpdateEnabled)
|
||||
if (_updateManager is null)
|
||||
return;
|
||||
|
||||
// Onova only works on Windows currently
|
||||
if (!OperatingSystem.IsWindows())
|
||||
if (!settingsService.IsAutoUpdateEnabled)
|
||||
return;
|
||||
|
||||
if (_updateVersion is null || !_updatePrepared || _updaterLaunched)
|
||||
@@ -82,5 +89,5 @@ public class UpdateService(SettingsService settingsService) : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _updateManager.Dispose();
|
||||
public void Dispose() => _updateManager?.Dispose();
|
||||
}
|
||||
|
||||
@@ -11,15 +11,11 @@ internal static class NotifyPropertyChangedExtensions
|
||||
this TOwner owner,
|
||||
Expression<Func<TOwner, TProperty>> propertyExpression,
|
||||
Action callback,
|
||||
bool watchInitialValue = true
|
||||
bool watchInitialValue = false
|
||||
)
|
||||
where TOwner : INotifyPropertyChanged
|
||||
{
|
||||
var memberExpression =
|
||||
propertyExpression.Body as MemberExpression
|
||||
// Property value might be boxed inside a conversion expression, if the types don't match
|
||||
?? (propertyExpression.Body as UnaryExpression)?.Operand as MemberExpression;
|
||||
|
||||
var memberExpression = propertyExpression.Body as MemberExpression;
|
||||
if (memberExpression?.Member is not PropertyInfo property)
|
||||
throw new ArgumentException("Provided expression must reference a property.");
|
||||
|
||||
@@ -45,7 +41,7 @@ internal static class NotifyPropertyChangedExtensions
|
||||
public static IDisposable WatchAllProperties<TOwner>(
|
||||
this TOwner owner,
|
||||
Action callback,
|
||||
bool watchInitialValues = true
|
||||
bool watchInitialValues = false
|
||||
)
|
||||
where TOwner : INotifyPropertyChanged
|
||||
{
|
||||
|
||||
@@ -53,7 +53,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
private Guild? _selectedGuild;
|
||||
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<ChannelNode>? _availableChannels;
|
||||
private IReadOnlyList<ChannelConnection>? _availableChannels;
|
||||
|
||||
public DashboardViewModel(
|
||||
ViewModelManager viewModelManager,
|
||||
@@ -88,7 +88,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
|
||||
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
|
||||
|
||||
public ObservableCollection<ChannelNode> SelectedChannels { get; } = [];
|
||||
public ObservableCollection<ChannelConnection> SelectedChannels { get; } = [];
|
||||
|
||||
[RelayCommand]
|
||||
private void Initialize()
|
||||
@@ -102,7 +102,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowHelp() => ProcessEx.StartShellExecute(Program.DocumentationUrl);
|
||||
private void ShowHelp() => ProcessEx.StartShellExecute(Program.ProjectDocumentationUrl);
|
||||
|
||||
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
|
||||
|
||||
@@ -190,7 +190,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
// Build a hierarchy of channels
|
||||
var channelTree = ChannelNode.BuildTree(
|
||||
var channelTree = ChannelConnection.BuildTree(
|
||||
channels
|
||||
.OrderByDescending(c => c.IsDirect ? c.LastMessageId : null)
|
||||
.ThenBy(c => c.Position)
|
||||
@@ -253,7 +253,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
channelProgressPairs,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit)
|
||||
MaxDegreeOfParallelism = Math.Max(1, _settingsService.ParallelLimit),
|
||||
},
|
||||
async (pair, cancellationToken) =>
|
||||
{
|
||||
|
||||
@@ -138,8 +138,8 @@ public partial class ExportSetupViewModel(
|
||||
[
|
||||
new FilePickerFileType($"{extension.ToUpperInvariant()} file")
|
||||
{
|
||||
Patterns = [$"*.{extension}"]
|
||||
}
|
||||
Patterns = [$"*.{extension}"],
|
||||
},
|
||||
],
|
||||
defaultFileName
|
||||
);
|
||||
|
||||
@@ -85,7 +85,7 @@ public class SettingsViewModel : DialogViewModelBase
|
||||
"zh-CN",
|
||||
"ja-JP",
|
||||
"zh-TW",
|
||||
"ko-KR"
|
||||
"ko-KR",
|
||||
];
|
||||
|
||||
// This has to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -46,6 +47,30 @@ public partial class MainViewModel(
|
||||
ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter");
|
||||
}
|
||||
|
||||
private async Task ShowDevelopmentBuildMessageAsync()
|
||||
{
|
||||
if (!Program.IsDevelopmentBuild)
|
||||
return;
|
||||
|
||||
// If debugging, the user is likely a developer
|
||||
if (Debugger.IsAttached)
|
||||
return;
|
||||
|
||||
var dialog = viewModelManager.CreateMessageBoxViewModel(
|
||||
"Unstable build warning",
|
||||
$"""
|
||||
You're using a development build of {Program.Name}. These builds are not thoroughly tested and may contain bugs.
|
||||
|
||||
Auto-updates are disabled for development builds. If you want to switch to a stable release, please download it manually.
|
||||
""",
|
||||
"SEE RELEASES",
|
||||
"CLOSE"
|
||||
);
|
||||
|
||||
if (await dialogManager.ShowDialogAsync(dialog) == true)
|
||||
ProcessEx.StartShellExecute(Program.ProjectReleasesUrl);
|
||||
}
|
||||
|
||||
private async Task CheckForUpdatesAsync()
|
||||
{
|
||||
try
|
||||
@@ -80,6 +105,7 @@ public partial class MainViewModel(
|
||||
private async Task InitializeAsync()
|
||||
{
|
||||
await ShowUkraineSupportMessageAsync();
|
||||
await ShowDevelopmentBuildMessageAsync();
|
||||
await CheckForUpdatesAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,8 @@
|
||||
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
||||
x:Name="UserControl"
|
||||
x:DataType="components:DashboardViewModel"
|
||||
Loaded="UserControl_OnLoaded">
|
||||
<Design.DataContext>
|
||||
<components:DashboardViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<DockPanel>
|
||||
<!-- Header -->
|
||||
<StackPanel
|
||||
@@ -95,126 +92,6 @@
|
||||
</Style>
|
||||
</Style>
|
||||
</Panel.Styles>
|
||||
<!-- Placeholder / usage instructions -->
|
||||
<Panel IsVisible="{Binding !AvailableGuilds.Count}">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock
|
||||
Margin="32,16"
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
LineHeight="23">
|
||||
<!-- User token -->
|
||||
<InlineUIContainer>
|
||||
<materialIcons:MaterialIcon
|
||||
Width="18"
|
||||
Height="18"
|
||||
Margin="0,-2,0,0"
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Account" />
|
||||
</InlineUIContainer>
|
||||
<Run Text="" />
|
||||
<Run
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="To get the token for your personal account:" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="* Automating user accounts is technically against TOS —" />
|
||||
<Run FontWeight="SemiBold" Text="use at your own risk" /><Run Text="!" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="1. Open Discord in your" />
|
||||
<controls:HyperLink Command="{Binding OpenDiscordCommand}" Text="web browser" />
|
||||
<Run Text="and login" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="2. Open any server or direct message channel" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="3. Press" />
|
||||
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
|
||||
<Run Text="to show developer tools" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="4. Navigate to the" />
|
||||
<Run FontWeight="SemiBold" Text="Network" />
|
||||
<Run Text="tab" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="5. Press" />
|
||||
<Run FontWeight="SemiBold" Text="Ctrl+R" />
|
||||
<Run Text="to reload" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="6. Switch between random channels to trigger network requests" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="7. Search for a request that starts with" />
|
||||
<Run FontWeight="SemiBold" Text="messages" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="8. Select the" />
|
||||
<Run FontWeight="SemiBold" Text="Headers" />
|
||||
<Run Text="tab on the right" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="9. Scroll down to the" />
|
||||
<Run FontWeight="SemiBold" Text="Request Headers" />
|
||||
<Run Text="section" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="10. Copy the value of the" />
|
||||
<Run FontWeight="SemiBold" Text="authorization" />
|
||||
<Run Text="header" />
|
||||
<LineBreak />
|
||||
<LineBreak />
|
||||
|
||||
<!-- Bot token -->
|
||||
<InlineUIContainer>
|
||||
<materialIcons:MaterialIcon
|
||||
Width="18"
|
||||
Height="18"
|
||||
Margin="0,-2,0,0"
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Robot" />
|
||||
</InlineUIContainer>
|
||||
<Run Text="" />
|
||||
<Run
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="To get the token for your bot:" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="1. Open Discord" />
|
||||
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="developer portal" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="2. Open your application's settings" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="3. Navigate to the" />
|
||||
<Run FontWeight="SemiBold" Text="Bot" />
|
||||
<Run Text="section on the left" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="4. Under" />
|
||||
<Run FontWeight="SemiBold" Text="Token" />
|
||||
<Run Text="click" />
|
||||
<Run FontWeight="SemiBold" Text="Copy" />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="* Your bot needs to have the" />
|
||||
<Run FontWeight="SemiBold" Text="Message Content Intent" />
|
||||
<Run Text="enabled to read messages" />
|
||||
<LineBreak />
|
||||
<LineBreak />
|
||||
|
||||
<Run Text="If you have questions or issues, please refer to the" />
|
||||
<controls:HyperLink Command="{Binding ShowHelpCommand}" Text="documentation" />
|
||||
</TextBlock>
|
||||
</ScrollViewer>
|
||||
</Panel>
|
||||
|
||||
<!-- Guilds and channels -->
|
||||
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding !!AvailableGuilds.Count}">
|
||||
<!-- Guilds -->
|
||||
@@ -339,6 +216,164 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Placeholder / usage instructions -->
|
||||
<Panel IsVisible="{Binding !AvailableGuilds.Count}">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock
|
||||
Margin="32,16"
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
LineHeight="23">
|
||||
<!-- User token -->
|
||||
<InlineUIContainer>
|
||||
<materialIcons:MaterialIcon
|
||||
Width="18"
|
||||
Height="18"
|
||||
Margin="0,-2,0,0"
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Account" />
|
||||
</InlineUIContainer>
|
||||
<Run BaselineAlignment="Center" Text="" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="To get the token for your personal account:" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="* Automating user accounts is technically against TOS —" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="use at your own risk" /><Run Text="!" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="1. Open Discord in your" />
|
||||
<controls:HyperLink Command="{Binding OpenDiscordCommand}" Text="web browser" />
|
||||
<Run BaselineAlignment="Center" Text="and login" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="2. Open any server or direct message channel" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="3. Press" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Ctrl+Shift+I" />
|
||||
<Run BaselineAlignment="Center" Text="to show developer tools" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="4. Navigate to the" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Network" />
|
||||
<Run BaselineAlignment="Center" Text="tab" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="5. Press" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Ctrl+R" />
|
||||
<Run BaselineAlignment="Center" Text="to reload" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="6. Switch between random channels to trigger network requests" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="7. Search for a request that starts with" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="messages" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="8. Select the" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Headers" />
|
||||
<Run BaselineAlignment="Center" Text="tab on the right" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="9. Scroll down to the" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Request Headers" />
|
||||
<Run BaselineAlignment="Center" Text="section" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="10. Copy the value of the" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="authorization" />
|
||||
<Run BaselineAlignment="Center" Text="header" />
|
||||
<LineBreak />
|
||||
<LineBreak />
|
||||
|
||||
<!-- Bot token -->
|
||||
<InlineUIContainer>
|
||||
<materialIcons:MaterialIcon
|
||||
Width="18"
|
||||
Height="18"
|
||||
Margin="0,-2,0,0"
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Robot" />
|
||||
</InlineUIContainer>
|
||||
<Run BaselineAlignment="Center" Text="" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="To get the token for your bot:" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="1. Open Discord" />
|
||||
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="developer portal" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="2. Open your application's settings" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="3. Navigate to the" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Bot" />
|
||||
<Run BaselineAlignment="Center" Text="section on the left" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="4. Under" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Token" />
|
||||
<Run BaselineAlignment="Center" Text="click" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Copy" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="* Your bot needs to have the" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Message Content Intent" />
|
||||
<Run BaselineAlignment="Center" Text="enabled to read messages" />
|
||||
<LineBreak />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="If you have questions or issues, please refer to the" />
|
||||
<controls:HyperLink Command="{Binding ShowHelpCommand}" Text="documentation" />
|
||||
</TextBlock>
|
||||
</ScrollViewer>
|
||||
</Panel>
|
||||
|
||||
<!-- Export button -->
|
||||
<Button
|
||||
Width="56"
|
||||
|
||||
@@ -28,7 +28,9 @@ public partial class DashboardView : UserControl<DashboardViewModel>
|
||||
)
|
||||
{
|
||||
// Hack: unselect categories because they cannot be exported
|
||||
foreach (var item in args.AddedItems.OfType<ChannelNode>().Where(x => x.Channel.IsCategory))
|
||||
foreach (
|
||||
var item in args.AddedItems.OfType<ChannelConnection>().Where(x => x.Channel.IsCategory)
|
||||
)
|
||||
{
|
||||
if (AvailableChannelsTreeView.TreeContainerFromItem(item) is TreeViewItem container)
|
||||
container.IsSelected = false;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<UserControl
|
||||
x:Class="DiscordChatExporter.Gui.Views.Controls.HyperLink"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="clr-namespace:DiscordChatExporter.Gui.Views.Controls">
|
||||
<TextBlock
|
||||
x:Name="TextBlock"
|
||||
Cursor="Hand"
|
||||
Foreground="{DynamicResource MaterialSecondaryDarkBrush}"
|
||||
PointerReleased="TextBlock_OnPointerReleased"
|
||||
Text="{Binding $parent[UserControl].Text, Mode=OneWay}">
|
||||
Text="{Binding $parent[controls:HyperLink].Text, Mode=OneWay}">
|
||||
<TextBlock.Styles>
|
||||
<Style Selector="TextBlock">
|
||||
<Style Selector="^:pointerover">
|
||||
|
||||
@@ -10,11 +10,8 @@
|
||||
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
|
||||
x:Name="UserControl"
|
||||
Width="380"
|
||||
x:DataType="dialogs:ExportSetupViewModel"
|
||||
Loaded="UserControl_OnLoaded">
|
||||
<Design.DataContext>
|
||||
<dialogs:ExportSetupViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- Guild/channel info -->
|
||||
<Grid
|
||||
@@ -40,7 +37,7 @@
|
||||
FontWeight="Light"
|
||||
IsVisible="{Binding !IsSingleChannel}"
|
||||
TextTrimming="CharacterEllipsis">
|
||||
<Run Text="{Binding Channels.Count, Mode=OneWay}" />
|
||||
<Run Text="{Binding Channels.Count, FallbackValue=0, Mode=OneWay}" />
|
||||
<Run Text="channels selected" />
|
||||
</TextBlock>
|
||||
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
|
||||
xmlns:system="clr-namespace:System;assembly=System.Runtime"
|
||||
Width="500">
|
||||
<Design.DataContext>
|
||||
<dialogs:MessageBoxViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
Width="500"
|
||||
x:DataType="dialogs:MessageBoxViewModel">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- Title -->
|
||||
<TextBlock
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
|
||||
Width="380">
|
||||
<Design.DataContext>
|
||||
<dialogs:SettingsViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
Width="380"
|
||||
x:DataType="dialogs:SettingsViewModel">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
|
||||
@@ -10,13 +10,10 @@
|
||||
Height="625"
|
||||
MinWidth="600"
|
||||
MinHeight="400"
|
||||
x:DataType="viewModels:MainViewModel"
|
||||
Icon="/favicon.ico"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Design.DataContext>
|
||||
<viewModels:MainViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<dialogHostAvalonia:DialogHost
|
||||
x:Name="DialogHost"
|
||||
CloseOnClickAway="False"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-2024 Oleksii Holub
|
||||
Copyright (c) 2017-2025 Oleksii Holub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
Reference in New Issue
Block a user