Compare commits

..

60 Commits

Author SHA1 Message Date
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
Tyrrrz 9b8985a920 Fix tests 2024-01-18 20:39:54 +02:00
Tyrrrz 014712b747 Reorder tests 2024-01-18 20:25:30 +02:00
Oleksii Holub d8e43d89be Add support for has:invite filter that matches messages with guild invites (#1188) 2024-01-18 20:24:25 +02:00
Tyrrrz 4e69ff317b Fix nullability mismatch in WPF converters 2024-01-15 23:59:16 +02:00
Oleksii Holub 057beaacd6 Use null as the default locale, which resolves to the current system default locale (#1185) 2024-01-15 23:58:28 +02:00
Tyrrrz 982ba6a76c Fix tests 2024-01-11 23:28:11 +02:00
Tyrrrz dccd9a2f08 Cleanup 2024-01-11 22:43:13 +02:00
Tyrrrz 182f24846b Use new array syntax 2024-01-11 22:21:53 +02:00
Oleksii Holub 777f92a3f1 Fix docker user permission issues by creating the user manually (#1183) 2024-01-09 21:28:19 +02:00
Tyrrrz d4be307fb1 Specify the user in dockerfile via username rather than ID 2024-01-08 17:13:48 +02:00
Tyrrrz 2bbb585931 Update license 2024-01-01 03:09:52 +02:00
Tyrrrz 73080ecfa6 Specify comparison type in Replace(...) 2023-12-31 06:38:53 +02:00
Tyrrrz 0745d49f44 Use native method for enumerating runes 2023-12-31 06:24:23 +02:00
Tyrrrz c16e14e9b6 Update readme 2023-12-30 17:57:18 +02:00
Tyrrrz 8e36002ae7 Slightly better naming 2023-12-29 00:17:11 +02:00
Oleksii Holub a2c5d2e2dd Warn about redirected streams in the CLI only when running inside Docker (#1176) 2023-12-29 00:14:43 +02:00
Tyrrrz 4e3deb409c Update NuGet packages 2023-12-29 00:08:16 +02:00
Oleksii Holub 512f181be2 Refer to "guilds" as "servers" in docs and UI elements (#1175) 2023-12-29 00:03:28 +02:00
Tyrrrz e04eb890e6 Improve wording 2023-12-28 23:56:19 +02:00
Tyrrrz 1ad42ee48b Fix typo 2023-12-27 19:25:06 +02:00
Tyrrrz b8a67617bc Set a high default assembly version to avoid auto-update prompts 2023-12-27 16:20:05 +02:00
Tyrrrz 062427d143 Make test assertions more relaxed in terms of user-made content URLs 2023-12-26 02:40:50 +02:00
Tyrrrz 5f343a46d1 Standardize exception messages 2023-12-25 18:59:47 +02:00
Tyrrrz b3a1cbf635 Update NuGet packages 2023-12-10 22:38:34 +02:00
Tyrrrz 619fe9ccf7 Refactor using c# 12 features 2023-12-10 22:38:34 +02:00
LostXOR 174b92cbb0 Fix GUI link in docs going to CLI page (#1167) 2023-11-30 15:31:31 +02:00
159 changed files with 3290 additions and 3692 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. 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 ```console
docker pull tyrrrz/discordchatexporter:stable $ docker pull tyrrrz/discordchatexporter:stable
``` ```
Note the `:stable` tag. DiscordChatExporter images are tagged according to the following patterns: 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: To run the CLI in Docker and render help text:
```console ```console
docker run --rm tyrrrz/discordchatexporter:stable $ docker run --rm tyrrrz/discordchatexporter:stable
``` ```
To export a channel: To export a channel:
```console ```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: If you want colored output and real-time progress reporting, pass the `-it` (interactive + pseudo-terminal) option:
```console ```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. 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.: > If you are running SELinux, you will need to add the `:z` option after `/out`, e.g.:
> >
> ```console > ```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). > 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). 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 ## 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. 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: **DCE** has two different versions:
- **Graphical User Interface** (**GUI**) - it's the preferred version for newcomers as it is easy to use. - **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. - **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. 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.
There are dedicated guides for each version: 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**: > **Warning**:
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed. > 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 ### Plain Text
<img src="https://i.imgur.com/PbUyRXD.png" height="400"/> <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. You can open `.txt` files with a text editor, such as Notepad.
### JSON ### 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: In most cases, you will need to enclose your filter in quotes (`"`) to escape characters that may have special meaning in your shell:
```console ```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: If you need to include quotes inside the filter itself as well, use single quotes (`'`) for those instead:
```console ```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 (`-`): 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 ```console
DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz $ ./DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
``` ```
+1 -4
View File
@@ -2,10 +2,8 @@
## Installation & Usage ## 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: - Getting started:
- [Using the GUI](Using-the-CLI.md) - [Using the GUI](Using-the-GUI.md)
- [Using the CLI](Using-the-CLI.md) - [Using the CLI](Using-the-CLI.md)
- [File formats](Getting-started.md#file-formats) - [File formats](Getting-started.md#file-formats)
@@ -29,6 +27,5 @@
- [General questions](Troubleshooting.md#general) - [General questions](Troubleshooting.md#general)
- [First steps help](Troubleshooting.md#first-steps) - [First steps help](Troubleshooting.md#first-steps)
- [It's crashing/failing](Troubleshooting.md#DCE-is-crashingfailing) - [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) - [Errors](Troubleshooting.md#errors)
- [**More help**](Troubleshooting.md) - [**More help**](Troubleshooting.md)
+1 -3
View File
@@ -1,7 +1,5 @@
# Scheduling exports with Cron # Scheduling exports with Cron
Make sure you already have **DiscordChatExporter.CLI** and **.NET Core** properly installed ([instructions here](Linux.md)).
## Creating the script ## Creating the script
1. Open Terminal and create a new text file with `nano /path/to/DiscordChatExporter/cron.sh` 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 cd $DLLFOLDER || exit 1
# This will export your chat # 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 # This sets the current time to a variable
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S") CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")
+1 -5
View File
@@ -1,9 +1,5 @@
# Scheduling exports on macOS # 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 ## Creating the script
1. Open TextEdit.app and create a new file 1. Open TextEdit.app and create a new file
@@ -51,7 +47,7 @@ fi
cd $DLLFOLDER || exit 1 cd $DLLFOLDER || exit 1
# This will export your chat # 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 # This sets the current time to a variable
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S") CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")
+1 -3
View File
@@ -1,7 +1,5 @@
# Scheduling exports on Windows # Scheduling exports on Windows
We'll be using [DiscordChatExporter CLI](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest), PowerShell, and Task Scheduler.
## Creating the script ## Creating the script
1. Open a text editor such as Notepad and paste: 1. Open a text editor such as Notepad and paste:
@@ -19,7 +17,7 @@ $EXPORTFORMAT = "formathere"
cd $EXEPATH 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" $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 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() (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 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() (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 ### 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. 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 do I run DCE on macOS or Linux?
Check the following pages:
- [macOS usage instructions](MacOS.md)
- [Linux usage instructions](Linux.md)
### How can I set DCE to export automatically at certain times? ### 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`. 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 ### 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. 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 ## Errors
```console ```yml
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Authentication token is invalid. DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Authentication token is invalid.
...
``` ```
↳ Make sure the provided token is correct. ↳ Make sure the provided token is correct.
```console ```yml
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Requested resource does not exist. 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). ↳ 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. DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Access is forbidden.
``` ```
↳ This means you don't have access to the channel. ↳ This means you don't have access to the channel.
```console ```yml
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
System.Net.WebException: Error: TrustFailure ... Invalid certificate received from server. System.Net.WebException: Error: TrustFailure ... Invalid certificate received from server.
...
``` ```
↳ Try running cert-sync. ↳ Try running cert-sync.
+50 -57
View File
@@ -1,24 +1,17 @@
# Using the CLI # 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 ## Step 1
After extracting the `.zip`, open Command Prompt, aka `cmd` (`Terminal` on **macOS** and **Linux**). After extracting the `.zip`, open Command Prompt, aka `cmd` (`Terminal` on **macOS** and **Linux**).
## Step 2 ## 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) ![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 **macOS** users can press Command+Option+C (⌘⌥C) while inside the folder (or selecting it) to copy its path to the clipboard.
![Mac info panel](https://camo.githubusercontent.com/3c51a904b0099c9f68a4797461d4a7914902fc04/68747470733a2f2f692e696d6775722e636f6d2f323975364e79782e706e67)
You can also drag and drop the folder on **every platform**. You can also drag and drop the folder on **every platform**.
![Drag and drop folder](https://i.imgur.com/sOpZQAb.gif) ![Drag and drop folder](https://i.imgur.com/sOpZQAb.gif)
@@ -28,39 +21,39 @@ You can also drag and drop the folder on **every platform**.
Now we're ready to run the commands. The examples on this page follow the Windows file path format, change the file Now we're ready to run the commands. The examples on this page follow the Windows file path format, change the file
paths according to your system. paths according to your system.
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 ```console
dotnet DiscordChatExporter.Cli.dll $ ./DiscordChatExporter.Cli
``` ```
> **Docker** users, please refer to the [Docker usage instructions](Docker.md). > **Docker** users, please refer to the [Docker usage instructions](Docker.md).
## CLI commands ## CLI commands
| Command | Description | | Command | Description |
|-------------------------|-----------------------------------------------------| |-------------------------|------------------------------------------------------|
| export | Exports a channel | | export | Exports a channel |
| exportdm | Exports all direct message channels | | exportdm | Exports all direct message channels |
| exportguild | Exports all channels within the specified server | | exportguild | Exports all channels within the specified server |
| exportall | Exports all accessible channels | | exportall | Exports all accessible channels |
| channels | Outputs the list of channels in the given server | | channels | Outputs the list of channels in the given server |
| dm | Outputs the list of direct message channels | | dm | Outputs the list of direct message channels |
| guilds | Outputs the list of accessible servers | | guilds | Outputs the list of accessible servers |
| guide | Explains how to obtain token, guild, and channel ID | | 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: To get help with a specific command, run:
```console ```console
dotnet DiscordChatExporter.Cli.dll command --help $ ./DiscordChatExporter.Cli command --help
``` ```
For example, to figure out how to use the `export` command, run: For example, to figure out how to use the `export` command, run:
```console ```console
dotnet DiscordChatExporter.Cli.dll export --help $ ./DiscordChatExporter.Cli export --help
``` ```
## Export a specific channel ## Export a specific channel
@@ -68,7 +61,7 @@ dotnet DiscordChatExporter.Cli.dll export --help
You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`. You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`.
```console ```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 $ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
``` ```
#### Changing the format #### Changing the format
@@ -77,7 +70,7 @@ You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json`
format is `HtmlDark`. format is `HtmlDark`.
```console ```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 #### Changing the output filename
@@ -85,7 +78,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: You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:
```console ```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 #### Changing the output directory
@@ -95,7 +88,7 @@ extension.
If any of the folders in the path have a space in its name, escape them with quotes ("). If any of the folders in the path have a space in its name, escape them with quotes (").
```console ```console
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 #### Changing the filename and output directory
@@ -105,15 +98,15 @@ Note that the filename must have an extension, otherwise it will be considered a
If any of the folders in the path have a space in its name, escape them with quotes ("). If any of the folders in the path have a space in its name, escape them with quotes (").
```console ```console
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 #### Generating the filename and output directory dynamically
You can use template tokens to generate the output file path based on the guild and channel metadata. You can use template tokens to generate the output file path based on the server and channel metadata.
```console ```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 Assuming you are exporting a channel named `"my-channel"` in the `"Text channels"` category from a server
@@ -122,8 +115,8 @@ path: `C:\Discord Exports\My server\Text channels\my-channel.html`
Here is the full list of supported template tokens: Here is the full list of supported template tokens:
- `%g` - guild ID - `%g` - server ID
- `%G` - guild name - `%G` - server name
- `%t` - category ID - `%t` - category ID
- `%T` - category name - `%T` - category name
- `%c` - channel ID - `%c` - channel ID
@@ -140,13 +133,13 @@ You can use partitioning to split files after a given number of messages or file
For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files. For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
```console ```console
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. A 45 MB channel set to be partitioned every 20 MB will output 3 files.
```console ```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 #### Downloading assets
@@ -157,7 +150,7 @@ downloaded when using the plain text (TXT) export format.
A folder containing the assets will be created along with the exported chat. They must be kept together. A folder containing the assets will be created along with the exported chat. They must be kept together.
```console ```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --media $ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
``` ```
#### Reusing assets #### Reusing assets
@@ -166,7 +159,7 @@ Previously downloaded assets can be reused to skip redundant downloads as long a
same folder. Using this option can speed up future exports. This option requires the `--media` option. same folder. Using this option can speed up future exports. This option requires the `--media` option.
```console ```console
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 #### Changing the media directory
@@ -175,7 +168,7 @@ By default, the media directory is created alongside the exported chat. You can
providing a path that ends with a slash. All of the exported media will be stored in this directory. providing a path that ends with a slash. All of the exported media will be stored in this directory.
```console ```console
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 #### Changing the date format
@@ -184,7 +177,7 @@ You can customize how dates are formatted in the exported files by using `--loca
locales. The default locale is `en-US`. locales. The default locale is `en-US`.
```console ```console
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 #### Date ranges
@@ -193,14 +186,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: Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019:
```console ```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** **Messages sent after a date**
Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM: Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM:
```console ```console
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** **Messages sent in a date range**
@@ -208,7 +201,7 @@ Use `--before` and `--after` to export messages sent during the provided date ra
September 17th, 2019 11:34 PM and September 18th: September 17th, 2019 11:34 PM and September 18th:
```console ```console
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 You can try different formats like `17-SEP-2019 11:34 PM` or even refine your ranges down to
@@ -217,12 +210,12 @@ Don't forget to quote (") the date if it has spaces!
More info about .NET date More info about .NET date
formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings). formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
### Export channels from a specific guild (server) ### Export channels from a specific server
To export all channels in a specific guild, use the `exportguild` command and provide the guild ID through the `-g|--guild` option: To export all channels in a specific server, use the `exportguild` command and provide the server ID through the `-g|--guild` option:
```console ```console
dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814 $ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
``` ```
#### Including threads #### Including threads
@@ -232,7 +225,7 @@ specifying which threads should be included. It has possible values of `none`, `
threads should be included. To include both active and archived threads, use `--include-threads all`. threads should be included. To include both active and archived threads, use `--include-threads all`.
```console ```console
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 #### Including voice channels
@@ -242,7 +235,7 @@ specifying whether to include voice channels in the export. It has possible valu
voice channels, use `--include-vc false`. voice channels, use `--include-vc false`.
```console ```console
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 ### Export all channels
@@ -250,7 +243,7 @@ dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814 --include-
To export all accessible channels, use the `exportall` command: To export all accessible channels, use the `exportall` command:
```console ```console
dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn" $ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
``` ```
#### Excluding DMs #### Excluding DMs
@@ -258,15 +251,15 @@ dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn"
To exclude DMs, add the `--include-dm false` option. To exclude DMs, add the `--include-dm false` option.
```console ```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 guild (server) ### List channels in a server
To list the channels available in a specific guild, use the `channels` command and provide the guild ID through the `-g|--guild` option: To list the channels available in a specific server, use the `channels` command and provide the server ID through the `-g|--guild` option:
```console ```console
dotnet DiscordChatExporter.Cli.dll channels -t "mfa.Ifrn" -g 21814 $ ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
``` ```
### List direct message channels ### List direct message channels
@@ -274,13 +267,13 @@ dotnet DiscordChatExporter.Cli.dll channels -t "mfa.Ifrn" -g 21814
To list all DM channels accessible to the current account, use the `dm` command: To list all DM channels accessible to the current account, use the `dm` command:
```console ```console
dotnet DiscordChatExporter.Cli.dll dm -t "mfa.Ifrn" $ ./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
``` ```
### List guilds (servers) ### List servers
To list all guilds accessible by the current account, use the `guilds` command: To list all servers accessible by the current account, use the `guilds` command:
```console ```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 ### 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 ### Step 2
@@ -61,37 +61,30 @@ In this screen you can customize the following:
## Settings ## Settings
- **Auto-update** - **Auto-update** - Perform automatic updates on every launch.
Perform automatic updates on every launch.
Default: Enabled Default: Enabled
> **Note**: > **Note**:
> Keep this option enabled to receive the latest features and bug fixes! > Keep this option enabled to receive the latest features and bug fixes!
- **Dark mode** - **Dark mode** - Use darker colors in the UI (User Interface).
Use darker colors in the UI (User Interface).
Default: Disabled Default: Disabled
- **Persist token** - **Persist token** - Persist last used token between sessions.
Persist last used token between sessions.
Default: Enabled Default: Enabled
- **Show threads** - **Show threads** - Controls whether threads are shown in the channel list.
Controls whether threads are shown in the channel list.
Default: none Default: none
- **Locale** - **Locale** - Customize how dates are formatted in the exported files.
Customize how dates are formatted in the exported files.
- **Date format** - **Date format** - Customize how dates are formatted in the exported files in the settings menu ().
Customize how dates are formatted in the exported files in the settings menu ().
- **Parallel limit** - **Parallel limit** - The number of channels that will be exported at the same time.
The number of channels that will be exported at the same time.
Default: 1 Default: 1
- **Normalize to UTC**
Convert all dates to UTC before exporting.
> **Note**: > **Note**:
> Try to keep this number low so that your account doesn't get flagged. > Try to keep this number low so that your account doesn't get flagged.
- **Normalize to UTC** - Convert all dates to UTC before exporting.
+3 -1
View File
@@ -94,12 +94,14 @@ body:
required: true required: true
- label: I have provided a descriptive title for this issue - label: I have provided a descriptive title for this issue
required: true required: true
- label: I have made sure that that this bug is reproducible on the latest version of the application - label: I have made sure that this bug is reproducible on the latest version of the application
required: true required: true
- label: I have provided all the information needed to reproduce this bug as efficiently as possible - label: I have provided all the information needed to reproduce this bug as efficiently as possible
required: true required: true
- label: I have sponsored this project - label: I have sponsored this project
required: false 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 - type: markdown
attributes: attributes:
+6 -7
View File
@@ -12,8 +12,7 @@ on:
- master - master
jobs: jobs:
# Outputs from this job aren't really used, but it's here to verify that # Outputs from this job aren't really used, but it's here to verify that the Dockerfile builds correctly
# the Dockerfile builds correctly on pull requests.
pack: pack:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
@@ -24,10 +23,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0 uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Install Docker Buildx - name: Install Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # 3.0.0 uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
- name: Build image - name: Build image
run: > run: >
@@ -38,7 +37,7 @@ jobs:
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar --output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # 3.1.3 uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
with: with:
name: DiscordChatExporter.Cli.Docker name: DiscordChatExporter.Cli.Docker
path: DiscordChatExporter.Cli.Docker.tar path: DiscordChatExporter.Cli.Docker.tar
@@ -56,10 +55,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0 uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Install Docker Buildx - name: Install Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # 3.0.0 uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
- name: Login to DockerHub - name: Login to DockerHub
run: > run: >
+49 -17
View File
@@ -18,7 +18,7 @@ env:
jobs: jobs:
format: format:
runs-on: windows-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
permissions: permissions:
@@ -26,10 +26,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0 uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # 3.2.0 uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
with: with:
dotnet-version: 8.0.x dotnet-version: 8.0.x
@@ -43,7 +43,7 @@ jobs:
# Tests need access to secrets, so we can't run them against PRs because of limited trust # Tests need access to secrets, so we can't run them against PRs because of limited trust
if: ${{ github.event_name != 'pull_request' }} if: ${{ github.event_name != 'pull_request' }}
runs-on: windows-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
permissions: permissions:
@@ -51,10 +51,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0 uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # 3.2.0 uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
with: with:
dotnet-version: 8.0.x dotnet-version: 8.0.x
@@ -72,7 +72,7 @@ jobs:
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # 3.1.4 uses: codecov/codecov-action@f30e4959ba63075080d4f7f90cacc18d9f3fafd7 # v4.0.0
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
@@ -82,8 +82,24 @@ jobs:
app: app:
- DiscordChatExporter.Cli - DiscordChatExporter.Cli
- DiscordChatExporter.Gui - 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 timeout-minutes: 10
permissions: permissions:
@@ -92,10 +108,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0 uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # 3.2.0 uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
with: with:
dotnet-version: 8.0.x dotnet-version: 8.0.x
@@ -106,11 +122,13 @@ jobs:
-p:CSharpier_Bypass=true -p:CSharpier_Bypass=true
--output ${{ matrix.app }}/bin/publish/ --output ${{ matrix.app }}/bin/publish/
--configuration Release --configuration Release
--runtime ${{ matrix.rid }}
--self-contained
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # 3.1.3 uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
with: with:
name: ${{ matrix.app }} name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/bin/publish/ path: ${{ matrix.app }}/bin/publish/
if-no-files-found: error if-no-files-found: error
@@ -147,11 +165,21 @@ jobs:
app: app:
- DiscordChatExporter.Cli - DiscordChatExporter.Cli
- DiscordChatExporter.Gui - DiscordChatExporter.Gui
rid:
- win-arm64
- win-x86
- win-x64
- linux-arm
- linux-arm64
- linux-musl-x64
- linux-x64
- osx-arm64
- osx-x64
include: include:
- app: DiscordChatExporter.Cli - app: DiscordChatExporter.Cli
asset: DiscordChatExporter.Cli asset: DiscordChatExporter.Cli
- app: DiscordChatExporter.Gui - app: DiscordChatExporter.Gui
# GUI asset isn't suffixed, unlike the CLI asset # GUI assets aren't suffixed, unlike the CLI assets
asset: DiscordChatExporter asset: DiscordChatExporter
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -163,22 +191,26 @@ jobs:
steps: steps:
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # 3.0.2 uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1
with: with:
name: ${{ matrix.app }} name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/ path: ${{ matrix.app }}/
- name: Set permissions
if: ${{ !startsWith(matrix.rid, 'win') }}
run: chmod +x ${{ matrix.app }}/${{ matrix.asset }}
- name: Create package - name: Create package
# Change into the artifacts directory to avoid including the directory itself in the zip archive # Change into the artifacts directory to avoid including the directory itself in the zip archive
working-directory: ${{ matrix.app }}/ working-directory: ${{ matrix.app }}/
run: zip -r ../${{ matrix.asset }}.zip . run: zip -r ../${{ matrix.asset }}.${{ matrix.rid }}.zip .
- name: Upload release asset - name: Upload release asset
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: > run: >
gh release upload ${{ github.ref_name }} gh release upload ${{ github.ref_name }}
${{ matrix.asset }}.zip ${{ matrix.asset }}.${{ matrix.rid }}.zip
--repo ${{ github.event.repository.full_name }} --repo ${{ github.event.repository.full_name }}
notify: notify:
+2
View File
@@ -2,11 +2,13 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>999.9.9-dev</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright> <Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
@@ -11,19 +11,18 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.0.7" /> <PackageReference Include="AngleSharp" Version="1.1.2" />
<PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" /> <PackageReference Include="coverlet.collector" Version="6.0.2" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.2" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="6.12.0" /> <PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" 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.Extensions.Configuration.UserSecrets" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="ReflectionMagic" Version="5.0.0" /> <PackageReference Include="xunit" Version="2.8.0" />
<PackageReference Include="xunit" Version="2.6.1" /> <PackageReference Include="xunit.runner.visualstudio" Version="2.8.0" PrivateAssets="all" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -57,7 +57,7 @@ public static class ExportWrapper
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { channelId }, ChannelIds = [channelId],
ExportFormat = format, ExportFormat = format,
OutputPath = filePath, OutputPath = filePath,
Locale = "en-US", Locale = "en-US",
@@ -93,13 +93,12 @@ public static class ExportWrapper
Snowflake messageId Snowflake messageId
) )
{ {
var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault( var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(e =>
e => string.Equals(
string.Equals( e.GetAttribute("data-message-id"),
e.GetAttribute("data-message-id"), messageId.ToString(),
messageId.ToString(), StringComparison.OrdinalIgnoreCase
StringComparison.OrdinalIgnoreCase )
)
); );
if (message is null) if (message is null)
@@ -117,13 +116,12 @@ public static class ExportWrapper
Snowflake messageId Snowflake messageId
) )
{ {
var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault( var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(j =>
j => string.Equals(
string.Equals( j.GetProperty("id").GetString(),
j.GetProperty("id").GetString(), messageId.ToString(),
messageId.ToString(), StringComparison.OrdinalIgnoreCase
StringComparison.OrdinalIgnoreCase )
)
); );
if (message.ValueKind == JsonValueKind.Undefined) if (message.ValueKind == JsonValueKind.Undefined)
@@ -27,7 +27,7 @@ public class DateRangeSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
After = Snowflake.FromDate(after) After = Snowflake.FromDate(after)
@@ -45,24 +45,18 @@ public class DateRangeSpecs
timestamps timestamps
.Should() .Should()
.BeEquivalentTo( .BeEquivalentTo(
new[] [
{
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero) new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero)
}, ],
o => o =>
{ o.Using<DateTimeOffset>(ctx =>
return o.Using<DateTimeOffset>( ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
ctx =>
ctx.Subject
.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
) )
.WhenTypeIs<DateTimeOffset>(); .WhenTypeIs<DateTimeOffset>()
}
); );
} }
@@ -77,7 +71,7 @@ public class DateRangeSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
Before = Snowflake.FromDate(before) Before = Snowflake.FromDate(before)
@@ -95,22 +89,16 @@ public class DateRangeSpecs
timestamps timestamps
.Should() .Should()
.BeEquivalentTo( .BeEquivalentTo(
new[] [
{
new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero), new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero), new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero) new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero)
}, ],
o => o =>
{ o.Using<DateTimeOffset>(ctx =>
return o.Using<DateTimeOffset>( ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
ctx =>
ctx.Subject
.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
) )
.WhenTypeIs<DateTimeOffset>(); .WhenTypeIs<DateTimeOffset>()
}
); );
} }
@@ -126,7 +114,7 @@ public class DateRangeSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
Before = Snowflake.FromDate(before), Before = Snowflake.FromDate(before),
@@ -145,23 +133,17 @@ public class DateRangeSpecs
timestamps timestamps
.Should() .Should()
.BeEquivalentTo( .BeEquivalentTo(
new[] [
{
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero), new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero) new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero)
}, ],
o => o =>
{ o.Using<DateTimeOffset>(ctx =>
return o.Using<DateTimeOffset>( ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
ctx =>
ctx.Subject
.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
) )
.WhenTypeIs<DateTimeOffset>(); .WhenTypeIs<DateTimeOffset>()
}
); );
} }
} }
@@ -1,4 +1,5 @@
using System.IO; using System;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Infrastructure; using CliFx.Infrastructure;
@@ -25,7 +26,7 @@ public class FilterSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("some text") MessageFilter = MessageFilter.Parse("some text")
@@ -37,7 +38,7 @@ public class FilterSpecs
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("content").GetString()) .Select(j => j.GetProperty("content").GetString())
.Should() .Should()
.ContainSingle("Some random text"); .AllSatisfy(c => c.Contains("Some random text", StringComparison.Ordinal));
} }
[Fact] [Fact]
@@ -50,7 +51,7 @@ public class FilterSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("from:Tyrrrz") MessageFilter = MessageFilter.Parse("from:Tyrrrz")
@@ -66,7 +67,7 @@ public class FilterSpecs
} }
[Fact] [Fact]
public async Task I_can_filter_the_export_to_only_include_messages_that_contain_the_specified_content() public async Task I_can_filter_the_export_to_only_include_messages_that_contain_images()
{ {
// Arrange // Arrange
using var file = TempFile.Create(); using var file = TempFile.Create();
@@ -75,7 +76,7 @@ public class FilterSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:image") MessageFilter = MessageFilter.Parse("has:image")
@@ -87,7 +88,7 @@ public class FilterSpecs
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("content").GetString()) .Select(j => j.GetProperty("content").GetString())
.Should() .Should()
.ContainSingle("This has image"); .AllSatisfy(c => c.Contains("This has image", StringComparison.Ordinal));
} }
[Fact] [Fact]
@@ -100,7 +101,7 @@ public class FilterSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:pin") MessageFilter = MessageFilter.Parse("has:pin")
@@ -112,7 +113,32 @@ public class FilterSpecs
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("content").GetString()) .Select(j => j.GetProperty("content").GetString())
.Should() .Should()
.ContainSingle("This is pinned"); .AllSatisfy(c => c.Contains("This is pinned", StringComparison.Ordinal));
}
[Fact]
public async Task I_can_filter_the_export_to_only_include_messages_that_contain_guild_invites()
{
// Arrange
using var file = TempFile.Create();
// Act
await new ExportChannelsCommand
{
Token = Secrets.DiscordToken,
ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("has:invite")
}.ExecuteAsync(new FakeConsole());
// Assert
Json.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages")
.EnumerateArray()
.Select(j => j.GetProperty("content").GetString())
.Should()
.AllSatisfy(c => c.Contains("This has invite", StringComparison.Ordinal));
} }
[Fact] [Fact]
@@ -125,7 +151,7 @@ public class FilterSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.FilterTestCases }, ChannelIds = [ChannelIds.FilterTestCases],
ExportFormat = ExportFormat.Json, ExportFormat = ExportFormat.Json,
OutputPath = file.Path, OutputPath = file.Path,
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz") MessageFilter = MessageFilter.Parse("mentions:Tyrrrz")
@@ -137,6 +163,6 @@ public class FilterSpecs
.EnumerateArray() .EnumerateArray()
.Select(j => j.GetProperty("content").GetString()) .Select(j => j.GetProperty("content").GetString())
.Should() .Should()
.ContainSingle("This has mention"); .AllSatisfy(c => c.Contains("This has mention", StringComparison.Ordinal));
} }
} }
@@ -27,13 +27,7 @@ public class HtmlAttachmentSpecs
.QuerySelectorAll("a") .QuerySelectorAll("a")
.Select(e => e.GetAttribute("href")) .Select(e => e.GetAttribute("href"))
.Should() .Should()
.Contain( .Contain(u => u.Contains("Test.txt", StringComparison.Ordinal));
u =>
u.StartsWith(
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt",
StringComparison.Ordinal
)
);
} }
[Fact] [Fact]
@@ -52,13 +46,7 @@ public class HtmlAttachmentSpecs
.QuerySelectorAll("img") .QuerySelectorAll("img")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.Should() .Should()
.Contain( .Contain(u => u.Contains("bird-thumbnail.png", StringComparison.Ordinal));
u =>
u.StartsWith(
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png",
StringComparison.Ordinal
)
);
} }
[Fact] [Fact]
@@ -1,4 +1,5 @@
using System.Linq; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp.Dom; using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra; using DiscordChatExporter.Cli.Tests.Infra;
@@ -54,7 +55,7 @@ public class HtmlEmbedSpecs
.QuerySelectorAll("img") .QuerySelectorAll("img")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.WhereNotNull() .WhereNotNull()
.Where(s => s.EndsWith("f8w05ja8s4e61.png")) .Where(s => s.Contains("f8w05ja8s4e61.png", StringComparison.Ordinal))
.Should() .Should()
.ContainSingle(); .ContainSingle();
} }
@@ -89,11 +90,11 @@ public class HtmlEmbedSpecs
.QuerySelectorAll("source") .QuerySelectorAll("source")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.WhereNotNull() .WhereNotNull()
.Where( .Where(s =>
s => s.Contains(
s.EndsWith( "i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4",
"i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4" StringComparison.Ordinal
) )
) )
.Should() .Should()
.ContainSingle(); .ContainSingle();
@@ -113,7 +114,7 @@ public class HtmlEmbedSpecs
.QuerySelectorAll("source") .QuerySelectorAll("source")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.WhereNotNull() .WhereNotNull()
.Where(s => s.EndsWith("tooncasm-test-copy.mp4")) .Where(s => s.Contains("tooncasm-test-copy.mp4", StringComparison.Ordinal))
.Should() .Should()
.ContainSingle(); .ContainSingle();
} }
@@ -148,6 +149,26 @@ public class HtmlEmbedSpecs
iframeUrl.Should().StartWith("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP"); 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] [Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_a_YouTube_video_embed() public async Task I_can_export_a_channel_that_contains_a_message_with_a_YouTube_video_embed()
{ {
@@ -176,15 +197,45 @@ public class HtmlEmbedSpecs
); );
// Assert // Assert
message var imageUrls = message
.QuerySelectorAll("img") .QuerySelectorAll("img")
.Select(e => e.GetAttribute("src")) .Select(e => e.GetAttribute("src"))
.ToArray();
imageUrls
.Should() .Should()
.ContainInOrder( .Contain(u =>
"https://images-ext-1.discordapp.net/external/-n--xW3EHH_3jlrheVkMXHCM7T86b5Ty4-MzXCT4m1Q/https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png", u.EndsWith(
"https://images-ext-2.discordapp.net/external/z5nEmGeEldV-kswydGLhqUsFHbb5AWHtdvc9XT6N5rE/https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png", "https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
"https://images-ext-2.discordapp.net/external/gnip03SawMB6uZLagN5sRDpA_1Ap1CcEhMbJfK1z6WQ/https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png", StringComparison.Ordinal
"https://images-ext-2.discordapp.net/external/jl1v6cCbLaGmiwmKU-ZkXnF4cFsJ39f9A3-oEdqPdZs/https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png" )
);
imageUrls
.Should()
.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
)
);
imageUrls
.Should()
.Contain(u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png",
StringComparison.Ordinal
)
); );
message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle(); message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle();
@@ -202,6 +253,6 @@ public class HtmlEmbedSpecs
); );
// Assert // Assert
message.Text().Should().Contain("DiscordChatExporter TestServer"); message.Text().Should().Contain("DiscordChatExporter Test Server");
} }
} }
@@ -26,7 +26,7 @@ public class HtmlGroupingSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.GroupingTestCases }, ChannelIds = [ChannelIds.GroupingTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = file.Path OutputPath = file.Path
}.ExecuteAsync(new FakeConsole()); }.ExecuteAsync(new FakeConsole());
@@ -24,7 +24,7 @@ public class PartitioningSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
PartitionLimit = PartitionLimit.Parse("3") PartitionLimit = PartitionLimit.Parse("3")
@@ -45,7 +45,7 @@ public class PartitioningSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.DateRangeTestCases }, ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
PartitionLimit = PartitionLimit.Parse("1kb") PartitionLimit = PartitionLimit.Parse("1kb")
@@ -24,7 +24,7 @@ public class SelfContainedSpecs
await new ExportChannelsCommand await new ExportChannelsCommand
{ {
Token = Secrets.DiscordToken, Token = Secrets.DiscordToken,
ChannelIds = new[] { ChannelIds.SelfContainedTestCases }, ChannelIds = [ChannelIds.SelfContainedTestCases],
ExportFormat = ExportFormat.HtmlDark, ExportFormat = ExportFormat.HtmlDark,
OutputPath = filePath, OutputPath = filePath,
ShouldDownloadAssets = true ShouldDownloadAssets = true
@@ -5,11 +5,9 @@ using PathEx = System.IO.Path;
namespace DiscordChatExporter.Cli.Tests.Utils; namespace DiscordChatExporter.Cli.Tests.Utils;
internal partial class TempDir : IDisposable internal partial class TempDir(string path) : IDisposable
{ {
public string Path { get; } public string Path { get; } = path;
public TempDir(string path) => Path = path;
public void Dispose() public void Dispose()
{ {
@@ -5,11 +5,9 @@ using PathEx = System.IO.Path;
namespace DiscordChatExporter.Cli.Tests.Utils; namespace DiscordChatExporter.Cli.Tests.Utils;
internal partial class TempFile : IDisposable internal partial class TempFile(string path) : IDisposable
{ {
public string Path { get; } public string Path { get; } = path;
public TempFile(string path) => Path = path;
public void Dispose() public void Dispose()
{ {
+7 -6
View File
@@ -40,20 +40,21 @@ LABEL org.opencontainers.image.licenses="MIT"
# Alpine image doesn't come with the ICU libraries pre-installed, so we need to install them manually. # 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. # 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-libs icu-data-full
RUN apk add --no-cache icu-data-full
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
ENV LC_ALL=en_US.UTF-8 ENV LC_ALL=en_US.UTF-8
ENV LANG=en_US.UTF-8 ENV LANG=en_US.UTF-8
# Use a non-root user to ensure that the files shared with the host are accessible by the host user. # Use a non-root user to ensure that the files shared with the host are accessible by the host user
# We can use the default user provided by the base image for this purpose.
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851 # https://github.com/Tyrrrz/DiscordChatExporter/issues/851
USER $APP_UID # 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 # This directory is exposed to the user for mounting purposes, so it's important that it always
# stays the same for backwards compatibility. # stays the same for backwards compatibility.
WORKDIR /out WORKDIR /out
COPY --from=build /tmp/app/DiscordChatExporter.Cli/bin/publish /opt/app 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"]
@@ -4,6 +4,7 @@ using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils;
namespace DiscordChatExporter.Cli.Commands.Base; namespace DiscordChatExporter.Cli.Commands.Base;
@@ -37,26 +38,22 @@ public abstract class DiscordCommandBase : ICommand
{ {
using (console.WithForegroundColor(ConsoleColor.DarkYellow)) using (console.WithForegroundColor(ConsoleColor.DarkYellow))
{ {
console console.Error.WriteLine(
.Error "Warning: The --bot option is deprecated and should not be used. "
.WriteLine( + "The token type is now inferred automatically. "
"Warning: Option --bot is deprecated and should not be used. " + "Please update your workflows as this option may be completely removed in a future version."
+ "The type of the provided token is now inferred automatically. " );
+ "Please update your workflows as this option may be completely removed in a future version."
);
} }
} }
#pragma warning restore CS0618 #pragma warning restore CS0618
// Note about interactivity // Note about interactivity for Docker
if (console.IsOutputRedirected) if (console.IsOutputRedirected && Docker.IsRunningInContainer)
{ {
console console.Error.WriteLine(
.Output "Note: Output streams are redirected, rich console interactions are disabled. "
.WriteLine( + "If you are running this command in Docker, consider allocating a pseudo-terminal for better user experience (docker run -it ...)."
"Note: Output streams are redirected, rich console interactions are disabled. " );
+ "If you are running this command in Docker, consider allocating a pseudo-terminal for better user experience (docker run -it ...)."
);
} }
return default; return default;
@@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -30,8 +29,8 @@ public abstract class ExportCommandBase : DiscordCommandBase
"output", "output",
'o', 'o',
Description = "Output file or directory path. " Description = "Output file or directory path. "
+ "Directory path must end with a slash to avoid ambiguity. " + "If a directory is specified, file names will be generated automatically based on the channel names and export parameters. "
+ "If a directory is specified, file names will be generated automatically. " + "Directory paths must end with a slash to avoid ambiguity. "
+ "Supports template tokens, see the documentation for more info." + "Supports template tokens, see the documentation for more info."
)] )]
public string OutputPath public string OutputPath
@@ -118,8 +117,12 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt"; public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt";
[CommandOption("locale", Description = "Locale to use when formatting dates and numbers.")] [CommandOption(
public string Locale { get; init; } = CultureInfo.CurrentCulture.Name; "locale",
Description = "Locale to use when formatting dates and numbers. "
+ "If not specified, the default system locale will be used."
)]
public string? Locale { get; init; }
[CommandOption("utc", Description = "Normalize all timestamps to UTC+0.")] [CommandOption("utc", Description = "Normalize all timestamps to UTC+0.")]
public bool IsUtcNormalizationEnabled { get; init; } = false; public bool IsUtcNormalizationEnabled { get; init; } = false;
@@ -244,11 +247,9 @@ public abstract class ExportCommandBase : DiscordCommandBase
// Print the result // Print the result
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
{ {
await console await console.Output.WriteLineAsync(
.Output $"Successfully exported {channels.Count - errorsByChannel.Count} channel(s)."
.WriteLineAsync( );
$"Successfully exported {channels.Count - errorsByChannel.Count} channel(s)."
);
} }
// Print errors // Print errors
@@ -258,11 +259,9 @@ public abstract class ExportCommandBase : DiscordCommandBase
using (console.WithForegroundColor(ConsoleColor.Red)) using (console.WithForegroundColor(ConsoleColor.Red))
{ {
await console await console.Error.WriteLineAsync(
.Error $"Failed to export {errorsByChannel.Count} the following channel(s):"
.WriteLineAsync( );
$"Failed to export {errorsByChannel.Count} the following channel(s):"
);
} }
foreach (var (channel, error) in errorsByChannel) foreach (var (channel, error) in errorsByChannel)
@@ -324,51 +323,33 @@ public abstract class ExportCommandBase : DiscordCommandBase
// Support Ukraine callout // Support Ukraine callout
if (!IsUkraineSupportMessageDisabled) if (!IsUkraineSupportMessageDisabled)
{ {
console console.Output.WriteLine(
.Output "┌────────────────────────────────────────────────────────────────────┐"
.WriteLine( );
"┌────────────────────────────────────────────────────────────────────┐" console.Output.WriteLine(
); "│ Thank you for supporting Ukraine <3 │"
console );
.Output console.Output.WriteLine(
.WriteLine( "│ │"
"│ Thank you for supporting Ukraine <3 │" );
); console.Output.WriteLine(
console "│ As Russia wages a genocidal war against my country, │"
.Output );
.WriteLine( console.Output.WriteLine(
"│ │" "│ I'm grateful to everyone who continues to │"
); );
console console.Output.WriteLine(
.Output "│ stand with Ukraine in our fight for freedom. │"
.WriteLine( );
"│ As Russia wages a genocidal war against my country, │" console.Output.WriteLine(
); "│ │"
console );
.Output console.Output.WriteLine(
.WriteLine( "│ Learn more: https://tyrrrz.me/ukraine │"
"│ I'm grateful to everyone who continues to │" );
); console.Output.WriteLine(
console "└────────────────────────────────────────────────────────────────────┘"
.Output );
.WriteLine(
"│ stand with Ukraine in our fight for freedom. │"
);
console
.Output
.WriteLine(
"│ │"
);
console
.Output
.WriteLine(
"│ Learn more: https://tyrrrz.me/ukraine │"
);
console
.Output
.WriteLine(
"└────────────────────────────────────────────────────────────────────┘"
);
console.Output.WriteLine(""); console.Output.WriteLine("");
} }
@@ -21,7 +21,7 @@ public class ExportAllCommand : ExportCommandBase
[CommandOption("include-dm", Description = "Include direct message channels.")] [CommandOption("include-dm", Description = "Include direct message channels.")]
public bool IncludeDirectChannels { get; init; } = true; public bool IncludeDirectChannels { get; init; } = true;
[CommandOption("include-guilds", Description = "Include guild channels.")] [CommandOption("include-guilds", Description = "Include server channels.")]
public bool IncludeGuildChannels { get; init; } = true; public bool IncludeGuildChannels { get; init; } = true;
[CommandOption("include-vc", Description = "Include voice channels.")] [CommandOption("include-vc", Description = "Include voice channels.")]
@@ -54,9 +54,9 @@ public class ExportAllCommand : ExportCommandBase
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken)) await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
{ {
// Regular channels // Regular channels
await console await console.Output.WriteLineAsync(
.Output $"Fetching channels for server '{guild.Name}'..."
.WriteLineAsync($"Fetching channels for guild '{guild.Name}'..."); );
var fetchedChannelsCount = 0; var fetchedChannelsCount = 0;
await console await console
@@ -94,9 +94,9 @@ public class ExportAllCommand : ExportCommandBase
// Threads // Threads
if (ThreadInclusionMode != ThreadInclusionMode.None) if (ThreadInclusionMode != ThreadInclusionMode.None)
{ {
await console await console.Output.WriteLineAsync(
.Output $"Fetching threads for server '{guild.Name}'..."
.WriteLineAsync($"Fetching threads for guild '{guild.Name}'..."); );
var fetchedThreadsCount = 0; var fetchedThreadsCount = 0;
await console await console
@@ -126,9 +126,9 @@ public class ExportAllCommand : ExportCommandBase
} }
); );
await console await console.Output.WriteLineAsync(
.Output $"Fetched {fetchedThreadsCount} thread(s)."
.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s)."); );
} }
} }
} }
@@ -180,9 +180,9 @@ public class ExportAllCommand : ExportCommandBase
using (console.WithForegroundColor(ConsoleColor.Red)) using (console.WithForegroundColor(ConsoleColor.Red))
{ {
await console await console.Error.WriteLineAsync(
.Error "Failed to access the following channel(s):"
.WriteLineAsync("Failed to access the following channel(s):"); );
} }
foreach (var dumpChannel in inaccessibleChannels) foreach (var dumpChannel in inaccessibleChannels)
@@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
@@ -13,10 +12,10 @@ using Spectre.Console;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("exportguild", Description = "Exports all channels within the specified guild.")] [Command("exportguild", Description = "Exports all channels within the specified server.")]
public class ExportGuildCommand : ExportCommandBase public class ExportGuildCommand : ExportCommandBase
{ {
[CommandOption("guild", 'g', Description = "Guild ID.")] [CommandOption("guild", 'g', Description = "Server ID.")]
public required Snowflake GuildId { get; init; } public required Snowflake GuildId { get; init; }
[CommandOption("include-vc", Description = "Include voice channels.")] [CommandOption("include-vc", Description = "Include voice channels.")]
@@ -7,15 +7,14 @@ using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters; using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared; using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("channels", Description = "Get the list of channels in a guild.")] [Command("channels", Description = "Get the list of channels in a server.")]
public class GetChannelsCommand : DiscordCommandBase public class GetChannelsCommand : DiscordCommandBase
{ {
[CommandOption("guild", 'g', Description = "Guild ID.")] [CommandOption("guild", 'g', Description = "Server ID.")]
public required Snowflake GuildId { get; init; } public required Snowflake GuildId { get; init; }
[CommandOption("include-vc", Description = "Include voice channels.")] [CommandOption("include-vc", Description = "Include voice channels.")]
@@ -59,14 +58,14 @@ public class GetChannelsCommand : DiscordCommandBase
) )
.OrderBy(c => c.Name) .OrderBy(c => c.Name)
.ToArray() .ToArray()
: Array.Empty<Channel>(); : [];
foreach (var channel in channels) foreach (var channel in channels)
{ {
// Channel ID // Channel ID
await console await console.Output.WriteAsync(
.Output channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
.WriteAsync(channel.Id.ToString().PadRight(channelIdMaxLength, ' ')); );
// Separator // Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray)) using (console.WithForegroundColor(ConsoleColor.DarkGray))
@@ -88,11 +87,9 @@ public class GetChannelsCommand : DiscordCommandBase
await console.Output.WriteAsync(" * "); await console.Output.WriteAsync(" * ");
// Thread ID // Thread ID
await console await console.Output.WriteAsync(
.Output channelThread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
.WriteAsync( );
channelThread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
);
// Separator // Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray)) using (console.WithForegroundColor(ConsoleColor.DarkGray))
@@ -108,9 +105,9 @@ public class GetChannelsCommand : DiscordCommandBase
// Thread status // Thread status
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
await console await console.Output.WriteLineAsync(
.Output channelThread.IsArchived ? "Archived" : "Active"
.WriteLineAsync(channelThread.IsArchived ? "Archived" : "Active"); );
} }
} }
} }
@@ -33,9 +33,9 @@ public class GetDirectChannelsCommand : DiscordCommandBase
foreach (var channel in channels) foreach (var channel in channels)
{ {
// Channel ID // Channel ID
await console await console.Output.WriteAsync(
.Output channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
.WriteAsync(channel.Id.ToString().PadRight(channelIdMaxLength, ' ')); );
// Separator // Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray)) using (console.WithForegroundColor(ConsoleColor.DarkGray))
@@ -9,7 +9,7 @@ using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("guilds", Description = "Gets the list of accessible guilds.")] [Command("guilds", Description = "Gets the list of accessible servers.")]
public class GetGuildsCommand : DiscordCommandBase public class GetGuildsCommand : DiscordCommandBase
{ {
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
@@ -6,20 +6,18 @@ using CliFx.Infrastructure;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
[Command("guide", Description = "Explains how to obtain the token, guild or channel ID.")] [Command("guide", Description = "Explains how to obtain the token, server or channel ID.")]
public class GuideCommand : ICommand public class GuideCommand : ICommand
{ {
public ValueTask ExecuteAsync(IConsole console) public ValueTask ExecuteAsync(IConsole console)
{ {
// User token // User token
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get user token:"); console.Output.WriteLine("To get the token for your personal account:");
console console.Output.WriteLine(
.Output " * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!"
.WriteLine( );
" * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!"
);
console.Output.WriteLine(" 1. Open Discord in your web browser and login"); console.Output.WriteLine(" 1. Open Discord in your web browser and login");
console.Output.WriteLine(" 2. Open any server or direct message channel"); console.Output.WriteLine(" 2. Open any server or direct message channel");
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools"); console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
@@ -34,43 +32,44 @@ public class GuideCommand : ICommand
// Bot token // Bot token
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get bot token:"); console.Output.WriteLine("To get the token for your bot:");
console.Output.WriteLine(" 1. Go to Discord developer portal"); console.Output.WriteLine(" 1. Go to Discord developer portal");
console.Output.WriteLine(" 2. Open your application's settings"); console.Output.WriteLine(" 2. Open your application's settings");
console.Output.WriteLine(" 3. Navigate to the Bot section on the left"); console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
console.Output.WriteLine(" 4. Under Token click Copy"); console.Output.WriteLine(" 4. Under Token click Copy");
console console.Output.WriteLine(
.Output " * Your bot needs to have the Message Content Intent enabled to read messages"
.WriteLine( );
" * Your bot needs to have Message Content Intent enabled to read messages"
);
console.Output.WriteLine(); console.Output.WriteLine();
// Guild or channel ID // Guild or channel ID
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console.Output.WriteLine("To get guild ID or channel ID:"); console.Output.WriteLine("To get the ID of a server or a channel:");
console.Output.WriteLine(" 1. Open Discord"); console.Output.WriteLine(" 1. Open Discord");
console.Output.WriteLine(" 2. Open Settings"); console.Output.WriteLine(" 2. Open Settings");
console.Output.WriteLine(" 3. Go to Advanced section"); console.Output.WriteLine(" 3. Go to Advanced section");
console.Output.WriteLine(" 4. Enable Developer Mode"); console.Output.WriteLine(" 4. Enable Developer Mode");
console console.Output.WriteLine(
.Output " 5. Right-click on the desired server or channel and click Copy Server ID or Copy Channel ID"
.WriteLine( );
" 5. Right-click on the desired guild or channel and click Copy Server ID or Copy Channel ID"
);
console.Output.WriteLine(); console.Output.WriteLine();
// Docs link // Docs link
using (console.WithForegroundColor(ConsoleColor.White)) using (console.WithForegroundColor(ConsoleColor.White))
console {
.Output console.Output.WriteLine(
.WriteLine("If you have questions or issues, please refer to the documentation:"); "If you have questions or issues, please refer to the documentation:"
);
}
using (console.WithForegroundColor(ConsoleColor.DarkCyan)) using (console.WithForegroundColor(ConsoleColor.DarkCyan))
console {
.Output console.Output.WriteLine(
.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"); "https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"
);
}
return default; return default;
} }
@@ -3,15 +3,16 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<ApplicationIcon>..\favicon.ico</ApplicationIcon> <ApplicationIcon>..\favicon.ico</ApplicationIcon>
<PublishTrimmed>true</PublishTrimmed>
<NoWarn>$(NoWarn);IL2104</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="2.3.5" /> <PackageReference Include="CliFx" Version="2.3.5" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.2" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" /> <PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="Spectre.Console" Version="0.47.0" /> <PackageReference Include="Spectre.Console" Version="0.49.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+36 -2
View File
@@ -1,3 +1,37 @@
using CliFx; using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CliFx;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Commands.Converters;
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))]
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);
}
@@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Discord.Data;
public record ChannelNode(Channel Channel, IReadOnlyList<ChannelNode> Children)
{
public static IReadOnlyList<ChannelNode> BuildTree(IReadOnlyList<Channel> channels)
{
IReadOnlyList<ChannelNode> GetChildren(Channel parent) =>
channels
.Where(c => c.Parent?.Id == parent.Id)
.Select(c => new ChannelNode(c, GetChildren(c)))
.ToArray();
return channels
.Where(c => c.Parent is null)
.Select(c => new ChannelNode(c, GetChildren(c)))
.ToArray();
}
}
@@ -1,7 +1,6 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Discord.Data.Common; namespace DiscordChatExporter.Core.Discord.Data.Common;
@@ -11,7 +10,7 @@ public static class ImageCdn
// Standard emoji are rendered through Twemoji // Standard emoji are rendered through Twemoji
public static string GetStandardEmojiUrl(string emojiName) public static string GetStandardEmojiUrl(string emojiName)
{ {
var runes = emojiName.GetRunes().ToArray(); var runes = emojiName.EnumerateRunes().ToArray();
// Variant selector rune is skipped in Twemoji IDs, // Variant selector rune is skipped in Twemoji IDs,
// except when the emoji also contains a zero-width joiner. // except when the emoji also contains a zero-width joiner.
@@ -31,6 +31,9 @@ public partial record Embed(
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() => public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() =>
SpotifyTrackEmbedProjection.TryResolve(this); SpotifyTrackEmbedProjection.TryResolve(this);
public TwitchClipEmbedProjection? TryGetTwitchClip() =>
TwitchClipEmbedProjection.TryResolve(this);
public YouTubeVideoEmbedProjection? TryGetYouTubeVideo() => public YouTubeVideoEmbedProjection? TryGetYouTubeVideo() =>
YouTubeVideoEmbedProjection.TryResolve(this); YouTubeVideoEmbedProjection.TryResolve(this);
} }
@@ -60,7 +63,7 @@ public partial record Embed
json.GetPropertyOrNull("fields") json.GetPropertyOrNull("fields")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(EmbedField.Parse) ?.Select(EmbedField.Parse)
.ToArray() ?? Array.Empty<EmbedField>(); .ToArray() ?? [];
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse); var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
@@ -75,7 +78,7 @@ public partial record Embed
json.GetPropertyOrNull("image") json.GetPropertyOrNull("image")
?.Pipe(EmbedImage.Parse) ?.Pipe(EmbedImage.Parse)
.ToSingletonEnumerable() .ToSingletonEnumerable()
.ToArray() ?? Array.Empty<EmbedImage>(); .ToArray() ?? [];
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse); var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
@@ -0,0 +1,53 @@
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
public partial record TwitchClipEmbedProjection(string ClipId)
{
public string Url => $"https://clips.twitch.tv/embed?clip={ClipId}&parent=localhost";
}
public 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);
}
}
@@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Common;
@@ -21,8 +20,7 @@ public partial record Member(
public partial record Member public partial record Member
{ {
public static Member CreateFallback(User user) => public static Member CreateFallback(User user) => new(user, null, null, []);
new(user, null, null, Array.Empty<Snowflake>());
public static Member Parse(JsonElement json, Snowflake? guildId = null) public static Member Parse(JsonElement json, Snowflake? guildId = null)
{ {
@@ -34,7 +32,7 @@ public partial record Member
?.EnumerateArray() ?.EnumerateArray()
.Select(j => j.GetNonWhiteSpaceString()) .Select(j => j.GetNonWhiteSpaceString())
.Select(Snowflake.Parse) .Select(Snowflake.Parse)
.ToArray() ?? Array.Empty<Snowflake>(); .ToArray() ?? [];
var avatarUrl = guildId is not null var avatarUrl = guildId is not null
? json.GetPropertyOrNull("avatar") ? json.GetPropertyOrNull("avatar")
@@ -83,16 +83,15 @@ public partial record Message
// Find embeds with the same URL that only contain a single image and nothing else // Find embeds with the same URL that only contain a single image and nothing else
var trailingEmbeds = embeds var trailingEmbeds = embeds
.Skip(i + 1) .Skip(i + 1)
.TakeWhile( .TakeWhile(e =>
e => e.Url == embed.Url
e.Url == embed.Url && e.Timestamp is null
&& e.Timestamp is null && e.Author is null
&& e.Author is null && e.Color is null
&& e.Color is null && string.IsNullOrWhiteSpace(e.Description)
&& string.IsNullOrWhiteSpace(e.Description) && !e.Fields.Any()
&& !e.Fields.Any() && e.Images.Count == 1
&& e.Images.Count == 1 && e.Footer is null
&& e.Footer is null
) )
.ToArray(); .ToArray();
@@ -100,8 +99,7 @@ public partial record Message
{ {
// Concatenate all images into one embed // Concatenate all images into one embed
var images = embed var images = embed
.Images .Images.Concat(trailingEmbeds.SelectMany(e => e.Images))
.Concat(trailingEmbeds.SelectMany(e => e.Images))
.ToArray(); .ToArray();
normalizedEmbeds.Add(embed with { Images = images }); normalizedEmbeds.Add(embed with { Images = images });
@@ -145,28 +143,28 @@ public partial record Message
json.GetPropertyOrNull("attachments") json.GetPropertyOrNull("attachments")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(Attachment.Parse) ?.Select(Attachment.Parse)
.ToArray() ?? Array.Empty<Attachment>(); .ToArray() ?? [];
var embeds = NormalizeEmbeds( var embeds = NormalizeEmbeds(
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
?? Array.Empty<Embed>() ?? []
); );
var stickers = var stickers =
json.GetPropertyOrNull("sticker_items") json.GetPropertyOrNull("sticker_items")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(Sticker.Parse) ?.Select(Sticker.Parse)
.ToArray() ?? Array.Empty<Sticker>(); .ToArray() ?? [];
var reactions = var reactions =
json.GetPropertyOrNull("reactions") json.GetPropertyOrNull("reactions")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(Reaction.Parse) ?.Select(Reaction.Parse)
.ToArray() ?? Array.Empty<Reaction>(); .ToArray() ?? [];
var mentionedUsers = var mentionedUsers =
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray() json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
?? Array.Empty<User>(); ?? [];
var messageReference = json.GetPropertyOrNull("message_reference") var messageReference = json.GetPropertyOrNull("message_reference")
?.Pipe(MessageReference.Parse); ?.Pipe(MessageReference.Parse);
@@ -18,15 +18,11 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord; namespace DiscordChatExporter.Core.Discord;
public class DiscordClient public class DiscordClient(string token)
{ {
private readonly string _token;
private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute); private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute);
private TokenKind? _resolvedTokenKind; private TokenKind? _resolvedTokenKind;
public DiscordClient(string token) => _token = token;
private async ValueTask<HttpResponseMessage> GetResponseAsync( private async ValueTask<HttpResponseMessage> GetResponseAsync(
string url, string url,
TokenKind tokenKind, TokenKind tokenKind,
@@ -40,12 +36,10 @@ public class DiscordClient
// Don't validate because the token can have special characters // Don't validate because the token can have special characters
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828 // https://github.com/Tyrrrz/DiscordChatExporter/issues/828
request request.Headers.TryAddWithoutValidation(
.Headers "Authorization",
.TryAddWithoutValidation( tokenKind == TokenKind.Bot ? $"Bot {token}" : token
"Authorization", );
tokenKind == TokenKind.Bot ? $"Bot {_token}" : _token
);
var response = await Http.Client.SendAsync( var response = await Http.Client.SendAsync(
request, request,
@@ -61,25 +55,23 @@ public class DiscordClient
// rate limits and that's just way too much effort. // rate limits and that's just way too much effort.
// https://discord.com/developers/docs/topics/rate-limits // https://discord.com/developers/docs/topics/rate-limits
var remainingRequestCount = response var remainingRequestCount = response
.Headers .Headers.TryGetValue("X-RateLimit-Remaining")
.TryGetValue("X-RateLimit-Remaining")
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture)); ?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
var resetAfterDelay = response var resetAfterDelay = response
.Headers .Headers.TryGetValue("X-RateLimit-Reset-After")
.TryGetValue("X-RateLimit-Reset-After")
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture)) ?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
.Pipe(TimeSpan.FromSeconds); .Pipe(TimeSpan.FromSeconds);
if (remainingRequestCount <= 0 && resetAfterDelay is not null) if (remainingRequestCount <= 0 && resetAfterDelay is not null)
{ {
var delay = var delay =
// Adding a small buffer to the reset time reduces the chance of getting // 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. // rate limited again, because it allows for more requests to be released.
(resetAfterDelay.Value + TimeSpan.FromSeconds(1)) (resetAfterDelay.Value + TimeSpan.FromSeconds(1))
// Sometimes Discord returns an absurdly high value for the reset time, which // 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. // is not actually enforced by the server. So we cap it at a reasonable value.
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60)); .Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
await Task.Delay(delay, innerCancellationToken); await Task.Delay(delay, innerCancellationToken);
} }
@@ -160,8 +152,13 @@ public class DiscordClient
_ _
=> throw new DiscordChatExporterException( => throw new DiscordChatExporterException(
$""" $"""
Request to '{url}' failed: {response.StatusCode.ToString().ToSpaceSeparatedWords().ToLowerInvariant()}. Request to '{url}' failed: {response
Response content: {await response.Content.ReadAsStringAsync(cancellationToken)} .StatusCode.ToString()
.ToSpaceSeparatedWords()
.ToLowerInvariant()}.
Response content: {await response.Content.ReadAsStringAsync(
cancellationToken
)}
""", """,
true true
) )
@@ -679,10 +676,14 @@ public class DiscordClient
.SetQueryParameter("after", currentAfter.ToString()) .SetQueryParameter("after", currentAfter.ToString())
.Build(); .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; var count = 0;
foreach (var userJson in response.EnumerateArray()) foreach (var userJson in response.Value.EnumerateArray())
{ {
var user = User.Parse(userJson); var user = User.Parse(userJson);
yield return user; yield return user;
@@ -8,11 +8,9 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Dump; namespace DiscordChatExporter.Core.Discord.Dump;
public partial class DataDump public partial class DataDump(IReadOnlyList<DataDumpChannel> channels)
{ {
public IReadOnlyList<DataDumpChannel> Channels { get; } public IReadOnlyList<DataDumpChannel> Channels { get; } = channels;
public DataDump(IReadOnlyList<DataDumpChannel> channels) => Channels = channels;
} }
public partial class DataDump public partial class DataDump
@@ -48,7 +46,7 @@ public partial class DataDump
if (entry is null) if (entry is null)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"Could not find the channel index inside the data package." "Failed to locate the channel index inside the data package."
); );
} }
@@ -1,15 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncKeyedLock" Version="6.2.2" /> <PackageReference Include="AsyncKeyedLock" Version="6.4.2" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.2" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Polly" Version="8.2.0" /> <PackageReference Include="Polly" Version="8.4.0" />
<PackageReference Include="RazorBlade" Version="0.4.4" /> <PackageReference Include="RazorBlade" Version="0.6.0" />
<PackageReference Include="Superpower" Version="3.0.0" /> <PackageReference Include="Superpower" Version="3.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.14.0" /> <PackageReference Include="WebMarkupMin.Core" Version="2.16.0" />
<PackageReference Include="YoutubeExplode" Version="6.3.7" /> <PackageReference Include="YoutubeExplode" Version="6.3.16" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -2,17 +2,11 @@
namespace DiscordChatExporter.Core.Exceptions; namespace DiscordChatExporter.Core.Exceptions;
public class DiscordChatExporterException : Exception public class DiscordChatExporterException(
string message,
bool isFatal = false,
Exception? innerException = null
) : Exception(message, innerException)
{ {
public bool IsFatal { get; } public bool IsFatal { get; } = isFatal;
public DiscordChatExporterException(
string message,
bool isFatal = false,
Exception? innerException = null
)
: base(message, innerException)
{
IsFatal = isFatal;
}
} }
@@ -10,8 +10,6 @@ namespace DiscordChatExporter.Core.Exporting;
public class ChannelExporter(DiscordClient discord) public class ChannelExporter(DiscordClient discord)
{ {
private readonly DiscordClient _discord = discord;
public async ValueTask ExportChannelAsync( public async ValueTask ExportChannelAsync(
ExportRequest request, ExportRequest request,
IProgress<Percentage>? progress = null, IProgress<Percentage>? progress = null,
@@ -22,8 +20,8 @@ public class ChannelExporter(DiscordClient discord)
if (request.Channel.Kind == ChannelKind.GuildForum) if (request.Channel.Kind == ChannelKind.GuildForum)
{ {
throw new DiscordChatExporterException( throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) " $"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) " + $"of guild '{request.Guild.Name}' "
+ $"is a forum and cannot be exported directly. " + $"is a forum and cannot be exported directly. "
+ "You need to pull its threads and export them individually." + "You need to pull its threads and export them individually."
); );
@@ -33,8 +31,8 @@ public class ChannelExporter(DiscordClient discord)
if (request.Channel.IsEmpty) if (request.Channel.IsEmpty)
{ {
throw new DiscordChatExporterException( throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) " $"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) " + $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages." + $"does not contain any messages."
); );
} }
@@ -43,8 +41,8 @@ public class ChannelExporter(DiscordClient discord)
if (request.After is not null && !request.Channel.MayHaveMessagesAfter(request.After.Value)) if (request.After is not null && !request.Channel.MayHaveMessagesAfter(request.After.Value))
{ {
throw new DiscordChatExporterException( throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) " $"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) " + $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages within the specified period." + $"does not contain any messages within the specified period."
); );
} }
@@ -56,20 +54,20 @@ public class ChannelExporter(DiscordClient discord)
) )
{ {
throw new DiscordChatExporterException( throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) " $"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) " + $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages within the specified period." + $"does not contain any messages within the specified period."
); );
} }
// Build context // Build context
var context = new ExportContext(_discord, request); var context = new ExportContext(discord, request);
await context.PopulateChannelsAndRolesAsync(cancellationToken); await context.PopulateChannelsAndRolesAsync(cancellationToken);
// Export messages // Export messages
await using var messageExporter = new MessageExporter(context); await using var messageExporter = new MessageExporter(context);
await foreach ( await foreach (
var message in _discord.GetMessagesAsync( var message in discord.GetMessagesAsync(
request.Channel.Id, request.Channel.Id,
request.After, request.After,
request.Before, request.Before,
@@ -1,4 +1,5 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text; using System.Text;
using System.Threading; using System.Threading;
@@ -122,7 +123,7 @@ internal partial class CsvMessageWriter
{ {
private static string CsvEncode(string value) private static string CsvEncode(string value)
{ {
value = value.Replace("\"", "\"\""); value = value.Replace("\"", "\"\"", StringComparison.Ordinal);
return $"\"{value}\""; return $"\"{value}\"";
} }
} }
@@ -23,9 +23,6 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
o.PoolInitialFill = 1; o.PoolInitialFill = 1;
}); });
private readonly string _workingDirPath = workingDirPath;
private readonly bool _reuse = reuse;
// File paths of the previously downloaded assets // File paths of the previously downloaded assets
private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal); private readonly Dictionary<string, string> _previousPathsByUrl = new(StringComparer.Ordinal);
@@ -35,7 +32,7 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
) )
{ {
var fileName = GetFileNameFromUrl(url); var fileName = GetFileNameFromUrl(url);
var filePath = Path.Combine(_workingDirPath, fileName); var filePath = Path.Combine(workingDirPath, fileName);
using var _ = await Locker.LockAsync(filePath, cancellationToken); using var _ = await Locker.LockAsync(filePath, cancellationToken);
@@ -43,10 +40,10 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
return cachedFilePath; return cachedFilePath;
// Reuse existing files if we're allowed to // Reuse existing files if we're allowed to
if (_reuse && File.Exists(filePath)) if (reuse && File.Exists(filePath))
return _previousPathsByUrl[url] = filePath; return _previousPathsByUrl[url] = filePath;
Directory.CreateDirectory(_workingDirPath); Directory.CreateDirectory(workingDirPath);
await Http.ResiliencePipeline.ExecuteAsync( await Http.ResiliencePipeline.ExecuteAsync(
async innerCancellationToken => async innerCancellationToken =>
@@ -60,19 +57,16 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
try try
{ {
var lastModified = response var lastModified = response
.Content .Content.Headers.TryGetValue("Last-Modified")
.Headers ?.Pipe(s =>
.TryGetValue("Last-Modified") DateTimeOffset.TryParse(
?.Pipe( s,
s => CultureInfo.InvariantCulture,
DateTimeOffset.TryParse( DateTimeStyles.None,
s, out var instant
CultureInfo.InvariantCulture, )
DateTimeStyles.None, ? instant
out var instant : (DateTimeOffset?)null
)
? instant
: (DateTimeOffset?)null
); );
if (lastModified is not null) if (lastModified is not null)
@@ -93,11 +93,10 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
public IReadOnlyList<Role> GetUserRoles(Snowflake id) => public IReadOnlyList<Role> GetUserRoles(Snowflake id) =>
TryGetMember(id) TryGetMember(id)
?.RoleIds ?.RoleIds.Select(TryGetRole)
.Select(TryGetRole)
.WhereNotNull() .WhereNotNull()
.OrderByDescending(r => r.Position) .OrderByDescending(r => r.Position)
.ToArray() ?? Array.Empty<Role>(); .ToArray() ?? [];
public Color? TryGetUserColor(Snowflake id) => public Color? TryGetUserColor(Snowflake id) =>
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault(); GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
@@ -8,6 +8,7 @@ using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting.Filtering; using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning; using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils; using DiscordChatExporter.Core.Utils;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting; namespace DiscordChatExporter.Core.Exporting;
@@ -39,9 +40,9 @@ public partial class ExportRequest
public bool ShouldReuseAssets { get; } public bool ShouldReuseAssets { get; }
public string Locale { get; } public string? Locale { get; }
public CultureInfo CultureInfo { get; } public CultureInfo? CultureInfo { get; }
public bool IsUtcNormalizationEnabled { get; } public bool IsUtcNormalizationEnabled { get; }
@@ -58,7 +59,7 @@ public partial class ExportRequest
bool shouldFormatMarkdown, bool shouldFormatMarkdown,
bool shouldDownloadAssets, bool shouldDownloadAssets,
bool shouldReuseAssets, bool shouldReuseAssets,
string locale, string? locale,
bool isUtcNormalizationEnabled bool isUtcNormalizationEnabled
) )
{ {
@@ -83,7 +84,7 @@ public partial class ExportRequest
? FormatPath(assetsDirPath, Guild, Channel, After, Before) ? FormatPath(assetsDirPath, Guild, Channel, After, Before)
: $"{OutputFilePath}_Files{Path.DirectorySeparatorChar}"; : $"{OutputFilePath}_Files{Path.DirectorySeparatorChar}";
CultureInfo = CultureInfo.GetCultureInfo(Locale); CultureInfo = Locale?.Pipe(CultureInfo.GetCultureInfo);
} }
} }
@@ -182,9 +183,10 @@ public partial class ExportRequest
=> before?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) => before?.ToDate().ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
?? "", ?? "",
"%d" "%d"
=> DateTimeOffset => DateTimeOffset.Now.ToString(
.Now "yyyy-MM-dd",
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), CultureInfo.InvariantCulture
),
"%%" => "%", "%%" => "%",
_ => m.Value _ => m.Value
@@ -3,28 +3,17 @@ using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class BinaryExpressionMessageFilter : MessageFilter internal class BinaryExpressionMessageFilter(
MessageFilter first,
MessageFilter second,
BinaryExpressionKind kind
) : MessageFilter
{ {
private readonly MessageFilter _first;
private readonly MessageFilter _second;
private readonly BinaryExpressionKind _kind;
public BinaryExpressionMessageFilter(
MessageFilter first,
MessageFilter second,
BinaryExpressionKind kind
)
{
_first = first;
_second = second;
_kind = kind;
}
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
_kind switch kind switch
{ {
BinaryExpressionKind.Or => _first.IsMatch(message) || _second.IsMatch(message), BinaryExpressionKind.Or => first.IsMatch(message) || second.IsMatch(message),
BinaryExpressionKind.And => _first.IsMatch(message) && _second.IsMatch(message), BinaryExpressionKind.And => first.IsMatch(message) && second.IsMatch(message),
_ => throw new InvalidOperationException($"Unknown binary expression kind '{_kind}'.") _ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'.")
}; };
} }
@@ -4,12 +4,8 @@ using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class ContainsMessageFilter : MessageFilter internal class ContainsMessageFilter(string text) : MessageFilter
{ {
private readonly string _text;
public ContainsMessageFilter(string text) => _text = text;
// Match content within word boundaries, between spaces, or as the whole input. // Match content within word boundaries, between spaces, or as the whole input.
// For example, "max" shouldn't match on content "our maximum effort", // For example, "max" shouldn't match on content "our maximum effort",
// but should match on content "our max effort". // but should match on content "our max effort".
@@ -20,20 +16,17 @@ internal class ContainsMessageFilter : MessageFilter
!string.IsNullOrWhiteSpace(content) !string.IsNullOrWhiteSpace(content)
&& Regex.IsMatch( && Regex.IsMatch(
content, content,
@"(?:\b|\s|^)" + Regex.Escape(_text) + @"(?:\b|\s|$)", @"(?:\b|\s|^)" + Regex.Escape(text) + @"(?:\b|\s|$)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
); );
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
IsMatch(message.Content) IsMatch(message.Content)
|| message || message.Embeds.Any(e =>
.Embeds IsMatch(e.Title)
.Any( || IsMatch(e.Author?.Name)
e => || IsMatch(e.Description)
IsMatch(e.Title) || IsMatch(e.Footer?.Text)
|| IsMatch(e.Author?.Name) || e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
|| IsMatch(e.Description) );
|| IsMatch(e.Footer?.Text)
|| e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
);
} }
@@ -3,15 +3,11 @@ using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class FromMessageFilter : MessageFilter internal class FromMessageFilter(string value) : MessageFilter
{ {
private readonly string _value;
public FromMessageFilter(string value) => _value = value;
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
string.Equals(_value, message.Author.Name, StringComparison.OrdinalIgnoreCase) string.Equals(value, message.Author.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(_value, message.Author.DisplayName, StringComparison.OrdinalIgnoreCase) || string.Equals(value, message.Author.DisplayName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(_value, message.Author.FullName, StringComparison.OrdinalIgnoreCase) || string.Equals(value, message.Author.FullName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(_value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase); || string.Equals(value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase);
} }
@@ -1,30 +1,31 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown.Parsing;
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class HasMessageFilter : MessageFilter internal class HasMessageFilter(MessageContentMatchKind kind) : MessageFilter
{ {
private readonly MessageContentMatchKind _kind;
public HasMessageFilter(MessageContentMatchKind kind) => _kind = kind;
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
_kind switch kind switch
{ {
MessageContentMatchKind.Link MessageContentMatchKind.Link => MarkdownParser.ExtractLinks(message.Content).Any(),
=> Regex.IsMatch(message.Content, "https?://\\S*[^\\.,:;\"\'\\s]"),
MessageContentMatchKind.Embed => message.Embeds.Any(), MessageContentMatchKind.Embed => message.Embeds.Any(),
MessageContentMatchKind.File => message.Attachments.Any(), MessageContentMatchKind.File => message.Attachments.Any(),
MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo), MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo),
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage), MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio), MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
MessageContentMatchKind.Pin => message.IsPinned, MessageContentMatchKind.Pin => message.IsPinned,
MessageContentMatchKind.Invite
=> MarkdownParser
.ExtractLinks(message.Content)
.Select(l => l.Url)
.Select(Invite.TryGetCodeFromUrl)
.Any(c => !string.IsNullOrWhiteSpace(c)),
_ _
=> throw new InvalidOperationException( => throw new InvalidOperationException(
$"Unknown message content match kind '{_kind}'." $"Unknown message content match kind '{kind}'."
) )
}; };
} }
@@ -4,20 +4,13 @@ using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class MentionsMessageFilter : MessageFilter internal class MentionsMessageFilter(string value) : MessageFilter
{ {
private readonly string _value;
public MentionsMessageFilter(string value) => _value = value;
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
message message.MentionedUsers.Any(user =>
.MentionedUsers string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase)
.Any( || string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase)
user => || string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase)
string.Equals(_value, user.Name, StringComparison.OrdinalIgnoreCase) || string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
|| string.Equals(_value, user.DisplayName, StringComparison.OrdinalIgnoreCase) );
|| string.Equals(_value, user.FullName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(_value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
);
} }
@@ -8,5 +8,6 @@ internal enum MessageContentMatchKind
Video, Video,
Image, Image,
Sound, Sound,
Pin Pin,
Invite
} }
@@ -2,11 +2,7 @@
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class NegatedMessageFilter : MessageFilter internal class NegatedMessageFilter(MessageFilter filter) : MessageFilter
{ {
private readonly MessageFilter _filter; public override bool IsMatch(Message message) => !filter.IsMatch(message);
public NegatedMessageFilter(MessageFilter filter) => _filter = filter;
public override bool IsMatch(Message message) => !_filter.IsMatch(message);
} }
@@ -30,8 +30,8 @@ internal static class FilterGrammar
.OneOf(QuotedString, UnquotedString) .OneOf(QuotedString, UnquotedString)
.Named("text string"); .Named("text string");
private static readonly TextParser<MessageFilter> ContainsFilter = String.Select( private static readonly TextParser<MessageFilter> ContainsFilter = String.Select(v =>
v => (MessageFilter)new ContainsMessageFilter(v) (MessageFilter)new ContainsMessageFilter(v)
); );
private static readonly TextParser<MessageFilter> FromFilter = Span.EqualToIgnoreCase("from:") private static readonly TextParser<MessageFilter> FromFilter = Span.EqualToIgnoreCase("from:")
@@ -61,18 +61,28 @@ internal static class FilterGrammar
.IgnoreThen( .IgnoreThen(
Parse.OneOf( Parse.OneOf(
Span.EqualToIgnoreCase("link") Span.EqualToIgnoreCase("link")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Link)), .IgnoreThen(Parse.Return(MessageContentMatchKind.Link))
.Try(),
Span.EqualToIgnoreCase("embed") Span.EqualToIgnoreCase("embed")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)), .IgnoreThen(Parse.Return(MessageContentMatchKind.Embed))
.Try(),
Span.EqualToIgnoreCase("file") Span.EqualToIgnoreCase("file")
.IgnoreThen(Parse.Return(MessageContentMatchKind.File)), .IgnoreThen(Parse.Return(MessageContentMatchKind.File))
.Try(),
Span.EqualToIgnoreCase("video") Span.EqualToIgnoreCase("video")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Video)), .IgnoreThen(Parse.Return(MessageContentMatchKind.Video))
.Try(),
Span.EqualToIgnoreCase("image") Span.EqualToIgnoreCase("image")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Image)), .IgnoreThen(Parse.Return(MessageContentMatchKind.Image))
.Try(),
Span.EqualToIgnoreCase("sound") Span.EqualToIgnoreCase("sound")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)), .IgnoreThen(Parse.Return(MessageContentMatchKind.Sound)),
Span.EqualToIgnoreCase("pin").IgnoreThen(Parse.Return(MessageContentMatchKind.Pin)) Span.EqualToIgnoreCase("pin")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Pin))
.Try(),
Span.EqualToIgnoreCase("invite")
.IgnoreThen(Parse.Return(MessageContentMatchKind.Invite))
.Try()
) )
) )
.Select(k => (MessageFilter)new HasMessageFilter(k)) .Select(k => (MessageFilter)new HasMessageFilter(k))
@@ -4,23 +4,12 @@ using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Core.Exporting.Filtering; namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class ReactionMessageFilter : MessageFilter internal class ReactionMessageFilter(string value) : MessageFilter
{ {
private readonly string _value;
public ReactionMessageFilter(string value) => _value = value;
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
message message.Reactions.Any(r =>
.Reactions string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase)
.Any( || string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
r => || string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
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)
);
} }
@@ -18,16 +18,12 @@ internal partial class HtmlMarkdownVisitor(
bool isJumbo bool isJumbo
) : MarkdownVisitor ) : MarkdownVisitor
{ {
private readonly ExportContext _context = context;
private readonly StringBuilder _buffer = buffer;
private readonly bool _isJumbo = isJumbo;
protected override ValueTask VisitTextAsync( protected override ValueTask VisitTextAsync(
TextNode text, TextNode text,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append(HtmlEncode(text.Text)); buffer.Append(HtmlEncode(text.Text));
return default; return default;
} }
@@ -92,9 +88,9 @@ internal partial class HtmlMarkdownVisitor(
) )
}; };
_buffer.Append(openingTag); buffer.Append(openingTag);
await VisitAsync(formatting.Children, cancellationToken); await VisitAsync(formatting.Children, cancellationToken);
_buffer.Append(closingTag); buffer.Append(closingTag);
} }
protected override async ValueTask VisitHeadingAsync( protected override async ValueTask VisitHeadingAsync(
@@ -102,14 +98,14 @@ internal partial class HtmlMarkdownVisitor(
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append( buffer.Append(
// lang=html // lang=html
$"<h{heading.Level}>" $"<h{heading.Level}>"
); );
await VisitAsync(heading.Children, cancellationToken); await VisitAsync(heading.Children, cancellationToken);
_buffer.Append( buffer.Append(
// lang=html // lang=html
$"</h{heading.Level}>" $"</h{heading.Level}>"
); );
@@ -120,14 +116,14 @@ internal partial class HtmlMarkdownVisitor(
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append( buffer.Append(
// lang=html // lang=html
"<ul>" "<ul>"
); );
await VisitAsync(list.Items, cancellationToken); await VisitAsync(list.Items, cancellationToken);
_buffer.Append( buffer.Append(
// lang=html // lang=html
"</ul>" "</ul>"
); );
@@ -138,14 +134,14 @@ internal partial class HtmlMarkdownVisitor(
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append( buffer.Append(
// lang=html // lang=html
"<li>" "<li>"
); );
await VisitAsync(listItem.Children, cancellationToken); await VisitAsync(listItem.Children, cancellationToken);
_buffer.Append( buffer.Append(
// lang=html // lang=html
"</li>" "</li>"
); );
@@ -156,10 +152,12 @@ internal partial class HtmlMarkdownVisitor(
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append( buffer.Append(
// lang=html // 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>
""" """
); );
@@ -175,10 +173,12 @@ internal partial class HtmlMarkdownVisitor(
? $"language-{multiLineCodeBlock.Language}" ? $"language-{multiLineCodeBlock.Language}"
: "nohighlight"; : "nohighlight";
_buffer.Append( buffer.Append(
// lang=html // 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>
""" """
); );
@@ -196,7 +196,7 @@ internal partial class HtmlMarkdownVisitor(
.Groups[1] .Groups[1]
.Value; .Value;
_buffer.Append( buffer.Append(
!string.IsNullOrWhiteSpace(linkedMessageId) !string.IsNullOrWhiteSpace(linkedMessageId)
// lang=html // lang=html
? $"""<a href="{HtmlEncode(link.Url)}" onclick="scrollToMessage(event, '{linkedMessageId}')">""" ? $"""<a href="{HtmlEncode(link.Url)}" onclick="scrollToMessage(event, '{linkedMessageId}')">"""
@@ -206,7 +206,7 @@ internal partial class HtmlMarkdownVisitor(
await VisitAsync(link.Children, cancellationToken); await VisitAsync(link.Children, cancellationToken);
_buffer.Append( buffer.Append(
// lang=html // lang=html
"</a>" "</a>"
); );
@@ -218,9 +218,9 @@ internal partial class HtmlMarkdownVisitor(
) )
{ {
var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated); var emojiImageUrl = Emoji.GetImageUrl(emoji.Id, emoji.Name, emoji.IsAnimated);
var jumboClass = _isJumbo ? "chatlog__emoji--large" : ""; var jumboClass = isJumbo ? "chatlog__emoji--large" : "";
_buffer.Append( buffer.Append(
// lang=html // lang=html
$""" $"""
<img <img
@@ -228,7 +228,7 @@ internal partial class HtmlMarkdownVisitor(
class="chatlog__emoji {jumboClass}" class="chatlog__emoji {jumboClass}"
alt="{emoji.Name}" alt="{emoji.Name}"
title="{emoji.Code}" title="{emoji.Code}"
src="{await _context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}"> src="{await context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}">
""" """
); );
} }
@@ -240,7 +240,7 @@ internal partial class HtmlMarkdownVisitor(
{ {
if (mention.Kind == MentionKind.Everyone) if (mention.Kind == MentionKind.Everyone)
{ {
_buffer.Append( buffer.Append(
// lang=html // lang=html
""" """
<span class="chatlog__markdown-mention">@everyone</span> <span class="chatlog__markdown-mention">@everyone</span>
@@ -249,7 +249,7 @@ internal partial class HtmlMarkdownVisitor(
} }
else if (mention.Kind == MentionKind.Here) else if (mention.Kind == MentionKind.Here)
{ {
_buffer.Append( buffer.Append(
// lang=html // lang=html
""" """
<span class="chatlog__markdown-mention">@here</span> <span class="chatlog__markdown-mention">@here</span>
@@ -262,26 +262,28 @@ internal partial class HtmlMarkdownVisitor(
// which means they need to be populated on demand. // which means they need to be populated on demand.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/304 // https://github.com/Tyrrrz/DiscordChatExporter/issues/304
if (mention.TargetId is not null) if (mention.TargetId is not null)
await _context.PopulateMemberAsync(mention.TargetId.Value, cancellationToken); await context.PopulateMemberAsync(mention.TargetId.Value, cancellationToken);
var member = mention.TargetId?.Pipe(_context.TryGetMember); var member = mention.TargetId?.Pipe(context.TryGetMember);
var fullName = member?.User.FullName ?? "Unknown"; var fullName = member?.User.FullName ?? "Unknown";
var displayName = member?.DisplayName ?? member?.User.DisplayName ?? "Unknown"; var displayName = member?.DisplayName ?? member?.User.DisplayName ?? "Unknown";
_buffer.Append( buffer.Append(
// lang=html // lang=html
$""" $"""
<span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(displayName)}</span> <span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(
displayName
)}</span>
""" """
); );
} }
else if (mention.Kind == MentionKind.Channel) else if (mention.Kind == MentionKind.Channel)
{ {
var channel = mention.TargetId?.Pipe(_context.TryGetChannel); var channel = mention.TargetId?.Pipe(context.TryGetChannel);
var symbol = channel?.IsVoice == true ? "🔊" : "#"; var symbol = channel?.IsVoice == true ? "🔊" : "#";
var name = channel?.Name ?? "deleted-channel"; var name = channel?.Name ?? "deleted-channel";
_buffer.Append( buffer.Append(
// lang=html // lang=html
$""" $"""
<span class="chatlog__markdown-mention">{symbol}{HtmlEncode(name)}</span> <span class="chatlog__markdown-mention">{symbol}{HtmlEncode(name)}</span>
@@ -290,17 +292,21 @@ internal partial class HtmlMarkdownVisitor(
} }
else if (mention.Kind == MentionKind.Role) else if (mention.Kind == MentionKind.Role)
{ {
var role = mention.TargetId?.Pipe(_context.TryGetRole); var role = mention.TargetId?.Pipe(context.TryGetRole);
var name = role?.Name ?? "deleted-role"; var name = role?.Name ?? "deleted-role";
var color = role?.Color; var color = role?.Color;
var style = color is not null 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; : null;
_buffer.Append( buffer.Append(
// lang=html // lang=html
$""" $"""
<span class="chatlog__markdown-mention" style="{style}">@{HtmlEncode(name)}</span> <span class="chatlog__markdown-mention" style="{style}">@{HtmlEncode(name)}</span>
@@ -315,17 +321,19 @@ internal partial class HtmlMarkdownVisitor(
) )
{ {
var formatted = timestamp.Instant is not null var formatted = timestamp.Instant is not null
? _context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g") ? context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g")
: "Invalid date"; : "Invalid date";
var formattedLong = timestamp.Instant is not null var formattedLong = timestamp.Instant is not null
? _context.FormatDate(timestamp.Instant.Value, "f") ? context.FormatDate(timestamp.Instant.Value, "f")
: ""; : "";
_buffer.Append( buffer.Append(
// lang=html // lang=html
$""" $"""
<span class="chatlog__markdown-timestamp" title="{HtmlEncode(formattedLong)}">{HtmlEncode(formatted)}</span> <span class="chatlog__markdown-timestamp" title="{HtmlEncode(
formattedLong
)}">{HtmlEncode(formatted)}</span>
""" """
); );
@@ -348,10 +356,8 @@ internal partial class HtmlMarkdownVisitor
var isJumbo = var isJumbo =
isJumboAllowed isJumboAllowed
&& nodes.All( && nodes.All(n =>
n => n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)
n is EmojiNode
|| n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)
); );
var buffer = new StringBuilder(); var buffer = new StringBuilder();
@@ -13,7 +13,6 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
: MessageWriter(stream, context) : MessageWriter(stream, context)
{ {
private readonly TextWriter _writer = new StreamWriter(stream); private readonly TextWriter _writer = new StreamWriter(stream);
private readonly string _themeName = themeName;
private readonly HtmlMinifier _minifier = new(); private readonly HtmlMinifier _minifier = new();
private readonly List<Message> _messageGroup = new(); private readonly List<Message> _messageGroup = new();
@@ -74,11 +73,9 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
{ {
await _writer.WriteLineAsync( await _writer.WriteLineAsync(
Minify( Minify(
await new PreambleTemplate await new PreambleTemplate { Context = Context, ThemeName = themeName }.RenderAsync(
{ cancellationToken
Context = Context, )
ThemeName = _themeName
}.RenderAsync(cancellationToken)
) )
); );
} }
@@ -15,18 +15,19 @@ namespace DiscordChatExporter.Core.Exporting;
internal class JsonMessageWriter(Stream stream, ExportContext context) internal class JsonMessageWriter(Stream stream, ExportContext context)
: MessageWriter(stream, context) : MessageWriter(stream, context)
{ {
private readonly Utf8JsonWriter _writer = new Utf8JsonWriter( private readonly Utf8JsonWriter _writer =
stream, new(
new JsonWriterOptions stream,
{ new JsonWriterOptions
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450 {
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // https://github.com/Tyrrrz/DiscordChatExporter/issues/450
Indented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
// Validation errors may mask actual failures Indented = true,
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413 // Validation errors may mask actual failures
SkipValidation = true // https://github.com/Tyrrrz/DiscordChatExporter/issues/413
} SkipValidation = true
); }
);
private async ValueTask<string> FormatMarkdownAsync( private async ValueTask<string> FormatMarkdownAsync(
string markdown, string markdown,
@@ -445,14 +446,12 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteStartArray("users"); _writer.WriteStartArray("users");
await foreach ( await foreach (
var user in Context var user in Context.Discord.GetMessageReactionsAsync(
.Discord Context.Request.Channel.Id,
.GetMessageReactionsAsync( message.Id,
Context.Request.Channel.Id, reaction.Emoji,
message.Id, cancellationToken
reaction.Emoji, )
cancellationToken
)
) )
{ {
_writer.WriteStartObject(); _writer.WriteStartObject();
@@ -8,8 +8,6 @@ namespace DiscordChatExporter.Core.Exporting;
internal partial class MessageExporter(ExportContext context) : IAsyncDisposable internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
{ {
private readonly ExportContext _context = context;
private int _partitionIndex; private int _partitionIndex;
private MessageWriter? _writer; private MessageWriter? _writer;
@@ -39,10 +37,10 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
// Ensure that the partition limit has not been reached // Ensure that the partition limit has not been reached
if ( if (
_writer is not null _writer is not null
&& _context && context.Request.PartitionLimit.IsReached(
.Request _writer.MessagesWritten,
.PartitionLimit _writer.BytesWritten
.IsReached(_writer.MessagesWritten, _writer.BytesWritten) )
) )
{ {
await ResetWriterAsync(cancellationToken); await ResetWriterAsync(cancellationToken);
@@ -53,10 +51,10 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
if (_writer is not null) if (_writer is not null)
return _writer; return _writer;
Directory.CreateDirectory(_context.Request.OutputDirPath); Directory.CreateDirectory(context.Request.OutputDirPath);
var filePath = GetPartitionFilePath(_context.Request.OutputFilePath, _partitionIndex); var filePath = GetPartitionFilePath(context.Request.OutputFilePath, _partitionIndex);
var writer = CreateMessageWriter(filePath, _context.Request.Format, _context); var writer = CreateMessageWriter(filePath, context.Request.Format, context);
await writer.WritePreambleAsync(cancellationToken); await writer.WritePreambleAsync(cancellationToken);
return _writer = writer; return _writer = writer;
@@ -448,7 +448,10 @@
</div> </div>
} }
// Generic video embed // 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 = var embedVideoUrl =
embed.Video?.ProxyUrl ?? embed.Video?.Url ?? embed.Video?.ProxyUrl ?? embed.Video?.Url ??
@@ -1,11 +1,7 @@
namespace DiscordChatExporter.Core.Exporting.Partitioning; namespace DiscordChatExporter.Core.Exporting.Partitioning;
internal class FileSizePartitionLimit : PartitionLimit internal class FileSizePartitionLimit(long limit) : PartitionLimit
{ {
private readonly long _limit;
public FileSizePartitionLimit(long limit) => _limit = limit;
public override bool IsReached(long messagesWritten, long bytesWritten) => public override bool IsReached(long messagesWritten, long bytesWritten) =>
bytesWritten >= _limit; bytesWritten >= limit;
} }
@@ -1,11 +1,7 @@
namespace DiscordChatExporter.Core.Exporting.Partitioning; namespace DiscordChatExporter.Core.Exporting.Partitioning;
internal class MessageCountPartitionLimit : PartitionLimit internal class MessageCountPartitionLimit(long limit) : PartitionLimit
{ {
private readonly long _limit;
public MessageCountPartitionLimit(long limit) => _limit = limit;
public override bool IsReached(long messagesWritten, long bytesWritten) => public override bool IsReached(long messagesWritten, long bytesWritten) =>
messagesWritten >= _limit; messagesWritten >= limit;
} }
@@ -1,7 +1,6 @@
using System.Text; using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Markdown; using DiscordChatExporter.Core.Markdown;
using DiscordChatExporter.Core.Markdown.Parsing; using DiscordChatExporter.Core.Markdown.Parsing;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
@@ -11,15 +10,12 @@ namespace DiscordChatExporter.Core.Exporting;
internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBuilder buffer) internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBuilder buffer)
: MarkdownVisitor : MarkdownVisitor
{ {
private readonly ExportContext _context = context;
private readonly StringBuilder _buffer = buffer;
protected override ValueTask VisitTextAsync( protected override ValueTask VisitTextAsync(
TextNode text, TextNode text,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append(text.Text); buffer.Append(text.Text);
return default; return default;
} }
@@ -28,7 +24,7 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append(emoji.IsCustomEmoji ? $":{emoji.Name}:" : emoji.Name); buffer.Append(emoji.IsCustomEmoji ? $":{emoji.Name}:" : emoji.Name);
return default; return default;
} }
@@ -40,11 +36,11 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
{ {
if (mention.Kind == MentionKind.Everyone) if (mention.Kind == MentionKind.Everyone)
{ {
_buffer.Append("@everyone"); buffer.Append("@everyone");
} }
else if (mention.Kind == MentionKind.Here) else if (mention.Kind == MentionKind.Here)
{ {
_buffer.Append("@here"); buffer.Append("@here");
} }
else if (mention.Kind == MentionKind.User) else if (mention.Kind == MentionKind.User)
{ {
@@ -52,30 +48,30 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
// which means they need to be populated on demand. // which means they need to be populated on demand.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/304 // https://github.com/Tyrrrz/DiscordChatExporter/issues/304
if (mention.TargetId is not null) if (mention.TargetId is not null)
await _context.PopulateMemberAsync(mention.TargetId.Value, cancellationToken); await context.PopulateMemberAsync(mention.TargetId.Value, cancellationToken);
var member = mention.TargetId?.Pipe(_context.TryGetMember); var member = mention.TargetId?.Pipe(context.TryGetMember);
var displayName = member?.DisplayName ?? member?.User.DisplayName ?? "Unknown"; var displayName = member?.DisplayName ?? member?.User.DisplayName ?? "Unknown";
_buffer.Append($"@{displayName}"); buffer.Append($"@{displayName}");
} }
else if (mention.Kind == MentionKind.Channel) else if (mention.Kind == MentionKind.Channel)
{ {
var channel = mention.TargetId?.Pipe(_context.TryGetChannel); var channel = mention.TargetId?.Pipe(context.TryGetChannel);
var name = channel?.Name ?? "deleted-channel"; var name = channel?.Name ?? "deleted-channel";
_buffer.Append($"#{name}"); buffer.Append($"#{name}");
// Voice channel marker // Voice channel marker
if (channel?.IsVoice == true) if (channel?.IsVoice == true)
_buffer.Append(" [voice]"); buffer.Append(" [voice]");
} }
else if (mention.Kind == MentionKind.Role) else if (mention.Kind == MentionKind.Role)
{ {
var role = mention.TargetId?.Pipe(_context.TryGetRole); var role = mention.TargetId?.Pipe(context.TryGetRole);
var name = role?.Name ?? "deleted-role"; var name = role?.Name ?? "deleted-role";
_buffer.Append($"@{name}"); buffer.Append($"@{name}");
} }
} }
@@ -84,9 +80,9 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
{ {
_buffer.Append( buffer.Append(
timestamp.Instant is not null timestamp.Instant is not null
? _context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g") ? context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g")
: "Invalid date" : "Invalid date"
); );
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds; using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting; namespace DiscordChatExporter.Core.Exporting;
@@ -172,18 +173,21 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
await _writer.WriteLineAsync("{Reactions}"); await _writer.WriteLineAsync("{Reactions}");
foreach (var reaction in reactions) foreach (var (reaction, i) in reactions.WithIndex())
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (i > 0)
{
await _writer.WriteAsync(' ');
}
await _writer.WriteAsync(reaction.Emoji.Name); await _writer.WriteAsync(reaction.Emoji.Name);
if (reaction.Count > 1) if (reaction.Count > 1)
{ {
await _writer.WriteAsync($" ({reaction.Count})"); await _writer.WriteAsync($" ({reaction.Count})");
} }
await _writer.WriteAsync(' ');
} }
await _writer.WriteLineAsync(); await _writer.WriteLineAsync();
@@ -702,6 +702,10 @@
.chatlog__embed-spotify { .chatlog__embed-spotify {
border: 0; border: 0;
} }
.chatlog__embed-twitch {
border: 0;
}
.chatlog__embed-youtube-container { .chatlog__embed-youtube-container {
margin-top: 0.6rem; margin-top: 0.6rem;
@@ -8,5 +8,5 @@ internal record LinkNode(string Url, IReadOnlyList<MarkdownNode> Children)
IContainerNode IContainerNode
{ {
public LinkNode(string url) public LinkNode(string url)
: this(url, new[] { new TextNode(url) }) { } : this(url, [new TextNode(url)]) { }
} }
@@ -2,15 +2,8 @@
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class AggregateMatcher<T> : IMatcher<T> internal class AggregateMatcher<T>(IReadOnlyList<IMatcher<T>> matchers) : IMatcher<T>
{ {
private readonly IReadOnlyList<IMatcher<T>> _matchers;
public AggregateMatcher(IReadOnlyList<IMatcher<T>> matchers)
{
_matchers = matchers;
}
public AggregateMatcher(params IMatcher<T>[] matchers) public AggregateMatcher(params IMatcher<T>[] matchers)
: this((IReadOnlyList<IMatcher<T>>)matchers) { } : this((IReadOnlyList<IMatcher<T>>)matchers) { }
@@ -19,7 +12,7 @@ internal class AggregateMatcher<T> : IMatcher<T>
ParsedMatch<T>? earliestMatch = null; ParsedMatch<T>? earliestMatch = null;
// Try to match the input with each matcher and get the match with the lowest start index // Try to match the input with each matcher and get the match with the lowest start index
foreach (var matcher in _matchers) foreach (var matcher in matchers)
{ {
// Try to match // Try to match
var match = matcher.TryMatch(segment); var match = matcher.TryMatch(segment);
@@ -1,14 +1,8 @@
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class ParsedMatch<T> internal class ParsedMatch<T>(StringSegment segment, T value)
{ {
public StringSegment Segment { get; } public StringSegment Segment { get; } = segment;
public T Value { get; } public T Value { get; } = value;
public ParsedMatch(StringSegment segment, T value)
{
Segment = segment;
Value = value;
}
} }
@@ -3,20 +3,11 @@ using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class RegexMatcher<T> : IMatcher<T> internal class RegexMatcher<T>(Regex regex, Func<StringSegment, Match, T?> transform) : IMatcher<T>
{ {
private readonly Regex _regex;
private readonly Func<StringSegment, Match, T?> _transform;
public RegexMatcher(Regex regex, Func<StringSegment, Match, T?> transform)
{
_regex = regex;
_transform = transform;
}
public ParsedMatch<T>? TryMatch(StringSegment segment) public ParsedMatch<T>? TryMatch(StringSegment segment)
{ {
var match = _regex.Match(segment.Source, segment.StartIndex, segment.Length); var match = regex.Match(segment.Source, segment.StartIndex, segment.Length);
if (!match.Success) if (!match.Success)
return null; return null;
@@ -25,11 +16,11 @@ internal class RegexMatcher<T> : IMatcher<T>
// Which is super weird because regex.Match(string, int) takes the whole input in context. // Which is super weird because regex.Match(string, int) takes the whole input in context.
// So in order to properly account for ^/$ regex tokens, we need to make sure that // So in order to properly account for ^/$ regex tokens, we need to make sure that
// the expression also matches on the bigger part of the input. // the expression also matches on the bigger part of the input.
if (!_regex.IsMatch(segment.Source[..segment.EndIndex], segment.StartIndex)) if (!regex.IsMatch(segment.Source[..segment.EndIndex], segment.StartIndex))
return null; return null;
var segmentMatch = segment.Relocate(match); var segmentMatch = segment.Relocate(match);
var value = _transform(segmentMatch, match); var value = transform(segmentMatch, match);
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null; return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
} }
@@ -2,37 +2,24 @@
namespace DiscordChatExporter.Core.Markdown.Parsing; namespace DiscordChatExporter.Core.Markdown.Parsing;
internal class StringMatcher<T> : IMatcher<T> internal class StringMatcher<T>(
string needle,
StringComparison comparison,
Func<StringSegment, T?> transform
) : IMatcher<T>
{ {
private readonly string _needle;
private readonly StringComparison _comparison;
private readonly Func<StringSegment, T?> _transform;
public StringMatcher(
string needle,
StringComparison comparison,
Func<StringSegment, T?> transform
)
{
_needle = needle;
_comparison = comparison;
_transform = transform;
}
public StringMatcher(string needle, Func<StringSegment, T> transform) public StringMatcher(string needle, Func<StringSegment, T> transform)
: this(needle, StringComparison.Ordinal, transform) { } : this(needle, StringComparison.Ordinal, transform) { }
public ParsedMatch<T>? TryMatch(StringSegment segment) public ParsedMatch<T>? TryMatch(StringSegment segment)
{ {
var index = segment var index = segment.Source.IndexOf(needle, segment.StartIndex, segment.Length, comparison);
.Source
.IndexOf(_needle, segment.StartIndex, segment.Length, _comparison);
if (index < 0) if (index < 0)
return null; return null;
var segmentMatch = segment.Relocate(index, _needle.Length); var segmentMatch = segment.Relocate(index, needle.Length);
var value = _transform(segmentMatch); var value = transform(segmentMatch);
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null; return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
} }
+9
View File
@@ -0,0 +1,9 @@
using System;
namespace DiscordChatExporter.Core.Utils;
public static class Docker
{
public static bool IsRunningInContainer { get; } =
Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true";
}
@@ -1,5 +1,4 @@
using System; using System;
using System.Collections.Generic;
using System.Text; using System.Text;
namespace DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Core.Utils.Extensions;
@@ -27,16 +26,6 @@ public static class StringExtensions
return builder.ToString(); return builder.ToString();
} }
public static IEnumerable<Rune> GetRunes(this string str)
{
var lastIndex = 0;
while (lastIndex < str.Length && Rune.TryGetRuneAt(str, lastIndex, out var rune))
{
yield return rune;
lastIndex += rune.Utf16SequenceLength;
}
}
public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true) public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true)
where T : struct, Enum => Enum.TryParse<T>(str, ignoreCase, out var result) ? result : null; where T : struct, Enum => Enum.TryParse<T>(str, ignoreCase, out var result) ? result : null;
+4 -5
View File
@@ -25,11 +25,10 @@ public static class Http
private static bool IsRetryableException(Exception exception) => private static bool IsRetryableException(Exception exception) =>
exception exception
.GetSelfAndChildren() .GetSelfAndChildren()
.Any( .Any(ex =>
ex => ex is TimeoutException or SocketException or AuthenticationException
ex is TimeoutException or SocketException or AuthenticationException || ex is HttpRequestException hrex
|| ex is HttpRequestException hrex && IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
&& IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
); );
public static ResiliencePipeline ResiliencePipeline { get; } = public static ResiliencePipeline ResiliencePipeline { get; } =
+153
View File
@@ -0,0 +1,153 @@
<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" />
</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>
+122
View File
@@ -0,0 +1,122 @@
using System;
using System.Net;
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();
},
false
)
);
}
public override void Initialize()
{
base.Initialize();
// Increase maximum concurrent connections
ServicePointManager.DefaultConnectionLimit = 20;
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>
-52
View File
@@ -1,52 +0,0 @@
using System;
using System.Reflection;
using DiscordChatExporter.Gui.Utils;
using MaterialDesignThemes.Wpf;
namespace DiscordChatExporter.Gui;
public partial class App
{
private static Assembly Assembly { get; } = typeof(App).Assembly;
public static string Name { get; } = Assembly.GetName().Name!;
public static Version Version { get; } = Assembly.GetName().Version!;
public static string VersionString { get; } = Version.ToString(3);
public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter";
public static string LatestReleaseUrl { get; } = ProjectUrl + "/releases/latest";
public static string DocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
}
public partial class App
{
private static Theme LightTheme { get; } =
Theme.Create(
new MaterialDesignLightTheme(),
MediaColor.FromHex("#343838"),
MediaColor.FromHex("#F9A825")
);
private static Theme DarkTheme { get; } =
Theme.Create(
new MaterialDesignDarkTheme(),
MediaColor.FromHex("#E8E8E8"),
MediaColor.FromHex("#F9A825")
);
public static void SetLightTheme()
{
var paletteHelper = new PaletteHelper();
paletteHelper.SetTheme(LightTheme);
}
public static void SetDarkTheme()
{
var paletteHelper = new PaletteHelper();
paletteHelper.SetTheme(DarkTheme);
}
}
@@ -1,5 +0,0 @@
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Gui.Behaviors;
public class ChannelMultiSelectionListBoxBehavior : MultiSelectionListBoxBehavior<Channel> { }
@@ -1,104 +0,0 @@
using System.Collections;
using System.Collections.Specialized;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using Microsoft.Xaml.Behaviors;
namespace DiscordChatExporter.Gui.Behaviors;
public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
{
public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register(
nameof(SelectedItems),
typeof(IList),
typeof(MultiSelectionListBoxBehavior<T>),
new FrameworkPropertyMetadata(
null,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnSelectedItemsChanged
)
);
private static void OnSelectedItemsChanged(
DependencyObject sender,
DependencyPropertyChangedEventArgs args
)
{
var behavior = (MultiSelectionListBoxBehavior<T>)sender;
if (behavior._modelHandled)
return;
if (behavior.AssociatedObject is null)
return;
behavior._modelHandled = true;
behavior.SelectItems();
behavior._modelHandled = false;
}
private bool _viewHandled;
private bool _modelHandled;
public IList? SelectedItems
{
get => (IList?)GetValue(SelectedItemsProperty);
set => SetValue(SelectedItemsProperty, value);
}
// Propagate selected items from the model to the view
private void SelectItems()
{
_viewHandled = true;
AssociatedObject.SelectedItems.Clear();
if (SelectedItems is not null)
{
foreach (var item in SelectedItems)
AssociatedObject.SelectedItems.Add(item);
}
_viewHandled = false;
}
// Propagate selected items from the view to the model
private void OnListBoxSelectionChanged(object? sender, SelectionChangedEventArgs args)
{
if (_viewHandled)
return;
if (AssociatedObject.Items.SourceCollection is null)
return;
SelectedItems = AssociatedObject.SelectedItems.Cast<T>().ToArray();
}
private void OnListBoxItemsChanged(object? sender, NotifyCollectionChangedEventArgs args)
{
if (_viewHandled)
return;
if (AssociatedObject.Items.SourceCollection is null)
return;
SelectItems();
}
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.SelectionChanged += OnListBoxSelectionChanged;
((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged +=
OnListBoxItemsChanged;
}
protected override void OnDetaching()
{
base.OnDetaching();
if (AssociatedObject is not null)
{
AssociatedObject.SelectionChanged -= OnListBoxSelectionChanged;
((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged -=
OnListBoxItemsChanged;
}
}
}
-45
View File
@@ -1,45 +0,0 @@
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Framework;
using Stylet;
using StyletIoC;
#if !DEBUG
using System.Windows;
using System.Windows.Threading;
#endif
namespace DiscordChatExporter.Gui;
public class Bootstrapper : Bootstrapper<RootViewModel>
{
protected override void OnStart()
{
base.OnStart();
// Set the default theme.
// Preferred theme will be set later, once the settings are loaded.
App.SetLightTheme();
}
protected override void ConfigureIoC(IStyletIoCBuilder builder)
{
base.ConfigureIoC(builder);
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
builder.Bind<IViewModelFactory>().ToAbstractFactory();
}
#if !DEBUG
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args)
{
base.OnUnhandledException(args);
MessageBox.Show(
args.Exception.ToString(),
"Error occured",
MessageBoxButton.OK,
MessageBoxImage.Error
);
}
#endif
}
@@ -1,30 +0,0 @@
using System;
using System.Globalization;
using System.Windows.Data;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Gui.Converters;
[ValueConversion(typeof(Channel), typeof(string))]
public class ChannelToGroupKeyConverter : IValueConverter
{
public static ChannelToGroupKeyConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value switch
{
Channel { IsThread: true, Parent: not null } channel
=> $"Threads in #{channel.Parent.Name}",
Channel channel => channel.Parent?.Name ?? "???",
_ => null
};
public object ConvertBack(
object value,
Type targetType,
object parameter,
CultureInfo culture
) => throw new NotSupportedException();
}
@@ -0,0 +1,25 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Gui.Converters;
public class ChannelToHierarchicalNameStringConverter : IValueConverter
{
public static ChannelToHierarchicalNameStringConverter Instance { get; } = new();
public object? Convert(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
) => value is Channel channel ? channel.GetHierarchicalName() : null;
public object ConvertBack(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
) => throw new NotSupportedException();
}
@@ -1,26 +0,0 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace DiscordChatExporter.Gui.Converters;
[ValueConversion(typeof(DateTimeOffset?), typeof(DateTime?))]
public class DateTimeOffsetToDateTimeConverter : IValueConverter
{
public static DateTimeOffsetToDateTimeConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is DateTimeOffset dateTimeOffsetValue
? dateTimeOffsetValue.DateTime
: default(DateTime?);
public object? ConvertBack(
object value,
Type targetType,
object parameter,
CultureInfo culture
) =>
value is DateTime dateTimeValue
? new DateTimeOffset(dateTimeValue)
: default(DateTimeOffset?);
}
@@ -1,22 +1,25 @@
using System; using System;
using System.Globalization; using System.Globalization;
using System.Windows.Data; using Avalonia.Data.Converters;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
namespace DiscordChatExporter.Gui.Converters; namespace DiscordChatExporter.Gui.Converters;
[ValueConversion(typeof(ExportFormat), typeof(string))]
public class ExportFormatToStringConverter : IValueConverter public class ExportFormatToStringConverter : IValueConverter
{ {
public static ExportFormatToStringConverter Instance { get; } = new(); public static ExportFormatToStringConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) => public object? Convert(
value is ExportFormat exportFormatValue ? exportFormatValue.GetDisplayName() : default; object? value,
Type targetType,
object? parameter,
CultureInfo culture
) => value is ExportFormat format ? format.GetDisplayName() : default;
public object ConvertBack( public object ConvertBack(
object value, object? value,
Type targetType, Type targetType,
object parameter, object? parameter,
CultureInfo culture CultureInfo culture
) => throw new NotSupportedException(); ) => throw new NotSupportedException();
} }
@@ -1,21 +0,0 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace DiscordChatExporter.Gui.Converters;
[ValueConversion(typeof(bool), typeof(bool))]
public class InverseBoolConverter : IValueConverter
{
public static InverseBoolConverter Instance { get; } = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is false;
public object ConvertBack(
object value,
Type targetType,
object parameter,
CultureInfo culture
) => value is false;
}
@@ -1,21 +0,0 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace DiscordChatExporter.Gui.Converters;
[ValueConversion(typeof(string), typeof(string))]
public class LocaleToDisplayNameConverter : IValueConverter
{
public static LocaleToDisplayNameConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is string locale ? CultureInfo.GetCultureInfo(locale).DisplayName : null;
public object ConvertBack(
object value,
Type targetType,
object parameter,
CultureInfo culture
) => throw new NotSupportedException();
}
@@ -0,0 +1,22 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
namespace DiscordChatExporter.Gui.Converters;
public class LocaleToDisplayNameStringConverter : IValueConverter
{
public static LocaleToDisplayNameStringConverter Instance { get; } = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is string locale && !string.IsNullOrWhiteSpace(locale)
? CultureInfo.GetCultureInfo(locale).DisplayName
: "System";
public object ConvertBack(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
) => throw new NotSupportedException();
}
@@ -1,22 +0,0 @@
using System;
using System.Globalization;
using System.Windows.Data;
using DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Gui.Converters;
[ValueConversion(typeof(Snowflake?), typeof(DateTimeOffset?))]
public class SnowflakeToDateTimeOffsetConverter : IValueConverter
{
public static SnowflakeToDateTimeOffsetConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is Snowflake snowflake ? snowflake.ToDate() : null;
public object ConvertBack(
object value,
Type targetType,
object parameter,
CultureInfo culture
) => throw new NotSupportedException();
}
@@ -0,0 +1,25 @@
using System;
using System.Globalization;
using Avalonia.Data.Converters;
using DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Gui.Converters;
public class SnowflakeToTimestampStringConverter : IValueConverter
{
public static SnowflakeToTimestampStringConverter Instance { get; } = new();
public object? Convert(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
) => value is Snowflake snowflake ? snowflake.ToDate().ToString("g", culture) : null;
public object ConvertBack(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
) => throw new NotSupportedException();
}
@@ -1,21 +0,0 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace DiscordChatExporter.Gui.Converters;
[ValueConversion(typeof(TimeSpan?), typeof(DateTime?))]
public class TimeSpanToDateTimeConverter : IValueConverter
{
public static TimeSpanToDateTimeConverter Instance { get; } = new();
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
value is TimeSpan timeSpanValue ? DateTime.Today.Add(timeSpanValue) : default(DateTime?);
public object? ConvertBack(
object value,
Type targetType,
object parameter,
CultureInfo culture
) => value is DateTime dateTimeValue ? dateTimeValue.TimeOfDay : default(TimeSpan?);
}
@@ -2,28 +2,31 @@
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>$(TargetFramework)-windows</TargetFramework>
<AssemblyName>DiscordChatExporter</AssemblyName> <AssemblyName>DiscordChatExporter</AssemblyName>
<UseWPF>true</UseWPF> <ApplicationIcon>..\favicon.ico</ApplicationIcon>
<ApplicationIcon>../favicon.ico</ApplicationIcon> <PublishTrimmed>true</PublishTrimmed>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Resource Include="../favicon.ico" /> <AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Cogwheel" Version="2.0.4" /> <PackageReference Include="AsyncImageLoader.Avalonia" Version="3.2.1" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.2" PrivateAssets="all" /> <PackageReference Include="Avalonia" Version="11.0.10" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.10" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.10" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Cogwheel" Version="2.1.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" /> <PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" /> <PackageReference Include="DialogHost.Avalonia" Version="0.7.7" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="MaterialDesignColors" Version="2.1.4" /> <PackageReference Include="Material.Avalonia" Version="3.6.0" />
<PackageReference Include="MaterialDesignThemes" Version="4.9.0" /> <PackageReference Include="Material.Icons.Avalonia" Version="2.1.9" />
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.77" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Onova" Version="2.6.10" /> <PackageReference Include="Onova" Version="2.6.11" />
<PackageReference Include="PropertyChanged.Fody" Version="4.1.0" PrivateAssets="all" />
<PackageReference Include="Stylet" Version="1.3.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

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