mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5623c2ca34 | |||
| 8525917a4c | |||
| b9c1c47474 | |||
| 74f99b4e59 | |||
| 12b590d9f9 | |||
| 6d1e8c2e02 | |||
| 72f02cd9a0 | |||
| 47d560229d | |||
| 0e87098889 | |||
| 0139eeaeed | |||
| c0ea57e9b1 | |||
| 7c04db40f8 | |||
| 4588bd0496 | |||
| 3a22db14a7 | |||
| ba72927c37 | |||
| 822bab2365 | |||
| 211db11041 | |||
| 9b8985a920 | |||
| 014712b747 | |||
| d8e43d89be | |||
| 4e69ff317b | |||
| 057beaacd6 | |||
| 982ba6a76c | |||
| dccd9a2f08 | |||
| 182f24846b | |||
| 777f92a3f1 | |||
| d4be307fb1 | |||
| 2bbb585931 | |||
| 73080ecfa6 | |||
| 0745d49f44 | |||
| c16e14e9b6 | |||
| 8e36002ae7 | |||
| a2c5d2e2dd | |||
| 4e3deb409c | |||
| 512f181be2 | |||
| e04eb890e6 | |||
| 1ad42ee48b | |||
| b8a67617bc | |||
| 062427d143 | |||
| 5f343a46d1 | |||
| b3a1cbf635 | |||
| 619fe9ccf7 | |||
| 174b92cbb0 |
+16
-5
@@ -10,7 +10,7 @@ Docker distribution of DiscordChatExporter provides a way to run the app in a vi
|
||||
This will download the [Docker image from the registry](https://hub.docker.com/r/tyrrrz/discordchatexporter) to your computer. You can run this command again to update when a new version is released.
|
||||
|
||||
```console
|
||||
docker pull tyrrrz/discordchatexporter:stable
|
||||
$ docker pull tyrrrz/discordchatexporter:stable
|
||||
```
|
||||
|
||||
Note the `:stable` tag. DiscordChatExporter images are tagged according to the following patterns:
|
||||
@@ -26,19 +26,19 @@ You can see all available tags [here](https://hub.docker.com/r/tyrrrz/discordcha
|
||||
To run the CLI in Docker and render help text:
|
||||
|
||||
```console
|
||||
docker run --rm tyrrrz/discordchatexporter:stable
|
||||
$ docker run --rm tyrrrz/discordchatexporter:stable
|
||||
```
|
||||
|
||||
To export a channel:
|
||||
|
||||
```console
|
||||
docker run --rm -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
|
||||
$ docker run --rm -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
|
||||
```
|
||||
|
||||
If you want colored output and real-time progress reporting, pass the `-it` (interactive + pseudo-terminal) option:
|
||||
|
||||
```console
|
||||
docker run --rm -it -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
|
||||
$ docker run --rm -it -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
|
||||
```
|
||||
|
||||
The `-v /path/on/machine:/out` option instructs Docker to bind the `/out` directory inside the container to a path on your host machine. Replace `/path/on/machine` with the directory you want the files to be saved at.
|
||||
@@ -47,7 +47,7 @@ The `-v /path/on/machine:/out` option instructs Docker to bind the `/out` direct
|
||||
> If you are running SELinux, you will need to add the `:z` option after `/out`, e.g.:
|
||||
>
|
||||
> ```console
|
||||
> docker run --rm -v /path/on/machine:/out:z tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
|
||||
> $ docker run --rm -v /path/on/machine:/out:z tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
|
||||
> ```
|
||||
>
|
||||
> For more information, refer to the [Docker docs SELinux labels for bind mounts page](https://docs.docker.com/storage/bind-mounts/#configure-the-selinux-label).
|
||||
@@ -61,6 +61,17 @@ For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz
|
||||
|
||||
To get your Token and Channel IDs, please refer to [this page](Token-and-IDs.md).
|
||||
|
||||
## Unix permissions issues
|
||||
|
||||
This image was designed with a user running as uid:gid of 1000:1000.
|
||||
|
||||
If your current user has different IDs, and you want to generate files directly editable for your user, you might want to run the container like this:
|
||||
|
||||
```console
|
||||
$ mkdir data # or chown -R $(id -u):$(id -g) data
|
||||
$ docker run -it --rm -v $PWD/data:/out --user $(id -u):$(id -g) tyrrrz/discordchatexporter:stable export -t TOKEN -g CHANNELID
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
DiscordChatExpoter CLI accepts the `DISCORD_TOKEN` environment variable as a fallback for the `--token` option. You can set this variable either with the `--env` Docker option or with a combination of the `--env-file` Docker option and a `.env` file.
|
||||
|
||||
+1
-5
@@ -43,12 +43,8 @@ For other distros, please check the _'Install on Linux'_ menu on the left of [th
|
||||
You can check which version of **.NET Runtime** is installed by running the following command in a terminal:
|
||||
|
||||
```console
|
||||
dotnet --info
|
||||
```
|
||||
$ 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]
|
||||
|
||||
@@ -15,12 +15,9 @@ The information presented on this page is valid for **all** platforms.
|
||||
**DCE** has two different versions:
|
||||
|
||||
- **Graphical User Interface** (**GUI**) - it's the preferred version for newcomers as it is easy to use.
|
||||
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.zip` file.
|
||||
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.*.zip` file.
|
||||
- **Command-line Interface** (**CLI**) - offers greater flexibility and more features for advanced users, such as export scheduling, ID lists, and more specific date ranges.
|
||||
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.CLI.zip` file.
|
||||
|
||||
If you're not comfortable with **Windows'** Command-line (cmd), please choose the GUI.
|
||||
[**macOS**](MacOS.md), [**Linux**](Linux.md) and [**Docker**](Docker.md) users can only use the CLI version.
|
||||
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.Cli.*.zip` file.
|
||||
|
||||
There are dedicated guides for each version:
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,22 +0,0 @@
|
||||
# macOS 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. 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.
|
||||
@@ -89,17 +89,17 @@ from:96\-LB
|
||||
In most cases, you will need to enclose your filter in quotes (`"`) to escape characters that may have special meaning in your shell:
|
||||
|
||||
```console
|
||||
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
|
||||
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
|
||||
```
|
||||
|
||||
If you need to include quotes inside the filter itself as well, use single quotes (`'`) for those instead:
|
||||
|
||||
```console
|
||||
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
|
||||
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
|
||||
```
|
||||
|
||||
Additionally, negated filters (those that start with `-`) may cause parsing issues even when enclosed in quotes. To avoid this, use the tilde (`~`) character instead of the dash (`-`):
|
||||
|
||||
```console
|
||||
DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
|
||||
$ ./DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
|
||||
```
|
||||
|
||||
+2
-3
@@ -2,10 +2,9 @@
|
||||
|
||||
## 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)
|
||||
- [Get .NET Runtime](Dotnet.md)
|
||||
- Getting started:
|
||||
- [Using the GUI](Using-the-CLI.md)
|
||||
- [Using the GUI](Using-the-GUI.md)
|
||||
- [Using the CLI](Using-the-CLI.md)
|
||||
- [File formats](Getting-started.md#file-formats)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Scheduling exports with Cron
|
||||
|
||||
Make sure you already have **DiscordChatExporter.CLI** and **.NET Core** properly installed ([instructions here](Linux.md)).
|
||||
Make sure you already have **DiscordChatExporter.CLI** downloaded and **.NET Runtime** installed.
|
||||
|
||||
## Creating the script
|
||||
|
||||
@@ -45,7 +45,7 @@ fi
|
||||
cd $DLLFOLDER || exit 1
|
||||
|
||||
# This will export your chat
|
||||
dotnet DiscordChatExporter.Cli.dll export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
|
||||
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
|
||||
|
||||
# This sets the current time to a variable
|
||||
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# 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)).
|
||||
Make sure you already have **DiscordChatExporter.CLI** downloaded and **.NET Runtime** installed.
|
||||
|
||||
## Creating the script
|
||||
|
||||
@@ -51,7 +49,7 @@ fi
|
||||
cd $DLLFOLDER || exit 1
|
||||
|
||||
# This will export your chat
|
||||
dotnet DiscordChatExporter.Cli.dll export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
|
||||
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
|
||||
|
||||
# This sets the current time to a variable
|
||||
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Scheduling exports on Windows
|
||||
|
||||
We'll be using [DiscordChatExporter CLI](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest), PowerShell, and Task Scheduler.
|
||||
Make sure you already have **DiscordChatExporter.CLI** downloaded and **.NET Runtime** installed.
|
||||
|
||||
## Creating the script
|
||||
|
||||
@@ -19,7 +20,7 @@ $EXPORTFORMAT = "formathere"
|
||||
|
||||
cd $EXEPATH
|
||||
|
||||
.\DiscordChatExporter.Cli.exe export -t $TOKEN -c $CHANNEL -f $EXPORTFORMAT -o "$FILENAME.tmp"
|
||||
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNEL -f $EXPORTFORMAT -o "$FILENAME.tmp"
|
||||
|
||||
$Date = Get-Date -Format "yyyy-MM-dd-HH-mm"
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
|
||||
3. Type
|
||||
|
||||
```console
|
||||
```js
|
||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
||||
```
|
||||
|
||||
@@ -118,7 +118,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
|
||||
1. Type
|
||||
|
||||
```console
|
||||
```js
|
||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
||||
```
|
||||
|
||||
|
||||
@@ -70,14 +70,7 @@ Check the following page: [Obtaining token](Token-and-IDs.md)
|
||||
|
||||
### When I open DCE a black window pops up quickly or nothing shows up
|
||||
|
||||
If you have [.NET Core Runtime correctly installed](Dotnet.md), you might have downloaded the CLi flavor, try [downloading the GUI](Getting-started.md#gui-or-cli) instead.
|
||||
|
||||
### How do I run DCE on macOS or Linux?
|
||||
|
||||
Check the following pages:
|
||||
|
||||
- [macOS usage instructions](MacOS.md)
|
||||
- [Linux usage instructions](Linux.md)
|
||||
If you have [.NET Runtime correctly installed](Dotnet.md), you might have downloaded the CLI flavor, try [downloading the GUI](Getting-started.md#gui-or-cli) instead.
|
||||
|
||||
### How can I set DCE to export automatically at certain times?
|
||||
|
||||
@@ -133,34 +126,26 @@ Make sure you're [copying the DM Channel ID](Token-and-IDs.md#how-to-get-a-direc
|
||||
|
||||
## Errors
|
||||
|
||||
```console
|
||||
```yml
|
||||
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Authentication token is invalid.
|
||||
...
|
||||
```
|
||||
|
||||
↳ Make sure the provided token is correct.
|
||||
|
||||
```console
|
||||
```yml
|
||||
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Requested resource does not exist.
|
||||
```
|
||||
|
||||
↳ Check your channel ID, it might be invalid. [Read this if you need help](Token-and-IDs.md).
|
||||
|
||||
```console
|
||||
```yml
|
||||
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Access is forbidden.
|
||||
```
|
||||
|
||||
↳ This means you don't have access to the channel.
|
||||
|
||||
```console
|
||||
The application to execute does not exist:
|
||||
```
|
||||
|
||||
↳ The `DiscordChatExporter.Cli.dll` file is missing. Keep the `.exe` and all the `.dll` files together. If you didn't move the files, try unzipping again.
|
||||
|
||||
```console
|
||||
```yml
|
||||
System.Net.WebException: Error: TrustFailure ... Invalid certificate received from server.
|
||||
...
|
||||
```
|
||||
|
||||
↳ Try running cert-sync.
|
||||
|
||||
+47
-47
@@ -28,39 +28,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
|
||||
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
|
||||
dotnet DiscordChatExporter.Cli.dll
|
||||
$ ./DiscordChatExporter.Cli
|
||||
```
|
||||
|
||||
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
|
||||
|
||||
## CLI commands
|
||||
|
||||
| Command | Description |
|
||||
|-------------------------|-----------------------------------------------------|
|
||||
| export | Exports a channel |
|
||||
| exportdm | Exports all direct message channels |
|
||||
| exportguild | Exports all channels within the specified server |
|
||||
| exportall | Exports all accessible channels |
|
||||
| channels | Outputs the list of channels in the given server |
|
||||
| dm | Outputs the list of direct message channels |
|
||||
| guilds | Outputs the list of accessible servers |
|
||||
| guide | Explains how to obtain token, guild, and channel ID |
|
||||
| Command | Description |
|
||||
|-------------------------|------------------------------------------------------|
|
||||
| export | Exports a channel |
|
||||
| exportdm | Exports all direct message channels |
|
||||
| exportguild | Exports all channels within the specified server |
|
||||
| exportall | Exports all accessible channels |
|
||||
| channels | Outputs the list of channels in the given server |
|
||||
| dm | Outputs the list of direct message channels |
|
||||
| guilds | Outputs the list of accessible servers |
|
||||
| guide | Explains how to obtain token, server, and channel ID |
|
||||
|
||||
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `dotnet DiscordChatExporter.Cli.dll guide`.
|
||||
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `./DiscordChatExporter.Cli guide`.
|
||||
|
||||
To get help with a specific command, run:
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll command --help
|
||||
$ ./DiscordChatExporter.Cli command --help
|
||||
```
|
||||
|
||||
For example, to figure out how to use the `export` command, run:
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export --help
|
||||
$ ./DiscordChatExporter.Cli export --help
|
||||
```
|
||||
|
||||
## Export a specific channel
|
||||
@@ -68,7 +68,7 @@ dotnet DiscordChatExporter.Cli.dll export --help
|
||||
You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`.
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
|
||||
```
|
||||
|
||||
#### Changing the format
|
||||
@@ -77,7 +77,7 @@ You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json`
|
||||
format is `HtmlDark`.
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -f Json
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json
|
||||
```
|
||||
|
||||
#### Changing the output filename
|
||||
@@ -85,7 +85,7 @@ dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -f Json
|
||||
You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o myserver.html
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
|
||||
```
|
||||
|
||||
#### Changing the output directory
|
||||
@@ -95,7 +95,7 @@ extension.
|
||||
If any of the folders in the path have a space in its name, escape them with quotes (").
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports"
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports"
|
||||
```
|
||||
|
||||
#### Changing the filename and output directory
|
||||
@@ -105,15 +105,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 (").
|
||||
|
||||
```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
|
||||
|
||||
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
|
||||
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
|
||||
@@ -122,8 +122,8 @@ path: `C:\Discord Exports\My server\Text channels\my-channel.html`
|
||||
|
||||
Here is the full list of supported template tokens:
|
||||
|
||||
- `%g` - guild ID
|
||||
- `%G` - guild name
|
||||
- `%g` - server ID
|
||||
- `%G` - server name
|
||||
- `%t` - category ID
|
||||
- `%T` - category name
|
||||
- `%c` - channel ID
|
||||
@@ -140,13 +140,13 @@ You can use partitioning to split files after a given number of messages or file
|
||||
For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -p 10
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 10
|
||||
```
|
||||
|
||||
A 45 MB channel set to be partitioned every 20 MB will output 3 files.
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -p 20mb
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb
|
||||
```
|
||||
|
||||
#### Downloading assets
|
||||
@@ -157,7 +157,7 @@ downloaded when using the plain text (TXT) export format.
|
||||
A folder containing the assets will be created along with the exported chat. They must be kept together.
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --media
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
|
||||
```
|
||||
|
||||
#### Reusing assets
|
||||
@@ -166,7 +166,7 @@ Previously downloaded assets can be reused to skip redundant downloads as long a
|
||||
same folder. Using this option can speed up future exports. This option requires the `--media` option.
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --media --reuse-media
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --reuse-media
|
||||
```
|
||||
|
||||
#### Changing the media directory
|
||||
@@ -175,7 +175,7 @@ By default, the media directory is created alongside the exported chat. You can
|
||||
providing a path that ends with a slash. All of the exported media will be stored in this directory.
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --media --media-dir "C:\Discord Media"
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --media-dir "C:\Discord Media"
|
||||
```
|
||||
|
||||
#### Changing the date format
|
||||
@@ -184,7 +184,7 @@ You can customize how dates are formatted in the exported files by using `--loca
|
||||
locales. The default locale is `en-US`.
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
|
||||
```
|
||||
|
||||
#### Date ranges
|
||||
@@ -193,14 +193,14 @@ dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --locale "de-DE
|
||||
Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019:
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
|
||||
```
|
||||
|
||||
**Messages sent after a date**
|
||||
Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM:
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
|
||||
```
|
||||
|
||||
**Messages sent in a date range**
|
||||
@@ -208,7 +208,7 @@ Use `--before` and `--after` to export messages sent during the provided date ra
|
||||
September 17th, 2019 11:34 PM and September 18th:
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" --before "2019-09-18"
|
||||
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" --before "2019-09-18"
|
||||
```
|
||||
|
||||
You can try different formats like `17-SEP-2019 11:34 PM` or even refine your ranges down to
|
||||
@@ -217,12 +217,12 @@ Don't forget to quote (") the date if it has spaces!
|
||||
More info about .NET date
|
||||
formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
|
||||
|
||||
### 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
|
||||
dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814
|
||||
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
|
||||
```
|
||||
|
||||
#### Including threads
|
||||
@@ -232,7 +232,7 @@ specifying which threads should be included. It has possible values of `none`, `
|
||||
threads should be included. To include both active and archived threads, use `--include-threads all`.
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814 --include-threads all
|
||||
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-threads all
|
||||
```
|
||||
|
||||
#### Including voice channels
|
||||
@@ -242,7 +242,7 @@ specifying whether to include voice channels in the export. It has possible valu
|
||||
voice channels, use `--include-vc false`.
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
|
||||
$ ./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
|
||||
```
|
||||
|
||||
### Export all channels
|
||||
@@ -250,7 +250,7 @@ dotnet DiscordChatExporter.Cli.dll exportguild -t "mfa.Ifrn" -g 21814 --include-
|
||||
To export all accessible channels, use the `exportall` command:
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn"
|
||||
$ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
|
||||
```
|
||||
|
||||
#### Excluding DMs
|
||||
@@ -258,15 +258,15 @@ dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn"
|
||||
To exclude DMs, add the `--include-dm false` option.
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn" --include-dm false
|
||||
$ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
|
||||
```
|
||||
|
||||
### List channels in a 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
|
||||
dotnet DiscordChatExporter.Cli.dll channels -t "mfa.Ifrn" -g 21814
|
||||
$ ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
|
||||
```
|
||||
|
||||
### List direct message channels
|
||||
@@ -274,13 +274,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:
|
||||
|
||||
```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
|
||||
dotnet DiscordChatExporter.Cli.dll guilds -t "mfa.Ifrn" > C:\path\to\output.txt
|
||||
$ ./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
|
||||
```
|
||||
@@ -94,7 +94,7 @@ body:
|
||||
required: true
|
||||
- label: I have provided a descriptive title for this issue
|
||||
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
|
||||
- label: I have provided all the information needed to reproduce this bug as efficiently as possible
|
||||
required: true
|
||||
|
||||
@@ -12,8 +12,7 @@ on:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
# Outputs from this job aren't really used, but it's here to verify that
|
||||
# the Dockerfile builds correctly on pull requests.
|
||||
# Outputs from this job aren't really used, but it's here to verify that the Dockerfile builds correctly
|
||||
pack:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
@@ -24,10 +23,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
|
||||
- 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
|
||||
run: >
|
||||
@@ -38,7 +37,7 @@ jobs:
|
||||
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # 3.1.3
|
||||
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
|
||||
with:
|
||||
name: DiscordChatExporter.Cli.Docker
|
||||
path: DiscordChatExporter.Cli.Docker.tar
|
||||
@@ -56,10 +55,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
|
||||
- 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
|
||||
run: >
|
||||
|
||||
+46
-17
@@ -18,7 +18,7 @@ env:
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: windows-latest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
permissions:
|
||||
@@ -26,10 +26,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # 3.2.0
|
||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
with:
|
||||
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
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
runs-on: windows-latest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
permissions:
|
||||
@@ -51,10 +51,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # 3.2.0
|
||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # 3.1.4
|
||||
uses: codecov/codecov-action@f30e4959ba63075080d4f7f90cacc18d9f3fafd7 # v4.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -82,7 +82,24 @@ jobs:
|
||||
app:
|
||||
- DiscordChatExporter.Cli
|
||||
- DiscordChatExporter.Gui
|
||||
rid:
|
||||
- win-arm64
|
||||
- win-x86
|
||||
- win-x64
|
||||
- linux-arm
|
||||
- linux-arm64
|
||||
- linux-musl-x64
|
||||
- linux-x64
|
||||
- osx-arm64
|
||||
- osx-x64
|
||||
include:
|
||||
- app: DiscordChatExporter.Cli
|
||||
asset: DiscordChatExporter.Cli
|
||||
- app: DiscordChatExporter.Gui
|
||||
# GUI assets aren't suffixed, unlike the CLI assets
|
||||
asset: DiscordChatExporter
|
||||
|
||||
# Need to run on Windows because of DotnetRuntimeBootstrapper's dependency on Ressy
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -92,10 +109,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # 3.2.0
|
||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
with:
|
||||
dotnet-version: 8.0.x
|
||||
|
||||
@@ -106,11 +123,13 @@ jobs:
|
||||
-p:CSharpier_Bypass=true
|
||||
--output ${{ matrix.app }}/bin/publish/
|
||||
--configuration Release
|
||||
--runtime ${{ matrix.rid }}
|
||||
--no-self-contained
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # 3.1.3
|
||||
uses: actions/upload-artifact@26f96dfa697d77e81fd5907df203aa23a56210a8 # v4.3.0
|
||||
with:
|
||||
name: ${{ matrix.app }}
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/bin/publish/
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -147,11 +166,21 @@ jobs:
|
||||
app:
|
||||
- DiscordChatExporter.Cli
|
||||
- DiscordChatExporter.Gui
|
||||
rid:
|
||||
- win-arm64
|
||||
- win-x86
|
||||
- win-x64
|
||||
- linux-arm
|
||||
- linux-arm64
|
||||
- linux-musl-x64
|
||||
- linux-x64
|
||||
- osx-arm64
|
||||
- osx-x64
|
||||
include:
|
||||
- app: DiscordChatExporter.Cli
|
||||
asset: DiscordChatExporter.Cli
|
||||
- app: DiscordChatExporter.Gui
|
||||
# GUI asset isn't suffixed, unlike the CLI asset
|
||||
# GUI assets aren't suffixed, unlike the CLI assets
|
||||
asset: DiscordChatExporter
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
@@ -163,22 +192,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # 3.0.2
|
||||
uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1
|
||||
with:
|
||||
name: ${{ matrix.app }}
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/
|
||||
|
||||
|
||||
- name: Create package
|
||||
# Change into the artifacts directory to avoid including the directory itself in the zip archive
|
||||
working-directory: ${{ matrix.app }}/
|
||||
run: zip -r ../${{ matrix.asset }}.zip .
|
||||
run: zip -r ../${{ matrix.asset }}.${{ matrix.rid }}.zip .
|
||||
|
||||
- name: Upload release asset
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: >
|
||||
gh release upload ${{ github.ref_name }}
|
||||
${{ matrix.asset }}.zip
|
||||
${{ matrix.asset }}.${{ matrix.rid }}.zip
|
||||
--repo ${{ github.event.repository.full_name }}
|
||||
|
||||
notify:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Version>999.9.9-dev</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||
<LangVersion>preview</LangVersion>
|
||||
|
||||
@@ -11,19 +11,18 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.0.7" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.26.2" PrivateAssets="all" />
|
||||
<PackageReference Include="AngleSharp" Version="1.1.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.3.3" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||
<PackageReference Include="ReflectionMagic" Version="5.0.0" />
|
||||
<PackageReference Include="xunit" Version="2.6.1" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" PrivateAssets="all" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.8.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -57,7 +57,7 @@ public static class ExportWrapper
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = new[] { channelId },
|
||||
ChannelIds = [channelId],
|
||||
ExportFormat = format,
|
||||
OutputPath = filePath,
|
||||
Locale = "en-US",
|
||||
@@ -93,13 +93,12 @@ public static class ExportWrapper
|
||||
Snowflake messageId
|
||||
)
|
||||
{
|
||||
var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(
|
||||
e =>
|
||||
string.Equals(
|
||||
e.GetAttribute("data-message-id"),
|
||||
messageId.ToString(),
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
var message = (await GetMessagesAsHtmlAsync(channelId)).SingleOrDefault(e =>
|
||||
string.Equals(
|
||||
e.GetAttribute("data-message-id"),
|
||||
messageId.ToString(),
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
);
|
||||
|
||||
if (message is null)
|
||||
@@ -117,13 +116,12 @@ public static class ExportWrapper
|
||||
Snowflake messageId
|
||||
)
|
||||
{
|
||||
var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(
|
||||
j =>
|
||||
string.Equals(
|
||||
j.GetProperty("id").GetString(),
|
||||
messageId.ToString(),
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
var message = (await GetMessagesAsJsonAsync(channelId)).SingleOrDefault(j =>
|
||||
string.Equals(
|
||||
j.GetProperty("id").GetString(),
|
||||
messageId.ToString(),
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
);
|
||||
|
||||
if (message.ValueKind == JsonValueKind.Undefined)
|
||||
|
||||
@@ -27,7 +27,7 @@ public class DateRangeSpecs
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
After = Snowflake.FromDate(after)
|
||||
@@ -45,24 +45,18 @@ public class DateRangeSpecs
|
||||
timestamps
|
||||
.Should()
|
||||
.BeEquivalentTo(
|
||||
new[]
|
||||
{
|
||||
[
|
||||
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero)
|
||||
},
|
||||
],
|
||||
o =>
|
||||
{
|
||||
return o.Using<DateTimeOffset>(
|
||||
ctx =>
|
||||
ctx.Subject
|
||||
.Should()
|
||||
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||
o.Using<DateTimeOffset>(ctx =>
|
||||
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||
)
|
||||
.WhenTypeIs<DateTimeOffset>();
|
||||
}
|
||||
.WhenTypeIs<DateTimeOffset>()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +71,7 @@ public class DateRangeSpecs
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
Before = Snowflake.FromDate(before)
|
||||
@@ -95,22 +89,16 @@ public class DateRangeSpecs
|
||||
timestamps
|
||||
.Should()
|
||||
.BeEquivalentTo(
|
||||
new[]
|
||||
{
|
||||
[
|
||||
new DateTimeOffset(2021, 07, 19, 13, 34, 18, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 19, 15, 58, 48, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero)
|
||||
},
|
||||
],
|
||||
o =>
|
||||
{
|
||||
return o.Using<DateTimeOffset>(
|
||||
ctx =>
|
||||
ctx.Subject
|
||||
.Should()
|
||||
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||
o.Using<DateTimeOffset>(ctx =>
|
||||
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||
)
|
||||
.WhenTypeIs<DateTimeOffset>();
|
||||
}
|
||||
.WhenTypeIs<DateTimeOffset>()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,7 +114,7 @@ public class DateRangeSpecs
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
Before = Snowflake.FromDate(before),
|
||||
@@ -145,23 +133,17 @@ public class DateRangeSpecs
|
||||
timestamps
|
||||
.Should()
|
||||
.BeEquivalentTo(
|
||||
new[]
|
||||
{
|
||||
[
|
||||
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero)
|
||||
},
|
||||
],
|
||||
o =>
|
||||
{
|
||||
return o.Using<DateTimeOffset>(
|
||||
ctx =>
|
||||
ctx.Subject
|
||||
.Should()
|
||||
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||
o.Using<DateTimeOffset>(ctx =>
|
||||
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||
)
|
||||
.WhenTypeIs<DateTimeOffset>();
|
||||
}
|
||||
.WhenTypeIs<DateTimeOffset>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.IO;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Infrastructure;
|
||||
@@ -25,7 +26,7 @@ public class FilterSpecs
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("some text")
|
||||
@@ -37,7 +38,7 @@ public class FilterSpecs
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("content").GetString())
|
||||
.Should()
|
||||
.ContainSingle("Some random text");
|
||||
.AllSatisfy(c => c.Contains("Some random text", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -50,7 +51,7 @@ public class FilterSpecs
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("from:Tyrrrz")
|
||||
@@ -66,7 +67,7 @@ public class FilterSpecs
|
||||
}
|
||||
|
||||
[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
|
||||
using var file = TempFile.Create();
|
||||
@@ -75,7 +76,7 @@ public class FilterSpecs
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("has:image")
|
||||
@@ -87,7 +88,7 @@ public class FilterSpecs
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("content").GetString())
|
||||
.Should()
|
||||
.ContainSingle("This has image");
|
||||
.AllSatisfy(c => c.Contains("This has image", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -100,7 +101,7 @@ public class FilterSpecs
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("has:pin")
|
||||
@@ -112,7 +113,32 @@ public class FilterSpecs
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("content").GetString())
|
||||
.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]
|
||||
@@ -125,7 +151,7 @@ public class FilterSpecs
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = new[] { ChannelIds.FilterTestCases },
|
||||
ChannelIds = [ChannelIds.FilterTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
MessageFilter = MessageFilter.Parse("mentions:Tyrrrz")
|
||||
@@ -137,6 +163,6 @@ public class FilterSpecs
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("content").GetString())
|
||||
.Should()
|
||||
.ContainSingle("This has mention");
|
||||
.AllSatisfy(c => c.Contains("This has mention", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,7 @@ public class HtmlAttachmentSpecs
|
||||
.QuerySelectorAll("a")
|
||||
.Select(e => e.GetAttribute("href"))
|
||||
.Should()
|
||||
.Contain(
|
||||
u =>
|
||||
u.StartsWith(
|
||||
"https://cdn.discordapp.com/attachments/885587741654536192/885587844964417596/Test.txt",
|
||||
StringComparison.Ordinal
|
||||
)
|
||||
);
|
||||
.Contain(u => u.Contains("Test.txt", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -52,13 +46,7 @@ public class HtmlAttachmentSpecs
|
||||
.QuerySelectorAll("img")
|
||||
.Select(e => e.GetAttribute("src"))
|
||||
.Should()
|
||||
.Contain(
|
||||
u =>
|
||||
u.StartsWith(
|
||||
"https://cdn.discordapp.com/attachments/885587741654536192/885654862430359613/bird-thumbnail.png",
|
||||
StringComparison.Ordinal
|
||||
)
|
||||
);
|
||||
.Contain(u => u.Contains("bird-thumbnail.png", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
@@ -54,7 +55,7 @@ public class HtmlEmbedSpecs
|
||||
.QuerySelectorAll("img")
|
||||
.Select(e => e.GetAttribute("src"))
|
||||
.WhereNotNull()
|
||||
.Where(s => s.EndsWith("f8w05ja8s4e61.png"))
|
||||
.Where(s => s.Contains("f8w05ja8s4e61.png", StringComparison.Ordinal))
|
||||
.Should()
|
||||
.ContainSingle();
|
||||
}
|
||||
@@ -89,11 +90,11 @@ public class HtmlEmbedSpecs
|
||||
.QuerySelectorAll("source")
|
||||
.Select(e => e.GetAttribute("src"))
|
||||
.WhereNotNull()
|
||||
.Where(
|
||||
s =>
|
||||
s.EndsWith(
|
||||
"i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4"
|
||||
)
|
||||
.Where(s =>
|
||||
s.Contains(
|
||||
"i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4",
|
||||
StringComparison.Ordinal
|
||||
)
|
||||
)
|
||||
.Should()
|
||||
.ContainSingle();
|
||||
@@ -113,7 +114,7 @@ public class HtmlEmbedSpecs
|
||||
.QuerySelectorAll("source")
|
||||
.Select(e => e.GetAttribute("src"))
|
||||
.WhereNotNull()
|
||||
.Where(s => s.EndsWith("tooncasm-test-copy.mp4"))
|
||||
.Where(s => s.Contains("tooncasm-test-copy.mp4", StringComparison.Ordinal))
|
||||
.Should()
|
||||
.ContainSingle();
|
||||
}
|
||||
@@ -148,6 +149,26 @@ public class HtmlEmbedSpecs
|
||||
iframeUrl.Should().StartWith("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Twitch does not allow embeds from inside local HTML files")]
|
||||
public async Task I_can_export_a_channel_that_contains_a_message_with_a_Twitch_clip_embed()
|
||||
{
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1196
|
||||
|
||||
// Act
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
ChannelIds.EmbedTestCases,
|
||||
Snowflake.Parse("1207002986128216074")
|
||||
);
|
||||
|
||||
// Assert
|
||||
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
|
||||
iframeUrl
|
||||
.Should()
|
||||
.StartWith(
|
||||
"https://clips.twitch.tv/embed?clip=SpicyMildCiderThisIsSparta--PQhbllrvej_Ee7v"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_message_with_a_YouTube_video_embed()
|
||||
{
|
||||
@@ -176,15 +197,45 @@ public class HtmlEmbedSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message
|
||||
var imageUrls = message
|
||||
.QuerySelectorAll("img")
|
||||
.Select(e => e.GetAttribute("src"))
|
||||
.ToArray();
|
||||
|
||||
imageUrls
|
||||
.Should()
|
||||
.ContainInOrder(
|
||||
"https://images-ext-1.discordapp.net/external/-n--xW3EHH_3jlrheVkMXHCM7T86b5Ty4-MzXCT4m1Q/https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
|
||||
"https://images-ext-2.discordapp.net/external/z5nEmGeEldV-kswydGLhqUsFHbb5AWHtdvc9XT6N5rE/https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
|
||||
"https://images-ext-2.discordapp.net/external/gnip03SawMB6uZLagN5sRDpA_1Ap1CcEhMbJfK1z6WQ/https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png",
|
||||
"https://images-ext-2.discordapp.net/external/jl1v6cCbLaGmiwmKU-ZkXnF4cFsJ39f9A3-oEdqPdZs/https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png"
|
||||
.Contain(u =>
|
||||
u.EndsWith(
|
||||
"https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
|
||||
StringComparison.Ordinal
|
||||
)
|
||||
);
|
||||
|
||||
imageUrls
|
||||
.Should()
|
||||
.Contain(u =>
|
||||
u.EndsWith(
|
||||
"https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
|
||||
StringComparison.Ordinal
|
||||
)
|
||||
);
|
||||
|
||||
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();
|
||||
@@ -202,6 +253,6 @@ public class HtmlEmbedSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("DiscordChatExporter TestServer");
|
||||
message.Text().Should().Contain("DiscordChatExporter Test Server");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ public class HtmlGroupingSpecs
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = new[] { ChannelIds.GroupingTestCases },
|
||||
ChannelIds = [ChannelIds.GroupingTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = file.Path
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
@@ -24,7 +24,7 @@ public class PartitioningSpecs
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = filePath,
|
||||
PartitionLimit = PartitionLimit.Parse("3")
|
||||
@@ -45,7 +45,7 @@ public class PartitioningSpecs
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = new[] { ChannelIds.DateRangeTestCases },
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = filePath,
|
||||
PartitionLimit = PartitionLimit.Parse("1kb")
|
||||
|
||||
@@ -24,7 +24,7 @@ public class SelfContainedSpecs
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = new[] { ChannelIds.SelfContainedTestCases },
|
||||
ChannelIds = [ChannelIds.SelfContainedTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = filePath,
|
||||
ShouldDownloadAssets = true
|
||||
|
||||
@@ -5,11 +5,9 @@ using PathEx = System.IO.Path;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||
|
||||
internal partial class TempDir : IDisposable
|
||||
internal partial class TempDir(string path) : IDisposable
|
||||
{
|
||||
public string Path { get; }
|
||||
|
||||
public TempDir(string path) => Path = path;
|
||||
public string Path { get; } = path;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -5,11 +5,9 @@ using PathEx = System.IO.Path;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||
|
||||
internal partial class TempFile : IDisposable
|
||||
internal partial class TempFile(string path) : IDisposable
|
||||
{
|
||||
public string Path { get; }
|
||||
|
||||
public TempFile(string path) => Path = path;
|
||||
public string Path { get; } = path;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
# We need the full ICU data because we allow the user to specify any locale for formatting purposes.
|
||||
RUN apk add --no-cache icu-libs
|
||||
RUN apk add --no-cache icu-data-full
|
||||
RUN apk add --no-cache icu-libs icu-data-full
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
ENV LANG=en_US.UTF-8
|
||||
|
||||
# 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.
|
||||
# Use a non-root user to ensure that the files shared with the host are accessible by the host user
|
||||
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851
|
||||
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
|
||||
# stays the same for backwards compatibility.
|
||||
WORKDIR /out
|
||||
|
||||
COPY --from=build /tmp/app/DiscordChatExporter.Cli/bin/publish /opt/app
|
||||
ENTRYPOINT ["/opt/app/DiscordChatExporter.Cli"]
|
||||
COPY docker-entrypoint.sh /opt/app
|
||||
ENTRYPOINT ["/opt/app/docker-entrypoint.sh"]
|
||||
|
||||
@@ -4,6 +4,7 @@ using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands.Base;
|
||||
|
||||
@@ -37,26 +38,22 @@ public abstract class DiscordCommandBase : ICommand
|
||||
{
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkYellow))
|
||||
{
|
||||
console
|
||||
.Error
|
||||
.WriteLine(
|
||||
"Warning: Option --bot is deprecated and should not be used. "
|
||||
+ "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."
|
||||
);
|
||||
console.Error.WriteLine(
|
||||
"Warning: The --bot option is deprecated and should not be used. "
|
||||
+ "The token type is now inferred automatically. "
|
||||
+ "Please update your workflows as this option may be completely removed in a future version."
|
||||
);
|
||||
}
|
||||
}
|
||||
#pragma warning restore CS0618
|
||||
|
||||
// Note about interactivity
|
||||
if (console.IsOutputRedirected)
|
||||
// Note about interactivity for Docker
|
||||
if (console.IsOutputRedirected && Docker.IsRunningInContainer)
|
||||
{
|
||||
console
|
||||
.Output
|
||||
.WriteLine(
|
||||
"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 ...)."
|
||||
);
|
||||
console.Error.WriteLine(
|
||||
"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;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@@ -30,8 +29,8 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
"output",
|
||||
'o',
|
||||
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. "
|
||||
+ "If a directory is specified, file names will be generated automatically based on the channel names and export parameters. "
|
||||
+ "Directory paths must end with a slash to avoid ambiguity. "
|
||||
+ "Supports template tokens, see the documentation for more info."
|
||||
)]
|
||||
public string OutputPath
|
||||
@@ -118,8 +117,12 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt";
|
||||
|
||||
[CommandOption("locale", Description = "Locale to use when formatting dates and numbers.")]
|
||||
public string Locale { get; init; } = CultureInfo.CurrentCulture.Name;
|
||||
[CommandOption(
|
||||
"locale",
|
||||
Description = "Locale to use when formatting dates and numbers. "
|
||||
+ "If not specified, the default system locale will be used."
|
||||
)]
|
||||
public string? Locale { get; init; }
|
||||
|
||||
[CommandOption("utc", Description = "Normalize all timestamps to UTC+0.")]
|
||||
public bool IsUtcNormalizationEnabled { get; init; } = false;
|
||||
@@ -244,11 +247,9 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
// Print the result
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
{
|
||||
await console
|
||||
.Output
|
||||
.WriteLineAsync(
|
||||
$"Successfully exported {channels.Count - errorsByChannel.Count} channel(s)."
|
||||
);
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Successfully exported {channels.Count - errorsByChannel.Count} channel(s)."
|
||||
);
|
||||
}
|
||||
|
||||
// Print errors
|
||||
@@ -258,11 +259,9 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
|
||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||
{
|
||||
await console
|
||||
.Error
|
||||
.WriteLineAsync(
|
||||
$"Failed to export {errorsByChannel.Count} the following channel(s):"
|
||||
);
|
||||
await console.Error.WriteLineAsync(
|
||||
$"Failed to export {errorsByChannel.Count} the following channel(s):"
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var (channel, error) in errorsByChannel)
|
||||
@@ -324,51 +323,33 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
// Support Ukraine callout
|
||||
if (!IsUkraineSupportMessageDisabled)
|
||||
{
|
||||
console
|
||||
.Output
|
||||
.WriteLine(
|
||||
"┌────────────────────────────────────────────────────────────────────┐"
|
||||
);
|
||||
console
|
||||
.Output
|
||||
.WriteLine(
|
||||
"│ Thank you for supporting Ukraine <3 │"
|
||||
);
|
||||
console
|
||||
.Output
|
||||
.WriteLine(
|
||||
"│ │"
|
||||
);
|
||||
console
|
||||
.Output
|
||||
.WriteLine(
|
||||
"│ As Russia wages a genocidal war against my country, │"
|
||||
);
|
||||
console
|
||||
.Output
|
||||
.WriteLine(
|
||||
"│ I'm grateful to everyone who continues to │"
|
||||
);
|
||||
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(
|
||||
"│ Thank you for supporting Ukraine <3 │"
|
||||
);
|
||||
console.Output.WriteLine(
|
||||
"│ │"
|
||||
);
|
||||
console.Output.WriteLine(
|
||||
"│ As Russia wages a genocidal war against my country, │"
|
||||
);
|
||||
console.Output.WriteLine(
|
||||
"│ I'm grateful to everyone who continues to │"
|
||||
);
|
||||
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("");
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ExportAllCommand : ExportCommandBase
|
||||
[CommandOption("include-dm", Description = "Include direct message channels.")]
|
||||
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;
|
||||
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
@@ -54,9 +54,9 @@ public class ExportAllCommand : ExportCommandBase
|
||||
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
|
||||
{
|
||||
// Regular channels
|
||||
await console
|
||||
.Output
|
||||
.WriteLineAsync($"Fetching channels for guild '{guild.Name}'...");
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Fetching channels for server '{guild.Name}'..."
|
||||
);
|
||||
|
||||
var fetchedChannelsCount = 0;
|
||||
await console
|
||||
@@ -94,9 +94,9 @@ public class ExportAllCommand : ExportCommandBase
|
||||
// Threads
|
||||
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
||||
{
|
||||
await console
|
||||
.Output
|
||||
.WriteLineAsync($"Fetching threads for guild '{guild.Name}'...");
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Fetching threads for server '{guild.Name}'..."
|
||||
);
|
||||
|
||||
var fetchedThreadsCount = 0;
|
||||
await console
|
||||
@@ -126,9 +126,9 @@ public class ExportAllCommand : ExportCommandBase
|
||||
}
|
||||
);
|
||||
|
||||
await console
|
||||
.Output
|
||||
.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Fetched {fetchedThreadsCount} thread(s)."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,9 +180,9 @@ public class ExportAllCommand : ExportCommandBase
|
||||
|
||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||
{
|
||||
await console
|
||||
.Error
|
||||
.WriteLineAsync("Failed to access the following channel(s):");
|
||||
await console.Error.WriteLineAsync(
|
||||
"Failed to access the following channel(s):"
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var dumpChannel in inaccessibleChannels)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
@@ -13,10 +12,10 @@ using Spectre.Console;
|
||||
|
||||
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
|
||||
{
|
||||
[CommandOption("guild", 'g', Description = "Guild ID.")]
|
||||
[CommandOption("guild", 'g', Description = "Server ID.")]
|
||||
public required Snowflake GuildId { get; init; }
|
||||
|
||||
[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.Shared;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
[Command("channels", Description = "Get the list of channels in a guild.")]
|
||||
[Command("channels", Description = "Get the list of channels in a server.")]
|
||||
public class GetChannelsCommand : DiscordCommandBase
|
||||
{
|
||||
[CommandOption("guild", 'g', Description = "Guild ID.")]
|
||||
[CommandOption("guild", 'g', Description = "Server ID.")]
|
||||
public required Snowflake GuildId { get; init; }
|
||||
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
@@ -59,14 +58,14 @@ public class GetChannelsCommand : DiscordCommandBase
|
||||
)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToArray()
|
||||
: Array.Empty<Channel>();
|
||||
: [];
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
// Channel ID
|
||||
await console
|
||||
.Output
|
||||
.WriteAsync(channel.Id.ToString().PadRight(channelIdMaxLength, ' '));
|
||||
await console.Output.WriteAsync(
|
||||
channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
|
||||
);
|
||||
|
||||
// Separator
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||
@@ -88,11 +87,9 @@ public class GetChannelsCommand : DiscordCommandBase
|
||||
await console.Output.WriteAsync(" * ");
|
||||
|
||||
// Thread ID
|
||||
await console
|
||||
.Output
|
||||
.WriteAsync(
|
||||
channelThread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
|
||||
);
|
||||
await console.Output.WriteAsync(
|
||||
channelThread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
|
||||
);
|
||||
|
||||
// Separator
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||
@@ -108,9 +105,9 @@ public class GetChannelsCommand : DiscordCommandBase
|
||||
|
||||
// Thread status
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
await console
|
||||
.Output
|
||||
.WriteLineAsync(channelThread.IsArchived ? "Archived" : "Active");
|
||||
await console.Output.WriteLineAsync(
|
||||
channelThread.IsArchived ? "Archived" : "Active"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ public class GetDirectChannelsCommand : DiscordCommandBase
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
// Channel ID
|
||||
await console
|
||||
.Output
|
||||
.WriteAsync(channel.Id.ToString().PadRight(channelIdMaxLength, ' '));
|
||||
await console.Output.WriteAsync(
|
||||
channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
|
||||
);
|
||||
|
||||
// Separator
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkGray))
|
||||
|
||||
@@ -9,7 +9,7 @@ using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
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 override async ValueTask ExecuteAsync(IConsole console)
|
||||
|
||||
@@ -6,20 +6,18 @@ using CliFx.Infrastructure;
|
||||
|
||||
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 ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
// User token
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
console.Output.WriteLine("To get user token:");
|
||||
console.Output.WriteLine("To get the token for your personal account:");
|
||||
|
||||
console
|
||||
.Output
|
||||
.WriteLine(
|
||||
" * Automating user accounts is technically against TOS — USE AT YOUR OWN RISK!"
|
||||
);
|
||||
console.Output.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(" 2. Open any server or direct message channel");
|
||||
console.Output.WriteLine(" 3. Press Ctrl+Shift+I to show developer tools");
|
||||
@@ -34,43 +32,44 @@ public class GuideCommand : ICommand
|
||||
|
||||
// Bot token
|
||||
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(" 2. Open your application's settings");
|
||||
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
||||
console.Output.WriteLine(" 4. Under Token click Copy");
|
||||
console
|
||||
.Output
|
||||
.WriteLine(
|
||||
" * Your bot needs to have Message Content Intent enabled to read messages"
|
||||
);
|
||||
console.Output.WriteLine(
|
||||
" * Your bot needs to have the Message Content Intent enabled to read messages"
|
||||
);
|
||||
console.Output.WriteLine();
|
||||
|
||||
// Guild or channel ID
|
||||
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(" 2. Open Settings");
|
||||
console.Output.WriteLine(" 3. Go to Advanced section");
|
||||
console.Output.WriteLine(" 4. Enable Developer Mode");
|
||||
console
|
||||
.Output
|
||||
.WriteLine(
|
||||
" 5. Right-click on the desired guild or channel and click Copy Server ID or Copy Channel ID"
|
||||
);
|
||||
console.Output.WriteLine(
|
||||
" 5. Right-click on the desired server or channel and click Copy Server ID or Copy Channel ID"
|
||||
);
|
||||
console.Output.WriteLine();
|
||||
|
||||
// Docs link
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
console
|
||||
.Output
|
||||
.WriteLine("If you have questions or issues, please refer to the documentation:");
|
||||
{
|
||||
console.Output.WriteLine(
|
||||
"If you have questions or issues, please refer to the documentation:"
|
||||
);
|
||||
}
|
||||
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
|
||||
console
|
||||
.Output
|
||||
.WriteLine("https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs");
|
||||
{
|
||||
console.Output.WriteLine(
|
||||
"https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"
|
||||
);
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
<OutputType>Exe</OutputType>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<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="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" />
|
||||
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.4" PrivateAssets="all" />
|
||||
<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>
|
||||
|
||||
@@ -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.Globalization;
|
||||
using System.Linq;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Common;
|
||||
|
||||
@@ -11,7 +10,7 @@ public static class ImageCdn
|
||||
// Standard emoji are rendered through Twemoji
|
||||
public static string GetStandardEmojiUrl(string emojiName)
|
||||
{
|
||||
var runes = emojiName.GetRunes().ToArray();
|
||||
var runes = emojiName.EnumerateRunes().ToArray();
|
||||
|
||||
// Variant selector rune is skipped in Twemoji IDs,
|
||||
// except when the emoji also contains a zero-width joiner.
|
||||
|
||||
@@ -31,6 +31,9 @@ public partial record Embed(
|
||||
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() =>
|
||||
SpotifyTrackEmbedProjection.TryResolve(this);
|
||||
|
||||
public TwitchClipEmbedProjection? TryGetTwitchClip() =>
|
||||
TwitchClipEmbedProjection.TryResolve(this);
|
||||
|
||||
public YouTubeVideoEmbedProjection? TryGetYouTubeVideo() =>
|
||||
YouTubeVideoEmbedProjection.TryResolve(this);
|
||||
}
|
||||
@@ -60,7 +63,7 @@ public partial record Embed
|
||||
json.GetPropertyOrNull("fields")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(EmbedField.Parse)
|
||||
.ToArray() ?? Array.Empty<EmbedField>();
|
||||
.ToArray() ?? [];
|
||||
|
||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||
|
||||
@@ -75,7 +78,7 @@ public partial record Embed
|
||||
json.GetPropertyOrNull("image")
|
||||
?.Pipe(EmbedImage.Parse)
|
||||
.ToSingletonEnumerable()
|
||||
.ToArray() ?? Array.Empty<EmbedImage>();
|
||||
.ToArray() ?? [];
|
||||
|
||||
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
||||
|
||||
|
||||
@@ -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.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
@@ -21,8 +20,7 @@ public partial record Member(
|
||||
|
||||
public partial record Member
|
||||
{
|
||||
public static Member CreateFallback(User user) =>
|
||||
new(user, null, null, Array.Empty<Snowflake>());
|
||||
public static Member CreateFallback(User user) => new(user, null, null, []);
|
||||
|
||||
public static Member Parse(JsonElement json, Snowflake? guildId = null)
|
||||
{
|
||||
@@ -34,7 +32,7 @@ public partial record Member
|
||||
?.EnumerateArray()
|
||||
.Select(j => j.GetNonWhiteSpaceString())
|
||||
.Select(Snowflake.Parse)
|
||||
.ToArray() ?? Array.Empty<Snowflake>();
|
||||
.ToArray() ?? [];
|
||||
|
||||
var avatarUrl = guildId is not null
|
||||
? json.GetPropertyOrNull("avatar")
|
||||
|
||||
@@ -83,16 +83,15 @@ public partial record Message
|
||||
// Find embeds with the same URL that only contain a single image and nothing else
|
||||
var trailingEmbeds = embeds
|
||||
.Skip(i + 1)
|
||||
.TakeWhile(
|
||||
e =>
|
||||
e.Url == embed.Url
|
||||
&& e.Timestamp is null
|
||||
&& e.Author is null
|
||||
&& e.Color is null
|
||||
&& string.IsNullOrWhiteSpace(e.Description)
|
||||
&& !e.Fields.Any()
|
||||
&& e.Images.Count == 1
|
||||
&& e.Footer is null
|
||||
.TakeWhile(e =>
|
||||
e.Url == embed.Url
|
||||
&& e.Timestamp is null
|
||||
&& e.Author is null
|
||||
&& e.Color is null
|
||||
&& string.IsNullOrWhiteSpace(e.Description)
|
||||
&& !e.Fields.Any()
|
||||
&& e.Images.Count == 1
|
||||
&& e.Footer is null
|
||||
)
|
||||
.ToArray();
|
||||
|
||||
@@ -100,8 +99,7 @@ public partial record Message
|
||||
{
|
||||
// Concatenate all images into one embed
|
||||
var images = embed
|
||||
.Images
|
||||
.Concat(trailingEmbeds.SelectMany(e => e.Images))
|
||||
.Images.Concat(trailingEmbeds.SelectMany(e => e.Images))
|
||||
.ToArray();
|
||||
|
||||
normalizedEmbeds.Add(embed with { Images = images });
|
||||
@@ -145,28 +143,28 @@ public partial record Message
|
||||
json.GetPropertyOrNull("attachments")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Attachment.Parse)
|
||||
.ToArray() ?? Array.Empty<Attachment>();
|
||||
.ToArray() ?? [];
|
||||
|
||||
var embeds = NormalizeEmbeds(
|
||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||
?? Array.Empty<Embed>()
|
||||
?? []
|
||||
);
|
||||
|
||||
var stickers =
|
||||
json.GetPropertyOrNull("sticker_items")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Sticker.Parse)
|
||||
.ToArray() ?? Array.Empty<Sticker>();
|
||||
.ToArray() ?? [];
|
||||
|
||||
var reactions =
|
||||
json.GetPropertyOrNull("reactions")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Reaction.Parse)
|
||||
.ToArray() ?? Array.Empty<Reaction>();
|
||||
.ToArray() ?? [];
|
||||
|
||||
var mentionedUsers =
|
||||
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
||||
?? Array.Empty<User>();
|
||||
?? [];
|
||||
|
||||
var messageReference = json.GetPropertyOrNull("message_reference")
|
||||
?.Pipe(MessageReference.Parse);
|
||||
|
||||
@@ -18,15 +18,11 @@ using JsonExtensions.Reading;
|
||||
|
||||
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 TokenKind? _resolvedTokenKind;
|
||||
|
||||
public DiscordClient(string token) => _token = token;
|
||||
|
||||
private async ValueTask<HttpResponseMessage> GetResponseAsync(
|
||||
string url,
|
||||
TokenKind tokenKind,
|
||||
@@ -40,12 +36,10 @@ public class DiscordClient
|
||||
|
||||
// Don't validate because the token can have special characters
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/828
|
||||
request
|
||||
.Headers
|
||||
.TryAddWithoutValidation(
|
||||
"Authorization",
|
||||
tokenKind == TokenKind.Bot ? $"Bot {_token}" : _token
|
||||
);
|
||||
request.Headers.TryAddWithoutValidation(
|
||||
"Authorization",
|
||||
tokenKind == TokenKind.Bot ? $"Bot {token}" : token
|
||||
);
|
||||
|
||||
var response = await Http.Client.SendAsync(
|
||||
request,
|
||||
@@ -61,25 +55,23 @@ public class DiscordClient
|
||||
// rate limits and that's just way too much effort.
|
||||
// https://discord.com/developers/docs/topics/rate-limits
|
||||
var remainingRequestCount = response
|
||||
.Headers
|
||||
.TryGetValue("X-RateLimit-Remaining")
|
||||
.Headers.TryGetValue("X-RateLimit-Remaining")
|
||||
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
|
||||
|
||||
var resetAfterDelay = response
|
||||
.Headers
|
||||
.TryGetValue("X-RateLimit-Reset-After")
|
||||
.Headers.TryGetValue("X-RateLimit-Reset-After")
|
||||
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
|
||||
.Pipe(TimeSpan.FromSeconds);
|
||||
|
||||
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
|
||||
{
|
||||
var delay =
|
||||
// Adding a small buffer to the reset time reduces the chance of getting
|
||||
// rate limited again, because it allows for more requests to be released.
|
||||
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
|
||||
// Sometimes Discord returns an absurdly high value for the reset time, which
|
||||
// is not actually enforced by the server. So we cap it at a reasonable value.
|
||||
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
|
||||
// Adding a small buffer to the reset time reduces the chance of getting
|
||||
// rate limited again, because it allows for more requests to be released.
|
||||
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
|
||||
// Sometimes Discord returns an absurdly high value for the reset time, which
|
||||
// is not actually enforced by the server. So we cap it at a reasonable value.
|
||||
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
|
||||
|
||||
await Task.Delay(delay, innerCancellationToken);
|
||||
}
|
||||
@@ -160,8 +152,13 @@ public class DiscordClient
|
||||
_
|
||||
=> throw new DiscordChatExporterException(
|
||||
$"""
|
||||
Request to '{url}' failed: {response.StatusCode.ToString().ToSpaceSeparatedWords().ToLowerInvariant()}.
|
||||
Response content: {await response.Content.ReadAsStringAsync(cancellationToken)}
|
||||
Request to '{url}' failed: {response
|
||||
.StatusCode.ToString()
|
||||
.ToSpaceSeparatedWords()
|
||||
.ToLowerInvariant()}.
|
||||
Response content: {await response.Content.ReadAsStringAsync(
|
||||
cancellationToken
|
||||
)}
|
||||
""",
|
||||
true
|
||||
)
|
||||
|
||||
@@ -8,11 +8,9 @@ using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Dump;
|
||||
|
||||
public partial class DataDump
|
||||
public partial class DataDump(IReadOnlyList<DataDumpChannel> channels)
|
||||
{
|
||||
public IReadOnlyList<DataDumpChannel> Channels { get; }
|
||||
|
||||
public DataDump(IReadOnlyList<DataDumpChannel> channels) => Channels = channels;
|
||||
public IReadOnlyList<DataDumpChannel> Channels { get; } = channels;
|
||||
}
|
||||
|
||||
public partial class DataDump
|
||||
@@ -48,7 +46,7 @@ public partial class DataDump
|
||||
if (entry is null)
|
||||
{
|
||||
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">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncKeyedLock" Version="6.2.2" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.26.2" PrivateAssets="all" />
|
||||
<PackageReference Include="AsyncKeyedLock" Version="6.4.2" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Polly" Version="8.2.0" />
|
||||
<PackageReference Include="RazorBlade" Version="0.4.4" />
|
||||
<PackageReference Include="Polly" Version="8.3.1" />
|
||||
<PackageReference Include="RazorBlade" Version="0.6.0" />
|
||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.14.0" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.3.7" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.16.0" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.3.14" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -2,17 +2,11 @@
|
||||
|
||||
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 DiscordChatExporterException(
|
||||
string message,
|
||||
bool isFatal = false,
|
||||
Exception? innerException = null
|
||||
)
|
||||
: base(message, innerException)
|
||||
{
|
||||
IsFatal = isFatal;
|
||||
}
|
||||
public bool IsFatal { get; } = isFatal;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
|
||||
public class ChannelExporter(DiscordClient discord)
|
||||
{
|
||||
private readonly DiscordClient _discord = discord;
|
||||
|
||||
public async ValueTask ExportChannelAsync(
|
||||
ExportRequest request,
|
||||
IProgress<Percentage>? progress = null,
|
||||
@@ -22,8 +20,8 @@ public class ChannelExporter(DiscordClient discord)
|
||||
if (request.Channel.Kind == ChannelKind.GuildForum)
|
||||
{
|
||||
throw new DiscordChatExporterException(
|
||||
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
|
||||
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
|
||||
$"Channel '{request.Channel.Name}' "
|
||||
+ $"of guild '{request.Guild.Name}' "
|
||||
+ $"is a forum and cannot be exported directly. "
|
||||
+ "You need to pull its threads and export them individually."
|
||||
);
|
||||
@@ -33,8 +31,8 @@ public class ChannelExporter(DiscordClient discord)
|
||||
if (request.Channel.IsEmpty)
|
||||
{
|
||||
throw new DiscordChatExporterException(
|
||||
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
|
||||
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
|
||||
$"Channel '{request.Channel.Name}' "
|
||||
+ $"of guild '{request.Guild.Name}' "
|
||||
+ $"does not contain any messages."
|
||||
);
|
||||
}
|
||||
@@ -43,8 +41,8 @@ public class ChannelExporter(DiscordClient discord)
|
||||
if (request.After is not null && !request.Channel.MayHaveMessagesAfter(request.After.Value))
|
||||
{
|
||||
throw new DiscordChatExporterException(
|
||||
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
|
||||
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
|
||||
$"Channel '{request.Channel.Name}' "
|
||||
+ $"of guild '{request.Guild.Name}' "
|
||||
+ $"does not contain any messages within the specified period."
|
||||
);
|
||||
}
|
||||
@@ -56,20 +54,20 @@ public class ChannelExporter(DiscordClient discord)
|
||||
)
|
||||
{
|
||||
throw new DiscordChatExporterException(
|
||||
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
|
||||
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
|
||||
$"Channel '{request.Channel.Name}' "
|
||||
+ $"of guild '{request.Guild.Name}' "
|
||||
+ $"does not contain any messages within the specified period."
|
||||
);
|
||||
}
|
||||
|
||||
// Build context
|
||||
var context = new ExportContext(_discord, request);
|
||||
var context = new ExportContext(discord, request);
|
||||
await context.PopulateChannelsAndRolesAsync(cancellationToken);
|
||||
|
||||
// Export messages
|
||||
await using var messageExporter = new MessageExporter(context);
|
||||
await foreach (
|
||||
var message in _discord.GetMessagesAsync(
|
||||
var message in discord.GetMessagesAsync(
|
||||
request.Channel.Id,
|
||||
request.After,
|
||||
request.Before,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
@@ -122,7 +123,7 @@ internal partial class CsvMessageWriter
|
||||
{
|
||||
private static string CsvEncode(string value)
|
||||
{
|
||||
value = value.Replace("\"", "\"\"");
|
||||
value = value.Replace("\"", "\"\"", StringComparison.Ordinal);
|
||||
return $"\"{value}\"";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,6 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
o.PoolInitialFill = 1;
|
||||
});
|
||||
|
||||
private readonly string _workingDirPath = workingDirPath;
|
||||
private readonly bool _reuse = reuse;
|
||||
|
||||
// File paths of the previously downloaded assets
|
||||
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 filePath = Path.Combine(_workingDirPath, fileName);
|
||||
var filePath = Path.Combine(workingDirPath, fileName);
|
||||
|
||||
using var _ = await Locker.LockAsync(filePath, cancellationToken);
|
||||
|
||||
@@ -43,10 +40,10 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
return cachedFilePath;
|
||||
|
||||
// Reuse existing files if we're allowed to
|
||||
if (_reuse && File.Exists(filePath))
|
||||
if (reuse && File.Exists(filePath))
|
||||
return _previousPathsByUrl[url] = filePath;
|
||||
|
||||
Directory.CreateDirectory(_workingDirPath);
|
||||
Directory.CreateDirectory(workingDirPath);
|
||||
|
||||
await Http.ResiliencePipeline.ExecuteAsync(
|
||||
async innerCancellationToken =>
|
||||
@@ -60,19 +57,16 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
try
|
||||
{
|
||||
var lastModified = response
|
||||
.Content
|
||||
.Headers
|
||||
.TryGetValue("Last-Modified")
|
||||
?.Pipe(
|
||||
s =>
|
||||
DateTimeOffset.TryParse(
|
||||
s,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var instant
|
||||
)
|
||||
? instant
|
||||
: (DateTimeOffset?)null
|
||||
.Content.Headers.TryGetValue("Last-Modified")
|
||||
?.Pipe(s =>
|
||||
DateTimeOffset.TryParse(
|
||||
s,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var instant
|
||||
)
|
||||
? instant
|
||||
: (DateTimeOffset?)null
|
||||
);
|
||||
|
||||
if (lastModified is not null)
|
||||
|
||||
@@ -93,11 +93,10 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
|
||||
public IReadOnlyList<Role> GetUserRoles(Snowflake id) =>
|
||||
TryGetMember(id)
|
||||
?.RoleIds
|
||||
.Select(TryGetRole)
|
||||
?.RoleIds.Select(TryGetRole)
|
||||
.WhereNotNull()
|
||||
.OrderByDescending(r => r.Position)
|
||||
.ToArray() ?? Array.Empty<Role>();
|
||||
.ToArray() ?? [];
|
||||
|
||||
public Color? TryGetUserColor(Snowflake id) =>
|
||||
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
|
||||
|
||||
@@ -8,6 +8,7 @@ using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting;
|
||||
|
||||
@@ -39,9 +40,9 @@ public partial class ExportRequest
|
||||
|
||||
public bool ShouldReuseAssets { get; }
|
||||
|
||||
public string Locale { get; }
|
||||
public string? Locale { get; }
|
||||
|
||||
public CultureInfo CultureInfo { get; }
|
||||
public CultureInfo? CultureInfo { get; }
|
||||
|
||||
public bool IsUtcNormalizationEnabled { get; }
|
||||
|
||||
@@ -58,7 +59,7 @@ public partial class ExportRequest
|
||||
bool shouldFormatMarkdown,
|
||||
bool shouldDownloadAssets,
|
||||
bool shouldReuseAssets,
|
||||
string locale,
|
||||
string? locale,
|
||||
bool isUtcNormalizationEnabled
|
||||
)
|
||||
{
|
||||
@@ -83,7 +84,7 @@ public partial class ExportRequest
|
||||
? FormatPath(assetsDirPath, Guild, Channel, After, Before)
|
||||
: $"{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)
|
||||
?? "",
|
||||
"%d"
|
||||
=> DateTimeOffset
|
||||
.Now
|
||||
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
=> DateTimeOffset.Now.ToString(
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture
|
||||
),
|
||||
|
||||
"%%" => "%",
|
||||
_ => m.Value
|
||||
|
||||
@@ -3,28 +3,17 @@ using DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
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) =>
|
||||
_kind switch
|
||||
kind switch
|
||||
{
|
||||
BinaryExpressionKind.Or => _first.IsMatch(message) || _second.IsMatch(message),
|
||||
BinaryExpressionKind.And => _first.IsMatch(message) && _second.IsMatch(message),
|
||||
_ => throw new InvalidOperationException($"Unknown binary expression kind '{_kind}'.")
|
||||
BinaryExpressionKind.Or => first.IsMatch(message) || second.IsMatch(message),
|
||||
BinaryExpressionKind.And => first.IsMatch(message) && second.IsMatch(message),
|
||||
_ => throw new InvalidOperationException($"Unknown binary expression kind '{kind}'.")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,12 +4,8 @@ using DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
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.
|
||||
// For example, "max" shouldn't match on content "our maximum effort",
|
||||
// but should match on content "our max effort".
|
||||
@@ -20,20 +16,17 @@ internal class ContainsMessageFilter : MessageFilter
|
||||
!string.IsNullOrWhiteSpace(content)
|
||||
&& Regex.IsMatch(
|
||||
content,
|
||||
@"(?:\b|\s|^)" + Regex.Escape(_text) + @"(?:\b|\s|$)",
|
||||
@"(?:\b|\s|^)" + Regex.Escape(text) + @"(?:\b|\s|$)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant
|
||||
);
|
||||
|
||||
public override bool IsMatch(Message message) =>
|
||||
IsMatch(message.Content)
|
||||
|| message
|
||||
.Embeds
|
||||
.Any(
|
||||
e =>
|
||||
IsMatch(e.Title)
|
||||
|| IsMatch(e.Author?.Name)
|
||||
|| IsMatch(e.Description)
|
||||
|| IsMatch(e.Footer?.Text)
|
||||
|| e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
|
||||
);
|
||||
|| message.Embeds.Any(e =>
|
||||
IsMatch(e.Title)
|
||||
|| IsMatch(e.Author?.Name)
|
||||
|| IsMatch(e.Description)
|
||||
|| IsMatch(e.Footer?.Text)
|
||||
|| e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,11 @@ using DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
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) =>
|
||||
string.Equals(_value, message.Author.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(_value, message.Author.DisplayName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(_value, message.Author.FullName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(_value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
string.Equals(value, message.Author.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, message.Author.DisplayName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, message.Author.FullName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, message.Author.Id.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||
|
||||
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) =>
|
||||
_kind switch
|
||||
kind switch
|
||||
{
|
||||
MessageContentMatchKind.Link
|
||||
=> Regex.IsMatch(message.Content, "https?://\\S*[^\\.,:;\"\'\\s]"),
|
||||
MessageContentMatchKind.Link => MarkdownParser.ExtractLinks(message.Content).Any(),
|
||||
MessageContentMatchKind.Embed => message.Embeds.Any(),
|
||||
MessageContentMatchKind.File => message.Attachments.Any(),
|
||||
MessageContentMatchKind.Video => message.Attachments.Any(file => file.IsVideo),
|
||||
MessageContentMatchKind.Image => message.Attachments.Any(file => file.IsImage),
|
||||
MessageContentMatchKind.Sound => message.Attachments.Any(file => file.IsAudio),
|
||||
MessageContentMatchKind.Pin => message.IsPinned,
|
||||
MessageContentMatchKind.Invite
|
||||
=> MarkdownParser
|
||||
.ExtractLinks(message.Content)
|
||||
.Select(l => l.Url)
|
||||
.Select(Invite.TryGetCodeFromUrl)
|
||||
.Any(c => !string.IsNullOrWhiteSpace(c)),
|
||||
_
|
||||
=> throw new InvalidOperationException(
|
||||
$"Unknown message content match kind '{_kind}'."
|
||||
$"Unknown message content match kind '{kind}'."
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,20 +4,13 @@ using DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
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) =>
|
||||
message
|
||||
.MentionedUsers
|
||||
.Any(
|
||||
user =>
|
||||
string.Equals(_value, user.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(_value, user.DisplayName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(_value, user.FullName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(_value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
message.MentionedUsers.Any(user =>
|
||||
string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ internal enum MessageContentMatchKind
|
||||
Video,
|
||||
Image,
|
||||
Sound,
|
||||
Pin
|
||||
Pin,
|
||||
Invite
|
||||
}
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting.Filtering;
|
||||
|
||||
internal class NegatedMessageFilter : MessageFilter
|
||||
internal class NegatedMessageFilter(MessageFilter filter) : MessageFilter
|
||||
{
|
||||
private readonly MessageFilter _filter;
|
||||
|
||||
public NegatedMessageFilter(MessageFilter filter) => _filter = filter;
|
||||
|
||||
public override bool IsMatch(Message message) => !_filter.IsMatch(message);
|
||||
public override bool IsMatch(Message message) => !filter.IsMatch(message);
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ internal static class FilterGrammar
|
||||
.OneOf(QuotedString, UnquotedString)
|
||||
.Named("text string");
|
||||
|
||||
private static readonly TextParser<MessageFilter> ContainsFilter = String.Select(
|
||||
v => (MessageFilter)new ContainsMessageFilter(v)
|
||||
private static readonly TextParser<MessageFilter> ContainsFilter = String.Select(v =>
|
||||
(MessageFilter)new ContainsMessageFilter(v)
|
||||
);
|
||||
|
||||
private static readonly TextParser<MessageFilter> FromFilter = Span.EqualToIgnoreCase("from:")
|
||||
@@ -61,18 +61,28 @@ internal static class FilterGrammar
|
||||
.IgnoreThen(
|
||||
Parse.OneOf(
|
||||
Span.EqualToIgnoreCase("link")
|
||||
.IgnoreThen(Parse.Return(MessageContentMatchKind.Link)),
|
||||
.IgnoreThen(Parse.Return(MessageContentMatchKind.Link))
|
||||
.Try(),
|
||||
Span.EqualToIgnoreCase("embed")
|
||||
.IgnoreThen(Parse.Return(MessageContentMatchKind.Embed)),
|
||||
.IgnoreThen(Parse.Return(MessageContentMatchKind.Embed))
|
||||
.Try(),
|
||||
Span.EqualToIgnoreCase("file")
|
||||
.IgnoreThen(Parse.Return(MessageContentMatchKind.File)),
|
||||
.IgnoreThen(Parse.Return(MessageContentMatchKind.File))
|
||||
.Try(),
|
||||
Span.EqualToIgnoreCase("video")
|
||||
.IgnoreThen(Parse.Return(MessageContentMatchKind.Video)),
|
||||
.IgnoreThen(Parse.Return(MessageContentMatchKind.Video))
|
||||
.Try(),
|
||||
Span.EqualToIgnoreCase("image")
|
||||
.IgnoreThen(Parse.Return(MessageContentMatchKind.Image)),
|
||||
.IgnoreThen(Parse.Return(MessageContentMatchKind.Image))
|
||||
.Try(),
|
||||
Span.EqualToIgnoreCase("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))
|
||||
|
||||
@@ -4,23 +4,12 @@ using DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
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) =>
|
||||
message
|
||||
.Reactions
|
||||
.Any(
|
||||
r =>
|
||||
string.Equals(
|
||||
_value,
|
||||
r.Emoji.Id?.ToString(),
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
)
|
||||
|| string.Equals(_value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(_value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
message.Reactions.Any(r =>
|
||||
string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,16 +18,12 @@ internal partial class HtmlMarkdownVisitor(
|
||||
bool isJumbo
|
||||
) : MarkdownVisitor
|
||||
{
|
||||
private readonly ExportContext _context = context;
|
||||
private readonly StringBuilder _buffer = buffer;
|
||||
private readonly bool _isJumbo = isJumbo;
|
||||
|
||||
protected override ValueTask VisitTextAsync(
|
||||
TextNode text,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(HtmlEncode(text.Text));
|
||||
buffer.Append(HtmlEncode(text.Text));
|
||||
return default;
|
||||
}
|
||||
|
||||
@@ -92,9 +88,9 @@ internal partial class HtmlMarkdownVisitor(
|
||||
)
|
||||
};
|
||||
|
||||
_buffer.Append(openingTag);
|
||||
buffer.Append(openingTag);
|
||||
await VisitAsync(formatting.Children, cancellationToken);
|
||||
_buffer.Append(closingTag);
|
||||
buffer.Append(closingTag);
|
||||
}
|
||||
|
||||
protected override async ValueTask VisitHeadingAsync(
|
||||
@@ -102,14 +98,14 @@ internal partial class HtmlMarkdownVisitor(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
$"<h{heading.Level}>"
|
||||
);
|
||||
|
||||
await VisitAsync(heading.Children, cancellationToken);
|
||||
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
$"</h{heading.Level}>"
|
||||
);
|
||||
@@ -120,14 +116,14 @@ internal partial class HtmlMarkdownVisitor(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
"<ul>"
|
||||
);
|
||||
|
||||
await VisitAsync(list.Items, cancellationToken);
|
||||
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
"</ul>"
|
||||
);
|
||||
@@ -138,14 +134,14 @@ internal partial class HtmlMarkdownVisitor(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
"<li>"
|
||||
);
|
||||
|
||||
await VisitAsync(listItem.Children, cancellationToken);
|
||||
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
"</li>"
|
||||
);
|
||||
@@ -156,10 +152,12 @@ internal partial class HtmlMarkdownVisitor(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
$"""
|
||||
<code class="chatlog__markdown-pre chatlog__markdown-pre--inline">{HtmlEncode(inlineCodeBlock.Code)}</code>
|
||||
<code class="chatlog__markdown-pre chatlog__markdown-pre--inline">{HtmlEncode(
|
||||
inlineCodeBlock.Code
|
||||
)}</code>
|
||||
"""
|
||||
);
|
||||
|
||||
@@ -175,10 +173,12 @@ internal partial class HtmlMarkdownVisitor(
|
||||
? $"language-{multiLineCodeBlock.Language}"
|
||||
: "nohighlight";
|
||||
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
$"""
|
||||
<code class="chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightClass}">{HtmlEncode(multiLineCodeBlock.Code)}</code>
|
||||
<code class="chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightClass}">{HtmlEncode(
|
||||
multiLineCodeBlock.Code
|
||||
)}</code>
|
||||
"""
|
||||
);
|
||||
|
||||
@@ -196,7 +196,7 @@ internal partial class HtmlMarkdownVisitor(
|
||||
.Groups[1]
|
||||
.Value;
|
||||
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
!string.IsNullOrWhiteSpace(linkedMessageId)
|
||||
// lang=html
|
||||
? $"""<a href="{HtmlEncode(link.Url)}" onclick="scrollToMessage(event, '{linkedMessageId}')">"""
|
||||
@@ -206,7 +206,7 @@ internal partial class HtmlMarkdownVisitor(
|
||||
|
||||
await VisitAsync(link.Children, cancellationToken);
|
||||
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
"</a>"
|
||||
);
|
||||
@@ -218,9 +218,9 @@ internal partial class HtmlMarkdownVisitor(
|
||||
)
|
||||
{
|
||||
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
|
||||
$"""
|
||||
<img
|
||||
@@ -228,7 +228,7 @@ internal partial class HtmlMarkdownVisitor(
|
||||
class="chatlog__emoji {jumboClass}"
|
||||
alt="{emoji.Name}"
|
||||
title="{emoji.Code}"
|
||||
src="{await _context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}">
|
||||
src="{await context.ResolveAssetUrlAsync(emojiImageUrl, cancellationToken)}">
|
||||
"""
|
||||
);
|
||||
}
|
||||
@@ -240,7 +240,7 @@ internal partial class HtmlMarkdownVisitor(
|
||||
{
|
||||
if (mention.Kind == MentionKind.Everyone)
|
||||
{
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
"""
|
||||
<span class="chatlog__markdown-mention">@everyone</span>
|
||||
@@ -249,7 +249,7 @@ internal partial class HtmlMarkdownVisitor(
|
||||
}
|
||||
else if (mention.Kind == MentionKind.Here)
|
||||
{
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
"""
|
||||
<span class="chatlog__markdown-mention">@here</span>
|
||||
@@ -262,26 +262,28 @@ internal partial class HtmlMarkdownVisitor(
|
||||
// which means they need to be populated on demand.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/304
|
||||
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 displayName = member?.DisplayName ?? member?.User.DisplayName ?? "Unknown";
|
||||
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
$"""
|
||||
<span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(displayName)}</span>
|
||||
<span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(
|
||||
displayName
|
||||
)}</span>
|
||||
"""
|
||||
);
|
||||
}
|
||||
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 name = channel?.Name ?? "deleted-channel";
|
||||
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
$"""
|
||||
<span class="chatlog__markdown-mention">{symbol}{HtmlEncode(name)}</span>
|
||||
@@ -290,17 +292,21 @@ internal partial class HtmlMarkdownVisitor(
|
||||
}
|
||||
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 color = role?.Color;
|
||||
|
||||
var style = color is not null
|
||||
? $"""
|
||||
color: rgb({color.Value.R}, {color.Value.G}, {color.Value.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color.Value.B}, 0.1);
|
||||
"""
|
||||
color: rgb({color.Value.R}, {color.Value.G}, {color
|
||||
.Value
|
||||
.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color
|
||||
.Value
|
||||
.B}, 0.1);
|
||||
"""
|
||||
: null;
|
||||
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
// lang=html
|
||||
$"""
|
||||
<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
|
||||
? _context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g")
|
||||
? context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g")
|
||||
: "Invalid date";
|
||||
|
||||
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
|
||||
$"""
|
||||
<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 =
|
||||
isJumboAllowed
|
||||
&& nodes.All(
|
||||
n =>
|
||||
n is EmojiNode
|
||||
|| n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)
|
||||
&& nodes.All(n =>
|
||||
n is EmojiNode || n is TextNode textNode && string.IsNullOrWhiteSpace(textNode.Text)
|
||||
);
|
||||
|
||||
var buffer = new StringBuilder();
|
||||
|
||||
@@ -13,7 +13,6 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
||||
: MessageWriter(stream, context)
|
||||
{
|
||||
private readonly TextWriter _writer = new StreamWriter(stream);
|
||||
private readonly string _themeName = themeName;
|
||||
|
||||
private readonly HtmlMinifier _minifier = new();
|
||||
private readonly List<Message> _messageGroup = new();
|
||||
@@ -74,11 +73,9 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
||||
{
|
||||
await _writer.WriteLineAsync(
|
||||
Minify(
|
||||
await new PreambleTemplate
|
||||
{
|
||||
Context = Context,
|
||||
ThemeName = _themeName
|
||||
}.RenderAsync(cancellationToken)
|
||||
await new PreambleTemplate { Context = Context, ThemeName = themeName }.RenderAsync(
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,18 +15,19 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
: MessageWriter(stream, context)
|
||||
{
|
||||
private readonly Utf8JsonWriter _writer = new Utf8JsonWriter(
|
||||
stream,
|
||||
new JsonWriterOptions
|
||||
{
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = true,
|
||||
// Validation errors may mask actual failures
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
||||
SkipValidation = true
|
||||
}
|
||||
);
|
||||
private readonly Utf8JsonWriter _writer =
|
||||
new(
|
||||
stream,
|
||||
new JsonWriterOptions
|
||||
{
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
Indented = true,
|
||||
// Validation errors may mask actual failures
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
||||
SkipValidation = true
|
||||
}
|
||||
);
|
||||
|
||||
private async ValueTask<string> FormatMarkdownAsync(
|
||||
string markdown,
|
||||
@@ -445,14 +446,12 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
_writer.WriteStartArray("users");
|
||||
await foreach (
|
||||
var user in Context
|
||||
.Discord
|
||||
.GetMessageReactionsAsync(
|
||||
Context.Request.Channel.Id,
|
||||
message.Id,
|
||||
reaction.Emoji,
|
||||
cancellationToken
|
||||
)
|
||||
var user in Context.Discord.GetMessageReactionsAsync(
|
||||
Context.Request.Channel.Id,
|
||||
message.Id,
|
||||
reaction.Emoji,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
@@ -8,8 +8,6 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
|
||||
internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
||||
{
|
||||
private readonly ExportContext _context = context;
|
||||
|
||||
private int _partitionIndex;
|
||||
private MessageWriter? _writer;
|
||||
|
||||
@@ -39,10 +37,10 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
||||
// Ensure that the partition limit has not been reached
|
||||
if (
|
||||
_writer is not null
|
||||
&& _context
|
||||
.Request
|
||||
.PartitionLimit
|
||||
.IsReached(_writer.MessagesWritten, _writer.BytesWritten)
|
||||
&& context.Request.PartitionLimit.IsReached(
|
||||
_writer.MessagesWritten,
|
||||
_writer.BytesWritten
|
||||
)
|
||||
)
|
||||
{
|
||||
await ResetWriterAsync(cancellationToken);
|
||||
@@ -53,10 +51,10 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
||||
if (_writer is not null)
|
||||
return _writer;
|
||||
|
||||
Directory.CreateDirectory(_context.Request.OutputDirPath);
|
||||
var filePath = GetPartitionFilePath(_context.Request.OutputFilePath, _partitionIndex);
|
||||
Directory.CreateDirectory(context.Request.OutputDirPath);
|
||||
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);
|
||||
|
||||
return _writer = writer;
|
||||
|
||||
@@ -448,7 +448,10 @@
|
||||
</div>
|
||||
}
|
||||
// Generic video embed
|
||||
else if (embed.Kind == EmbedKind.Video && !string.IsNullOrWhiteSpace(embed.Url))
|
||||
else if (embed.Kind == EmbedKind.Video
|
||||
&& !string.IsNullOrWhiteSpace(embed.Url)
|
||||
// Twitch clips cannot be embedded in local HTML files
|
||||
&& embed.TryGetTwitchClip() is null)
|
||||
{
|
||||
var embedVideoUrl =
|
||||
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
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) =>
|
||||
bytesWritten >= _limit;
|
||||
bytesWritten >= limit;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
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) =>
|
||||
messagesWritten >= _limit;
|
||||
messagesWritten >= limit;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Markdown;
|
||||
using DiscordChatExporter.Core.Markdown.Parsing;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
@@ -11,15 +10,12 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBuilder buffer)
|
||||
: MarkdownVisitor
|
||||
{
|
||||
private readonly ExportContext _context = context;
|
||||
private readonly StringBuilder _buffer = buffer;
|
||||
|
||||
protected override ValueTask VisitTextAsync(
|
||||
TextNode text,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(text.Text);
|
||||
buffer.Append(text.Text);
|
||||
return default;
|
||||
}
|
||||
|
||||
@@ -28,7 +24,7 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(emoji.IsCustomEmoji ? $":{emoji.Name}:" : emoji.Name);
|
||||
buffer.Append(emoji.IsCustomEmoji ? $":{emoji.Name}:" : emoji.Name);
|
||||
|
||||
return default;
|
||||
}
|
||||
@@ -40,11 +36,11 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
|
||||
{
|
||||
if (mention.Kind == MentionKind.Everyone)
|
||||
{
|
||||
_buffer.Append("@everyone");
|
||||
buffer.Append("@everyone");
|
||||
}
|
||||
else if (mention.Kind == MentionKind.Here)
|
||||
{
|
||||
_buffer.Append("@here");
|
||||
buffer.Append("@here");
|
||||
}
|
||||
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.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/304
|
||||
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";
|
||||
|
||||
_buffer.Append($"@{displayName}");
|
||||
buffer.Append($"@{displayName}");
|
||||
}
|
||||
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";
|
||||
|
||||
_buffer.Append($"#{name}");
|
||||
buffer.Append($"#{name}");
|
||||
|
||||
// Voice channel marker
|
||||
if (channel?.IsVoice == true)
|
||||
_buffer.Append(" [voice]");
|
||||
buffer.Append(" [voice]");
|
||||
}
|
||||
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";
|
||||
|
||||
_buffer.Append($"@{name}");
|
||||
buffer.Append($"@{name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,9 +80,9 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_buffer.Append(
|
||||
buffer.Append(
|
||||
timestamp.Instant is not null
|
||||
? _context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g")
|
||||
? context.FormatDate(timestamp.Instant.Value, timestamp.Format ?? "g")
|
||||
: "Invalid date"
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting;
|
||||
|
||||
@@ -172,18 +173,21 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
await _writer.WriteLineAsync("{Reactions}");
|
||||
|
||||
foreach (var reaction in reactions)
|
||||
foreach (var (reaction, i) in reactions.WithIndex())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
await _writer.WriteAsync(' ');
|
||||
}
|
||||
|
||||
await _writer.WriteAsync(reaction.Emoji.Name);
|
||||
|
||||
if (reaction.Count > 1)
|
||||
{
|
||||
await _writer.WriteAsync($" ({reaction.Count})");
|
||||
}
|
||||
|
||||
await _writer.WriteAsync(' ');
|
||||
}
|
||||
|
||||
await _writer.WriteLineAsync();
|
||||
|
||||
@@ -702,6 +702,10 @@
|
||||
.chatlog__embed-spotify {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.chatlog__embed-twitch {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.chatlog__embed-youtube-container {
|
||||
margin-top: 0.6rem;
|
||||
|
||||
@@ -8,5 +8,5 @@ internal record LinkNode(string Url, IReadOnlyList<MarkdownNode> Children)
|
||||
IContainerNode
|
||||
{
|
||||
public LinkNode(string url)
|
||||
: this(url, new[] { new TextNode(url) }) { }
|
||||
: this(url, [new TextNode(url)]) { }
|
||||
}
|
||||
|
||||
@@ -2,15 +2,8 @@
|
||||
|
||||
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)
|
||||
: this((IReadOnlyList<IMatcher<T>>)matchers) { }
|
||||
|
||||
@@ -19,7 +12,7 @@ internal class AggregateMatcher<T> : IMatcher<T>
|
||||
ParsedMatch<T>? earliestMatch = null;
|
||||
|
||||
// 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
|
||||
var match = matcher.TryMatch(segment);
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
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 ParsedMatch(StringSegment segment, T value)
|
||||
{
|
||||
Segment = segment;
|
||||
Value = value;
|
||||
}
|
||||
public T Value { get; } = value;
|
||||
}
|
||||
|
||||
@@ -3,20 +3,11 @@ using System.Text.RegularExpressions;
|
||||
|
||||
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)
|
||||
{
|
||||
var match = _regex.Match(segment.Source, segment.StartIndex, segment.Length);
|
||||
var match = regex.Match(segment.Source, segment.StartIndex, segment.Length);
|
||||
if (!match.Success)
|
||||
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.
|
||||
// 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.
|
||||
if (!_regex.IsMatch(segment.Source[..segment.EndIndex], segment.StartIndex))
|
||||
if (!regex.IsMatch(segment.Source[..segment.EndIndex], segment.StartIndex))
|
||||
return null;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -2,37 +2,24 @@
|
||||
|
||||
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)
|
||||
: this(needle, StringComparison.Ordinal, transform) { }
|
||||
|
||||
public ParsedMatch<T>? TryMatch(StringSegment segment)
|
||||
{
|
||||
var index = segment
|
||||
.Source
|
||||
.IndexOf(_needle, segment.StartIndex, segment.Length, _comparison);
|
||||
var index = segment.Source.IndexOf(needle, segment.StartIndex, segment.Length, comparison);
|
||||
|
||||
if (index < 0)
|
||||
return null;
|
||||
|
||||
var segmentMatch = segment.Relocate(index, _needle.Length);
|
||||
var value = _transform(segmentMatch);
|
||||
var segmentMatch = segment.Relocate(index, needle.Length);
|
||||
var value = transform(segmentMatch);
|
||||
|
||||
return value is not null ? new ParsedMatch<T>(segmentMatch, value) : null;
|
||||
}
|
||||
|
||||
@@ -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.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
@@ -27,16 +26,6 @@ public static class StringExtensions
|
||||
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)
|
||||
where T : struct, Enum => Enum.TryParse<T>(str, ignoreCase, out var result) ? result : null;
|
||||
|
||||
|
||||
@@ -25,11 +25,10 @@ public static class Http
|
||||
private static bool IsRetryableException(Exception exception) =>
|
||||
exception
|
||||
.GetSelfAndChildren()
|
||||
.Any(
|
||||
ex =>
|
||||
ex is TimeoutException or SocketException or AuthenticationException
|
||||
|| ex is HttpRequestException hrex
|
||||
&& IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
|
||||
.Any(ex =>
|
||||
ex is TimeoutException or SocketException or AuthenticationException
|
||||
|| ex is HttpRequestException hrex
|
||||
&& IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
|
||||
);
|
||||
|
||||
public static ResiliencePipeline ResiliencePipeline { get; } =
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<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">
|
||||
<Application.DataTemplates>
|
||||
<framework:ViewManager />
|
||||
</Application.DataTemplates>
|
||||
|
||||
<Application.Styles>
|
||||
<materialStyles:MaterialTheme />
|
||||
<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 MaterialBackgroundBrush}" />
|
||||
</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>
|
||||
@@ -0,0 +1,110 @@
|
||||
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.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 partial class App : Application, IDisposable
|
||||
{
|
||||
private readonly ServiceProvider _services;
|
||||
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);
|
||||
_mainViewModel = _services.GetRequiredService<ViewModelManager>().CreateMainViewModel();
|
||||
}
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
// Increase maximum concurrent connections
|
||||
ServicePointManager.DefaultConnectionLimit = 20;
|
||||
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
desktop.MainWindow = new MainView { DataContext = _mainViewModel };
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
|
||||
// Set custom theme colors
|
||||
SetDefaultTheme();
|
||||
}
|
||||
|
||||
public void Dispose() => _services.Dispose();
|
||||
}
|
||||
|
||||
public partial class App
|
||||
{
|
||||
public static void SetLightTheme()
|
||||
{
|
||||
if (Current is null)
|
||||
return;
|
||||
|
||||
Current.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme = Theme.Create(
|
||||
Theme.Light,
|
||||
Color.Parse("#343838"),
|
||||
Color.Parse("#F9A825")
|
||||
);
|
||||
}
|
||||
|
||||
public static void SetDarkTheme()
|
||||
{
|
||||
if (Current is null)
|
||||
return;
|
||||
|
||||
Current.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme = Theme.Create(
|
||||
Theme.Dark,
|
||||
Color.Parse("#E8E8E8"),
|
||||
Color.Parse("#F9A825")
|
||||
);
|
||||
}
|
||||
|
||||
public static void SetDefaultTheme()
|
||||
{
|
||||
if (Current is null)
|
||||
return;
|
||||
|
||||
var isDarkModeEnabledByDefault =
|
||||
Current.PlatformSettings?.GetColorValues().ThemeVariant == PlatformThemeVariant.Dark;
|
||||
|
||||
if (isDarkModeEnabledByDefault)
|
||||
SetDarkTheme();
|
||||
else
|
||||
SetLightTheme();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.Globalization;
|
||||
using System.Windows.Data;
|
||||
using Avalonia.Data.Converters;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Converters;
|
||||
|
||||
[ValueConversion(typeof(ExportFormat), typeof(string))]
|
||||
public class ExportFormatToStringConverter : IValueConverter
|
||||
{
|
||||
public static ExportFormatToStringConverter Instance { get; } = new();
|
||||
|
||||
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) =>
|
||||
value is ExportFormat exportFormatValue ? exportFormatValue.GetDisplayName() : default;
|
||||
public object? Convert(
|
||||
object? value,
|
||||
Type targetType,
|
||||
object? parameter,
|
||||
CultureInfo culture
|
||||
) => value is ExportFormat format ? format.GetDisplayName() : default;
|
||||
|
||||
public object ConvertBack(
|
||||
object value,
|
||||
object? value,
|
||||
Type targetType,
|
||||
object parameter,
|
||||
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(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 default";
|
||||
|
||||
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,30 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>$(TargetFramework)-windows</TargetFramework>
|
||||
<AssemblyName>DiscordChatExporter</AssemblyName>
|
||||
<UseWPF>true</UseWPF>
|
||||
<ApplicationIcon>../favicon.ico</ApplicationIcon>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Resource Include="../favicon.ico" />
|
||||
<AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.2.1" />
|
||||
<PackageReference Include="Avalonia" Version="11.0.10" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.10" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.10" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Cogwheel" Version="2.0.4" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.26.2" PrivateAssets="all" />
|
||||
<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="DotnetRuntimeBootstrapper" Version="2.5.1" PrivateAssets="all" />
|
||||
<PackageReference Include="DialogHost.Avalonia" Version="0.7.7" />
|
||||
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.4" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="MaterialDesignColors" Version="2.1.4" />
|
||||
<PackageReference Include="MaterialDesignThemes" Version="4.9.0" />
|
||||
<PackageReference Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.77" />
|
||||
<PackageReference Include="Onova" Version="2.6.10" />
|
||||
<PackageReference Include="PropertyChanged.Fody" Version="4.1.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Stylet" Version="1.3.6" />
|
||||
<PackageReference Include="Material.Avalonia" Version="3.5.0" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.1.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
<PackageReference Include="Onova" Version="2.6.11" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<PropertyChanged />
|
||||
</Weavers>
|
||||
@@ -1,74 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
|
||||
<xs:element name="Weavers">
|
||||
<xs:complexType>
|
||||
<xs:all>
|
||||
<xs:element name="PropertyChanged" minOccurs="0" maxOccurs="1">
|
||||
<xs:complexType>
|
||||
<xs:attribute name="InjectOnPropertyNameChanged" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if the On_PropertyName_Changed feature is enabled.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="TriggerDependentProperties" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if the Dependent properties feature is enabled.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="EnableIsChangedProperty" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if the IsChanged property feature is enabled.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="EventInvokerNames" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="CheckForEquality" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if equality checks should be inserted. If false, equality checking will be disabled for the project.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="CheckForEqualityUsingBaseEquals" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if equality checks should use the Equals method resolved from the base class.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="UseStaticEqualsFromBase" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if equality checks should use the static Equals method resolved from the base class.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="SuppressWarnings" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to turn off build warnings from this weaver.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="SuppressOnPropertyNameChangedWarning" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to turn off build warnings about mismatched On_PropertyName_Changed methods.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:all>
|
||||
<xs:attribute name="VerifyAssembly" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="GenerateXsd" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user