Compare commits

..

90 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
Oleksii Holub b7548756a8 Use self-contained builds (#1224) 2024-05-16 20:42:15 +03:00
Tyrrrz 8f9c0f8660 Clean up 2024-05-15 23:16:14 +03:00
Tyrrrz b12be796c7 Simplify 2024-05-14 00:05:46 +03:00
Oleksii Holub 7a69c87b56 Use a 3-way theme switcher instead of a 2-way switcher (#1233) 2024-05-13 23:56:21 +03:00
Tyrrrz 9e7ad4d85c Fix naming inconsistency 2024-05-13 22:12:43 +03:00
Tyrrrz 0fb4bea64d Update issue forms 2024-05-05 20:00:28 +03:00
Tyrrrz d8abd011e5 Add helper link for OS/arch in readme 2024-05-02 02:13:17 +03:00
Oleksii Holub 9a5697b5b5 Skip invalid reactions instead of crashing (#1227) 2024-04-30 20:08:03 +03:00
LostXOR 886b8442d3 Improve documentation (#1225) 2024-04-30 00:07:29 +03:00
Tyrrrz bedf7de8c6 Update readme 2024-04-29 19:55:14 +03:00
Tyrrrz 5ef329b63c Remove unneeded attribute 2024-04-29 02:39:08 +03:00
Tyrrrz 060e55a0d7 Fix tiny issues in the readme 2024-04-28 19:33:01 +03:00
Oleksii Holub 61444aad30 Set correct file permissions for Linux/OSX release assets (#1223) 2024-04-28 18:53:36 +03:00
Tyrrrz 9b3421e8a5 Remove changelog notification 2024-04-28 03:06:02 +03:00
Tyrrrz 5623c2ca34 Update NuGet packages 2024-04-28 02:26:34 +03:00
Oleksii Holub 8525917a4c Make the GUI x-platform and adapt the CI/CD pipeline to accommodate that (#1222) 2024-04-28 02:24:23 +03:00
Oleksii Holub b9c1c47474 Migrate to Avalonia (#1220) 2024-04-27 04:17:46 +03:00
Mark Cilia Vincenti 74f99b4e59 Switched from SemaphoreSlim lock to AsyncNonKeyedLocker (#1184) 2024-04-21 18:32:57 +03:00
Tyrrrz 12b590d9f9 Use [] in place of Array.Empty<T>() 2024-03-28 03:22:51 +02:00
xs2237 6d1e8c2e02 Remove unnecessary whitespaces at the end of reactions in PlainText (#1213) 2024-03-21 17:29:14 +02:00
Tyrrrz 72f02cd9a0 Update docs with the easier way to run the CLI 2024-03-18 03:20:46 +02:00
Tyrrrz 47d560229d Set the executable permission on CLI entrypoint script before packaging for release 2024-03-18 03:12:31 +02:00
Tyrrrz 0e87098889 Make the DCE.CLI unix entrypoint script work regardless of current working directory 2024-03-05 19:39:06 +02:00
Oleksii Holub 0139eeaeed Add DiscordChatExporter.Cli.sh for simpler usage from shell (#1205) 2024-03-02 04:59:49 +02:00
Oleksii Holub c0ea57e9b1 Shorten error messages for when attempting to export a period with no messages (#1201) 2024-02-13 19:13:38 +02:00
Oleksii Holub 7c04db40f8 Skip Twitch clip projections because they can't be rendered locally (#1200) 2024-02-13 18:59:51 +02:00
Oleksii Holub 4588bd0496 Add support for Twitch embed projections (#1199) 2024-02-13 18:48:15 +02:00
Tyrrrz 3a22db14a7 Update GitHub Actions 2024-02-01 21:54:50 +02:00
Oleksii Holub ba72927c37 Fix pathing in docker entrypoint 2024-01-31 21:32:40 +02:00
Tyrrrz 822bab2365 Update action versions 2024-01-31 14:44:28 +02:00
Philippe Vaucher 211db11041 Fix docker user permissions issues (#1194) 2024-01-31 14:39:25 +02:00
173 changed files with 12616 additions and 12636 deletions
+16 -5
View File
@@ -10,7 +10,7 @@ Docker distribution of DiscordChatExporter provides a way to run the app in a vi
This will download the [Docker image from the registry](https://hub.docker.com/r/tyrrrz/discordchatexporter) to your computer. You can run this command again to update when a new version is released.
```console
docker pull tyrrrz/discordchatexporter:stable
$ docker pull tyrrrz/discordchatexporter:stable
```
Note the `:stable` tag. DiscordChatExporter images are tagged according to the following patterns:
@@ -26,19 +26,19 @@ You can see all available tags [here](https://hub.docker.com/r/tyrrrz/discordcha
To run the CLI in Docker and render help text:
```console
docker run --rm tyrrrz/discordchatexporter:stable
$ docker run --rm tyrrrz/discordchatexporter:stable
```
To export a channel:
```console
docker run --rm -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
$ docker run --rm -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
```
If you want colored output and real-time progress reporting, pass the `-it` (interactive + pseudo-terminal) option:
```console
docker run --rm -it -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
$ docker run --rm -it -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
```
The `-v /path/on/machine:/out` option instructs Docker to bind the `/out` directory inside the container to a path on your host machine. Replace `/path/on/machine` with the directory you want the files to be saved at.
@@ -47,7 +47,7 @@ The `-v /path/on/machine:/out` option instructs Docker to bind the `/out` direct
> If you are running SELinux, you will need to add the `:z` option after `/out`, e.g.:
>
> ```console
> docker run --rm -v /path/on/machine:/out:z tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
> $ docker run --rm -v /path/on/machine:/out:z tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
> ```
>
> For more information, refer to the [Docker docs SELinux labels for bind mounts page](https://docs.docker.com/storage/bind-mounts/#configure-the-selinux-label).
@@ -61,6 +61,17 @@ For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz
To get your Token and Channel IDs, please refer to [this page](Token-and-IDs.md).
## Unix permissions issues
This image was designed with a user running as uid:gid of 1000:1000.
If your current user has different IDs, and you want to generate files directly editable for your user, you might want to run the container like this:
```console
$ mkdir data # or chown -R $(id -u):$(id -g) data
$ docker run -it --rm -v $PWD/data:/out --user $(id -u):$(id -g) tyrrrz/discordchatexporter:stable export -t TOKEN -g CHANNELID
```
## Environment variables
DiscordChatExpoter CLI accepts the `DISCORD_TOKEN` environment variable as a fallback for the `--token` option. You can set this variable either with the `--env` Docker option or with a combination of the `--env-file` Docker option and a `.env` file.
-55
View File
@@ -1,55 +0,0 @@
# Install .NET runtime
**.NET 8.0 Runtime** is required by **DiscordChatExporter**.
> **Note**:
> Installing the **.NET Runtime** is not required if you running **DiscordChatExporter** using [Docker](Docker.md).
## Windows
Both the GUI and the CLI flavors of **DiscordChatExporter** for Windows come with a bootstrapper that should automatically install all the required prerequisites. Simply run the application and follow the presented instructions.
If, for some reason, you need to install the runtime manually, use one of the download links below:
- [.NET 8.0 Runtime for **Windows x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-windows-x86-installer)
- [.NET 8.0 Runtime for **Windows x86**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-windows-x64-installer)
- [.NET 8.0 Runtime for **Windows arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-windows-arm64-installer)
> **Note**:
> [How can I tell if my computer is running a 32-bit (x86) or a 64-bit (x64) version of Windows?](https://support.microsoft.com/help/15056/windows-32-64-bit-faq)
## macOS
Use one of the download links below:
- [.NET 8.0 Runtime for **macOS x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-macos-x64-installer)
- [.NET 8.0 Runtime for **macOS arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-macos-arm64-installer)
> **Note**:
> [Is my Mac using an Intel processor (x64) or Apple Silicon (arm64)?](https://support.apple.com/HT211814)
## Linux
Installing the .NET Runtime works differently depending on which Linux distribution you use. Check out the instructions for your distro:
- [Ubuntu](https://docs.microsoft.com/dotnet/core/install/linux-ubuntu)
- [Debian](https://docs.microsoft.com/dotnet/core/install/linux-debian)
- [Fedora](https://docs.microsoft.com/dotnet/core/install/linux-fedora)
For other distros, please check the _'Install on Linux'_ menu on the left of [this page](https://docs.microsoft.com/dotnet/core/install/linux).
## Verify installation (optional)
You can check which version of **.NET Runtime** is installed by running the following command in a terminal:
```console
dotnet --info
```
If the **.NET Runtime** is correctly installed, the command will output something similar to the following:
```console
.NET runtimes installed:
Microsoft.NETCore.App 7.0.x [C:\path\to\dotnet\shared\Microsoft.NETCore.App]
Microsoft.WindowsDesktop.App 7.0.x [C:\path\to\dotnet\shared\Microsoft.WindowsDesktop.App]
```
+4 -7
View File
@@ -15,12 +15,9 @@ The information presented on this page is valid for **all** platforms.
**DCE** has two different versions:
- **Graphical User Interface** (**GUI**) - it's the preferred version for newcomers as it is easy to use.
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.zip` file.
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.*.zip` file.
- **Command-line Interface** (**CLI**) - offers greater flexibility and more features for advanced users, such as export scheduling, ID lists, and more specific date ranges.
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.CLI.zip` file.
If you're not comfortable with **Windows'** Command-line (cmd), please choose the GUI.
[**macOS**](MacOS.md), [**Linux**](Linux.md) and [**Docker**](Docker.md) users can only use the CLI version.
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.Cli.*.zip` file.
There are dedicated guides for each version:
@@ -38,13 +35,13 @@ You can open `.html` files with a web browser, such as Google Chrome.
> **Warning**:
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed.
> Export using the "Download referenced assets" (`--media`) option to avoid this.
> Export using the "Download assets" (`--media`) option to avoid this.
### Plain Text
<img src="https://i.imgur.com/PbUyRXD.png" height="400"/>
The Plain Text format is the best option for archiving due to its small size.
The Plain Text format formats messages as plain text, and has the smallest size.
You can open `.txt` files with a text editor, such as Notepad.
### JSON
-26
View File
@@ -1,26 +0,0 @@
# Linux usage instructions
## Installing .NET Runtime
Please follow the [instructions provided here](Dotnet.md).
## Downloading and using DiscordChatExporter.Cli
1. Download [DiscordChatExporter.CLI.zip](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) and extract it to a folder.
2. Open Terminal.
3. `cd` into the extracted folder. You can do this in Terminal by typing `cd`, then press the SPACE key, drag and drop the extracted folder into the Terminal window, and press the ENTER key.
4. Replace `TOKEN` and `CHANNEL`, then execute this command to export:
```console
dotnet DiscordChatExporter.Cli.dll export -t TOKEN -c CHANNEL
```
If the above command throws a "Permission denied" error, execute this command to fix the permissions:
```console
chmod 644 *.dll DiscordChatExporter.*
```
> [How to get Token and Channel IDs](Token-and-IDs.md).
There's much more you can do with DCE.CLI! Read the [CLI explained](Using-the-CLI.md) page to get started.
-22
View File
@@ -1,22 +0,0 @@
# macOS usage instructions
![Might look different depending on your macOS version](https://i.imgur.com/gA8jFjJ.png)
## Installing .NET Runtime
Please follow the [instructions provided here](Dotnet.md).
## Downloading and using DiscordChatExporter.Cli
1. Download [DiscordChatExporter.CLI.zip](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) and extract it to a folder.
2. Search for `Terminal.app` in Spotlight (⌘+SPACE), then open it.
3. In the Terminal window, type `cd` , press the SPACE key, then drag and drop the extracted folder into the window, then press the RETURN key.
4. Execute the following command to export, replacing `TOKEN` and `CHANNEL` with your own values:
```console
dotnet DiscordChatExporter.Cli.dll export -t TOKEN -c CHANNEL
```
> [How to get Token and Channel IDs](Token-and-IDs.md).
There's much more you can do with DCE.CLI! Read the [CLI explained](Using-the-CLI.md) page to get started.
+3 -3
View File
@@ -89,17 +89,17 @@ from:96\-LB
In most cases, you will need to enclose your filter in quotes (`"`) to escape characters that may have special meaning in your shell:
```console
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
```
If you need to include quotes inside the filter itself as well, use single quotes (`'`) for those instead:
```console
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
```
Additionally, negated filters (those that start with `-`) may cause parsing issues even when enclosed in quotes. To avoid this, use the tilde (`~`) character instead of the dash (`-`):
```console
DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
$ ./DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
```
-3
View File
@@ -2,8 +2,6 @@
## Installation & Usage
- [Get .NET Core Runtime](Dotnet.md) (Required for CLI; Installed automatically for GUI; Not required in Docker)
- [Windows](Getting-started.md#gui-or-cli) | [macOS](MacOS.md) | [Linux](Linux.md) | [Docker](Docker.md)
- Getting started:
- [Using the GUI](Using-the-GUI.md)
- [Using the CLI](Using-the-CLI.md)
@@ -29,6 +27,5 @@
- [General questions](Troubleshooting.md#general)
- [First steps help](Troubleshooting.md#first-steps)
- [It's crashing/failing](Troubleshooting.md#DCE-is-crashingfailing)
- [.NET Core Runtime is required](Troubleshooting.md#net-core-runtime-is-required)
- [Errors](Troubleshooting.md#errors)
- [**More help**](Troubleshooting.md)
+1 -3
View File
@@ -1,7 +1,5 @@
# Scheduling exports with Cron
Make sure you already have **DiscordChatExporter.CLI** and **.NET Core** properly installed ([instructions here](Linux.md)).
## Creating the script
1. Open Terminal and create a new text file with `nano /path/to/DiscordChatExporter/cron.sh`
@@ -45,7 +43,7 @@ fi
cd $DLLFOLDER || exit 1
# This will export your chat
dotnet DiscordChatExporter.Cli.dll export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
# This sets the current time to a variable
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")
+1 -5
View File
@@ -1,9 +1,5 @@
# Scheduling exports on macOS
Scheduling on macOS is a bit tricky, but it should work if you follow the instructions accordingly.
Make sure you already have **DiscordChatExporter.CLI** and **.NET Core** properly installed ([instructions here](MacOS.md)).
## Creating the script
1. Open TextEdit.app and create a new file
@@ -51,7 +47,7 @@ fi
cd $DLLFOLDER || exit 1
# This will export your chat
dotnet DiscordChatExporter.Cli.dll export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
# This sets the current time to a variable
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")
+1 -3
View File
@@ -1,7 +1,5 @@
# Scheduling exports on Windows
We'll be using [DiscordChatExporter CLI](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest), PowerShell, and Task Scheduler.
## Creating the script
1. Open a text editor such as Notepad and paste:
@@ -19,7 +17,7 @@ $EXPORTFORMAT = "formathere"
cd $EXEPATH
.\DiscordChatExporter.Cli.exe export -t $TOKEN -c $CHANNEL -f $EXPORTFORMAT -o "$FILENAME.tmp"
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNEL -f $EXPORTFORMAT -o "$FILENAME.tmp"
$Date = Get-Date -Format "yyyy-MM-dd-HH-mm"
+2 -2
View File
@@ -25,7 +25,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
3. Type
```console
```js
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
```
@@ -118,7 +118,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
1. Type
```console
```js
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
```
+5 -30
View File
@@ -70,14 +70,7 @@ Check the following page: [Obtaining token](Token-and-IDs.md)
### When I open DCE a black window pops up quickly or nothing shows up
If you have [.NET Core Runtime correctly installed](Dotnet.md), you might have downloaded the CLi flavor, try [downloading the GUI](Getting-started.md#gui-or-cli) instead.
### How do I run DCE on macOS or Linux?
Check the following pages:
- [macOS usage instructions](MacOS.md)
- [Linux usage instructions](Linux.md)
You might have downloaded the CLI flavor of the app, which is meant to be run in a terminal. Try [downloading the GUI](Getting-started.md#gui-or-cli) instead if that's what you want.
### How can I set DCE to export automatically at certain times?
@@ -91,16 +84,6 @@ Check the following pages to learn how to schedule **DiscordChatExporter.CLI** r
Try opening it with a different program, try partitioning or use a different file format, like `PlainText`.
### DCE is crashing/failing
Check the following page: [Installing .NET Core Runtime](Dotnet.md)
If you already have .NET Core installed, please check if your problem is a [known issue](https://github.com/Tyrrrz/DiscordChatExporter/issues?q=is%3Aissue) before [opening a bug report](https://github.com/Tyrrrz/DiscordChatExporter/issues/new).
### .NET Core Runtime is required
Check the following page: [Installing .NET Core Runtime](Dotnet.md)
### I see messages in the export, but they have no content
Your bot is missing the 'Message Content Intent'. Go to the [Discord Developer Portal](https://discord.com/developers/applications), navigate to the 'Bot' section and enable it.
@@ -133,34 +116,26 @@ Make sure you're [copying the DM Channel ID](Token-and-IDs.md#how-to-get-a-direc
## Errors
```console
```yml
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Authentication token is invalid.
...
```
↳ Make sure the provided token is correct.
```console
```yml
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Requested resource does not exist.
```
↳ Check your channel ID, it might be invalid. [Read this if you need help](Token-and-IDs.md).
```console
```yml
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Access is forbidden.
```
↳ This means you don't have access to the channel.
```console
The application to execute does not exist:
```
↳ The `DiscordChatExporter.Cli.dll` file is missing. Keep the `.exe` and all the `.dll` files together. If you didn't move the files, try unzipping again.
```console
```yml
System.Net.WebException: Error: TrustFailure ... Invalid certificate received from server.
...
```
↳ Try running cert-sync.
+49 -42
View File
@@ -1,39 +1,35 @@
# Using the CLI
## Guide
> **Note**:
> Make sure you have [.NET Core installed](Dotnet.md) before attempting to run the commands below.
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
## 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
Change the current directory to DCE's folder with `cd C:\path\to\directory`, then press ENTER to run the command.
Change the current directory to DCE's folder with `cd C:\path\to\DiscordChatExporter` (`cd /path/to/DiscordChatExporter` on **MacOS** and **Linux**), then press ENTER to run the command.
**Windows** users can quickly get the directory's path by clicking the address bar while inside the folder.
**Windows** users can quickly get the folder's path by clicking the address bar while inside the folder.
![Copy path from Explorer](https://i.imgur.com/XncnhC2.gif)
**macOS** users can select the `.exe`, hit Command+I (⌘I), and copy what's after `Where:` to get the directory
![Mac info panel](https://camo.githubusercontent.com/3c51a904b0099c9f68a4797461d4a7914902fc04/68747470733a2f2f692e696d6775722e636f6d2f323975364e79782e706e67)
**macOS** users can press Command+Option+C (⌘⌥C) while inside the folder (or selecting it) to copy its path to the clipboard.
You can also drag and drop the folder on **every platform**.
![Drag and drop folder](https://i.imgur.com/sOpZQAb.gif)
## Step 3
Now we're ready to run the commands. The examples on this page follow the Windows file path format, change the file
paths according to your system.
Now we're ready to run the commands.
Type the following in Command Prompt (Terminal), then press ENTER to run it. This will list DCE's 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
dotnet DiscordChatExporter.Cli.dll
./DiscordChatExporter.Cli
```
> **Note**:
> On Windows, if you're using the default Command Prompt (`cmd`), omit the leading `./` at the start of the command.
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
## CLI commands
@@ -49,18 +45,18 @@ dotnet DiscordChatExporter.Cli.dll
| guilds | Outputs the list of accessible servers |
| guide | Explains how to obtain token, server, and channel ID |
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `dotnet DiscordChatExporter.Cli.dll guide`.
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `./DiscordChatExporter.Cli guide`.
To get help with a specific command, run:
```console
dotnet DiscordChatExporter.Cli.dll command --help
./DiscordChatExporter.Cli command --help
```
For example, to figure out how to use the `export` command, run:
```console
dotnet DiscordChatExporter.Cli.dll export --help
./DiscordChatExporter.Cli export --help
```
## Export a specific channel
@@ -68,7 +64,7 @@ dotnet DiscordChatExporter.Cli.dll export --help
You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`.
```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
```
#### Changing the format
@@ -77,7 +73,7 @@ You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json`
format is `HtmlDark`.
```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -f Json
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json
```
#### Changing the output filename
@@ -85,7 +81,7 @@ dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -f Json
You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:
```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o myserver.html
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
```
#### Changing the output directory
@@ -95,7 +91,7 @@ extension.
If any of the folders in the path have a space in its name, escape them with quotes (").
```console
dotnet DiscordChatExporter.Cli.dll 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
@@ -105,7 +101,7 @@ Note that the filename must have an extension, otherwise it will be considered a
If any of the folders in the path have a space in its name, escape them with quotes (").
```console
dotnet DiscordChatExporter.Cli.dll 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
@@ -113,7 +109,7 @@ dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o "C:\Discord
You can use template tokens to generate the output file path based on the server and channel metadata.
```console
dotnet DiscordChatExporter.Cli.dll 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
@@ -132,6 +128,7 @@ Here is the full list of supported template tokens:
- `%P` - category position
- `%a` - the "after" date
- `%b` - the "before" date
- `%d` - the current date
- `%%` - escapes `%`
#### Partitioning
@@ -140,13 +137,13 @@ You can use partitioning to split files after a given number of messages or file
For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -p 10
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 10
```
A 45 MB channel set to be partitioned every 20 MB will output 3 files.
```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -p 20mb
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb
```
#### Downloading assets
@@ -157,7 +154,7 @@ downloaded when using the plain text (TXT) export format.
A folder containing the assets will be created along with the exported chat. They must be kept together.
```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --media
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
```
#### Reusing assets
@@ -166,7 +163,7 @@ Previously downloaded assets can be reused to skip redundant downloads as long a
same folder. Using this option can speed up future exports. This option requires the `--media` option.
```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --media --reuse-media
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --reuse-media
```
#### Changing the media directory
@@ -175,7 +172,7 @@ By default, the media directory is created alongside the exported chat. You can
providing a path that ends with a slash. All of the exported media will be stored in this directory.
```console
dotnet DiscordChatExporter.Cli.dll 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
@@ -184,7 +181,7 @@ You can customize how dates are formatted in the exported files by using `--loca
locales. The default locale is `en-US`.
```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
```
#### Date ranges
@@ -193,14 +190,14 @@ dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --locale "de-DE
Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019:
```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
```
**Messages sent after a date**
Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM:
```console
dotnet DiscordChatExporter.Cli.dll 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**
@@ -208,7 +205,7 @@ Use `--before` and `--after` to export messages sent during the provided date ra
September 17th, 2019 11:34 PM and September 18th:
```console
dotnet DiscordChatExporter.Cli.dll 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
@@ -217,12 +214,22 @@ Don't forget to quote (") the date if it has spaces!
More info about .NET date
formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
#### Filtering messages
Use `--filter` to filter what messages are included in the export.
```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --filter "from:Tyrrrz has:image"
```
Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Message-filters.md).
### Export channels from a specific server
To export all channels in a specific server, use the `exportguild` command and provide the server ID through the `-g|--guild` option:
```console
dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
```
#### Including threads
@@ -232,7 +239,7 @@ specifying which threads should be included. It has possible values of `none`, `
threads should be included. To include both active and archived threads, use `--include-threads all`.
```console
dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814 --include-threads all
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-threads all
```
#### Including voice channels
@@ -242,7 +249,7 @@ specifying whether to include voice channels in the export. It has possible valu
voice channels, use `--include-vc false`.
```console
dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
```
### Export all channels
@@ -250,7 +257,7 @@ dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814 --include-
To export all accessible channels, use the `exportall` command:
```console
dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn"
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
```
#### Excluding DMs
@@ -258,7 +265,7 @@ dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn"
To exclude DMs, add the `--include-dm false` option.
```console
dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn" --include-dm false
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
```
### List channels in a server
@@ -266,7 +273,7 @@ dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn" --include-dm false
To list the channels available in a specific server, use the `channels` command and provide the server ID through the `-g|--guild` option:
```console
dotnet DiscordChatExporter.Cli.dll channels -t "mfa.Ifrn" -g 21814
./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
```
### List direct message channels
@@ -274,7 +281,7 @@ dotnet DiscordChatExporter.Cli.dll channels -t "mfa.Ifrn" -g 21814
To list all DM channels accessible to the current account, use the `dm` command:
```console
dotnet DiscordChatExporter.Cli.dll dm -t "mfa.Ifrn"
./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
```
### List servers
@@ -282,5 +289,5 @@ dotnet DiscordChatExporter.Cli.dll dm -t "mfa.Ifrn"
To list all servers accessible by the current account, use the `guilds` command:
```console
dotnet DiscordChatExporter.Cli.dll guilds -t "mfa.Ifrn" > C:\path\to\output.txt
```
./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
```
+11 -18
View File
@@ -10,7 +10,7 @@
### Step 1
After extracting the `.zip`, open `DiscordChatExporter.exe`
After extracting the `.zip`, run `DiscordChatExporter.exe` (Windows), or `DiscordChatExporter` (Mac OS and Linux).
### Step 2
@@ -61,37 +61,30 @@ In this screen you can customize the following:
## Settings
- **Auto-update**
Perform automatic updates on every launch.
- **Auto-update** - Perform automatic updates on every launch.
Default: Enabled
> **Note**:
> Keep this option enabled to receive the latest features and bug fixes!
- **Dark mode**
Use darker colors in the UI (User Interface).
- **Dark mode** - Use darker colors in the UI (User Interface).
Default: Disabled
- **Persist token**
Persist last used token between sessions.
- **Persist token** - Persist last used token between sessions.
Default: Enabled
- **Show threads**
Controls whether threads are shown in the channel list.
- **Show threads** - Controls whether threads are shown in the channel list.
Default: none
- **Locale**
Customize how dates are formatted in the exported files.
- **Locale** - Customize how dates are formatted in the exported files.
- **Date format**
Customize how dates are formatted in the exported files in the settings menu ().
- **Date format** - Customize how dates are formatted in the exported files in the settings menu ().
- **Parallel limit**
The number of channels that will be exported at the same time.
- **Parallel limit** - The number of channels that will be exported at the same time.
Default: 1
- **Normalize to UTC**
Convert all dates to UTC before exporting.
> **Note**:
> Try to keep this number low so that your account doesn't get flagged.
- **Normalize to UTC** - Convert all dates to UTC before exporting.
+2
View File
@@ -100,6 +100,8 @@ body:
required: true
- label: I have sponsored this project
required: false
- label: I have not read any of the above and just checked all the boxes to submit the issue
required: false
- type: markdown
attributes:
+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:
- "*"
+6 -7
View File
@@ -12,8 +12,7 @@ on:
- master
jobs:
# Outputs from this job aren't really used, but it's here to verify that
# the Dockerfile builds correctly on pull requests.
# Outputs from this job aren't really used, but it's here to verify that the Dockerfile builds correctly
pack:
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -24,10 +23,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # 3.0.0
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Build image
run: >
@@ -38,7 +37,7 @@ jobs:
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
- name: Upload artifacts
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # 3.1.3
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: DiscordChatExporter.Cli.Docker
path: DiscordChatExporter.Cli.Docker.tar
@@ -56,10 +55,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # 3.0.0
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Login to DockerHub
run: >
+66 -21
View File
@@ -18,7 +18,7 @@ env:
jobs:
format:
runs-on: windows-latest
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -26,24 +26,37 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # 3.2.0
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
with:
dotnet-version: 8.0.x
dotnet-version: 9.0.x
# Build the project separately to discern between build and format errors
- name: Build
run: >
dotnet build
-p:CSharpier_Bypass=true
--configuration Release
- name: Verify formatting
id: verify
run: >
dotnet build
-t:CSharpierFormat
--configuration Release
--no-restore
- name: Report issues
if: ${{ failure() && steps.verify.outcome == 'failure' }}
run: echo "::error title=Bad formatting::Formatting issues detected. Please build the solution locally to fix them."
test:
# Tests need access to secrets, so we can't run them against PRs because of limited trust
if: ${{ github.event_name != 'pull_request' }}
runs-on: windows-latest
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -51,12 +64,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # 3.2.0
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
with:
dotnet-version: 8.0.x
dotnet-version: 9.0.x
- name: Run tests
env:
@@ -72,7 +85,7 @@ jobs:
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- name: Upload coverage
uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # 3.1.4
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -82,8 +95,24 @@ jobs:
app:
- DiscordChatExporter.Cli
- DiscordChatExporter.Gui
rid:
- win-arm64
- win-x86
- win-x64
- linux-arm
- linux-arm64
- linux-musl-x64
- linux-x64
- osx-arm64
- osx-x64
include:
- app: DiscordChatExporter.Cli
asset: DiscordChatExporter.Cli
- app: DiscordChatExporter.Gui
# GUI assets aren't suffixed, unlike the CLI assets
asset: DiscordChatExporter
runs-on: windows-latest
runs-on: ${{ startsWith(matrix.rid, 'win-') && 'windows-latest' || startsWith(matrix.rid, 'osx-') && 'macos-latest' || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
@@ -92,12 +121,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # 3.2.0
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
with:
dotnet-version: 8.0.x
dotnet-version: 9.0.x
- name: Publish app
run: >
@@ -106,11 +135,13 @@ jobs:
-p:CSharpier_Bypass=true
--output ${{ matrix.app }}/bin/publish/
--configuration Release
--runtime ${{ matrix.rid }}
--self-contained
- name: Upload artifacts
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # 3.1.3
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
with:
name: ${{ matrix.app }}
name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/bin/publish/
if-no-files-found: error
@@ -147,11 +178,21 @@ jobs:
app:
- DiscordChatExporter.Cli
- DiscordChatExporter.Gui
rid:
- win-arm64
- win-x86
- win-x64
- linux-arm
- linux-arm64
- linux-musl-x64
- linux-x64
- osx-arm64
- osx-x64
include:
- app: DiscordChatExporter.Cli
asset: DiscordChatExporter.Cli
- app: DiscordChatExporter.Gui
# GUI asset isn't suffixed, unlike the CLI asset
# GUI assets aren't suffixed, unlike the CLI assets
asset: DiscordChatExporter
runs-on: ubuntu-latest
@@ -163,22 +204,26 @@ jobs:
steps:
- name: Download artifacts
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # 3.0.2
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
with:
name: ${{ matrix.app }}
name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/
- name: Set permissions
if: ${{ !startsWith(matrix.rid, 'win-') }}
run: chmod +x ${{ matrix.app }}/${{ matrix.asset }}
- name: Create package
# Change into the artifacts directory to avoid including the directory itself in the zip archive
working-directory: ${{ matrix.app }}/
run: zip -r ../${{ matrix.asset }}.zip .
run: zip -r ../${{ matrix.asset }}.${{ matrix.rid }}.zip .
- name: Upload release asset
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: >
gh release upload ${{ github.ref_name }}
${{ matrix.asset }}.zip
${{ matrix.asset }}.${{ matrix.rid }}.zip
--repo ${{ github.event.repository.full_name }}
notify:
@@ -191,7 +236,7 @@ jobs:
steps:
- name: Notify Discord
uses: tyrrrz/action-http-request@64c70c67f5ebc54d4c7ea09cbe3553322778afd5 # 1.1.2
uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3
with:
url: ${{ secrets.DISCORD_WEBHOOK }}
method: POST
+2 -1
View File
@@ -1,13 +1,14 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net9.0</TargetFramework>
<Version>999.9.9-dev</Version>
<Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
</PropertyGroup>
</Project>
@@ -11,19 +11,18 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.0.7" />
<PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" />
<PackageReference Include="AngleSharp" Version="1.2.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="8.1.1" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="ReflectionMagic" Version="5.0.0" />
<PackageReference Include="xunit" Version="2.6.4" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
@@ -10,6 +10,8 @@ public static class ChannelIds
public static Snowflake EmbedTestCases { get; } = Snowflake.Parse("866472452459462687");
public static Snowflake EmojiTestCases { get; } = Snowflake.Parse("866768438290415636");
public static Snowflake GroupingTestCases { get; } = Snowflake.Parse("992092091545034842");
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
@@ -19,12 +19,7 @@ namespace DiscordChatExporter.Cli.Tests.Infra;
public static class ExportWrapper
{
private static readonly AsyncKeyedLocker<string> Locker =
new(o =>
{
o.PoolSize = 20;
o.PoolInitialFill = 1;
});
private static readonly AsyncKeyedLocker<string> Locker = new();
private static readonly string DirPath = Path.Combine(
Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
@@ -61,7 +56,7 @@ public static class ExportWrapper
ExportFormat = format,
OutputPath = filePath,
Locale = "en-US",
IsUtcNormalizationEnabled = true
IsUtcNormalizationEnabled = true,
}.ExecuteAsync(console);
}
@@ -93,13 +88,12 @@ public static class ExportWrapper
Snowflake messageId
)
{
var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(
e =>
string.Equals(
e.GetAttribute("data-message-id"),
messageId.ToString(),
StringComparison.OrdinalIgnoreCase
)
var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(e =>
string.Equals(
e.GetAttribute("data-message-id"),
messageId.ToString(),
StringComparison.OrdinalIgnoreCase
)
);
if (message is null)
@@ -117,13 +111,12 @@ public static class ExportWrapper
Snowflake messageId
)
{
var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(
j =>
string.Equals(
j.GetProperty("id").GetString(),
messageId.ToString(),
StringComparison.OrdinalIgnoreCase
)
var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(j =>
string.Equals(
j.GetProperty("id").GetString(),
messageId.ToString(),
StringComparison.OrdinalIgnoreCase
)
);
if (message.ValueKind == JsonValueKind.Undefined)
@@ -30,7 +30,7 @@ public class DateRangeSpecs
ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
After = Snowflake.FromDate(after)
After = Snowflake.FromDate(after),
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -50,13 +50,11 @@ public class DateRangeSpecs
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero)
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero),
],
o =>
o.Using<DateTimeOffset>(
ctx =>
ctx.Subject.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
o.Using<DateTimeOffset>(ctx =>
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
)
.WhenTypeIs<DateTimeOffset>()
);
@@ -76,7 +74,7 @@ public class DateRangeSpecs
ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
Before = Snowflake.FromDate(before)
Before = Snowflake.FromDate(before),
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -94,13 +92,11 @@ public class DateRangeSpecs
[
new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero)
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero),
],
o =>
o.Using<DateTimeOffset>(
ctx =>
ctx.Subject.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
o.Using<DateTimeOffset>(ctx =>
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
)
.WhenTypeIs<DateTimeOffset>()
);
@@ -122,7 +118,7 @@ public class DateRangeSpecs
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
Before = Snowflake.FromDate(before),
After = Snowflake.FromDate(after)
After = Snowflake.FromDate(after),
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -141,13 +137,11 @@ public class DateRangeSpecs
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero)
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
],
o =>
o.Using<DateTimeOffset>(
ctx =>
ctx.Subject.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
o.Using<DateTimeOffset>(ctx =>
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
)
.WhenTypeIs<DateTimeOffset>()
);
@@ -29,7 +29,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("some text")
MessageFilter = MessageFilter.Parse("some text"),
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -54,7 +54,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("from:Tyrrrz")
MessageFilter = MessageFilter.Parse("from:Tyrrrz"),
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -79,7 +79,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:image")
MessageFilter = MessageFilter.Parse("has:image"),
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -104,7 +104,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:pin")
MessageFilter = MessageFilter.Parse("has:pin"),
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -129,7 +129,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:invite")
MessageFilter = MessageFilter.Parse("has:invite"),
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -154,7 +154,7 @@ public class FilterSpecs
ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz")
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz"),
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -90,12 +90,11 @@ public class HtmlEmbedSpecs
.QuerySelectorAll("source")
.Select(e => e.GetAttribute("src"))
.WhereNotNull()
.Where(
s =>
s.Contains(
"i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4",
StringComparison.Ordinal
)
.Where(s =>
s.Contains(
"i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4",
StringComparison.Ordinal
)
)
.Should()
.ContainSingle();
@@ -150,6 +149,26 @@ public class HtmlEmbedSpecs
iframeUrl.Should().StartWith("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP");
}
[Fact(Skip = "Twitch does not allow embeds from inside local HTML files")]
public async Task I_can_export_a_channel_that_contains_a_message_with_a_Twitch_clip_embed()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1196
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.EmbedTestCases,
Snowflake.Parse("1207002986128216074")
);
// Assert
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
iframeUrl
.Should()
.StartWith(
"https://clips.twitch.tv/embed?clip=SpicyMildCiderThisIsSparta--PQhbllrvej_Ee7v"
);
}
[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_a_YouTube_video_embed()
{
@@ -185,42 +204,38 @@ public class HtmlEmbedSpecs
imageUrls
.Should()
.Contain(
u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
StringComparison.Ordinal
)
.Contain(u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
StringComparison.Ordinal
)
);
imageUrls
.Should()
.Contain(
u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
StringComparison.Ordinal
)
.Contain(u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
StringComparison.Ordinal
)
);
imageUrls
.Should()
.Contain(
u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png",
StringComparison.Ordinal
)
.Contain(u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png",
StringComparison.Ordinal
)
);
imageUrls
.Should()
.Contain(
u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png",
StringComparison.Ordinal
)
.Contain(u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png",
StringComparison.Ordinal
)
);
message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle();
@@ -28,7 +28,7 @@ public class HtmlGroupingSpecs
Token = Secrets.DiscordToken,
ChannelIds = [ChannelIds.GroupingTestCases],
ExportFormat = ExportFormat.HtmlDark,
OutputPath = file.Path
OutputPath = file.Path,
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -1,6 +1,7 @@
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;
@@ -19,8 +20,13 @@ public class HtmlMarkdownSpecs
);
// Assert
message.Text().Should().Contain("Default timestamp: 2/12/2023 1:36 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.Text()
.ReplaceWhiteSpace()
.Should()
.Contain("Default timestamp: 2/12/2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -33,8 +39,8 @@ public class HtmlMarkdownSpecs
);
// Assert
message.Text().Should().Contain("Short time timestamp: 1:36 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
message.Text().ReplaceWhiteSpace().Should().Contain("Short time timestamp: 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -47,8 +53,8 @@ public class HtmlMarkdownSpecs
);
// Assert
message.Text().Should().Contain("Long time timestamp: 1:36:12 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
message.Text().ReplaceWhiteSpace().Should().Contain("Long time timestamp: 1:36:12 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -61,8 +67,8 @@ public class HtmlMarkdownSpecs
);
// Assert
message.Text().Should().Contain("Short date timestamp: 2/12/2023");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
message.Text().ReplaceWhiteSpace().Should().Contain("Short date timestamp: 2/12/2023");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -75,8 +81,13 @@ public class HtmlMarkdownSpecs
);
// Assert
message.Text().Should().Contain("Long date timestamp: Sunday, February 12, 2023");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.Text()
.ReplaceWhiteSpace()
.Should()
.Contain("Long date timestamp: Sunday, February 12, 2023");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -89,8 +100,13 @@ public class HtmlMarkdownSpecs
);
// Assert
message.Text().Should().Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.Text()
.ReplaceWhiteSpace()
.Should()
.Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -105,9 +121,11 @@ public class HtmlMarkdownSpecs
// Assert
message
.Text()
.ReplaceWhiteSpace()
.Should()
.Contain("Full long timestamp: Sunday, February 12, 2023 1:36:12 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -120,8 +138,13 @@ public class HtmlMarkdownSpecs
);
// Assert
message.Text().Should().Contain("Relative timestamp: 2/12/2023 1:36 PM");
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
message
.Text()
.ReplaceWhiteSpace()
.Should()
.Contain("Relative timestamp: 2/12/2023 1:36 PM");
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
}
[Fact]
@@ -0,0 +1,69 @@
using System.Linq;
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonEmojiSpecs
{
[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_inline_emoji_and_have_them_listed_separately()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.EmojiTestCases,
Snowflake.Parse("866768521052553216")
);
// Assert
var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
inlineEmojis.Should().HaveCount(4);
inlineEmojis[0].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[0].GetProperty("name").GetString().Should().Be("🙂");
inlineEmojis[0].GetProperty("code").GetString().Should().Be("slight_smile");
inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
inlineEmojis[1].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[1].GetProperty("name").GetString().Should().Be("😦");
inlineEmojis[1].GetProperty("code").GetString().Should().Be("frowning");
inlineEmojis[1].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[1].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
inlineEmojis[2].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[2].GetProperty("name").GetString().Should().Be("😔");
inlineEmojis[2].GetProperty("code").GetString().Should().Be("pensive");
inlineEmojis[2].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[2].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
inlineEmojis[3].GetProperty("id").GetString().Should().BeNullOrEmpty();
inlineEmojis[3].GetProperty("name").GetString().Should().Be("😂");
inlineEmojis[3].GetProperty("code").GetString().Should().Be("joy");
inlineEmojis[3].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[3].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
}
[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_custom_inline_emoji_and_have_them_listed_separately()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.EmojiTestCases,
Snowflake.Parse("1299804867447230594")
);
// Assert
var inlineEmojis = message.GetProperty("inlineEmojis").EnumerateArray().ToArray();
inlineEmojis.Should().HaveCount(1);
inlineEmojis[0].GetProperty("id").GetString().Should().Be("754441880066064584");
inlineEmojis[0].GetProperty("name").GetString().Should().Be("lemon_blush");
inlineEmojis[0].GetProperty("code").GetString().Should().Be("lemon_blush");
inlineEmojis[0].GetProperty("isAnimated").GetBoolean().Should().BeFalse();
inlineEmojis[0].GetProperty("imageUrl").GetString().Should().NotBeNullOrWhiteSpace();
}
}
@@ -27,7 +27,7 @@ public class PartitioningSpecs
ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath,
PartitionLimit = PartitionLimit.Parse("3")
PartitionLimit = PartitionLimit.Parse("3"),
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -48,7 +48,7 @@ public class PartitioningSpecs
ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath,
PartitionLimit = PartitionLimit.Parse("1kb")
PartitionLimit = PartitionLimit.Parse("1kb"),
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -27,7 +27,7 @@ public class SelfContainedSpecs
ChannelIds = [ChannelIds.SelfContainedTestCases],
ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath,
ShouldDownloadAssets = true
ShouldDownloadAssets = true,
}.ExecuteAsync(new FakeConsole());
// Assert
@@ -0,0 +1,16 @@
using System.Text;
namespace DiscordChatExporter.Cli.Tests.Utils.Extensions;
internal static class StringExtensions
{
public static string ReplaceWhiteSpace(this string str, string replacement = " ")
{
var buffer = new StringBuilder(str.Length);
foreach (var ch in str)
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
return buffer.ToString();
}
}
+11 -7
View File
@@ -1,7 +1,7 @@
# -- Build
# Specify the platform here so that we pull the SDK image matching the host platform,
# instead of the target platform specified during build by the `--platform` option.
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
# Expose the target architecture set by the `docker build --platform` option, so that
# we can build the assembly for the correct platform.
@@ -30,7 +30,7 @@ RUN dotnet publish DiscordChatExporter.Cli \
# -- Run
# Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:8.0-alpine AS run
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:9.0-alpine AS run
LABEL org.opencontainers.image.title="DiscordChatExporter.Cli"
LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
@@ -40,20 +40,24 @@ LABEL org.opencontainers.image.licenses="MIT"
# Alpine image doesn't come with the ICU libraries pre-installed, so we need to install them manually.
# We need the full ICU data because we allow the user to specify any locale for formatting purposes.
RUN apk add --no-cache icu-libs
RUN apk add --no-cache icu-data-full
RUN apk add --no-cache icu-libs icu-data-full
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
ENV LC_ALL=en_US.UTF-8
ENV LANG=en_US.UTF-8
# Alpine is missing tzdata, which we need to support timezones
RUN apk add --no-cache tzdata
# Use a non-root user to ensure that the files shared with the host are accessible by the host user
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851
RUN adduser --disabled-password --no-create-home dce
USER dce
# https://github.com/Tyrrrz/DiscordChatExporter/issues/1174
RUN apk add --no-cache su-exec
RUN addgroup -S -g 1000 dce && adduser -S -H -G dce -u 1000 dce
# This directory is exposed to the user for mounting purposes, so it's important that it always
# stays the same for backwards compatibility.
WORKDIR /out
COPY --from=build /tmp/app/DiscordChatExporter.Cli/bin/publish /opt/app
ENTRYPOINT ["/opt/app/DiscordChatExporter.Cli"]
COPY docker-entrypoint.sh /opt/app
ENTRYPOINT ["/opt/app/docker-entrypoint.sh"]
@@ -196,7 +196,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
new ParallelOptions
{
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
CancellationToken = cancellationToken
CancellationToken = cancellationToken,
},
async (channel, innerCancellationToken) =>
{
@@ -7,7 +7,6 @@ using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
@@ -59,7 +58,7 @@ public class GetChannelsCommand : DiscordCommandBase
)
.OrderBy(c => c.Name)
.ToArray()
: Array.Empty<Channel>();
: [];
foreach (var channel in channels)
{
@@ -4,5 +4,5 @@ public enum ThreadInclusionMode
{
None,
Active,
All
All,
}
@@ -3,15 +3,16 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
<PublishTrimmed>true</PublishTrimmed>
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="2.3.5" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.2" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="Spectre.Console" Version="0.48.0" />
<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,
ColorSystem = ColorSystemSupport.Detect,
Out = new AnsiConsoleOutput(console.Output)
Out = new AnsiConsoleOutput(console.Output),
}
);
@@ -7,7 +7,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/application#application-object
public partial record Application(Snowflake Id, string Name, ApplicationFlags Flags)
{
public bool IsMessageContentIntentEnabled =>
public bool IsMessageContentIntentEnabled { get; } =
Flags.HasFlag(ApplicationFlags.GatewayMessageContent)
|| Flags.HasFlag(ApplicationFlags.GatewayMessageContentLimited);
}
@@ -16,5 +16,5 @@ public enum ApplicationFlags
Embedded = 131072,
GatewayMessageContent = 262144,
GatewayMessageContentLimited = 524288,
ApplicationCommandBadge = 8388608
ApplicationCommandBadge = 8388608,
}
@@ -18,7 +18,7 @@ public partial record Attachment(
FileSize FileSize
) : IHasId
{
public string FileExtension => Path.GetExtension(FileName);
public string FileExtension { get; } = Path.GetExtension(FileName);
public bool IsImage =>
string.Equals(FileExtension, ".jpg", StringComparison.OrdinalIgnoreCase)
@@ -41,7 +41,7 @@ public partial record Attachment(
|| string.Equals(FileExtension, ".flac", StringComparison.OrdinalIgnoreCase)
|| string.Equals(FileExtension, ".m4a", StringComparison.OrdinalIgnoreCase);
public bool IsSpoiler => FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
public bool IsSpoiler { get; } = FileName.StartsWith("SPOILER_", StringComparison.Ordinal);
}
public partial record Attachment
@@ -21,21 +21,23 @@ public partial record Channel(
Snowflake? LastMessageId
) : IHasId
{
public bool IsDirect => Kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat;
public bool IsDirect { get; } =
Kind is ChannelKind.DirectTextChat or ChannelKind.DirectGroupTextChat;
public bool IsGuild => !IsDirect;
public bool IsCategory => Kind == ChannelKind.GuildCategory;
public bool IsCategory { get; } = Kind == ChannelKind.GuildCategory;
public bool IsVoice => Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
public bool IsVoice { get; } =
Kind is ChannelKind.GuildVoiceChat or ChannelKind.GuildStageVoice;
public bool IsThread =>
public bool IsThread { get; } =
Kind
is ChannelKind.GuildNewsThread
or ChannelKind.GuildPublicThread
or ChannelKind.GuildPrivateThread;
public bool IsEmpty => LastMessageId is null;
public bool IsEmpty { get; } = LastMessageId is null;
public IEnumerable<Channel> GetParents()
{
@@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Discord.Data;
public record ChannelConnection(Channel Channel, IReadOnlyList<ChannelConnection> Children)
{
public static IReadOnlyList<ChannelConnection> BuildTree(IReadOnlyList<Channel> channels)
{
IReadOnlyList<ChannelConnection> GetChildren(Channel parent) =>
channels
.Where(c => c.Parent?.Id == parent.Id)
.Select(c => new ChannelConnection(c, GetChildren(c)))
.ToArray();
return channels
.Where(c => c.Parent is null)
.Select(c => new ChannelConnection(c, GetChildren(c)))
.ToArray();
}
}
@@ -14,5 +14,5 @@ public enum ChannelKind
GuildPrivateThread = 12,
GuildStageVoice = 13,
GuildDirectory = 14,
GuildForum = 15
GuildForum = 15,
}
@@ -31,6 +31,9 @@ public partial record Embed(
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() =>
SpotifyTrackEmbedProjection.TryResolve(this);
public TwitchClipEmbedProjection? TryGetTwitchClip() =>
TwitchClipEmbedProjection.TryResolve(this);
public YouTubeVideoEmbedProjection? TryGetYouTubeVideo() =>
YouTubeVideoEmbedProjection.TryResolve(this);
}
@@ -60,7 +63,7 @@ public partial record Embed
json.GetPropertyOrNull("fields")
?.EnumerateArrayOrNull()
?.Select(EmbedField.Parse)
.ToArray() ?? Array.Empty<EmbedField>();
.ToArray() ?? [];
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
@@ -75,7 +78,7 @@ public partial record Embed
json.GetPropertyOrNull("image")
?.Pipe(EmbedImage.Parse)
.ToSingletonEnumerable()
.ToArray() ?? Array.Empty<EmbedImage>();
.ToArray() ?? [];
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
@@ -7,5 +7,5 @@ public enum EmbedKind
Image,
Video,
Gifv,
Link
Link,
}
@@ -4,7 +4,7 @@ namespace DiscordChatExporter.Core.Discord.Data.Embeds;
public partial record SpotifyTrackEmbedProjection(string TrackId)
{
public string Url => $"https://open.spotify.com/embed/track/{TrackId}";
public string Url { get; } = $"https://open.spotify.com/embed/track/{TrackId}";
}
public partial record SpotifyTrackEmbedProjection
@@ -0,0 +1,53 @@
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
public partial record TwitchClipEmbedProjection(string ClipId)
{
public string Url { get; } = $"https://clips.twitch.tv/embed?clip={ClipId}&parent=localhost";
}
public partial record TwitchClipEmbedProjection
{
private static string? TryParseClipId(string embedUrl)
{
// https://clips.twitch.tv/SpookyTenuousPidgeonPanicVis
{
var clipId = Regex
.Match(embedUrl, @"clips\.twitch\.tv/(.*?)(?:\?|&|/|$)")
.Groups[1]
.Value;
if (!string.IsNullOrWhiteSpace(clipId))
return clipId;
}
// https://twitch.tv/clip/SpookyTenuousPidgeonPanicVis
{
var clipId = Regex
.Match(embedUrl, @"twitch\.tv/clip/(.*?)(?:\?|&|/|$)")
.Groups[1]
.Value;
if (!string.IsNullOrWhiteSpace(clipId))
return clipId;
}
return null;
}
public static TwitchClipEmbedProjection? TryResolve(Embed embed)
{
if (embed.Kind != EmbedKind.Video)
return null;
if (string.IsNullOrWhiteSpace(embed.Url))
return null;
var clipId = TryParseClipId(embed.Url);
if (string.IsNullOrWhiteSpace(clipId))
return null;
return new TwitchClipEmbedProjection(clipId);
}
}
@@ -2,7 +2,7 @@
public partial record YouTubeVideoEmbedProjection(string VideoId)
{
public string Url => $"https://www.youtube.com/embed/{VideoId}";
public string Url { get; } = $"https://www.youtube.com/embed/{VideoId}";
}
public partial record YouTubeVideoEmbedProjection
+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.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
@@ -13,29 +12,22 @@ public partial record Emoji(
Snowflake? Id,
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
string Name,
bool IsAnimated,
string ImageUrl
bool IsAnimated
)
{
public bool IsCustomEmoji { get; } = Id is not null;
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
public string Code => Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
public string Code { get; } = Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
public string ImageUrl { get; } =
Id is not null
? ImageCdn.GetCustomEmojiUrl(Id.Value, IsAnimated)
: ImageCdn.GetStandardEmojiUrl(Name);
}
public partial record Emoji
{
public static string GetImageUrl(Snowflake? id, string? name, bool isAnimated)
{
// Custom emoji
if (id is not null)
return ImageCdn.GetCustomEmojiUrl(id.Value, isAnimated);
// Standard emoji
if (!string.IsNullOrWhiteSpace(name))
return ImageCdn.GetStandardEmojiUrl(name);
throw new InvalidOperationException("Either the emoji ID or name should be provided.");
}
public static Emoji Parse(JsonElement json)
{
var id = json.GetPropertyOrNull("id")
@@ -47,8 +39,7 @@ public partial record Emoji
json.GetPropertyOrNull("name")?.GetNonWhiteSpaceStringOrNull() ?? "Unknown Emoji";
var isAnimated = json.GetPropertyOrNull("animated")?.GetBooleanOrNull() ?? false;
var imageUrl = GetImageUrl(id, name, isAnimated);
return new Emoji(id, name, isAnimated, imageUrl);
return new Emoji(id, name, isAnimated);
}
}
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/guild#guild-object
public partial record Guild(Snowflake Id, string Name, string IconUrl) : IHasId
{
public bool IsDirect => Id == DirectMessages.Id;
public bool IsDirect { get; } = Id == Snowflake.Zero;
}
public partial record Guild
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common;
@@ -16,13 +15,12 @@ public partial record Member(
IReadOnlyList<Snowflake> RoleIds
) : IHasId
{
public Snowflake Id => User.Id;
public Snowflake Id { get; } = User.Id;
}
public partial record Member
{
public static Member CreateFallback(User user) =>
new(user, null, null, Array.Empty<Snowflake>());
public static Member CreateFallback(User user) => new(user, null, null, []);
public static Member Parse(JsonElement json, Snowflake? guildId = null)
{
@@ -34,7 +32,7 @@ public partial record Member
?.EnumerateArray()
.Select(j => j.GetNonWhiteSpaceString())
.Select(Snowflake.Parse)
.ToArray() ?? Array.Empty<Snowflake>();
.ToArray() ?? [];
var avatarUrl = guildId is not null
? json.GetPropertyOrNull("avatar")
@@ -30,15 +30,15 @@ public partial record Message(
Interaction? Interaction
) : IHasId
{
public bool IsSystemNotification =>
public bool IsSystemNotification { get; } =
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
public bool IsReply => Kind == MessageKind.Reply;
public bool IsReply { get; } = Kind == MessageKind.Reply;
// App interactions are rendered as replies in the Discord client, but they are not actually replies
public bool IsReplyLike => IsReply || Interaction is not null;
public bool IsEmpty =>
public bool IsEmpty { get; } =
string.IsNullOrWhiteSpace(Content)
&& !Attachments.Any()
&& !Embeds.Any()
@@ -83,16 +83,15 @@ public partial record Message
// Find embeds with the same URL that only contain a single image and nothing else
var trailingEmbeds = embeds
.Skip(i + 1)
.TakeWhile(
e =>
e.Url == embed.Url
&& e.Timestamp is null
&& e.Author is null
&& e.Color is null
&& string.IsNullOrWhiteSpace(e.Description)
&& !e.Fields.Any()
&& e.Images.Count == 1
&& e.Footer is null
.TakeWhile(e =>
e.Url == embed.Url
&& e.Timestamp is null
&& e.Author is null
&& e.Color is null
&& string.IsNullOrWhiteSpace(e.Description)
&& !e.Fields.Any()
&& e.Images.Count == 1
&& e.Footer is null
)
.ToArray();
@@ -144,28 +143,28 @@ public partial record Message
json.GetPropertyOrNull("attachments")
?.EnumerateArrayOrNull()
?.Select(Attachment.Parse)
.ToArray() ?? Array.Empty<Attachment>();
.ToArray() ?? [];
var embeds = NormalizeEmbeds(
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
?? Array.Empty<Embed>()
?? []
);
var stickers =
json.GetPropertyOrNull("sticker_items")
?.EnumerateArrayOrNull()
?.Select(Sticker.Parse)
.ToArray() ?? Array.Empty<Sticker>();
.ToArray() ?? [];
var reactions =
json.GetPropertyOrNull("reactions")
?.EnumerateArrayOrNull()
?.Select(Reaction.Parse)
.ToArray() ?? Array.Empty<Reaction>();
.ToArray() ?? [];
var mentionedUsers =
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
?? Array.Empty<User>();
?? [];
var messageReference = json.GetPropertyOrNull("message_reference")
?.Pipe(MessageReference.Parse);
@@ -14,5 +14,5 @@ public enum MessageFlags
Urgent = 16,
HasThread = 32,
Ephemeral = 64,
Loading = 128
Loading = 128,
}
@@ -12,5 +12,5 @@ public enum MessageKind
ChannelPinnedMessage = 6,
GuildMemberJoin = 7,
ThreadCreated = 18,
Reply = 19
Reply = 19,
}
@@ -9,7 +9,7 @@ namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/sticker#sticker-resource
public partial record Sticker(Snowflake Id, string Name, StickerFormat Format, string SourceUrl)
{
public bool IsImage => Format != StickerFormat.Lottie;
public bool IsImage { get; } = Format != StickerFormat.Lottie;
}
public partial record Sticker
@@ -28,7 +28,7 @@ public partial record Sticker
StickerFormat.Apng => "png",
StickerFormat.Lottie => "json",
StickerFormat.Gif => "gif",
_ => throw new InvalidOperationException($"Unknown sticker format '{format}'.")
_ => throw new InvalidOperationException($"Unknown sticker format '{format}'."),
}
);
@@ -5,5 +5,5 @@ public enum StickerFormat
Png = 1,
Apng = 2,
Lottie = 3,
Gif = 4
Gif = 4,
}
@@ -18,7 +18,7 @@ public partial record User(
string AvatarUrl
) : IHasId
{
public string DiscriminatorFormatted =>
public string DiscriminatorFormatted { get; } =
Discriminator is not null ? $"{Discriminator:0000}" : "0000";
// This effectively represents the user's true identity.
@@ -66,12 +66,12 @@ public class DiscordClient(string token)
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
{
var delay =
// Adding a small buffer to the reset time reduces the chance of getting
// rate limited again, because it allows for more requests to be released.
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
// Sometimes Discord returns an absurdly high value for the reset time, which
// is not actually enforced by the server. So we cap it at a reasonable value.
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
// Adding a small buffer to the reset time reduces the chance of getting
// rate limited again, because it allows for more requests to be released.
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
// Sometimes Discord returns an absurdly high value for the reset time, which
// is not actually enforced by the server. So we cap it at a reasonable value.
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
await Task.Delay(delay, innerCancellationToken);
}
@@ -133,30 +133,31 @@ public class DiscordClient(string token)
{
throw response.StatusCode switch
{
HttpStatusCode.Unauthorized
=> throw new DiscordChatExporterException(
"Authentication token is invalid.",
true
),
HttpStatusCode.Unauthorized => throw new DiscordChatExporterException(
"Authentication token is invalid.",
true
),
HttpStatusCode.Forbidden
=> throw new DiscordChatExporterException(
$"Request to '{url}' failed: forbidden."
),
HttpStatusCode.Forbidden => throw new DiscordChatExporterException(
$"Request to '{url}' failed: forbidden."
),
HttpStatusCode.NotFound
=> throw new DiscordChatExporterException(
$"Request to '{url}' failed: not found."
),
HttpStatusCode.NotFound => throw new DiscordChatExporterException(
$"Request to '{url}' failed: not found."
),
_
=> throw new DiscordChatExporterException(
$"""
Request to '{url}' failed: {response.StatusCode.ToString().ToSpaceSeparatedWords().ToLowerInvariant()}.
Response content: {await response.Content.ReadAsStringAsync(cancellationToken)}
_ => throw new DiscordChatExporterException(
$"""
Request to '{url}' failed: {response
.StatusCode.ToString()
.ToSpaceSeparatedWords()
.ToLowerInvariant()}.
Response content: {await response.Content.ReadAsStringAsync(
cancellationToken
)}
""",
true
)
true
),
};
}
@@ -671,10 +672,14 @@ public class DiscordClient(string token)
.SetQueryParameter("after", currentAfter.ToString())
.Build();
var response = await GetJsonResponseAsync(url, cancellationToken);
// Can be null on reactions with an emoji that has been deleted (?)
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1226
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
yield break;
var count = 0;
foreach (var userJson in response.EnumerateArray())
foreach (var userJson in response.Value.EnumerateArray())
{
var user = User.Parse(userJson);
yield return user;
@@ -3,5 +3,5 @@
public enum TokenKind
{
User,
Bot
Bot,
}
@@ -1,15 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="AsyncKeyedLock" Version="6.2.4" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
<PackageReference Include="AsyncKeyedLock" Version="7.1.4" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Polly" Version="8.2.0" />
<PackageReference Include="RazorBlade" Version="0.5.0" />
<PackageReference Include="Polly" Version="8.5.2" />
<PackageReference Include="RazorBlade" Version="0.8.0" />
<PackageReference Include="Superpower" Version="3.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.14.0" />
<PackageReference Include="YoutubeExplode" Version="6.3.10" />
<PackageReference Include="WebMarkupMin.Core" Version="2.17.0" />
<PackageReference Include="YoutubeExplode" Version="6.5.3" />
</ItemGroup>
</Project>
</Project>
@@ -20,8 +20,8 @@ public class ChannelExporter(DiscordClient discord)
if (request.Channel.Kind == ChannelKind.GuildForum)
{
throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
$"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' "
+ $"is a forum and cannot be exported directly. "
+ "You need to pull its threads and export them individually."
);
@@ -31,8 +31,8 @@ public class ChannelExporter(DiscordClient discord)
if (request.Channel.IsEmpty)
{
throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
$"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages."
);
}
@@ -41,8 +41,8 @@ public class ChannelExporter(DiscordClient discord)
if (request.After is not null && !request.Channel.MayHaveMessagesAfter(request.After.Value))
{
throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
$"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages within the specified period."
);
}
@@ -54,8 +54,8 @@ public class ChannelExporter(DiscordClient discord)
)
{
throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
$"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages within the specified period."
);
}
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text;
@@ -16,12 +15,7 @@ namespace DiscordChatExporter.Core.Exporting;
internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
{
private static readonly AsyncKeyedLocker<string> Locker =
new(o =>
{
o.PoolSize = 20;
o.PoolInitialFill = 1;
});
private static readonly AsyncKeyedLocker<string> Locker = new();
// File paths of the previously downloaded assets
private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal);
@@ -50,39 +44,8 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
{
// Download the file
using var response = await Http.Client.GetAsync(url, innerCancellationToken);
await using (var output = File.Create(filePath))
await response.Content.CopyToAsync(output, innerCancellationToken);
// Try to set the file date according to the last-modified header
try
{
var lastModified = response
.Content.Headers.TryGetValue("Last-Modified")
?.Pipe(
s =>
DateTimeOffset.TryParse(
s,
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var instant
)
? instant
: (DateTimeOffset?)null
);
if (lastModified is not null)
{
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
}
}
catch
{
// This can apparently fail for some reason.
// Updating the file date is not a critical task, so we'll just ignore exceptions thrown here.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
}
await using var output = File.Create(filePath);
await response.Content.CopyToAsync(output, innerCancellationToken);
},
cancellationToken
);
@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting;
@@ -18,8 +19,10 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
private readonly Dictionary<Snowflake, Channel> _channelsById = new();
private readonly Dictionary<Snowflake, Role> _rolesById = new();
private readonly ExportAssetDownloader _assetDownloader =
new(request.AssetsDirPath, request.ShouldReuseAssets);
private readonly ExportAssetDownloader _assetDownloader = new(
request.AssetsDirPath,
request.ShouldReuseAssets
);
public DiscordClient Discord { get; } = discord;
@@ -93,11 +96,10 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
public IReadOnlyList<Role> GetUserRoles(Snowflake id) =>
TryGetMember(id)
?.RoleIds
.Select(TryGetRole)
?.RoleIds.Select(TryGetRole)
.WhereNotNull()
.OrderByDescending(r => r.Position)
.ToArray() ?? Array.Empty<Role>();
.ToArray() ?? [];
public Color? TryGetUserColor(Snowflake id) =>
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
@@ -116,7 +118,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
// Prefer the relative path so that the export package can be copied around without breaking references.
// However, if the assets directory lies outside of the export directory, use the absolute path instead.
// However, if the assets directory lies outside the export directory, use the absolute path instead.
var shouldUseAbsoluteFilePath =
relativeFilePath.StartsWith(
".." + Path.DirectorySeparatorChar,
@@ -131,14 +133,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
// For HTML, the path needs to be properly formatted
if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
{
// Format the path into a valid file URI
var href = new Uri(new Uri("file:///"), optimalFilePath).ToString();
// File schema does not support relative paths, so strip it if that's the case
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1155
return shouldUseAbsoluteFilePath ? href : href[8..];
}
return Url.EncodeFilePath(optimalFilePath);
return optimalFilePath;
}
@@ -8,7 +8,7 @@ public enum ExportFormat
HtmlDark,
HtmlLight,
Csv,
Json
Json,
}
public static class ExportFormatExtensions
@@ -21,7 +21,7 @@ public static class ExportFormatExtensions
ExportFormat.HtmlLight => "html",
ExportFormat.Csv => "csv",
ExportFormat.Json => "json",
_ => throw new ArgumentOutOfRangeException(nameof(format))
_ => throw new ArgumentOutOfRangeException(nameof(format)),
};
public static string GetDisplayName(this ExportFormat format) =>
@@ -32,6 +32,6 @@ public static class ExportFormatExtensions
ExportFormat.HtmlLight => "HTML (Light)",
ExportFormat.Csv => "CSV",
ExportFormat.Json => "JSON",
_ => throw new ArgumentOutOfRangeException(nameof(format))
_ => throw new ArgumentOutOfRangeException(nameof(format)),
};
}
@@ -172,24 +172,21 @@ public partial class ExportRequest
"%C" => channel.Name,
"%p" => channel.Position?.ToString(CultureInfo.InvariantCulture) ?? "0",
"%P"
=> channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture)
?? "0",
"%P" => channel.Parent?.Position?.ToString(CultureInfo.InvariantCulture)
?? "0",
"%a"
=> after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
?? "",
"%b"
=> before?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
?? "",
"%d"
=> DateTimeOffset.Now.ToString(
"yyyy-MM-dd",
CultureInfo.InvariantCulture
),
"%a" => after?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
?? "",
"%b" => before
?.ToDate()
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "",
"%d" => DateTimeOffset.Now.ToString(
"yyyy-MM-dd",
CultureInfo.InvariantCulture
),
"%%" => "%",
_ => m.Value
_ => m.Value,
}
)
);
@@ -3,5 +3,5 @@
internal enum BinaryExpressionKind
{
Or,
And
And,
}
@@ -14,6 +14,6 @@ internal class BinaryExpressionMessageFilter(
{
BinaryExpressionKind.Or => first.IsMatch(message) || second.IsMatch(message),
BinaryExpressionKind.And => first.IsMatch(message) && second.IsMatch(message),
_ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'.")
_ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'."),
};
}
@@ -22,12 +22,11 @@ internal class ContainsMessageFilter(string text) : MessageFilter
public override bool IsMatch(Message message) =>
IsMatch(message.Content)
|| message.Embeds.Any(
e =>
IsMatch(e.Title)
|| IsMatch(e.Author?.Name)
|| IsMatch(e.Description)
|| IsMatch(e.Footer?.Text)
|| e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
|| message.Embeds.Any(e =>
IsMatch(e.Title)
|| IsMatch(e.Author?.Name)
|| IsMatch(e.Description)
|| IsMatch(e.Footer?.Text)
|| e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
);
}
@@ -17,15 +17,13 @@ internal class HasMessageFilter(MessageContentMatchKind kind) : MessageFilter
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
MessageContentMatchKind.Pin => message.IsPinned,
MessageContentMatchKind.Invite
=> MarkdownParser
.ExtractLinks(message.Content)
.Select(l => l.Url)
.Select(Invite.TryGetCodeFromUrl)
.Any(c => !string.IsNullOrWhiteSpace(c)),
_
=> throw new InvalidOperationException(
$"Unknown message content match kind '{kind}'."
)
MessageContentMatchKind.Invite => MarkdownParser
.ExtractLinks(message.Content)
.Select(l => l.Url)
.Select(Invite.TryGetCodeFromUrl)
.Any(c => !string.IsNullOrWhiteSpace(c)),
_ => throw new InvalidOperationException(
$"Unknown message content match kind '{kind}'."
),
};
}
@@ -7,11 +7,10 @@ namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class MentionsMessageFilter(string value) : MessageFilter
{
public override bool IsMatch(Message message) =>
message.MentionedUsers.Any(
user =>
string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
message.MentionedUsers.Any(user =>
string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
);
}
@@ -9,5 +9,5 @@ internal enum MessageContentMatchKind
Image,
Sound,
Pin,
Invite
Invite,
}
@@ -30,8 +30,8 @@ internal static class FilterGrammar
.OneOf(QuotedString, UnquotedString)
.Named("text string");
private static readonly TextParser<MessageFilter> ContainsFilter = String.Select(
v => (MessageFilter)new ContainsMessageFilter(v)
private static readonly TextParser<MessageFilter> ContainsFilter = String.Select(v =>
(MessageFilter)new ContainsMessageFilter(v)
);
private static readonly TextParser<MessageFilter> FromFilter = Span.EqualToIgnoreCase("from:")
@@ -125,7 +125,7 @@ internal static class FilterGrammar
op switch
{
'|' => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.Or),
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And)
_ => new BinaryExpressionMessageFilter(left, right, BinaryExpressionKind.And),
}
);
@@ -7,10 +7,9 @@ namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class ReactionMessageFilter(string value) : MessageFilter
{
public override bool IsMatch(Message message) =>
message.Reactions.Any(
r =>
string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
message.Reactions.Any(r =>
string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
);
}
@@ -5,7 +5,6 @@ using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
@@ -34,58 +33,51 @@ internal partial class HtmlMarkdownVisitor(
{
var (openingTag, closingTag) = formatting.Kind switch
{
FormattingKind.Bold
=> (
// lang=html
"<strong>",
// lang=html
"</strong>"
),
FormattingKind.Bold => (
// lang=html
"<strong>",
// lang=html
"</strong>"
),
FormattingKind.Italic
=> (
// lang=html
"<em>",
// lang=html
"</em>"
),
FormattingKind.Italic => (
// lang=html
"<em>",
// lang=html
"</em>"
),
FormattingKind.Underline
=> (
// lang=html
"<u>",
// lang=html
"</u>"
),
FormattingKind.Underline => (
// lang=html
"<u>",
// lang=html
"</u>"
),
FormattingKind.Strikethrough
=> (
// lang=html
"<s>",
// lang=html
"</s>"
),
FormattingKind.Strikethrough => (
// lang=html
"<s>",
// lang=html
"</s>"
),
FormattingKind.Spoiler
=> (
// lang=html
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
// lang=html
"""</span>"""
),
FormattingKind.Spoiler => (
// lang=html
"""<span class="chatlog__markdown-spoiler chatlog__markdown-spoiler--hidden" onclick="showSpoiler(event, this)">""",
// lang=html
"""</span>"""
),
FormattingKind.Quote
=> (
// lang=html
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
// lang=html
"""</div></div>"""
),
FormattingKind.Quote => (
// lang=html
"""<div class="chatlog__markdown-quote"><div class="chatlog__markdown-quote-border"></div><div class="chatlog__markdown-quote-content">""",
// lang=html
"""</div></div>"""
),
_
=> throw new InvalidOperationException(
$"Unknown formatting kind '{formatting.Kind}'."
)
_ => throw new InvalidOperationException(
$"Unknown formatting kind '{formatting.Kind}'."
),
};
buffer.Append(openingTag);
@@ -155,7 +147,9 @@ internal partial class HtmlMarkdownVisitor(
buffer.Append(
// lang=html
$"""
<code class="chatlog__markdown-pre chatlog__markdown-pre--inline">{HtmlEncode(inlineCodeBlock.Code)}</code>
<code class="chatlog__markdown-pre chatlog__markdown-pre--inline">{HtmlEncode(
inlineCodeBlock.Code
)}</code>
"""
);
@@ -174,7 +168,9 @@ internal partial class HtmlMarkdownVisitor(
buffer.Append(
// lang=html
$"""
<code class="chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightClass}">{HtmlEncode(multiLineCodeBlock.Code)}</code>
<code class="chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightClass}">{HtmlEncode(
multiLineCodeBlock.Code
)}</code>
"""
);
@@ -213,7 +209,6 @@ internal partial class HtmlMarkdownVisitor(
CancellationToken cancellationToken = default
)
{
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = isJumbo ? "chatlog__emoji--large" : "";
buffer.Append(
@@ -224,7 +219,7 @@ internal partial class HtmlMarkdownVisitor(
class="chatlog__emoji {jumboClass}"
alt="{emoji.Name}"
title="{emoji.Code}"
src="{await context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}">
src="{await context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)}">
"""
);
}
@@ -267,7 +262,9 @@ internal partial class HtmlMarkdownVisitor(
buffer.Append(
// lang=html
$"""
<span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(displayName)}</span>
<span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(
displayName
)}</span>
"""
);
}
@@ -292,8 +289,12 @@ internal partial class HtmlMarkdownVisitor(
var style = color is not null
? $"""
color: rgb({color.Value.R}, {color.Value.G}, {color.Value.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color.Value.B}, 0.1);
"""
color: rgb({color.Value.R}, {color.Value.G}, {color
.Value
.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color
.Value
.B}, 0.1);
"""
: null;
buffer.Append(
@@ -321,7 +322,9 @@ internal partial class HtmlMarkdownVisitor(
buffer.Append(
// lang=html
$"""
<span class="chatlog__markdown-timestamp" title="{HtmlEncode(formattedLong)}">{HtmlEncode(formatted)}</span>
<span class="chatlog__markdown-timestamp" title="{HtmlEncode(
formattedLong
)}">{HtmlEncode(formatted)}</span>
"""
);
@@ -344,10 +347,8 @@ internal partial class HtmlMarkdownVisitor
var isJumbo =
isJumboAllowed
&& nodes.All(
n =>
n is EmojiNode
|| n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)
&& nodes.All(n =>
n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)
);
var buffer = new StringBuilder();
@@ -15,7 +15,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
private readonly TextWriter _writer = new StreamWriter(stream);
private readonly HtmlMinifier _minifier = new();
private readonly List<Message> _messageGroup = new();
private readonly List<Message> _messageGroup = [];
private bool CanJoinGroup(Message message)
{
@@ -90,7 +90,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
await new MessageGroupTemplate
{
Context = Context,
Messages = messages
Messages = messages,
}.RenderAsync(cancellationToken)
)
);
@@ -131,7 +131,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
await new PostambleTemplate
{
Context = Context,
MessagesWritten = MessagesWritten
MessagesWritten = MessagesWritten,
}.RenderAsync(cancellationToken)
)
);
@@ -1,12 +1,14 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Writing;
@@ -15,19 +17,18 @@ namespace DiscordChatExporter.Core.Exporting;
internal class JsonMessageWriter(Stream stream, ExportContext context)
: MessageWriter(stream, context)
{
private readonly Utf8JsonWriter _writer =
new(
stream,
new JsonWriterOptions
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = true,
// Validation errors may mask actual failures
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
SkipValidation = true
}
);
private readonly Utf8JsonWriter _writer = new(
stream,
new JsonWriterOptions
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = true,
// Validation errors may mask actual failures
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
SkipValidation = true,
}
);
private async ValueTask<string> FormatMarkdownAsync(
string markdown,
@@ -37,22 +38,31 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
? await PlainTextMarkdownVisitor.FormatAsync(Context, markdown, cancellationToken)
: markdown;
private async ValueTask WriteUserAsync(User user, CancellationToken cancellationToken = default)
private async ValueTask WriteUserAsync(
User user,
bool includeRoles = true,
CancellationToken cancellationToken = default
)
{
_writer.WriteStartObject();
_writer.WriteString("id", user.Id.ToString());
_writer.WriteString("name", user.Name);
_writer.WriteString("discriminator", user.DiscriminatorFormatted);
_writer.WriteString(
"nickname",
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
);
_writer.WriteString("color", Context.TryGetUserColor(user.Id)?.ToHex());
_writer.WriteBoolean("isBot", user.IsBot);
_writer.WritePropertyName("roles");
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
if (includeRoles)
{
_writer.WritePropertyName("roles");
await WriteRolesAsync(Context.GetUserRoles(user.Id), cancellationToken);
}
_writer.WriteString(
"avatarUrl",
@@ -66,6 +76,26 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
await _writer.FlushAsync(cancellationToken);
}
private async ValueTask WriteEmojiAsync(
Emoji emoji,
CancellationToken cancellationToken = default
)
{
_writer.WriteStartObject();
_writer.WriteString("id", emoji.Id.ToString());
_writer.WriteString("name", emoji.Name);
_writer.WriteString("code", emoji.Code);
_writer.WriteBoolean("isAnimated", emoji.IsAnimated);
_writer.WriteString(
"imageUrl",
await Context.ResolveAssetUrlAsync(emoji.ImageUrl, cancellationToken)
);
_writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken);
}
private async ValueTask WriteRolesAsync(
IReadOnlyList<Role> roles,
CancellationToken cancellationToken = default
@@ -273,6 +303,26 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteEndArray();
// Inline emoji
_writer.WriteStartArray("inlineEmojis");
if (!string.IsNullOrWhiteSpace(embed.Description))
{
foreach (
var emoji in MarkdownParser
.ExtractEmojis(embed.Description)
.DistinctBy(e => e.Name, StringComparer.Ordinal)
)
{
await WriteEmojiAsync(
new Emoji(emoji.Id, emoji.Name, emoji.IsAnimated),
cancellationToken
);
}
}
_writer.WriteEndArray();
_writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken);
}
@@ -373,7 +423,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
// Author
_writer.WritePropertyName("author");
await WriteUserAsync(message.Author, cancellationToken);
await WriteUserAsync(message.Author, true, cancellationToken);
// Attachments
_writer.WriteStartArray("attachments");
@@ -431,20 +481,14 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteStartObject();
// Emoji
_writer.WriteStartObject("emoji");
_writer.WriteString("id", reaction.Emoji.Id.ToString());
_writer.WriteString("name", reaction.Emoji.Name);
_writer.WriteString("code", reaction.Emoji.Code);
_writer.WriteBoolean("isAnimated", reaction.Emoji.IsAnimated);
_writer.WriteString(
"imageUrl",
await Context.ResolveAssetUrlAsync(reaction.Emoji.ImageUrl, cancellationToken)
);
_writer.WriteEndObject();
_writer.WritePropertyName("emoji");
await WriteEmojiAsync(reaction.Emoji, cancellationToken);
_writer.WriteNumber("count", reaction.Count);
// Reaction authors
_writer.WriteStartArray("users");
await foreach (
var user in Context.Discord.GetMessageReactionsAsync(
Context.Request.Channel.Id,
@@ -454,28 +498,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
)
)
{
_writer.WriteStartObject();
// Write limited user information without color and roles,
// so we can avoid fetching guild member information for each user.
_writer.WriteString("id", user.Id.ToString());
_writer.WriteString("name", user.Name);
_writer.WriteString("discriminator", user.DiscriminatorFormatted);
_writer.WriteString(
"nickname",
Context.TryGetMember(user.Id)?.DisplayName ?? user.DisplayName
);
_writer.WriteBoolean("isBot", user.IsBot);
_writer.WriteString(
"avatarUrl",
await Context.ResolveAssetUrlAsync(
Context.TryGetMember(user.Id)?.AvatarUrl ?? user.AvatarUrl,
cancellationToken
)
);
_writer.WriteEndObject();
await WriteUserAsync(user, false, cancellationToken);
}
_writer.WriteEndArray();
@@ -487,9 +510,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
// Mentions
_writer.WriteStartArray("mentions");
foreach (var user in message.MentionedUsers)
await WriteUserAsync(user, cancellationToken);
await WriteUserAsync(user, true, cancellationToken);
_writer.WriteEndArray();
@@ -512,11 +534,28 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteString("name", message.Interaction.Name);
_writer.WritePropertyName("user");
await WriteUserAsync(message.Interaction.User, cancellationToken);
await WriteUserAsync(message.Interaction.User, true, cancellationToken);
_writer.WriteEndObject();
}
// Inline emoji
_writer.WriteStartArray("inlineEmojis");
foreach (
var emoji in MarkdownParser
.ExtractEmojis(message.Content)
.DistinctBy(e => e.Name, StringComparer.Ordinal)
)
{
await WriteEmojiAsync(
new Emoji(emoji.Id, emoji.Name, emoji.IsAnimated),
cancellationToken
);
}
_writer.WriteEndArray();
_writer.WriteEndObject();
await _writer.FlushAsync(cancellationToken);
}
@@ -100,13 +100,15 @@ internal partial class MessageExporter
ExportFormat.PlainText => new PlainTextMessageWriter(File.Create(filePath), context),
ExportFormat.Csv => new CsvMessageWriter(File.Create(filePath), context),
ExportFormat.HtmlDark => new HtmlMessageWriter(File.Create(filePath), context, "Dark"),
ExportFormat.HtmlLight
=> new HtmlMessageWriter(File.Create(filePath), context, "Light"),
ExportFormat.HtmlLight => new HtmlMessageWriter(
File.Create(filePath),
context,
"Light"
),
ExportFormat.Json => new JsonMessageWriter(File.Create(filePath), context),
_
=> throw new ArgumentOutOfRangeException(
nameof(format),
$"Unknown export format '{format}'."
)
_ => throw new ArgumentOutOfRangeException(
nameof(format),
$"Unknown export format '{format}'."
),
};
}
@@ -448,7 +448,10 @@
</div>
}
// Generic video embed
else if (embed.Kind == EmbedKind.Video && !string.IsNullOrWhiteSpace(embed.Url))
else if (embed.Kind == EmbedKind.Video
&& !string.IsNullOrWhiteSpace(embed.Url)
// Twitch clips cannot be embedded in local HTML files
&& embed.TryGetTwitchClip() is null)
{
var embedVideoUrl =
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
@@ -37,7 +37,7 @@ public partial class PartitionLimit
"M" => 1_000_000,
"K" => 1_000,
"" => 1,
_ => -1
_ => -1,
};
if (magnitude < 0)
@@ -10,20 +10,18 @@ internal static class PlainTextMessageExtensions
public static string GetFallbackContent(this Message message) =>
message.Kind switch
{
MessageKind.RecipientAdd
=> message.MentionedUsers.Any()
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
: "Added a recipient.",
MessageKind.RecipientAdd => message.MentionedUsers.Any()
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
: "Added a recipient.",
MessageKind.RecipientRemove
=> message.MentionedUsers.Any()
? message.Author.Id == message.MentionedUsers.First().Id
? "Left the group."
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
: "Removed a recipient.",
MessageKind.RecipientRemove => message.MentionedUsers.Any()
? message.Author.Id == message.MentionedUsers.First().Id
? "Left the group."
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
: "Removed a recipient.",
MessageKind.Call
=> $"Started a call that lasted {
MessageKind.Call =>
$"Started a call that lasted {
message
.CallEndedTimestamp?
.Pipe(t => t - message.Timestamp)
@@ -31,16 +29,15 @@ internal static class PlainTextMessageExtensions
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
} minutes.",
MessageKind.ChannelNameChange
=> !string.IsNullOrWhiteSpace(message.Content)
? $"Changed the channel name: {message.Content}"
: "Changed the channel name.",
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
? $"Changed the channel name: {message.Content}"
: "Changed the channel name.",
MessageKind.ChannelIconChange => "Changed the channel icon.",
MessageKind.ChannelPinnedMessage => "Pinned a message.",
MessageKind.ThreadCreated => "Started a thread.",
MessageKind.GuildMemberJoin => "Joined the server.",
_ => message.Content
_ => message.Content,
};
}
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting;
@@ -172,18 +173,21 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
await _writer.WriteLineAsync("{Reactions}");
foreach (var reaction in reactions)
foreach (var (reaction, i) in reactions.WithIndex())
{
cancellationToken.ThrowIfCancellationRequested();
if (i > 0)
{
await _writer.WriteAsync(' ');
}
await _writer.WriteAsync(reaction.Emoji.Name);
if (reaction.Count > 1)
{
await _writer.WriteAsync($" ({reaction.Count})");
}
await _writer.WriteAsync(' ');
}
await _writer.WriteLineAsync();
@@ -702,6 +702,10 @@
.chatlog__embed-spotify {
border: 0;
}
.chatlog__embed-twitch {
border: 0;
}
.chatlog__embed-youtube-container {
margin-top: 0.6rem;
+11 -5
View File
@@ -1,5 +1,5 @@
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Markdown;
@@ -11,11 +11,17 @@ internal record EmojiNode(
bool IsAnimated
) : MarkdownNode
{
public bool IsCustomEmoji => Id is not null;
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
public string Code => IsCustomEmoji ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
// This coupling is unsound from the domain-design perspective, but it helps us reuse
// some code for now. We can refactor this later, if the coupling becomes a problem.
private readonly Emoji _emoji = new(Id, Name, IsAnimated);
public EmojiNode(string name)
: this(null, name, false) { }
public bool IsCustomEmoji => _emoji.IsCustomEmoji;
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
public string Code => _emoji.Code;
public string ImageUrl => _emoji.ImageUrl;
}
@@ -7,5 +7,5 @@ internal enum FormattingKind
Underline,
Strikethrough,
Spoiler,
Quote
Quote,
}
@@ -6,5 +6,5 @@ internal enum MentionKind
Here,
User,
Channel,
Role
Role,
}
@@ -2,20 +2,19 @@
namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class AggregateMatcher<T>(IReadOnlyList<IMatcher<T>> matchers) : IMatcher<T>
internal class AggregateMatcher<TContext, TValue>(
params IReadOnlyList<IMatcher<TContext, TValue>> matchers
) : IMatcher<TContext, TValue>
{
public AggregateMatcher(params IMatcher<T>[] matchers)
: this((IReadOnlyList<IMatcher<T>>)matchers) { }
public ParsedMatch<T>? TryMatch(StringSegment segment)
public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
{
ParsedMatch<T>? earliestMatch = null;
ParsedMatch<TValue>? earliestMatch = null;
// Try to match the input with each matcher and get the match with the lowest start index
foreach (var matcher in matchers)
{
// Try to match
var match = matcher.TryMatch(segment);
var match = matcher.TryMatch(context, segment);
// If there's no match - continue
if (match is null)
@@ -26,7 +25,9 @@ internal class AggregateMatcher<T>(IReadOnlyList<IMatcher<T>> matchers) : IMatch
earliestMatch is null
|| match.Segment.StartIndex < earliestMatch.Segment.StartIndex
)
{
earliestMatch = match;
}
// If the earliest match starts at the very beginning - break,
// because it's impossible to find a match earlier than that
@@ -3,17 +3,18 @@ using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown.Parsing;
internal interface IMatcher<T>
internal interface IMatcher<in TContext, TValue>
{
ParsedMatch<T>? TryMatch(StringSegment segment);
ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment);
}
internal static class MatcherExtensions
{
public static IEnumerable<ParsedMatch<T>> MatchAll<T>(
this IMatcher<T> matcher,
public static IEnumerable<ParsedMatch<TValue>> MatchAll<TContext, TValue>(
this IMatcher<TContext, TValue> matcher,
TContext context,
StringSegment segment,
Func<StringSegment, T> transformFallback
Func<TContext, StringSegment, TValue> transformFallback
)
{
// Loop through segments divided by individual matches
@@ -22,6 +23,7 @@ internal static class MatcherExtensions
{
// Find a match within this segment
var match = matcher.TryMatch(
context,
segment.Relocate(currentIndex, segment.EndIndex - currentIndex)
);
@@ -36,9 +38,9 @@ internal static class MatcherExtensions
match.Segment.StartIndex - currentIndex
);
yield return new ParsedMatch<T>(
yield return new ParsedMatch<TValue>(
fallbackSegment,
transformFallback(fallbackSegment)
transformFallback(context, fallbackSegment)
);
}
@@ -53,7 +55,10 @@ internal static class MatcherExtensions
{
var fallbackSegment = segment.Relocate(currentIndex, segment.EndIndex - currentIndex);
yield return new ParsedMatch<T>(fallbackSegment, transformFallback(fallbackSegment));
yield return new ParsedMatch<TValue>(
fallbackSegment,
transformFallback(context, fallbackSegment)
);
}
}
}
@@ -0,0 +1,3 @@
namespace DiscordChatExporter.Core.Markdown.Parsing;
internal readonly record struct MarkdownContext(int Depth = 0);
@@ -23,15 +23,15 @@ internal static partial class MarkdownParser
/* Formatting */
private static readonly IMatcher<MarkdownNode> BoldFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> BoldFormattingNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// There must be exactly two closing asterisks.
new Regex(@"\*\*(.+?)\*\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Bold, Parse(s.Relocate(m.Groups[1])))
(c, s, m) => new FormattingNode(FormattingKind.Bold, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> ItalicFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> ItalicFormattingNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// There must be exactly one closing asterisk.
// Opening asterisk must not be followed by whitespace.
// Closing asterisk must not be preceded by whitespace.
@@ -39,156 +39,174 @@ internal static partial class MarkdownParser
@"\*(?!\s)(.+?)(?<!\s|\*)\*(?!\*)",
DefaultRegexOptions | RegexOptions.Singleline
),
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
(c, s, m) =>
new FormattingNode(FormattingKind.Italic, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> ItalicBoldFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
// There must be exactly three closing asterisks.
new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) =>
new FormattingNode(
FormattingKind.Italic,
Parse(s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher)
)
);
private static readonly IMatcher<MarkdownNode> ItalicAltFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
// Closing underscore must not be followed by a word character.
new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Italic, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> UnderlineFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
// There must be exactly two closing underscores.
new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Underline, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> ItalicUnderlineFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
// There must be exactly three closing underscores.
new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) =>
new FormattingNode(
FormattingKind.Italic,
Parse(s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher)
)
);
private static readonly IMatcher<MarkdownNode> StrikethroughFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) =>
new FormattingNode(FormattingKind.Strikethrough, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> SpoilerFormattingNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Spoiler, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> SingleLineQuoteNodeMatcher =
new RegexMatcher<MarkdownNode>(
// Include the linebreak in the content so that the lines are preserved in quotes.
new Regex(@"^>\s(.+\n?)", DefaultRegexOptions),
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> RepeatedSingleLineQuoteNodeMatcher =
new RegexMatcher<MarkdownNode>(
// Include the linebreaks in the content, so that the lines are preserved in quotes.
// Empty content is allowed within quotes.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1115
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions),
(s, m) =>
new FormattingNode(
FormattingKind.Quote,
m.Groups[1].Captures.SelectMany(c => Parse(s.Relocate(c))).ToArray()
)
);
private static readonly IMatcher<MarkdownNode> MultiLineQuoteNodeMatcher =
new RegexMatcher<MarkdownNode>(
new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
(s, m) => new FormattingNode(FormattingKind.Quote, Parse(s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> HeadingNodeMatcher =
new RegexMatcher<MarkdownNode>(
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions),
(s, m) => new HeadingNode(m.Groups[1].Length, Parse(s.Relocate(m.Groups[2])))
);
private static readonly IMatcher<MarkdownNode> ListNodeMatcher = new RegexMatcher<MarkdownNode>(
// Can be preceded by whitespace, which specifies the list's nesting level.
// Following lines that start with (level+1) whitespace are considered part of the list item.
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions),
(s, m) =>
new ListNode(
m.Groups[2].Captures.Select(c => new ListItemNode(Parse(s.Relocate(c)))).ToArray()
private static readonly IMatcher<
MarkdownContext,
MarkdownNode
> ItalicBoldFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
// There must be exactly three closing asterisks.
new Regex(@"\*(\*\*.+?\*\*)\*(?!\*)", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(
FormattingKind.Italic,
Parse(c, s.Relocate(m.Groups[1]), BoldFormattingNodeMatcher)
)
);
/* Code blocks */
private static readonly IMatcher<MarkdownNode> InlineCodeBlockNodeMatcher =
new RegexMatcher<MarkdownNode>(
// One or two backticks are allowed, but they must match on both sides.
new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline),
(_, m) => new InlineCodeBlockNode(m.Groups[2].Value)
private static readonly IMatcher<MarkdownContext, MarkdownNode> ItalicAltFormattingNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Closing underscore must not be followed by a word character.
new Regex(@"_(.+?)_(?!\w)", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(FormattingKind.Italic, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownNode> MultiLineCodeBlockNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> UnderlineFormattingNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// There must be exactly two closing underscores.
new Regex(@"__(.+?)__(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(FormattingKind.Underline, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<
MarkdownContext,
MarkdownNode
> ItalicUnderlineFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
// There must be exactly three closing underscores.
new Regex(@"_(__.+?__)_(?!_)", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(
FormattingKind.Italic,
Parse(c, s.Relocate(m.Groups[1]), UnderlineFormattingNodeMatcher)
)
);
private static readonly IMatcher<
MarkdownContext,
MarkdownNode
> StrikethroughFormattingNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex(@"~~(.+?)~~", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(FormattingKind.Strikethrough, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> SpoilerFormattingNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex(@"\|\|(.+?)\|\|", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) =>
new FormattingNode(FormattingKind.Spoiler, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> SingleLineQuoteNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Include the linebreak in the content so that the lines are preserved in quotes.
new Regex(@"^>\s(.+\n?)", DefaultRegexOptions),
(c, s, m) => new FormattingNode(FormattingKind.Quote, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<
MarkdownContext,
MarkdownNode
> RepeatedSingleLineQuoteNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
// Include the linebreaks in the content, so that the lines are preserved in quotes.
// Empty content is allowed within quotes.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1115
new Regex(@"(?:^>\s(.*\n?)){2,}", DefaultRegexOptions),
(c, s, m) =>
new FormattingNode(
FormattingKind.Quote,
m.Groups[1].Captures.SelectMany(r => Parse(c, s.Relocate(r))).ToArray()
)
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> MultiLineQuoteNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex(@"^>>>\s(.+)", DefaultRegexOptions | RegexOptions.Singleline),
(c, s, m) => new FormattingNode(FormattingKind.Quote, Parse(c, s.Relocate(m.Groups[1])))
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> HeadingNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\#{1,3})\s(.+)\n", DefaultRegexOptions),
(c, s, m) => new HeadingNode(m.Groups[1].Length, Parse(c, s.Relocate(m.Groups[2])))
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> ListNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Can be preceded by whitespace, which specifies the list's nesting level.
// Following lines that start with (level+1) whitespace are considered part of the list item.
// Consume the linebreak so that it's not attached to following nodes.
new Regex(@"^(\s*)(?:[\-\*]\s(.+(?:\n\s\1.*)*)?\n?)+", DefaultRegexOptions),
(c, s, m) =>
new ListNode(
m.Groups[2]
.Captures.Select(x => new ListItemNode(Parse(c, s.Relocate(x))))
.ToArray()
)
);
/* Code blocks */
private static readonly IMatcher<MarkdownContext, MarkdownNode> InlineCodeBlockNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// One or two backticks are allowed, but they must match on both sides.
new Regex(@"(`{1,2})([^`]+)\1", DefaultRegexOptions | RegexOptions.Singleline),
(_, _, m) => new InlineCodeBlockNode(m.Groups[2].Value)
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> MultiLineCodeBlockNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Language identifier is one word immediately after opening backticks, followed immediately by a linebreak.
// Blank lines at the beginning and at the end of content are trimmed.
new Regex(@"```(?:(\w*)\n)?(.+?)```", DefaultRegexOptions | RegexOptions.Singleline),
(_, m) =>
(_, _, m) =>
new MultiLineCodeBlockNode(m.Groups[1].Value, m.Groups[2].Value.Trim('\r', '\n'))
);
/* Mentions */
private static readonly IMatcher<MarkdownNode> EveryoneMentionNodeMatcher =
new StringMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> EveryoneMentionNodeMatcher =
new StringMatcher<MarkdownContext, MarkdownNode>(
"@everyone",
_ => new MentionNode(null, MentionKind.Everyone)
(_, _) => new MentionNode(null, MentionKind.Everyone)
);
private static readonly IMatcher<MarkdownNode> HereMentionNodeMatcher =
new StringMatcher<MarkdownNode>("@here", _ => new MentionNode(null, MentionKind.Here));
private static readonly IMatcher<MarkdownContext, MarkdownNode> HereMentionNodeMatcher =
new StringMatcher<MarkdownContext, MarkdownNode>(
"@here",
(_, _) => new MentionNode(null, MentionKind.Here)
);
private static readonly IMatcher<MarkdownNode> UserMentionNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> UserMentionNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <@123456> or <@!123456>
new Regex(@"<@!?(\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User)
(_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.User)
);
private static readonly IMatcher<MarkdownNode> ChannelMentionNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> ChannelMentionNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <#123456>
new Regex(@"<\#!?(\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel)
(_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Channel)
);
private static readonly IMatcher<MarkdownNode> RoleMentionNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> RoleMentionNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <@&123456>
new Regex(@"<@&(\d+)>", DefaultRegexOptions),
(_, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role)
(_, _, m) => new MentionNode(Snowflake.TryParse(m.Groups[1].Value), MentionKind.Role)
);
/* Emoji */
private static readonly IMatcher<MarkdownNode> StandardEmojiNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex(
@"("
+
@@ -239,21 +257,21 @@ internal static partial class MarkdownParser
+ @")",
DefaultRegexOptions
),
(_, m) => new EmojiNode(m.Groups[1].Value)
(_, _, m) => new EmojiNode(m.Groups[1].Value)
);
private static readonly IMatcher<MarkdownNode> CodedStandardEmojiNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> CodedStandardEmojiNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture :thinking:
new Regex(@":([\w_]+):", DefaultRegexOptions),
(_, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n))
(_, _, m) => EmojiIndex.TryGetName(m.Groups[1].Value)?.Pipe(n => new EmojiNode(n))
);
private static readonly IMatcher<MarkdownNode> CustomEmojiNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> CustomEmojiNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <:lul:123456> or <a:lul:123456>
new Regex(@"<(a)?:(.+?):(\d+?)>", DefaultRegexOptions),
(_, m) =>
(_, _, m) =>
new EmojiNode(
Snowflake.TryParse(m.Groups[3].Value),
m.Groups[2].Value,
@@ -263,70 +281,72 @@ internal static partial class MarkdownParser
/* Links */
private static readonly IMatcher<MarkdownNode> AutoLinkNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> AutoLinkNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Any non-whitespace character after http:// or https://
// until the last punctuation character or whitespace.
new Regex(@"(https?://\S*[^\.,:;""'\s])", DefaultRegexOptions),
(_, m) => new LinkNode(m.Groups[1].Value)
(_, _, m) => new LinkNode(m.Groups[1].Value)
);
private static readonly IMatcher<MarkdownNode> HiddenLinkNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> HiddenLinkNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Same as auto link but also surrounded by angular brackets
new Regex(@"<(https?://\S*[^\.,:;""'\s])>", DefaultRegexOptions),
(_, m) => new LinkNode(m.Groups[1].Value)
(_, _, m) => new LinkNode(m.Groups[1].Value)
);
private static readonly IMatcher<MarkdownNode> MaskedLinkNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> MaskedLinkNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture [title](link)
new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions),
(s, m) => new LinkNode(m.Groups[2].Value, Parse(s.Relocate(m.Groups[1])))
(c, s, m) => new LinkNode(m.Groups[2].Value, Parse(c, s.Relocate(m.Groups[1])))
);
/* Text */
private static readonly IMatcher<MarkdownNode> ShrugTextNodeMatcher =
new StringMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> ShrugTextNodeMatcher =
new StringMatcher<MarkdownContext, MarkdownNode>(
// Capture the shrug kaomoji.
// This escapes it from matching for formatting.
@"¯\_(ツ)_/¯",
s => new TextNode(s.ToString())
(s, _) => new TextNode(s.ToString())
);
private static readonly IMatcher<MarkdownNode> IgnoredEmojiTextNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture some specific emoji that don't get rendered.
// This escapes them from matching for emoji.
new Regex(@"([\u26A7\u2640\u2642\u2695\u267E\u00A9\u00AE\u2122])", DefaultRegexOptions),
(_, m) => new TextNode(m.Groups[1].Value)
(_, _, m) => new TextNode(m.Groups[1].Value)
);
private static readonly IMatcher<MarkdownNode> EscapedSymbolTextNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> EscapedSymbolTextNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture any "symbol/other" character or surrogate pair preceded by a backslash.
// This escapes them from matching for emoji.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/230
new Regex(@"\\(\p{So}|\p{Cs}{2})", DefaultRegexOptions),
(_, m) => new TextNode(m.Groups[1].Value)
(_, _, m) => new TextNode(m.Groups[1].Value)
);
private static readonly IMatcher<MarkdownNode> EscapedCharacterTextNodeMatcher =
new RegexMatcher<MarkdownNode>(
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
// This escapes them from matching for formatting or other tokens.
new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions),
(_, m) => new TextNode(m.Groups[1].Value)
);
private static readonly IMatcher<
MarkdownContext,
MarkdownNode
> EscapedCharacterTextNodeMatcher = new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture any non-whitespace, non latin alphanumeric character preceded by a backslash.
// This escapes them from matching for formatting or other tokens.
new Regex(@"\\([^a-zA-Z0-9\s])", DefaultRegexOptions),
(_, _, m) => new TextNode(m.Groups[1].Value)
);
/* Misc */
private static readonly IMatcher<MarkdownNode> TimestampNodeMatcher =
new RegexMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> TimestampNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture <t:12345678> or <t:12345678:R>
new Regex(@"<t:(-?\d+)(?::(\w))?>", DefaultRegexOptions),
(_, m) =>
(_, _, m) =>
{
try
{
@@ -357,10 +377,9 @@ internal static partial class MarkdownParser
null => null,
// Unknown format: throw an exception to consider this timestamp invalid
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1156
var f
=> throw new InvalidOperationException(
$"Unknown timestamp format '{f}'."
)
var f => throw new InvalidOperationException(
$"Unknown timestamp format '{f}'."
),
};
return new TimestampNode(instant, format);
@@ -382,50 +401,51 @@ internal static partial class MarkdownParser
);
// Matchers that have similar patterns are ordered from most specific to least specific
private static readonly IMatcher<MarkdownNode> NodeMatcher = new AggregateMatcher<MarkdownNode>(
// Escaped text
ShrugTextNodeMatcher,
IgnoredEmojiTextNodeMatcher,
EscapedSymbolTextNodeMatcher,
EscapedCharacterTextNodeMatcher,
// Formatting
ItalicBoldFormattingNodeMatcher,
ItalicUnderlineFormattingNodeMatcher,
BoldFormattingNodeMatcher,
ItalicFormattingNodeMatcher,
UnderlineFormattingNodeMatcher,
ItalicAltFormattingNodeMatcher,
StrikethroughFormattingNodeMatcher,
SpoilerFormattingNodeMatcher,
MultiLineQuoteNodeMatcher,
RepeatedSingleLineQuoteNodeMatcher,
SingleLineQuoteNodeMatcher,
HeadingNodeMatcher,
ListNodeMatcher,
// Code blocks
MultiLineCodeBlockNodeMatcher,
InlineCodeBlockNodeMatcher,
// Mentions
EveryoneMentionNodeMatcher,
HereMentionNodeMatcher,
UserMentionNodeMatcher,
ChannelMentionNodeMatcher,
RoleMentionNodeMatcher,
// Links
MaskedLinkNodeMatcher,
AutoLinkNodeMatcher,
HiddenLinkNodeMatcher,
// Emoji
StandardEmojiNodeMatcher,
CustomEmojiNodeMatcher,
CodedStandardEmojiNodeMatcher,
// Misc
TimestampNodeMatcher
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> NodeMatcher =
new AggregateMatcher<MarkdownContext, MarkdownNode>(
// Escaped text
ShrugTextNodeMatcher,
IgnoredEmojiTextNodeMatcher,
EscapedSymbolTextNodeMatcher,
EscapedCharacterTextNodeMatcher,
// Formatting
ItalicBoldFormattingNodeMatcher,
ItalicUnderlineFormattingNodeMatcher,
BoldFormattingNodeMatcher,
ItalicFormattingNodeMatcher,
UnderlineFormattingNodeMatcher,
ItalicAltFormattingNodeMatcher,
StrikethroughFormattingNodeMatcher,
SpoilerFormattingNodeMatcher,
MultiLineQuoteNodeMatcher,
RepeatedSingleLineQuoteNodeMatcher,
SingleLineQuoteNodeMatcher,
HeadingNodeMatcher,
ListNodeMatcher,
// Code blocks
MultiLineCodeBlockNodeMatcher,
InlineCodeBlockNodeMatcher,
// Mentions
EveryoneMentionNodeMatcher,
HereMentionNodeMatcher,
UserMentionNodeMatcher,
ChannelMentionNodeMatcher,
RoleMentionNodeMatcher,
// Links
MaskedLinkNodeMatcher,
AutoLinkNodeMatcher,
HiddenLinkNodeMatcher,
// Emoji
StandardEmojiNodeMatcher,
CustomEmojiNodeMatcher,
CodedStandardEmojiNodeMatcher,
// Misc
TimestampNodeMatcher
);
// Minimal set of matchers for non-multimedia formats (e.g. plain text)
private static readonly IMatcher<MarkdownNode> MinimalNodeMatcher =
new AggregateMatcher<MarkdownNode>(
private static readonly IMatcher<MarkdownContext, MarkdownNode> MinimalNodeMatcher =
new AggregateMatcher<MarkdownContext, MarkdownNode>(
// Mentions
EveryoneMentionNodeMatcher,
HereMentionNodeMatcher,
@@ -439,42 +459,75 @@ internal static partial class MarkdownParser
);
private static IReadOnlyList<MarkdownNode> Parse(
MarkdownContext context,
StringSegment segment,
IMatcher<MarkdownNode> matcher
) => matcher.MatchAll(segment, s => new TextNode(s.ToString())).Select(r => r.Value).ToArray();
IMatcher<MarkdownContext, MarkdownNode> matcher
)
{
// Limit recursion depth to a reasonable number to prevent
// stack overflow on messages with inadvertently deep nesting.
// Example: ********************************* (repeat ad nauseam)
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1214
if (context.Depth >= 32)
return [new TextNode(segment.ToString())];
return matcher
.MatchAll(
new MarkdownContext(context.Depth + 1),
segment,
(_, s) => new TextNode(s.ToString())
)
.Select(r => r.Value)
.ToArray();
}
}
internal static partial class MarkdownParser
{
private static IReadOnlyList<MarkdownNode> Parse(StringSegment segment) =>
Parse(segment, NodeMatcher);
public static IReadOnlyList<MarkdownNode> Parse(string markdown) =>
Parse(new StringSegment(markdown));
private static IReadOnlyList<MarkdownNode> ParseMinimal(StringSegment segment) =>
Parse(segment, MinimalNodeMatcher);
public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown) =>
ParseMinimal(new StringSegment(markdown));
private static void ExtractLinks(IEnumerable<MarkdownNode> nodes, ICollection<LinkNode> links)
private static void Extract<TNode>(
IEnumerable<MarkdownNode> nodes,
ICollection<TNode> extractedNodes
)
where TNode : MarkdownNode
{
foreach (var node in nodes)
{
if (node is LinkNode linkNode)
links.Add(linkNode);
if (node is TNode extractedNode)
extractedNodes.Add(extractedNode);
if (node is IContainerNode containerNode)
ExtractLinks(containerNode.Children, links);
Extract(containerNode.Children, extractedNodes);
}
}
public static IReadOnlyList<LinkNode> ExtractLinks(string markdown)
public static IReadOnlyList<TNode> Extract<TNode>(string markdown)
where TNode : MarkdownNode
{
var links = new List<LinkNode>();
ExtractLinks(Parse(markdown), links);
var extractedNodes = new List<TNode>();
Extract(Parse(markdown), extractedNodes);
return links;
return extractedNodes;
}
public static IReadOnlyList<LinkNode> ExtractLinks(string markdown) =>
Extract<LinkNode>(markdown);
public static IReadOnlyList<EmojiNode> ExtractEmojis(string markdown) =>
Extract<EmojiNode>(markdown);
private static IReadOnlyList<MarkdownNode> Parse(
MarkdownContext context,
StringSegment segment
) => Parse(context, segment, NodeMatcher);
public static IReadOnlyList<MarkdownNode> Parse(string markdown) =>
Parse(new MarkdownContext(), new StringSegment(markdown));
private static IReadOnlyList<MarkdownNode> ParseMinimal(
MarkdownContext context,
StringSegment segment
) => Parse(context, segment, MinimalNodeMatcher);
public static IReadOnlyList<MarkdownNode> ParseMinimal(string markdown) =>
ParseMinimal(new MarkdownContext(), new StringSegment(markdown));
}
@@ -3,9 +3,12 @@ using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class RegexMatcher<T>(Regex regex, Func<StringSegment, Match, T?> transform) : IMatcher<T>
internal class RegexMatcher<TContext, TValue>(
Regex regex,
Func<TContext, StringSegment, Match, TValue?> transform
) : IMatcher<TContext, TValue>
{
public ParsedMatch<T>? TryMatch(StringSegment segment)
public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
{
var match = regex.Match(segment.Source, segment.StartIndex, segment.Length);
if (!match.Success)
@@ -20,8 +23,8 @@ internal class RegexMatcher<T>(Regex regex, Func<StringSegment, Match, T?> trans
return null;
var segmentMatch = segment.Relocate(match);
var value = transform(segmentMatch, match);
var value = transform(context, segmentMatch, match);
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
return value is not null ? new ParsedMatch<TValue>(segmentMatch, value) : null;
}
}
@@ -2,16 +2,16 @@
namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class StringMatcher<T>(
internal class StringMatcher<TContext, TValue>(
string needle,
StringComparison comparison,
Func<StringSegment, T?> transform
) : IMatcher<T>
Func<TContext, StringSegment, TValue?> transform
) : IMatcher<TContext, TValue>
{
public StringMatcher(string needle, Func<StringSegment, T> transform)
public StringMatcher(string needle, Func<TContext, StringSegment, TValue> transform)
: this(needle, StringComparison.Ordinal, transform) { }
public ParsedMatch<T>? TryMatch(StringSegment segment)
public ParsedMatch<TValue>? TryMatch(TContext context, StringSegment segment)
{
var index = segment.Source.IndexOf(needle, segment.StartIndex, segment.Length, comparison);
@@ -19,8 +19,8 @@ internal class StringMatcher<T>(
return null;
var segmentMatch = segment.Relocate(index, needle.Length);
var value = transform(segmentMatch);
var value = transform(context, segmentMatch);
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
return value is not null ? new ParsedMatch<TValue>(segmentMatch, value) : null;
}
}
+6 -7
View File
@@ -25,11 +25,10 @@ public static class Http
private static bool IsRetryableException(Exception exception) =>
exception
.GetSelfAndChildren()
.Any(
ex =>
ex is TimeoutException or SocketException or AuthenticationException
|| ex is HttpRequestException hrex
&& IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
.Any(ex =>
ex is TimeoutException or SocketException or AuthenticationException
|| ex is HttpRequestException hrex
&& IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
);
public static ResiliencePipeline ResiliencePipeline { get; } =
@@ -40,7 +39,7 @@ public static class Http
ShouldHandle = new PredicateBuilder().Handle<Exception>(IsRetryableException),
MaxRetryAttempts = 4,
BackoffType = DelayBackoffType.Exponential,
Delay = TimeSpan.FromSeconds(1)
Delay = TimeSpan.FromSeconds(1),
}
)
.Build();
@@ -69,7 +68,7 @@ public static class Http
return ValueTask.FromResult<TimeSpan?>(
TimeSpan.FromSeconds(Math.Pow(2, args.AttemptNumber) + 1)
);
}
},
}
)
.Build();
+6 -4
View File
@@ -1,6 +1,6 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
namespace DiscordChatExporter.Core.Utils;
@@ -8,7 +8,9 @@ namespace DiscordChatExporter.Core.Utils;
public static class PathEx
{
private static readonly HashSet<char> InvalidFileNameChars =
new(Path.GetInvalidFileNameChars());
[
.. Path.GetInvalidFileNameChars(),
];
public static string EscapeFileName(string path)
{
@@ -19,7 +21,7 @@ public static class PathEx
// File names cannot end with a dot on Windows
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
if (OperatingSystem.IsWindows())
{
while (buffer.Length > 0 && buffer[^1] == '.')
buffer.Remove(buffer.Length - 1, 1);
+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 readonly Dictionary<string, string?> _queryParameters =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string?> _queryParameters = new(
StringComparer.OrdinalIgnoreCase
);
public UrlBuilder SetPath(string path)
{
+154
View File
@@ -0,0 +1,154 @@
<Application
x:Class="DiscordChatExporter.Gui.App"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dialogHostAvalonia="clr-namespace:DialogHostAvalonia;assembly=DialogHost.Avalonia"
xmlns:framework="clr-namespace:DiscordChatExporter.Gui.Framework"
xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles"
xmlns:materialControls="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
ActualThemeVariantChanged="Application_OnActualThemeVariantChanged">
<Application.DataTemplates>
<framework:ViewManager />
</Application.DataTemplates>
<Application.Styles>
<!-- This theme is used as a stub to pre-load default resources, the actual colors are set through code -->
<materialStyles:MaterialTheme
BaseTheme="Light"
PrimaryColor="Grey"
SecondaryColor="DeepOrange" />
<materialIcons:MaterialIconStyles />
<dialogHostAvalonia:DialogHostStyles />
<!-- Combo box -->
<Style Selector="ComboBox">
<Setter Property="FontSize" Value="14" />
<Style Selector="^ /template/ Panel#PART_RootPanel">
<Setter Property="Height" Value="22" />
</Style>
<Style Selector="^ /template/ ToggleButton">
<Style Selector="^:checked, ^:unchecked">
<Setter Property="Margin" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Style Selector="^ ContentPresenter#contentPresenter">
<Setter Property="Margin" Value="12,8" />
</Style>
</Style>
</Style>
</Style>
<!-- Dialog host -->
<Style Selector="dialogHostAvalonia|DialogHost">
<Setter Property="DialogMargin" Value="0" />
</Style>
<Style Selector="dialogHostAvalonia|DialogOverlayPopupHost">
<Setter Property="Margin" Value="48" />
<Setter Property="Background" Value="{DynamicResource MaterialPaperBrush}" />
</Style>
<!-- Snack bar host -->
<Style Selector="materialControls|SnackbarHost">
<Setter Property="SnackbarHorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Style Selector="^ /template/ ItemsControl#PART_SnackbarHostItemsContainer materialControls|Card">
<Setter Property="Background" Value="{DynamicResource MaterialDarkBackgroundBrush}" />
<Setter Property="Foreground" Value="{DynamicResource MaterialDarkForegroundBrush}" />
</Style>
<Style Selector="^ /template/ ItemsControl#PART_SnackbarHostItemsContainer Button">
<Setter Property="Foreground" Value="{DynamicResource MaterialSecondaryMidBrush}" />
</Style>
</Style>
<!-- Progress bar -->
<Style Selector="ProgressBar">
<Setter Property="Minimum" Value="0" />
<Setter Property="Maximum" Value="1" />
<Setter Property="Foreground" Value="{DynamicResource MaterialSecondaryMidBrush}" />
<Setter Property="materialAssists:TransitionAssist.DisableTransitions" Value="True" />
<Style Selector="^:horizontal">
<Setter Property="MinHeight" Value="0" />
</Style>
</Style>
<!-- Slider -->
<Style Selector="Slider">
<Style Selector="^ /template/ ProgressBar#PART_ProgressLayer">
<Style Selector="^:horizontal">
<Style Selector="^ Panel#PART_InnerPanel">
<Setter Property="Height" Value="2" />
<Style Selector="^ Border#PART_InactiveState">
<Setter Property="Margin" Value="0" />
<Setter Property="Height" Value="2" />
</Style>
<Style Selector="^ Border#PART_Indicator">
<Setter Property="Margin" Value="0" />
</Style>
</Style>
</Style>
</Style>
<Style Selector="^ /template/ Track#PART_Track">
<Style Selector="^:horizontal">
<Setter Property="Margin" Value="4,0" />
</Style>
<Style Selector="^ Border#PART_HoverEffect">
<Setter Property="Width" Value="24" />
<Setter Property="Height" Value="24" />
</Style>
<Style Selector="^ Border#PART_ThumbGrip">
<Setter Property="Width" Value="12" />
<Setter Property="Height" Value="12" />
</Style>
</Style>
</Style>
<!-- Text box -->
<Style Selector="TextBox">
<Setter Property="FontSize" Value="14" />
</Style>
<!-- Toggle button -->
<Style Selector="ToggleButton">
<Setter Property="TextElement.FontWeight" Value="Medium" />
</Style>
<!-- Toggle switch -->
<Style Selector="ToggleSwitch">
<Setter Property="materialAssists:ToggleSwitchAssist.SwitchThumbOffBackground" Value="{DynamicResource ToggleBackgroundBrush}" />
</Style>
<!-- Tooltip -->
<Style Selector="ToolTip">
<Setter Property="TextElement.FontSize" Value="14" />
<Setter Property="TextElement.FontWeight" Value="Normal" />
<Setter Property="TextElement.FontStyle" Value="Normal" />
<Setter Property="TextElement.FontStretch" Value="Normal" />
</Style>
</Application.Styles>
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Default">
<SolidColorBrush x:Key="ToggleBackgroundBrush" Color="#FFFFFF" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="ToggleBackgroundBrush" Color="#8E8E8E" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+117
View File
@@ -0,0 +1,117 @@
using System;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Platform;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.Views;
using Material.Styles.Themes;
using Microsoft.Extensions.DependencyInjection;
namespace DiscordChatExporter.Gui;
public class App : Application, IDisposable
{
private readonly DisposableCollector _eventRoot = new();
private readonly ServiceProvider _services;
private readonly SettingsService _settingsService;
private readonly MainViewModel _mainViewModel;
public App()
{
var services = new ServiceCollection();
// Framework
services.AddSingleton<DialogManager>();
services.AddSingleton<SnackbarManager>();
services.AddSingleton<ViewManager>();
services.AddSingleton<ViewModelManager>();
// Services
services.AddSingleton<SettingsService>();
services.AddSingleton<UpdateService>();
// View models
services.AddTransient<MainViewModel>();
services.AddTransient<DashboardViewModel>();
services.AddTransient<ExportSetupViewModel>();
services.AddTransient<MessageBoxViewModel>();
services.AddTransient<SettingsViewModel>();
_services = services.BuildServiceProvider(true);
_settingsService = _services.GetRequiredService<SettingsService>();
_mainViewModel = _services.GetRequiredService<ViewModelManager>().CreateMainViewModel();
// Re-initialize the theme when the user changes it
_eventRoot.Add(
_settingsService.WatchProperty(
o => o.Theme,
() =>
{
RequestedThemeVariant = _settingsService.Theme switch
{
ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light,
ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark,
_ => Avalonia.Styling.ThemeVariant.Default,
};
InitializeTheme();
}
)
);
}
public override void Initialize()
{
base.Initialize();
AvaloniaXamlLoader.Load(this);
}
private void InitializeTheme()
{
var actualTheme = RequestedThemeVariant?.Key switch
{
"Light" => PlatformThemeVariant.Light,
"Dark" => PlatformThemeVariant.Dark,
_ => PlatformSettings?.GetColorValues().ThemeVariant ?? PlatformThemeVariant.Light,
};
this.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme =
actualTheme == PlatformThemeVariant.Light
? Theme.Create(Theme.Light, Color.Parse("#343838"), Color.Parse("#F9A825"))
: Theme.Create(Theme.Dark, Color.Parse("#E8E8E8"), Color.Parse("#F9A825"));
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
desktop.MainWindow = new MainView { DataContext = _mainViewModel };
base.OnFrameworkInitializationCompleted();
// Set up custom theme colors
InitializeTheme();
// Load settings
_settingsService.Load();
}
private void Application_OnActualThemeVariantChanged(object? sender, EventArgs args) =>
// Re-initialize the theme when the system theme changes
InitializeTheme();
public void Dispose()
{
_eventRoot.Dispose();
_services.Dispose();
}
}
-543
View File
@@ -1,543 +0,0 @@
<Application
x:Class="DiscordChatExporter.Gui.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:s="https://github.com/canton7/Stylet">
<Application.Resources>
<s:ApplicationLoader>
<!-- Bootstrapper -->
<s:ApplicationLoader.Bootstrapper>
<local:Bootstrapper />
</s:ApplicationLoader.Bootstrapper>
<!-- Merged dictionaries -->
<ResourceDictionary.MergedDictionaries>
<!-- Colors are irrelevant because they are overriden in runtime -->
<materialDesign:BundledTheme
BaseTheme="Light"
PrimaryColor="Blue"
SecondaryColor="Blue" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
</ResourceDictionary.MergedDictionaries>
<!-- Styles -->
<Style x:Key="MaterialDesignRoot" TargetType="{x:Type Control}">
<Setter Property="FontFamily" Value="{DynamicResource MaterialDesignFont}" />
<Setter Property="RenderOptions.BitmapScalingMode" Value="HighQuality" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="TextElement.FontSize" Value="13" />
<Setter Property="TextElement.FontWeight" Value="Regular" />
<Setter Property="TextElement.Foreground" Value="{DynamicResource MaterialDesignBody}" />
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
<Setter Property="TextOptions.TextRenderingMode" Value="Auto" />
<Setter Property="UseLayoutRounding" Value="True" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignScrollBarMinimal}" TargetType="{x:Type ScrollBar}" />
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
<Setter Property="Height" Value="2" />
<Setter Property="Maximum" Value="1" />
<Setter Property="Minimum" Value="0" />
</Style>
<Style TargetType="{x:Type Hyperlink}">
<Setter Property="TextDecorations" Value="Underline" />
<Setter Property="FontWeight" Value="Regular" />
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignBody}" />
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
<Trigger Property="IsEnabled" Value="True">
<Setter Property="Cursor" Value="Hand" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsEnabled" Value="True" />
<Condition Property="IsMouseOver" Value="True" />
</MultiTrigger.Conditions>
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
</MultiTrigger>
</Style.Triggers>
</Style>
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}" />
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}" />
<Style TargetType="{x:Type materialDesign:Snackbar}">
<Setter Property="Background" Value="{DynamicResource MaterialDesignDarkBackground}" />
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignDarkForeground}" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="ActionButtonStyle">
<Setter.Value>
<Style BasedOn="{StaticResource MaterialDesignFlatButton}" TargetType="{x:Type Button}">
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Height" Value="36" />
<Setter Property="Margin" Value="8,-10,-8,-10" />
<Setter Property="Padding" Value="8" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
<Setter Property="materialDesign:RippleAssist.Feedback" Value="{DynamicResource MaterialDesignSnackbarRipple}" />
</Style>
</Setter.Value>
</Setter>
</Style>
<Style
x:Key="MaterialDesignFlatActionToggleButton"
BasedOn="{StaticResource MaterialDesignActionToggleButton}"
TargetType="{x:Type ToggleButton}">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
<Setter Property="BorderBrush" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
</Trigger>
</Style.Triggers>
</Style>
<Style BasedOn="{StaticResource MaterialDesignContextMenu}" TargetType="{x:Type ContextMenu}">
<Setter Property="Background" Value="{DynamicResource MaterialDesignPaper}" />
</Style>
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Expander">
<Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}">
<DockPanel Background="{TemplateBinding Background}">
<ToggleButton
Name="HeaderSite"
BorderThickness="0"
Content="{TemplateBinding Header}"
ContentStringFormat="{TemplateBinding HeaderStringFormat}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
ContentTemplateSelector="{TemplateBinding HeaderTemplateSelector}"
Cursor="Hand"
DockPanel.Dock="Top"
Focusable="False"
Foreground="{TemplateBinding Foreground}"
IsChecked="{Binding Path=IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
IsTabStop="False"
Opacity=".87"
Style="{StaticResource MaterialDesignExpanderToggleButton}"
TextElement.FontSize="15" />
<Border
Name="ContentSite"
DockPanel.Dock="Bottom"
Visibility="Collapsed">
<StackPanel
Name="ContentPanel"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
<ContentPresenter
Name="PART_Content"
ContentStringFormat="{TemplateBinding ContentStringFormat}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"
Focusable="False" />
</StackPanel>
</Border>
</DockPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="true">
<Setter TargetName="ContentSite" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Right">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Left" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Right" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignVerticalHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Left">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Right" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Left" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignVerticalHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Up">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Bottom" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Top" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignHorizontalHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Down">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Top" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Bottom" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignHorizontalHeaderStyle}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Use old MD style for slider, because the new one is too huge -->
<Style x:Key="MaterialDesignHorizontalTrackRepeatButton" TargetType="{x:Type RepeatButton}">
<Setter Property="OverridesDefaultStyle" Value="true" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Focusable" Value="false" />
<Setter Property="IsTabStop" Value="false" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type RepeatButton}">
<Canvas
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
Background="Transparent">
<Rectangle
x:Name="PART_SelectionRange"
Canvas.Top="8"
Width="{TemplateBinding ActualWidth}"
Height="2.0"
Fill="{TemplateBinding Background}" />
</Canvas>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="MaterialDesignVerticalTrackRepeatButton" TargetType="{x:Type RepeatButton}">
<Setter Property="OverridesDefaultStyle" Value="true" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Focusable" Value="false" />
<Setter Property="IsTabStop" Value="false" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type RepeatButton}">
<Canvas
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
Background="Transparent">
<Rectangle
x:Name="PART_SelectionRange"
Canvas.Left="8"
Width="2.0"
Height="{TemplateBinding ActualHeight}"
Fill="{TemplateBinding Background}" />
</Canvas>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<ControlTemplate x:Key="MaterialDesignSliderThumb" TargetType="{x:Type Thumb}">
<Grid
HorizontalAlignment="Center"
VerticalAlignment="Center"
UseLayoutRounding="True">
<Ellipse
x:Name="shadow"
Width="24"
Height="24"
Margin="-12"
Fill="{TemplateBinding Foreground}"
Opacity=".0"
UseLayoutRounding="True" />
<Ellipse
x:Name="grip"
Width="12"
Height="12"
Fill="{TemplateBinding Foreground}"
RenderTransformOrigin=".5,.5"
UseLayoutRounding="True">
<Ellipse.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="1" />
</Ellipse.RenderTransform>
</Ellipse>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="true">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="shadow"
Storyboard.TargetProperty="Opacity"
To=".26"
Duration="0:0:0.2" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="shadow"
Storyboard.TargetProperty="Opacity"
To=".0"
Duration="0:0:0.2" />
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsDragging" Value="true">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<Storyboard>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleX">
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1" />
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1.5">
<EasingDoubleKeyFrame.EasingFunction>
<SineEase EasingMode="EaseInOut" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleY">
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1" />
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1.5">
<EasingDoubleKeyFrame.EasingFunction>
<SineEase EasingMode="EaseInOut" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<Storyboard>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleX">
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1.5" />
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1">
<EasingDoubleKeyFrame.EasingFunction>
<SineEase EasingMode="EaseInOut" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleY">
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1.5" />
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1">
<EasingDoubleKeyFrame.EasingFunction>
<SineEase EasingMode="EaseInOut" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsEnabled" Value="false">
<Setter TargetName="grip" Property="Fill" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
<Setter TargetName="grip" Property="Stroke" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<ControlTemplate x:Key="MaterialDesignSliderHorizontal" TargetType="{x:Type Slider}">
<Border
x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
SnapsToDevicePixels="True">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" MinHeight="{TemplateBinding MinHeight}" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TickBar
x:Name="TopTick"
Grid.Row="0"
Height="4"
Margin="0,0,0,2"
Fill="{TemplateBinding Foreground}"
Placement="Top"
Visibility="Collapsed" />
<TickBar
x:Name="BottomTick"
Grid.Row="2"
Height="4"
Margin="0,2,0,0"
Fill="{TemplateBinding Foreground}"
Placement="Bottom"
Visibility="Collapsed" />
<Rectangle
x:Name="PART_SelectionRange"
Grid.Row="1"
Height="4.0"
Fill="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}"
Visibility="Hidden" />
<Track
x:Name="PART_Track"
Grid.Row="1"
OpacityMask="{x:Null}">
<Track.DecreaseRepeatButton>
<RepeatButton
Background="{TemplateBinding Foreground}"
Command="{x:Static Slider.DecreaseLarge}"
Style="{StaticResource MaterialDesignHorizontalTrackRepeatButton}" />
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton
x:Name="IncreaseRepeatButton"
Background="{DynamicResource MaterialDesignCheckBoxOff}"
Command="{x:Static Slider.IncreaseLarge}"
Style="{StaticResource MaterialDesignHorizontalTrackRepeatButton}" />
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb
x:Name="Thumb"
Width="12"
Height="18"
VerticalAlignment="Center"
Focusable="False"
OverridesDefaultStyle="True"
Template="{StaticResource MaterialDesignSliderThumb}" />
</Track.Thumb>
</Track>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="TickPlacement" Value="TopLeft">
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="TickPlacement" Value="BottomRight">
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="TickPlacement" Value="Both">
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsSelectionRangeEnabled" Value="true">
<Setter TargetName="PART_SelectionRange" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="IncreaseRepeatButton" Property="Background" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<ControlTemplate x:Key="MaterialDesignSliderVertical" TargetType="{x:Type Slider}">
<Border
x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
SnapsToDevicePixels="True">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" MinWidth="{TemplateBinding MinWidth}" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TickBar
x:Name="TopTick"
Grid.Column="0"
Width="4"
Margin="0,0,2,0"
Fill="{TemplateBinding Foreground}"
Placement="Left"
Visibility="Collapsed" />
<TickBar
x:Name="BottomTick"
Grid.Column="2"
Width="4"
Margin="2,0,0,0"
Fill="{TemplateBinding Foreground}"
Placement="Right"
Visibility="Collapsed" />
<Rectangle
x:Name="PART_SelectionRange"
Grid.Column="1"
Height="4.0"
Fill="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}"
Visibility="Hidden" />
<Track x:Name="PART_Track" Grid.Column="1">
<Track.DecreaseRepeatButton>
<RepeatButton
Background="{TemplateBinding Foreground}"
Command="{x:Static Slider.DecreaseLarge}"
Style="{StaticResource MaterialDesignVerticalTrackRepeatButton}" />
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton
x:Name="IncreaseRepeatButton"
Background="{DynamicResource MaterialDesignCheckBoxOff}"
Command="{x:Static Slider.IncreaseLarge}"
Style="{StaticResource MaterialDesignVerticalTrackRepeatButton}" />
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb
x:Name="Thumb"
Width="18"
Height="12"
VerticalAlignment="Top"
Focusable="False"
OverridesDefaultStyle="True"
Template="{StaticResource MaterialDesignSliderThumb}" />
</Track.Thumb>
</Track>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="TickPlacement" Value="TopLeft">
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="TickPlacement" Value="BottomRight">
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="TickPlacement" Value="Both">
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsSelectionRangeEnabled" Value="true">
<Setter TargetName="PART_SelectionRange" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="IncreaseRepeatButton" Property="Background" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<Style x:Key="MaterialDesignThinSlider" TargetType="{x:Type Slider}">
<Setter Property="Stylus.IsPressAndHoldEnabled" Value="false" />
<Setter Property="Background" Value="{x:Null}" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
<Setter Property="Template" Value="{StaticResource MaterialDesignSliderHorizontal}" />
<Style.Triggers>
<Trigger Property="Orientation" Value="Vertical">
<Setter Property="Template" Value="{StaticResource MaterialDesignSliderVertical}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
</Style.Triggers>
</Style>
</s:ApplicationLoader>
</Application.Resources>
</Application>

Some files were not shown because too many files have changed in this diff Show More