Compare commits

...

59 Commits

Author SHA1 Message Date
Lucas LaBuff 6f877cf543 Revert to Uri.EscapeDataString when encoding asset URIs (#1355) 2025-03-18 19:10:21 +02:00
Oleksii Holub 62babc0de5 Don't attempt to infer asset timestamp from message timestamp (#1352)
This is inaccurate and somewhat works only for attachments anyway. Not worth the overhead.
2025-03-13 21:43:19 +02:00
Tyrrrz 9e115562dc Use WebUtility.UrlEncode instead of Uri.EscapeDataString 2025-03-11 21:48:02 +02:00
Oleksii Holub db50a2bb96 Fix file path encoding edge cases in HTML export (#1351) 2025-03-10 19:11:17 +02:00
dependabot[bot] b39d015133 Bump the nuget group with 15 updates (#1347)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 21:42:10 +02:00
dependabot[bot] b4c3dce760 Bump the actions group with 4 updates (#1346)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 21:41:06 +02:00
Tyrrrz f31e73bb7a Unify whitespace characters to fix tests 2025-02-04 00:17:57 +02:00
Tyrrrz 75ff5c2f7e Improve the format workflow job 2025-02-03 23:48:02 +02:00
dependabot[bot] 7663f8ed31 Bump the nuget group with 14 updates (#1340)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-02 21:50:25 +02:00
dependabot[bot] c0e7334084 Bump the actions group with 3 updates (#1341)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-02 21:42:16 +02:00
Tyrrrz a9eca1ceb2 Formatting 2025-01-01 16:22:08 +02:00
dependabot[bot] 45ff1daff3 Bump the nuget group with 12 updates (#1329)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-01 13:13:45 +02:00
dependabot[bot] 5299c2ebc4 Bump the actions group with 4 updates (#1330)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-01 13:11:37 +02:00
Jason N. White f6c189f57c Update copyright license year (#1328) 2025-01-01 13:11:26 +02:00
Ritiek Malhotra a9acf17375 Prefer message timestamp over the last-modified header for asset file dates (#1321) 2024-12-12 19:55:44 +02:00
dependabot[bot] bf417db80c Bump the nuget group with 15 updates (#1318)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-01 18:51:14 +02:00
dependabot[bot] d78d22d066 Bump codecov/codecov-action from 4.6.0 to 5.0.7 in the actions group (#1319)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-01 18:50:14 +02:00
Tyrrrz 0fa5e93f0d Use .NET 9 2024-11-12 22:19:58 +02:00
Tyrrrz 7b892c8e5b Fix some text alignment issues with the new version of Avalonia 2024-11-07 18:46:32 +02:00
Oleksii Holub 692d886442 Remove obsolete/no-op ServicePoint references/calls (#1314) 2024-11-06 20:55:04 +02:00
Tyrrrz e664eab5fb Fix recursive initialization issue 2024-11-06 20:16:53 +02:00
Tyrrrz 1fb6156187 Refactor 2024-11-06 19:36:34 +02:00
Oleksii Holub 789e5af8ba Include inline emoji in JSON export (#1311) 2024-11-06 19:23:40 +02:00
dependabot[bot] 9c15baf799 Bump the actions group with 5 updates (#1312)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-02 00:47:52 +02:00
dependabot[bot] 56fa6ee150 Bump the nuget group with 7 updates (#1313)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-02 00:47:21 +02:00
Tyrrrz 09e0b3f133 Format stuff 2024-10-26 21:41:16 +03:00
Tyrrrz e8192b2b53 Improve wording 2024-10-23 23:54:30 +03:00
LostXOR 34e9b0bfcc Add info for --filter in CLI docs (#1310) 2024-10-23 03:44:42 +03:00
Kaden abd7adc9e0 Add %d template token to CLI docs (#1306) 2024-10-14 13:37:25 +03:00
dependabot[bot] f5cdd5c8be Bump the nuget group with 8 updates (#1302)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-01 21:10:21 +03:00
dependabot[bot] 9fb78509b4 Bump actions/checkout from 4.1.7 to 4.2.0 in the actions group (#1303)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-01 21:10:13 +03:00
Andrew Low 161e91ccf4 Add missing tzdata package to docker build (#1296) 2024-09-23 05:55:47 +03:00
dependabot[bot] 8519ec0fff Bump the nuget group with 9 updates (#1283)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-01 19:05:04 +03:00
dependabot[bot] 9f8f718c94 Bump actions/upload-artifact from 4.3.4 to 4.4.0 in the actions group (#1282)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-01 19:03:41 +03:00
Mark Cilia Vincenti bb14e3431d Bump AsyncKeyedLock to 7.0.1 (#1276) 2024-08-26 17:23:29 +03:00
Tyrrrz aa377f3131 Don't use AppDomain 2024-08-18 19:03:22 +03:00
Oleksii Holub 522789e01d Limit recursion depth in markdown parser to prevent stack overflow (#1273) 2024-08-14 23:52:03 +03:00
Tyrrrz 10adba3a4d Format schformat 2024-08-11 00:10:23 +03:00
Tyrrrz 73e2e0624b Use a few new APIs 2024-08-10 22:28:07 +03:00
Tyrrrz c978a4cf60 Fix popup background 2024-08-01 23:22:03 +03:00
dependabot[bot] 6d82930505 Bump the actions group with 4 updates (#1267)
Bumps the actions group with 4 updates: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action), [actions/upload-artifact](https://github.com/actions/upload-artifact), [actions/setup-dotnet](https://github.com/actions/setup-dotnet) and [actions/download-artifact](https://github.com/actions/download-artifact).


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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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