Compare commits

...

17 Commits

Author SHA1 Message Date
Tyrrrz 5623c2ca34 Update NuGet packages 2024-04-28 02:26:34 +03:00
Oleksii Holub 8525917a4c Make the GUI x-platform and adapt the CI/CD pipeline to accommodate that (#1222) 2024-04-28 02:24:23 +03:00
Oleksii Holub b9c1c47474 Migrate to Avalonia (#1220) 2024-04-27 04:17:46 +03:00
Mark Cilia Vincenti 74f99b4e59 Switched from SemaphoreSlim lock to AsyncNonKeyedLocker (#1184) 2024-04-21 18:32:57 +03:00
Tyrrrz 12b590d9f9 Use [] in place of Array.Empty<T>() 2024-03-28 03:22:51 +02:00
xs2237 6d1e8c2e02 Remove unnecessary whitespaces at the end of reactions in PlainText (#1213) 2024-03-21 17:29:14 +02:00
Tyrrrz 72f02cd9a0 Update docs with the easier way to run the CLI 2024-03-18 03:20:46 +02:00
Tyrrrz 47d560229d Set the executable permission on CLI entrypoint script before packaging for release 2024-03-18 03:12:31 +02:00
Tyrrrz 0e87098889 Make the DCE.CLI unix entrypoint script work regardless of current working directory 2024-03-05 19:39:06 +02:00
Oleksii Holub 0139eeaeed Add DiscordChatExporter.Cli.sh for simpler usage from shell (#1205) 2024-03-02 04:59:49 +02:00
Oleksii Holub c0ea57e9b1 Shorten error messages for when attempting to export a period with no messages (#1201) 2024-02-13 19:13:38 +02:00
Oleksii Holub 7c04db40f8 Skip Twitch clip projections because they can't be rendered locally (#1200) 2024-02-13 18:59:51 +02:00
Oleksii Holub 4588bd0496 Add support for Twitch embed projections (#1199) 2024-02-13 18:48:15 +02:00
Tyrrrz 3a22db14a7 Update GitHub Actions 2024-02-01 21:54:50 +02:00
Oleksii Holub ba72927c37 Fix pathing in docker entrypoint 2024-01-31 21:32:40 +02:00
Tyrrrz 822bab2365 Update action versions 2024-01-31 14:44:28 +02:00
Philippe Vaucher 211db11041 Fix docker user permissions issues (#1194) 2024-01-31 14:39:25 +02:00
114 changed files with 2732 additions and 3048 deletions
+16 -5
View File
@@ -10,7 +10,7 @@ Docker distribution of DiscordChatExporter provides a way to run the app in a vi
This will download the [Docker image from the registry](https://hub.docker.com/r/tyrrrz/discordchatexporter) to your computer. You can run this command again to update when a new version is released.
```console
docker pull tyrrrz/discordchatexporter:stable
$ docker pull tyrrrz/discordchatexporter:stable
```
Note the `:stable` tag. DiscordChatExporter images are tagged according to the following patterns:
@@ -26,19 +26,19 @@ You can see all available tags [here](https://hub.docker.com/r/tyrrrz/discordcha
To run the CLI in Docker and render help text:
```console
docker run --rm tyrrrz/discordchatexporter:stable
$ docker run --rm tyrrrz/discordchatexporter:stable
```
To export a channel:
```console
docker run --rm -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
$ docker run --rm -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
```
If you want colored output and real-time progress reporting, pass the `-it` (interactive + pseudo-terminal) option:
```console
docker run --rm -it -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
$ docker run --rm -it -v /path/on/machine:/out tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
```
The `-v /path/on/machine:/out` option instructs Docker to bind the `/out` directory inside the container to a path on your host machine. Replace `/path/on/machine` with the directory you want the files to be saved at.
@@ -47,7 +47,7 @@ The `-v /path/on/machine:/out` option instructs Docker to bind the `/out` direct
> If you are running SELinux, you will need to add the `:z` option after `/out`, e.g.:
>
> ```console
> docker run --rm -v /path/on/machine:/out:z tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
> $ docker run --rm -v /path/on/machine:/out:z tyrrrz/discordchatexporter:stable export -t TOKEN -c CHANNELID
> ```
>
> For more information, refer to the [Docker docs SELinux labels for bind mounts page](https://docs.docker.com/storage/bind-mounts/#configure-the-selinux-label).
@@ -61,6 +61,17 @@ For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz
To get your Token and Channel IDs, please refer to [this page](Token-and-IDs.md).
## Unix permissions issues
This image was designed with a user running as uid:gid of 1000:1000.
If your current user has different IDs, and you want to generate files directly editable for your user, you might want to run the container like this:
```console
$ mkdir data # or chown -R $(id -u):$(id -g) data
$ docker run -it --rm -v $PWD/data:/out --user $(id -u):$(id -g) tyrrrz/discordchatexporter:stable export -t TOKEN -g CHANNELID
```
## Environment variables
DiscordChatExpoter CLI accepts the `DISCORD_TOKEN` environment variable as a fallback for the `--token` option. You can set this variable either with the `--env` Docker option or with a combination of the `--env-file` Docker option and a `.env` file.
+1 -5
View File
@@ -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]
+2 -5
View File
@@ -15,12 +15,9 @@ The information presented on this page is valid for **all** platforms.
**DCE** has two different versions:
- **Graphical User Interface** (**GUI**) - it's the preferred version for newcomers as it is easy to use.
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.zip` file.
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.*.zip` file.
- **Command-line Interface** (**CLI**) - offers greater flexibility and more features for advanced users, such as export scheduling, ID lists, and more specific date ranges.
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.CLI.zip` file.
If you're not comfortable with **Windows'** Command-line (cmd), please choose the GUI.
[**macOS**](MacOS.md), [**Linux**](Linux.md) and [**Docker**](Docker.md) users can only use the CLI version.
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.Cli.*.zip` file.
There are dedicated guides for each version:
-26
View File
@@ -1,26 +0,0 @@
# Linux usage instructions
## Installing .NET Runtime
Please follow the [instructions provided here](Dotnet.md).
## Downloading and using DiscordChatExporter.Cli
1. Download [DiscordChatExporter.CLI.zip](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) and extract it to a folder.
2. Open Terminal.
3. `cd` into the extracted folder. You can do this in Terminal by typing `cd`, then press the SPACE key, drag and drop the extracted folder into the Terminal window, and press the ENTER key.
4. Replace `TOKEN` and `CHANNEL`, then execute this command to export:
```console
dotnet DiscordChatExporter.Cli.dll export -t TOKEN -c CHANNEL
```
If the above command throws a "Permission denied" error, execute this command to fix the permissions:
```console
chmod 644 *.dll DiscordChatExporter.*
```
> [How to get Token and Channel IDs](Token-and-IDs.md).
There's much more you can do with DCE.CLI! Read the [CLI explained](Using-the-CLI.md) page to get started.
-22
View File
@@ -1,22 +0,0 @@
# macOS usage instructions
![Might look different depending on your macOS version](https://i.imgur.com/gA8jFjJ.png)
## Installing .NET Runtime
Please follow the [instructions provided here](Dotnet.md).
## Downloading and using DiscordChatExporter.Cli
1. Download [DiscordChatExporter.CLI.zip](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) and extract it to a folder.
2. Search for `Terminal.app` in Spotlight (⌘+SPACE), then open it.
3. In the Terminal window, type `cd` , press the SPACE key, then drag and drop the extracted folder into the window, then press the RETURN key.
4. Execute the following command to export, replacing `TOKEN` and `CHANNEL` with your own values:
```console
dotnet DiscordChatExporter.Cli.dll export -t TOKEN -c CHANNEL
```
> [How to get Token and Channel IDs](Token-and-IDs.md).
There's much more you can do with DCE.CLI! Read the [CLI explained](Using-the-CLI.md) page to get started.
+3 -3
View File
@@ -89,17 +89,17 @@ from:96\-LB
In most cases, you will need to enclose your filter in quotes (`"`) to escape characters that may have special meaning in your shell:
```console
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
```
If you need to include quotes inside the filter itself as well, use single quotes (`'`) for those instead:
```console
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
```
Additionally, negated filters (those that start with `-`) may cause parsing issues even when enclosed in quotes. To avoid this, use the tilde (`~`) character instead of the dash (`-`):
```console
DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
$ ./DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
```
+1 -2
View File
@@ -2,8 +2,7 @@
## 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-GUI.md)
- [Using the CLI](Using-the-CLI.md)
+2 -2
View File
@@ -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")
+2 -4
View File
@@ -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")
+2 -1
View File
@@ -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"
+2 -2
View File
@@ -25,7 +25,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
3. Type
```console
```js
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
```
@@ -118,7 +118,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
1. Type
```console
```js
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
```
+5 -20
View File
@@ -70,14 +70,7 @@ Check the following page: [Obtaining token](Token-and-IDs.md)
### When I open DCE a black window pops up quickly or nothing shows up
If you have [.NET Core Runtime correctly installed](Dotnet.md), you might have downloaded the CLi flavor, try [downloading the GUI](Getting-started.md#gui-or-cli) instead.
### How do I run DCE on macOS or Linux?
Check the following pages:
- [macOS usage instructions](MacOS.md)
- [Linux usage instructions](Linux.md)
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.
+28 -28
View File
@@ -28,10 +28,10 @@ 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).
@@ -49,18 +49,18 @@ dotnet DiscordChatExporter.Cli.dll
| guilds | Outputs the list of accessible servers |
| guide | Explains how to obtain token, server, and channel ID |
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `dotnet DiscordChatExporter.Cli.dll guide`.
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `./DiscordChatExporter.Cli guide`.
To get help with a specific command, run:
```console
dotnet DiscordChatExporter.Cli.dll command --help
$ ./DiscordChatExporter.Cli command --help
```
For example, to figure out how to use the `export` command, run:
```console
dotnet DiscordChatExporter.Cli.dll export --help
$ ./DiscordChatExporter.Cli export --help
```
## Export a specific channel
@@ -68,7 +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,7 +105,7 @@ Note that the filename must have an extension, otherwise it will be considered a
If any of the folders in the path have a space in its name, escape them with quotes (").
```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\myserver.html"
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\myserver.html"
```
#### Generating the filename and output directory dynamically
@@ -113,7 +113,7 @@ dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o "C:\Discord
You can use template tokens to generate the output file path based on the server and channel metadata.
```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html"
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html"
```
Assuming you are exporting a channel named `"my-channel"` in the `"Text channels"` category from a server
@@ -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
@@ -222,7 +222,7 @@ formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custo
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,7 +258,7 @@ dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn"
To exclude DMs, add the `--include-dm false` option.
```console
dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn" --include-dm false
$ ./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
```
### List channels in a server
@@ -266,7 +266,7 @@ dotnet DiscordChatExporter.Cli.dll exportall -t "mfa.Ifrn" --include-dm false
To list the channels available in a specific server, use the `channels` command and provide the server ID through the `-g|--guild` option:
```console
dotnet DiscordChatExporter.Cli.dll channels -t "mfa.Ifrn" -g 21814
$ ./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
```
### List direct message channels
@@ -274,7 +274,7 @@ dotnet DiscordChatExporter.Cli.dll channels -t "mfa.Ifrn" -g 21814
To list all DM channels accessible to the current account, use the `dm` command:
```console
dotnet DiscordChatExporter.Cli.dll dm -t "mfa.Ifrn"
$ ./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
```
### List servers
@@ -282,5 +282,5 @@ dotnet DiscordChatExporter.Cli.dll dm -t "mfa.Ifrn"
To list all servers accessible by the current account, use the `guilds` command:
```console
dotnet DiscordChatExporter.Cli.dll guilds -t "mfa.Ifrn" > C:\path\to\output.txt
$ ./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
```
+6 -7
View File
@@ -12,8 +12,7 @@ on:
- master
jobs:
# Outputs from this job aren't really used, but it's here to verify that
# the Dockerfile builds correctly on pull requests.
# Outputs from this job aren't really used, but it's here to verify that the Dockerfile builds correctly
pack:
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -24,10 +23,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # 4.1.0
uses: actions/checkout@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
View File
@@ -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:
@@ -11,19 +11,18 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.0.7" />
<PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
<PackageReference Include="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.4" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6" 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>
@@ -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)
@@ -53,10 +53,8 @@ public class DateRangeSpecs
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero)
],
o =>
o.Using<DateTimeOffset>(
ctx =>
ctx.Subject.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
o.Using<DateTimeOffset>(ctx =>
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
)
.WhenTypeIs<DateTimeOffset>()
);
@@ -97,10 +95,8 @@ public class DateRangeSpecs
new DateTimeOffset(2021, 07, 19, 17, 23, 58, TimeSpan.Zero)
],
o =>
o.Using<DateTimeOffset>(
ctx =>
ctx.Subject.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
o.Using<DateTimeOffset>(ctx =>
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
)
.WhenTypeIs<DateTimeOffset>()
);
@@ -144,10 +140,8 @@ public class DateRangeSpecs
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero)
],
o =>
o.Using<DateTimeOffset>(
ctx =>
ctx.Subject.Should()
.BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
o.Using<DateTimeOffset>(ctx =>
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
)
.WhenTypeIs<DateTimeOffset>()
);
@@ -90,12 +90,11 @@ public class HtmlEmbedSpecs
.QuerySelectorAll("source")
.Select(e => e.GetAttribute("src"))
.WhereNotNull()
.Where(
s =>
s.Contains(
"i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4",
StringComparison.Ordinal
)
.Where(s =>
s.Contains(
"i_am_currently_feeling_slight_displeasure_of_what_you_have_just_sent_lqrem.mp4",
StringComparison.Ordinal
)
)
.Should()
.ContainSingle();
@@ -150,6 +149,26 @@ public class HtmlEmbedSpecs
iframeUrl.Should().StartWith("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP");
}
[Fact(Skip = "Twitch does not allow embeds from inside local HTML files")]
public async Task I_can_export_a_channel_that_contains_a_message_with_a_Twitch_clip_embed()
{
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1196
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.EmbedTestCases,
Snowflake.Parse("1207002986128216074")
);
// Assert
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
iframeUrl
.Should()
.StartWith(
"https://clips.twitch.tv/embed?clip=SpicyMildCiderThisIsSparta--PQhbllrvej_Ee7v"
);
}
[Fact]
public async Task I_can_export_a_channel_that_contains_a_message_with_a_YouTube_video_embed()
{
@@ -185,42 +204,38 @@ public class HtmlEmbedSpecs
imageUrls
.Should()
.Contain(
u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
StringComparison.Ordinal
)
.Contain(u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYIzYPWAAAMBqZ.png",
StringComparison.Ordinal
)
);
imageUrls
.Should()
.Contain(
u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
StringComparison.Ordinal
)
.Contain(u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJBWJWAAMNAx2.png",
StringComparison.Ordinal
)
);
imageUrls
.Should()
.Contain(
u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png",
StringComparison.Ordinal
)
.Contain(u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJHiRX0AANZcz.png",
StringComparison.Ordinal
)
);
imageUrls
.Should()
.Contain(
u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png",
StringComparison.Ordinal
)
.Contain(u =>
u.EndsWith(
"https/pbs.twimg.com/media/FVYJNZNXwAAPnVG.png",
StringComparison.Ordinal
)
);
message.QuerySelectorAll(".chatlog__embed").Should().ContainSingle();
+6 -5
View File
@@ -40,20 +40,21 @@ LABEL org.opencontainers.image.licenses="MIT"
# Alpine image doesn't come with the ICU libraries pre-installed, so we need to install them manually.
# 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
# https://github.com/Tyrrrz/DiscordChatExporter/issues/851
RUN adduser --disabled-password --no-create-home dce
USER dce
# https://github.com/Tyrrrz/DiscordChatExporter/issues/1174
RUN apk add --no-cache su-exec
RUN addgroup -S -g 1000 dce && adduser -S -H -G dce -u 1000 dce
# This directory is exposed to the user for mounting purposes, so it's important that it always
# stays the same for backwards compatibility.
WORKDIR /out
COPY --from=build /tmp/app/DiscordChatExporter.Cli/bin/publish /opt/app
ENTRYPOINT ["/opt/app/DiscordChatExporter.Cli"]
COPY docker-entrypoint.sh /opt/app
ENTRYPOINT ["/opt/app/docker-entrypoint.sh"]
@@ -7,7 +7,6 @@ using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
@@ -59,7 +58,7 @@ public class GetChannelsCommand : DiscordCommandBase
)
.OrderBy(c => c.Name)
.ToArray()
: Array.Empty<Channel>();
: [];
foreach (var channel in channels)
{
@@ -4,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.7" 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.2" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.4" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="Spectre.Console" Version="0.48.0" />
<PackageReference Include="Spectre.Console" Version="0.49.1" />
</ItemGroup>
<ItemGroup>
@@ -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();
}
}
@@ -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();
@@ -144,28 +143,28 @@ public partial record Message
json.GetPropertyOrNull("attachments")
?.EnumerateArrayOrNull()
?.Select(Attachment.Parse)
.ToArray() ?? Array.Empty<Attachment>();
.ToArray() ?? [];
var embeds = NormalizeEmbeds(
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
?? Array.Empty<Embed>()
?? []
);
var stickers =
json.GetPropertyOrNull("sticker_items")
?.EnumerateArrayOrNull()
?.Select(Sticker.Parse)
.ToArray() ?? Array.Empty<Sticker>();
.ToArray() ?? [];
var reactions =
json.GetPropertyOrNull("reactions")
?.EnumerateArrayOrNull()
?.Select(Reaction.Parse)
.ToArray() ?? Array.Empty<Reaction>();
.ToArray() ?? [];
var mentionedUsers =
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
?? Array.Empty<User>();
?? [];
var messageReference = json.GetPropertyOrNull("message_reference")
?.Pipe(MessageReference.Parse);
@@ -66,12 +66,12 @@ public class DiscordClient(string token)
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
{
var delay =
// Adding a small buffer to the reset time reduces the chance of getting
// rate limited again, because it allows for more requests to be released.
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
// Sometimes Discord returns an absurdly high value for the reset time, which
// is not actually enforced by the server. So we cap it at a reasonable value.
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
// Adding a small buffer to the reset time reduces the chance of getting
// rate limited again, because it allows for more requests to be released.
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
// Sometimes Discord returns an absurdly high value for the reset time, which
// is not actually enforced by the server. So we cap it at a reasonable value.
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
await Task.Delay(delay, innerCancellationToken);
}
@@ -152,8 +152,13 @@ public class DiscordClient(string token)
_
=> 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
)
@@ -1,15 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="AsyncKeyedLock" Version="6.2.4" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
<PackageReference Include="AsyncKeyedLock" Version="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.5.0" />
<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.10" />
<PackageReference Include="WebMarkupMin.Core" Version="2.16.0" />
<PackageReference Include="YoutubeExplode" Version="6.3.14" />
</ItemGroup>
</Project>
@@ -20,8 +20,8 @@ public class ChannelExporter(DiscordClient discord)
if (request.Channel.Kind == ChannelKind.GuildForum)
{
throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
$"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' "
+ $"is a forum and cannot be exported directly. "
+ "You need to pull its threads and export them individually."
);
@@ -31,8 +31,8 @@ public class ChannelExporter(DiscordClient discord)
if (request.Channel.IsEmpty)
{
throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
$"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages."
);
}
@@ -41,8 +41,8 @@ public class ChannelExporter(DiscordClient discord)
if (request.After is not null && !request.Channel.MayHaveMessagesAfter(request.After.Value))
{
throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
$"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages within the specified period."
);
}
@@ -54,8 +54,8 @@ public class ChannelExporter(DiscordClient discord)
)
{
throw new DiscordChatExporterException(
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
$"Channel '{request.Channel.Name}' "
+ $"of guild '{request.Guild.Name}' "
+ $"does not contain any messages within the specified period."
);
}
@@ -58,16 +58,15 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
{
var lastModified = response
.Content.Headers.TryGetValue("Last-Modified")
?.Pipe(
s =>
DateTimeOffset.TryParse(
s,
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var instant
)
? instant
: (DateTimeOffset?)null
?.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();
@@ -22,12 +22,11 @@ internal class ContainsMessageFilter(string text) : MessageFilter
public override bool IsMatch(Message message) =>
IsMatch(message.Content)
|| message.Embeds.Any(
e =>
IsMatch(e.Title)
|| IsMatch(e.Author?.Name)
|| IsMatch(e.Description)
|| IsMatch(e.Footer?.Text)
|| e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
|| message.Embeds.Any(e =>
IsMatch(e.Title)
|| IsMatch(e.Author?.Name)
|| IsMatch(e.Description)
|| IsMatch(e.Footer?.Text)
|| e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
);
}
@@ -7,11 +7,10 @@ namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class MentionsMessageFilter(string value) : MessageFilter
{
public override bool IsMatch(Message message) =>
message.MentionedUsers.Any(
user =>
string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
message.MentionedUsers.Any(user =>
string.Equals(value, user.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, user.DisplayName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, user.FullName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, user.Id.ToString(), StringComparison.OrdinalIgnoreCase)
);
}
@@ -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:")
@@ -7,10 +7,9 @@ namespace DiscordChatExporter.Core.Exporting.Filtering;
internal class ReactionMessageFilter(string value) : MessageFilter
{
public override bool IsMatch(Message message) =>
message.Reactions.Any(
r =>
string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
message.Reactions.Any(r =>
string.Equals(value, r.Emoji.Id?.ToString(), StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, r.Emoji.Name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value, r.Emoji.Code, StringComparison.OrdinalIgnoreCase)
);
}
@@ -155,7 +155,9 @@ internal partial class HtmlMarkdownVisitor(
buffer.Append(
// lang=html
$"""
<code class="chatlog__markdown-pre chatlog__markdown-pre--inline">{HtmlEncode(inlineCodeBlock.Code)}</code>
<code class="chatlog__markdown-pre chatlog__markdown-pre--inline">{HtmlEncode(
inlineCodeBlock.Code
)}</code>
"""
);
@@ -174,7 +176,9 @@ internal partial class HtmlMarkdownVisitor(
buffer.Append(
// lang=html
$"""
<code class="chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightClass}">{HtmlEncode(multiLineCodeBlock.Code)}</code>
<code class="chatlog__markdown-pre chatlog__markdown-pre--multiline {highlightClass}">{HtmlEncode(
multiLineCodeBlock.Code
)}</code>
"""
);
@@ -267,7 +271,9 @@ internal partial class HtmlMarkdownVisitor(
buffer.Append(
// lang=html
$"""
<span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(displayName)}</span>
<span class="chatlog__markdown-mention" title="{HtmlEncode(fullName)}">@{HtmlEncode(
displayName
)}</span>
"""
);
}
@@ -292,8 +298,12 @@ internal partial class HtmlMarkdownVisitor(
var style = color is not null
? $"""
color: rgb({color.Value.R}, {color.Value.G}, {color.Value.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color.Value.B}, 0.1);
"""
color: rgb({color.Value.R}, {color.Value.G}, {color
.Value
.B}); background-color: rgba({color.Value.R}, {color.Value.G}, {color
.Value
.B}, 0.1);
"""
: null;
buffer.Append(
@@ -321,7 +331,9 @@ internal partial class HtmlMarkdownVisitor(
buffer.Append(
// lang=html
$"""
<span class="chatlog__markdown-timestamp" title="{HtmlEncode(formattedLong)}">{HtmlEncode(formatted)}</span>
<span class="chatlog__markdown-timestamp" title="{HtmlEncode(
formattedLong
)}">{HtmlEncode(formatted)}</span>
"""
);
@@ -344,10 +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();
@@ -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 ??
@@ -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;
+4 -5
View File
@@ -25,11 +25,10 @@ public static class Http
private static bool IsRetryableException(Exception exception) =>
exception
.GetSelfAndChildren()
.Any(
ex =>
ex is TimeoutException or SocketException or AuthenticationException
|| ex is HttpRequestException hrex
&& IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
.Any(ex =>
ex is TimeoutException or SocketException or AuthenticationException
|| ex is HttpRequestException hrex
&& IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
);
public static ResiliencePipeline ResiliencePipeline { get; } =
+135
View File
@@ -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>
+110
View File
@@ -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();
}
}
-543
View File
@@ -1,543 +0,0 @@
<Application
x:Class="DiscordChatExporter.Gui.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:s="https://github.com/canton7/Stylet">
<Application.Resources>
<s:ApplicationLoader>
<!-- Bootstrapper -->
<s:ApplicationLoader.Bootstrapper>
<local:Bootstrapper />
</s:ApplicationLoader.Bootstrapper>
<!-- Merged dictionaries -->
<ResourceDictionary.MergedDictionaries>
<!-- Colors are irrelevant because they are overriden in runtime -->
<materialDesign:BundledTheme
BaseTheme="Light"
PrimaryColor="Blue"
SecondaryColor="Blue" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
</ResourceDictionary.MergedDictionaries>
<!-- Styles -->
<Style x:Key="MaterialDesignRoot" TargetType="{x:Type Control}">
<Setter Property="FontFamily" Value="{DynamicResource MaterialDesignFont}" />
<Setter Property="RenderOptions.BitmapScalingMode" Value="HighQuality" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="TextElement.FontSize" Value="13" />
<Setter Property="TextElement.FontWeight" Value="Regular" />
<Setter Property="TextElement.Foreground" Value="{DynamicResource MaterialDesignBody}" />
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
<Setter Property="TextOptions.TextRenderingMode" Value="Auto" />
<Setter Property="UseLayoutRounding" Value="True" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignScrollBarMinimal}" TargetType="{x:Type ScrollBar}" />
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
<Setter Property="Height" Value="2" />
<Setter Property="Maximum" Value="1" />
<Setter Property="Minimum" Value="0" />
</Style>
<Style TargetType="{x:Type Hyperlink}">
<Setter Property="TextDecorations" Value="Underline" />
<Setter Property="FontWeight" Value="Regular" />
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignBody}" />
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
<Trigger Property="IsEnabled" Value="True">
<Setter Property="Cursor" Value="Hand" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsEnabled" Value="True" />
<Condition Property="IsMouseOver" Value="True" />
</MultiTrigger.Conditions>
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
</MultiTrigger>
</Style.Triggers>
</Style>
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}" />
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}" />
<Style TargetType="{x:Type materialDesign:Snackbar}">
<Setter Property="Background" Value="{DynamicResource MaterialDesignDarkBackground}" />
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignDarkForeground}" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="ActionButtonStyle">
<Setter.Value>
<Style BasedOn="{StaticResource MaterialDesignFlatButton}" TargetType="{x:Type Button}">
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Height" Value="36" />
<Setter Property="Margin" Value="8,-10,-8,-10" />
<Setter Property="Padding" Value="8" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
<Setter Property="materialDesign:RippleAssist.Feedback" Value="{DynamicResource MaterialDesignSnackbarRipple}" />
</Style>
</Setter.Value>
</Setter>
</Style>
<Style
x:Key="MaterialDesignFlatActionToggleButton"
BasedOn="{StaticResource MaterialDesignActionToggleButton}"
TargetType="{x:Type ToggleButton}">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
<Setter Property="BorderBrush" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
</Trigger>
</Style.Triggers>
</Style>
<Style BasedOn="{StaticResource MaterialDesignContextMenu}" TargetType="{x:Type ContextMenu}">
<Setter Property="Background" Value="{DynamicResource MaterialDesignPaper}" />
</Style>
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Expander">
<Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}">
<DockPanel Background="{TemplateBinding Background}">
<ToggleButton
Name="HeaderSite"
BorderThickness="0"
Content="{TemplateBinding Header}"
ContentStringFormat="{TemplateBinding HeaderStringFormat}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
ContentTemplateSelector="{TemplateBinding HeaderTemplateSelector}"
Cursor="Hand"
DockPanel.Dock="Top"
Focusable="False"
Foreground="{TemplateBinding Foreground}"
IsChecked="{Binding Path=IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
IsTabStop="False"
Opacity=".87"
Style="{StaticResource MaterialDesignExpanderToggleButton}"
TextElement.FontSize="15" />
<Border
Name="ContentSite"
DockPanel.Dock="Bottom"
Visibility="Collapsed">
<StackPanel
Name="ContentPanel"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
<ContentPresenter
Name="PART_Content"
ContentStringFormat="{TemplateBinding ContentStringFormat}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"
Focusable="False" />
</StackPanel>
</Border>
</DockPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="true">
<Setter TargetName="ContentSite" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Right">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Left" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Right" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignVerticalHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Left">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Right" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Left" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignVerticalHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Up">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Bottom" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Top" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignHorizontalHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Down">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Top" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Bottom" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignHorizontalHeaderStyle}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Use old MD style for slider, because the new one is too huge -->
<Style x:Key="MaterialDesignHorizontalTrackRepeatButton" TargetType="{x:Type RepeatButton}">
<Setter Property="OverridesDefaultStyle" Value="true" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Focusable" Value="false" />
<Setter Property="IsTabStop" Value="false" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type RepeatButton}">
<Canvas
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
Background="Transparent">
<Rectangle
x:Name="PART_SelectionRange"
Canvas.Top="8"
Width="{TemplateBinding ActualWidth}"
Height="2.0"
Fill="{TemplateBinding Background}" />
</Canvas>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="MaterialDesignVerticalTrackRepeatButton" TargetType="{x:Type RepeatButton}">
<Setter Property="OverridesDefaultStyle" Value="true" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Focusable" Value="false" />
<Setter Property="IsTabStop" Value="false" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type RepeatButton}">
<Canvas
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
Background="Transparent">
<Rectangle
x:Name="PART_SelectionRange"
Canvas.Left="8"
Width="2.0"
Height="{TemplateBinding ActualHeight}"
Fill="{TemplateBinding Background}" />
</Canvas>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<ControlTemplate x:Key="MaterialDesignSliderThumb" TargetType="{x:Type Thumb}">
<Grid
HorizontalAlignment="Center"
VerticalAlignment="Center"
UseLayoutRounding="True">
<Ellipse
x:Name="shadow"
Width="24"
Height="24"
Margin="-12"
Fill="{TemplateBinding Foreground}"
Opacity=".0"
UseLayoutRounding="True" />
<Ellipse
x:Name="grip"
Width="12"
Height="12"
Fill="{TemplateBinding Foreground}"
RenderTransformOrigin=".5,.5"
UseLayoutRounding="True">
<Ellipse.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="1" />
</Ellipse.RenderTransform>
</Ellipse>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="true">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="shadow"
Storyboard.TargetProperty="Opacity"
To=".26"
Duration="0:0:0.2" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="shadow"
Storyboard.TargetProperty="Opacity"
To=".0"
Duration="0:0:0.2" />
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsDragging" Value="true">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<Storyboard>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleX">
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1" />
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1.5">
<EasingDoubleKeyFrame.EasingFunction>
<SineEase EasingMode="EaseInOut" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleY">
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1" />
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1.5">
<EasingDoubleKeyFrame.EasingFunction>
<SineEase EasingMode="EaseInOut" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<Storyboard>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleX">
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1.5" />
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1">
<EasingDoubleKeyFrame.EasingFunction>
<SineEase EasingMode="EaseInOut" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleY">
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1.5" />
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1">
<EasingDoubleKeyFrame.EasingFunction>
<SineEase EasingMode="EaseInOut" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsEnabled" Value="false">
<Setter TargetName="grip" Property="Fill" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
<Setter TargetName="grip" Property="Stroke" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<ControlTemplate x:Key="MaterialDesignSliderHorizontal" TargetType="{x:Type Slider}">
<Border
x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
SnapsToDevicePixels="True">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" MinHeight="{TemplateBinding MinHeight}" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TickBar
x:Name="TopTick"
Grid.Row="0"
Height="4"
Margin="0,0,0,2"
Fill="{TemplateBinding Foreground}"
Placement="Top"
Visibility="Collapsed" />
<TickBar
x:Name="BottomTick"
Grid.Row="2"
Height="4"
Margin="0,2,0,0"
Fill="{TemplateBinding Foreground}"
Placement="Bottom"
Visibility="Collapsed" />
<Rectangle
x:Name="PART_SelectionRange"
Grid.Row="1"
Height="4.0"
Fill="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}"
Visibility="Hidden" />
<Track
x:Name="PART_Track"
Grid.Row="1"
OpacityMask="{x:Null}">
<Track.DecreaseRepeatButton>
<RepeatButton
Background="{TemplateBinding Foreground}"
Command="{x:Static Slider.DecreaseLarge}"
Style="{StaticResource MaterialDesignHorizontalTrackRepeatButton}" />
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton
x:Name="IncreaseRepeatButton"
Background="{DynamicResource MaterialDesignCheckBoxOff}"
Command="{x:Static Slider.IncreaseLarge}"
Style="{StaticResource MaterialDesignHorizontalTrackRepeatButton}" />
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb
x:Name="Thumb"
Width="12"
Height="18"
VerticalAlignment="Center"
Focusable="False"
OverridesDefaultStyle="True"
Template="{StaticResource MaterialDesignSliderThumb}" />
</Track.Thumb>
</Track>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="TickPlacement" Value="TopLeft">
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="TickPlacement" Value="BottomRight">
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="TickPlacement" Value="Both">
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsSelectionRangeEnabled" Value="true">
<Setter TargetName="PART_SelectionRange" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="IncreaseRepeatButton" Property="Background" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<ControlTemplate x:Key="MaterialDesignSliderVertical" TargetType="{x:Type Slider}">
<Border
x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
SnapsToDevicePixels="True">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" MinWidth="{TemplateBinding MinWidth}" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TickBar
x:Name="TopTick"
Grid.Column="0"
Width="4"
Margin="0,0,2,0"
Fill="{TemplateBinding Foreground}"
Placement="Left"
Visibility="Collapsed" />
<TickBar
x:Name="BottomTick"
Grid.Column="2"
Width="4"
Margin="2,0,0,0"
Fill="{TemplateBinding Foreground}"
Placement="Right"
Visibility="Collapsed" />
<Rectangle
x:Name="PART_SelectionRange"
Grid.Column="1"
Height="4.0"
Fill="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}"
Visibility="Hidden" />
<Track x:Name="PART_Track" Grid.Column="1">
<Track.DecreaseRepeatButton>
<RepeatButton
Background="{TemplateBinding Foreground}"
Command="{x:Static Slider.DecreaseLarge}"
Style="{StaticResource MaterialDesignVerticalTrackRepeatButton}" />
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton
x:Name="IncreaseRepeatButton"
Background="{DynamicResource MaterialDesignCheckBoxOff}"
Command="{x:Static Slider.IncreaseLarge}"
Style="{StaticResource MaterialDesignVerticalTrackRepeatButton}" />
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb
x:Name="Thumb"
Width="18"
Height="12"
VerticalAlignment="Top"
Focusable="False"
OverridesDefaultStyle="True"
Template="{StaticResource MaterialDesignSliderThumb}" />
</Track.Thumb>
</Track>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="TickPlacement" Value="TopLeft">
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="TickPlacement" Value="BottomRight">
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="TickPlacement" Value="Both">
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsSelectionRangeEnabled" Value="true">
<Setter TargetName="PART_SelectionRange" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="IncreaseRepeatButton" Property="Background" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<Style x:Key="MaterialDesignThinSlider" TargetType="{x:Type Slider}">
<Setter Property="Stylus.IsPressAndHoldEnabled" Value="false" />
<Setter Property="Background" Value="{x:Null}" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
<Setter Property="Template" Value="{StaticResource MaterialDesignSliderHorizontal}" />
<Style.Triggers>
<Trigger Property="Orientation" Value="Vertical">
<Setter Property="Template" Value="{StaticResource MaterialDesignSliderVertical}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
</Style.Triggers>
</Style>
</s:ApplicationLoader>
</Application.Resources>
</Application>
-52
View File
@@ -1,52 +0,0 @@
using System;
using System.Reflection;
using DiscordChatExporter.Gui.Utils;
using MaterialDesignThemes.Wpf;
namespace DiscordChatExporter.Gui;
public partial class App
{
private static Assembly Assembly { get; } = typeof(App).Assembly;
public static string Name { get; } = Assembly.GetName().Name!;
public static Version Version { get; } = Assembly.GetName().Version!;
public static string VersionString { get; } = Version.ToString(3);
public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter";
public static string LatestReleaseUrl { get; } = ProjectUrl + "/releases/latest";
public static string DocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
}
public partial class App
{
private static Theme LightTheme { get; } =
Theme.Create(
new MaterialDesignLightTheme(),
MediaColor.FromHex("#343838"),
MediaColor.FromHex("#F9A825")
);
private static Theme DarkTheme { get; } =
Theme.Create(
new MaterialDesignDarkTheme(),
MediaColor.FromHex("#E8E8E8"),
MediaColor.FromHex("#F9A825")
);
public static void SetLightTheme()
{
var paletteHelper = new PaletteHelper();
paletteHelper.SetTheme(LightTheme);
}
public static void SetDarkTheme()
{
var paletteHelper = new PaletteHelper();
paletteHelper.SetTheme(DarkTheme);
}
}
@@ -1,5 +0,0 @@
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Gui.Behaviors;
public class ChannelMultiSelectionListBoxBehavior : MultiSelectionListBoxBehavior<Channel>;
@@ -1,104 +0,0 @@
using System.Collections;
using System.Collections.Specialized;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using Microsoft.Xaml.Behaviors;
namespace DiscordChatExporter.Gui.Behaviors;
public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
{
public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register(
nameof(SelectedItems),
typeof(IList),
typeof(MultiSelectionListBoxBehavior<T>),
new FrameworkPropertyMetadata(
null,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnSelectedItemsChanged
)
);
private static void OnSelectedItemsChanged(
DependencyObject sender,
DependencyPropertyChangedEventArgs args
)
{
var behavior = (MultiSelectionListBoxBehavior<T>)sender;
if (behavior._modelHandled)
return;
if (behavior.AssociatedObject is null)
return;
behavior._modelHandled = true;
behavior.SelectItems();
behavior._modelHandled = false;
}
private bool _viewHandled;
private bool _modelHandled;
public IList? SelectedItems
{
get => (IList?)GetValue(SelectedItemsProperty);
set => SetValue(SelectedItemsProperty, value);
}
// Propagate selected items from the model to the view
private void SelectItems()
{
_viewHandled = true;
AssociatedObject.SelectedItems.Clear();
if (SelectedItems is not null)
{
foreach (var item in SelectedItems)
AssociatedObject.SelectedItems.Add(item);
}
_viewHandled = false;
}
// Propagate selected items from the view to the model
private void OnListBoxSelectionChanged(object? sender, SelectionChangedEventArgs args)
{
if (_viewHandled)
return;
if (AssociatedObject.Items.SourceCollection is null)
return;
SelectedItems = AssociatedObject.SelectedItems.Cast<T>().ToArray();
}
private void OnListBoxItemsChanged(object? sender, NotifyCollectionChangedEventArgs args)
{
if (_viewHandled)
return;
if (AssociatedObject.Items.SourceCollection is null)
return;
SelectItems();
}
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.SelectionChanged += OnListBoxSelectionChanged;
((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged +=
OnListBoxItemsChanged;
}
protected override void OnDetaching()
{
base.OnDetaching();
if (AssociatedObject is not null)
{
AssociatedObject.SelectionChanged -= OnListBoxSelectionChanged;
((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged -=
OnListBoxItemsChanged;
}
}
}
-45
View File
@@ -1,45 +0,0 @@
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Framework;
using Stylet;
using StyletIoC;
#if !DEBUG
using System.Windows;
using System.Windows.Threading;
#endif
namespace DiscordChatExporter.Gui;
public class Bootstrapper : Bootstrapper<RootViewModel>
{
protected override void OnStart()
{
base.OnStart();
// Set the default theme.
// Preferred theme will be set later, once the settings are loaded.
App.SetLightTheme();
}
protected override void ConfigureIoC(IStyletIoCBuilder builder)
{
base.ConfigureIoC(builder);
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
builder.Bind<IViewModelFactory>().ToAbstractFactory();
}
#if !DEBUG
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args)
{
base.OnUnhandledException(args);
MessageBox.Show(
args.Exception.ToString(),
"Error occured",
MessageBoxButton.OK,
MessageBoxImage.Error
);
}
#endif
}
@@ -1,35 +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,31 +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,11 +1,10 @@
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();
@@ -15,7 +14,7 @@ public class ExportFormatToStringConverter : IValueConverter
Type targetType,
object? parameter,
CultureInfo culture
) => value is ExportFormat exportFormatValue ? exportFormatValue.GetDisplayName() : default;
) => value is ExportFormat format ? format.GetDisplayName() : default;
public object ConvertBack(
object? value,
@@ -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,13 +1,12 @@
using System;
using System.Globalization;
using System.Windows.Data;
using Avalonia.Data.Converters;
namespace DiscordChatExporter.Gui.Converters;
[ValueConversion(typeof(string), typeof(string))]
public class LocaleToDisplayNameConverter : IValueConverter
public class LocaleToDisplayNameStringConverter : IValueConverter
{
public static LocaleToDisplayNameConverter Instance { get; } = new();
public static LocaleToDisplayNameStringConverter Instance { get; } = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is string locale && !string.IsNullOrWhiteSpace(locale)
@@ -1,21 +1,20 @@
using System;
using System.Globalization;
using System.Windows.Data;
using Avalonia.Data.Converters;
using DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Gui.Converters;
[ValueConversion(typeof(Snowflake?), typeof(DateTimeOffset?))]
public class SnowflakeToDateTimeOffsetConverter : IValueConverter
public class SnowflakeToTimestampStringConverter : IValueConverter
{
public static SnowflakeToDateTimeOffsetConverter Instance { get; } = new();
public static SnowflakeToTimestampStringConverter Instance { get; } = new();
public object? Convert(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
) => value is Snowflake snowflake ? snowflake.ToDate() : null;
) => value is Snowflake snowflake ? snowflake.ToDate().ToString("g", culture) : null;
public object ConvertBack(
object? value,
@@ -1,25 +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.7" 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.2" 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>
-4
View File
@@ -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>
-74
View File
@@ -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>
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AsyncKeyedLock;
using Avalonia;
using Avalonia.Platform.Storage;
using DialogHostAvalonia;
using DiscordChatExporter.Gui.Utils.Extensions;
namespace DiscordChatExporter.Gui.Framework;
public class DialogManager : IDisposable
{
private readonly AsyncNonKeyedLocker _dialogLock = new();
public async Task<T?> ShowDialogAsync<T>(DialogViewModelBase<T> dialog)
{
using (await _dialogLock.LockAsync())
{
await DialogHost.Show(
dialog,
// It's fine to await in a void method here because it's an event handler
// ReSharper disable once AsyncVoidLambda
async (object _, DialogOpenedEventArgs args) =>
{
await dialog.WaitForCloseAsync();
try
{
args.Session.Close();
}
catch (InvalidOperationException)
{
// Dialog host is already processing a close operation
}
}
);
return dialog.DialogResult;
}
}
public async Task<string?> PromptSaveFilePathAsync(
IReadOnlyList<FilePickerFileType>? fileTypes = null,
string defaultFilePath = ""
)
{
var topLevel =
Application.Current?.ApplicationLifetime?.TryGetTopLevel()
?? throw new ApplicationException("Could not find the top-level visual element.");
var file = await topLevel.StorageProvider.SaveFilePickerAsync(
new FilePickerSaveOptions
{
FileTypeChoices = fileTypes,
SuggestedFileName = defaultFilePath,
DefaultExtension = Path.GetExtension(defaultFilePath).TrimStart('.')
}
);
return file?.Path.LocalPath;
}
public async Task<string?> PromptDirectoryPathAsync(string defaultDirPath = "")
{
var topLevel =
Application.Current?.ApplicationLifetime?.TryGetTopLevel()
?? throw new ApplicationException("Could not find the top-level visual element.");
var startLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(
defaultDirPath
);
var folderPickResult = await topLevel.StorageProvider.OpenFolderPickerAsync(
new FolderPickerOpenOptions
{
AllowMultiple = false,
SuggestedStartLocation = startLocation
}
);
return folderPickResult.FirstOrDefault()?.Path.LocalPath;
}
public void Dispose() => _dialogLock.Dispose();
}
@@ -0,0 +1,25 @@
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace DiscordChatExporter.Gui.Framework;
public abstract partial class DialogViewModelBase<T> : ViewModelBase
{
private readonly TaskCompletionSource<T> _closeTcs =
new(TaskCreationOptions.RunContinuationsAsynchronously);
[ObservableProperty]
private T? _dialogResult;
[RelayCommand]
protected void Close(T dialogResult)
{
DialogResult = dialogResult;
_closeTcs.TrySetResult(dialogResult);
}
public async Task<T> WaitForCloseAsync() => await _closeTcs.Task;
}
public abstract class DialogViewModelBase : DialogViewModelBase<bool?>;
@@ -0,0 +1,34 @@
using System;
using Avalonia.Threading;
using Material.Styles.Controls;
using Material.Styles.Models;
namespace DiscordChatExporter.Gui.Framework;
public class SnackbarManager
{
private readonly TimeSpan _defaultDuration = TimeSpan.FromSeconds(5);
public void Notify(string message, TimeSpan? duration = null) =>
SnackbarHost.Post(
new SnackbarModel(message, duration ?? _defaultDuration),
null,
DispatcherPriority.Normal
);
public void Notify(
string message,
string actionText,
Action actionHandler,
TimeSpan? duration = null
) =>
SnackbarHost.Post(
new SnackbarModel(
message,
duration ?? _defaultDuration,
new SnackbarButtonModel { Text = actionText, Action = actionHandler }
),
null,
DispatcherPriority.Normal
);
}
@@ -0,0 +1,18 @@
using System;
using Avalonia.Controls;
namespace DiscordChatExporter.Gui.Framework;
public class UserControl<TDataContext> : UserControl
{
public new TDataContext DataContext
{
get =>
base.DataContext is TDataContext dataContext
? dataContext
: throw new InvalidCastException(
$"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'."
);
set => base.DataContext = value;
}
}
@@ -0,0 +1,37 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
namespace DiscordChatExporter.Gui.Framework;
public partial class ViewManager
{
public Control? TryBindView(ViewModelBase viewModel)
{
var name = viewModel
.GetType()
.FullName?.Replace("ViewModel", "View", StringComparison.Ordinal);
if (string.IsNullOrWhiteSpace(name))
return null;
var type = Type.GetType(name);
if (type is null)
return null;
if (Activator.CreateInstance(type) is not Control view)
return null;
view.DataContext ??= viewModel;
return view;
}
}
public partial class ViewManager : IDataTemplate
{
bool IDataTemplate.Match(object? data) => data is ViewModelBase;
Control? ITemplate<object?, Control?>.Build(object? data) =>
data is ViewModelBase viewModel ? TryBindView(viewModel) : null;
}
@@ -0,0 +1,19 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
namespace DiscordChatExporter.Gui.Framework;
public abstract class ViewModelBase : ObservableObject, IDisposable
{
~ViewModelBase() => Dispose(false);
protected void OnAllPropertiesChanged() => OnPropertyChanged(string.Empty);
protected virtual void Dispose(bool disposing) { }
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using Microsoft.Extensions.DependencyInjection;
namespace DiscordChatExporter.Gui.Framework;
public class ViewModelManager(IServiceProvider services)
{
public MainViewModel CreateMainViewModel() => services.GetRequiredService<MainViewModel>();
public DashboardViewModel CreateDashboardViewModel() =>
services.GetRequiredService<DashboardViewModel>();
public ExportSetupViewModel CreateExportSetupViewModel(
Guild guild,
IReadOnlyList<Channel> channels
)
{
var viewModel = services.GetRequiredService<ExportSetupViewModel>();
viewModel.Guild = guild;
viewModel.Channels = channels;
return viewModel;
}
public MessageBoxViewModel CreateMessageBoxViewModel(
string title,
string message,
string? okButtonText,
string? cancelButtonText
)
{
var viewModel = services.GetRequiredService<MessageBoxViewModel>();
viewModel.Title = title;
viewModel.Message = message;
viewModel.DefaultButtonText = okButtonText;
viewModel.CancelButtonText = cancelButtonText;
return viewModel;
}
public MessageBoxViewModel CreateMessageBoxViewModel(string title, string message) =>
CreateMessageBoxViewModel(title, message, "CLOSE", null);
public SettingsViewModel CreateSettingsViewModel() =>
services.GetRequiredService<SettingsViewModel>();
}
@@ -0,0 +1,18 @@
using System;
using Avalonia.Controls;
namespace DiscordChatExporter.Gui.Framework;
public class Window<TDataContext> : Window
{
public new TDataContext DataContext
{
get =>
base.DataContext is TDataContext dataContext
? dataContext
: throw new InvalidCastException(
$"DataContext is null or not of the expected type '{typeof(TDataContext).FullName}'."
);
set => base.DataContext = value;
}
}
+51
View File
@@ -0,0 +1,51 @@
using System;
using System.Reflection;
using Avalonia;
using DiscordChatExporter.Gui.Utils;
namespace DiscordChatExporter.Gui;
public static class Program
{
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 static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>().UsePlatformDetect().LogToTrace();
[STAThread]
public static int Main(string[] args)
{
// Build and run the app
var builder = BuildAvaloniaApp();
try
{
return builder.StartWithClassicDesktopLifetime(args);
}
catch (Exception ex)
{
if (OperatingSystem.IsWindows())
_ = NativeMethods.Windows.MessageBox(0, ex.ToString(), "Fatal Error", 0x10);
throw;
}
finally
{
// Clean up after application shutdown
if (builder.Instance is IDisposable disposableApp)
disposableApp.Dispose();
}
}
}
@@ -1,48 +1,80 @@
using System;
using System.IO;
using Avalonia;
using Avalonia.Platform;
using Cogwheel;
using CommunityToolkit.Mvvm.ComponentModel;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Gui.Models;
using Microsoft.Win32;
namespace DiscordChatExporter.Gui.Services;
[INotifyPropertyChanged]
public partial class SettingsService()
: SettingsBase(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.dat"))
{
public bool IsUkraineSupportMessageEnabled { get; set; } = true;
[ObservableProperty]
private bool _isUkraineSupportMessageEnabled = true;
public bool IsAutoUpdateEnabled { get; set; } = true;
[ObservableProperty]
private bool _isAutoUpdateEnabled = true;
public bool IsDarkModeEnabled { get; set; } = IsDarkModeEnabledByDefault();
[ObservableProperty]
private bool _isDarkModeEnabled;
public bool IsTokenPersisted { get; set; } = true;
[ObservableProperty]
private bool _isTokenPersisted = true;
public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None;
[ObservableProperty]
private ThreadInclusionMode _threadInclusionMode;
public string? Locale { get; set; }
[ObservableProperty]
private string? _locale;
public bool IsUtcNormalizationEnabled { get; set; }
[ObservableProperty]
private bool _isUtcNormalizationEnabled;
public int ParallelLimit { get; set; } = 1;
[ObservableProperty]
private int _parallelLimit = 1;
public Version? LastAppVersion { get; set; }
[ObservableProperty]
private Version? _lastAppVersion;
public string? LastToken { get; set; }
[ObservableProperty]
private string? _lastToken;
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
[ObservableProperty]
private ExportFormat _lastExportFormat = ExportFormat.HtmlDark;
public string? LastPartitionLimitValue { get; set; }
[ObservableProperty]
private string? _lastPartitionLimitValue;
public string? LastMessageFilterValue { get; set; }
[ObservableProperty]
private string? _lastMessageFilterValue;
public bool LastShouldFormatMarkdown { get; set; } = true;
[ObservableProperty]
private bool _lastShouldFormatMarkdown = true;
public bool LastShouldDownloadAssets { get; set; }
[ObservableProperty]
private bool _lastShouldDownloadAssets;
public bool LastShouldReuseAssets { get; set; }
[ObservableProperty]
private bool _lastShouldReuseAssets;
public string? LastAssetsDirPath { get; set; }
[ObservableProperty]
private string? _lastAssetsDirPath;
public override void Reset()
{
base.Reset();
// Reset the dark mode setting separately because its default value is evaluated dynamically
// and cannot be set by the field initializer.
IsDarkModeEnabled =
Application.Current?.PlatformSettings?.GetColorValues().ThemeVariant
== PlatformThemeVariant.Dark;
}
public override void Save()
{
@@ -56,24 +88,3 @@ public partial class SettingsService()
LastToken = lastToken;
}
}
public partial class SettingsService
{
private static bool IsDarkModeEnabledByDefault()
{
try
{
return Registry
.CurrentUser.OpenSubKey(
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
false
)
?.GetValue("AppsUseLightTheme")
is 0;
}
catch
{
return false;
}
}
}
@@ -1,4 +1,5 @@
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Onova;
using Onova.Exceptions;
@@ -9,7 +10,15 @@ namespace DiscordChatExporter.Gui.Services;
public class UpdateService(SettingsService settingsService) : IDisposable
{
private readonly IUpdateManager _updateManager = new UpdateManager(
new GithubPackageResolver("Tyrrrz", "DiscordChatExporter", "DiscordChatExporter.zip"),
new GithubPackageResolver(
"Tyrrrz",
"DiscordChatExporter",
// Examples:
// DiscordChatExporter.win-arm64.zip
// DiscordChatExporter.win-x64.zip
// DiscordChatExporter.linux-x64.zip
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
),
new ZipPackageExtractor()
);
@@ -51,6 +60,10 @@ public class UpdateService(SettingsService settingsService) : IDisposable
if (!settingsService.IsAutoUpdateEnabled)
return;
// Onova only works on Windows currently
if (!OperatingSystem.IsWindows())
return;
if (_updateVersion is null || !_updatePrepared || _updaterLaunched)
return;
@@ -0,0 +1,10 @@
using System;
namespace DiscordChatExporter.Gui.Utils;
internal class Disposable(Action dispose) : IDisposable
{
public static IDisposable Create(Action dispose) => new Disposable(dispose);
public void Dispose() => dispose();
}
@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Gui.Utils.Extensions;
namespace DiscordChatExporter.Gui.Utils;
internal class DisposableCollector : IDisposable
{
private readonly object _lock = new();
private readonly List<IDisposable> _items = [];
public void Add(IDisposable item)
{
lock (_lock)
{
_items.Add(item);
}
}
public void Dispose()
{
lock (_lock)
{
_items.DisposeAll();
_items.Clear();
}
}
}
@@ -0,0 +1,34 @@
using System;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.VisualTree;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class AvaloniaExtensions
{
public static Window? TryGetMainWindow(this IApplicationLifetime lifetime) =>
lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime
? desktopLifetime.MainWindow
: null;
public static TopLevel? TryGetTopLevel(this IApplicationLifetime lifetime) =>
lifetime.TryGetMainWindow()
?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel;
public static bool TryShutdown(this IApplicationLifetime lifetime, int exitCode = 0)
{
if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)
{
return desktopLifetime.TryShutdown(exitCode);
}
if (lifetime is IControlledApplicationLifetime controlledLifetime)
{
controlledLifetime.Shutdown(exitCode);
return true;
}
return false;
}
}
@@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class DisposableExtensions
{
public static void DisposeAll(this IEnumerable<IDisposable> disposables)
{
var exceptions = default(List<Exception>);
foreach (var disposable in disposables)
{
try
{
disposable.Dispose();
}
catch (Exception ex)
{
(exceptions ??= []).Add(ex);
}
}
if (exceptions?.Any() == true)
throw new AggregateException(exceptions);
}
}
@@ -0,0 +1,60 @@
using System;
using System.ComponentModel;
using System.Linq.Expressions;
using System.Reflection;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class NotifyPropertyChangedExtensions
{
public static IDisposable WatchProperty<TOwner, TProperty>(
this TOwner owner,
Expression<Func<TOwner, TProperty>> propertyExpression,
Action callback,
bool watchInitialValue = true
)
where TOwner : INotifyPropertyChanged
{
var memberExpression =
propertyExpression.Body as MemberExpression
// Property value might be boxed inside a conversion expression, if the types don't match
?? (propertyExpression.Body as UnaryExpression)?.Operand as MemberExpression;
if (memberExpression?.Member is not PropertyInfo property)
throw new ArgumentException("Provided expression must reference a property.");
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (
string.IsNullOrWhiteSpace(args.PropertyName)
|| string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal)
)
{
callback();
}
}
owner.PropertyChanged += OnPropertyChanged;
if (watchInitialValue)
callback();
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
}
public static IDisposable WatchAllProperties<TOwner>(
this TOwner owner,
Action callback,
bool watchInitialValues = true
)
where TOwner : INotifyPropertyChanged
{
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback();
owner.PropertyChanged += OnPropertyChanged;
if (watchInitialValues)
callback();
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
}
}
@@ -7,4 +7,6 @@ internal static class Internationalization
public static bool Is24Hours =>
string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.AMDesignator)
&& string.IsNullOrWhiteSpace(CultureInfo.CurrentCulture.DateTimeFormat.PMDesignator);
public static string AvaloniaClockIdentifier => Is24Hours ? "24HourClock" : "12HourClock";
}
@@ -1,8 +0,0 @@
using System.Windows.Media;
namespace DiscordChatExporter.Gui.Utils;
internal static class MediaColor
{
public static Color FromHex(string hex) => (Color)ColorConverter.ConvertFromString(hex);
}
@@ -0,0 +1,12 @@
using System.Runtime.InteropServices;
namespace DiscordChatExporter.Gui.Utils;
internal static class NativeMethods
{
public static class Windows
{
[DllImport("user32.dll", SetLastError = true)]
public static extern int MessageBox(nint hWnd, string text, string caption, uint type);
}
}
+2 -4
View File
@@ -6,10 +6,8 @@ internal static class ProcessEx
{
public static void StartShellExecute(string path)
{
using var process = new Process
{
StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true }
};
using var process = new Process();
process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true };
process.Start();
}
@@ -1,104 +1,113 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Utils.Extensions;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Models;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.ViewModels.Framework;
using DiscordChatExporter.Gui.ViewModels.Messages;
using DiscordChatExporter.Gui.Utils.Extensions;
using Gress;
using Gress.Completable;
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Components;
public class DashboardViewModel : PropertyChangedBase
public partial class DashboardViewModel : ViewModelBase
{
private readonly IViewModelFactory _viewModelFactory;
private readonly IEventAggregator _eventAggregator;
private readonly ViewModelManager _viewModelManager;
private readonly SnackbarManager _snackbarManager;
private readonly DialogManager _dialogManager;
private readonly SettingsService _settingsService;
private readonly DisposableCollector _eventRoot = new();
private readonly AutoResetProgressMuxer _progressMuxer;
private DiscordClient? _discord;
public bool IsBusy { get; private set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsProgressIndeterminate))]
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
private bool _isBusy;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
private string? _token;
[ObservableProperty]
private IReadOnlyList<Guild>? _availableGuilds;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
private Guild? _selectedGuild;
[ObservableProperty]
private IReadOnlyList<ChannelNode>? _availableChannels;
public DashboardViewModel(
ViewModelManager viewModelManager,
DialogManager dialogManager,
SnackbarManager snackbarManager,
SettingsService settingsService
)
{
_viewModelManager = viewModelManager;
_dialogManager = dialogManager;
_snackbarManager = snackbarManager;
_settingsService = settingsService;
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
_eventRoot.Add(
Progress.WatchProperty(
o => o.Current,
() => OnPropertyChanged(nameof(IsProgressIndeterminate))
)
);
_eventRoot.Add(
SelectedChannels.WatchProperty(
o => o.Count,
() => ExportCommand.NotifyCanExecuteChanged()
)
);
}
public ProgressContainer<Percentage> Progress { get; } = new();
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
public string? Token { get; set; }
public ObservableCollection<ChannelNode> SelectedChannels { get; } = [];
public IReadOnlyList<Guild>? AvailableGuilds { get; private set; }
public Guild? SelectedGuild { get; set; }
public IReadOnlyList<Channel>? AvailableChannels { get; private set; }
public IReadOnlyList<Channel>? SelectedChannels { get; set; }
public DashboardViewModel(
IViewModelFactory viewModelFactory,
IEventAggregator eventAggregator,
DialogManager dialogManager,
SettingsService settingsService
)
{
_viewModelFactory = viewModelFactory;
_eventAggregator = eventAggregator;
_dialogManager = dialogManager;
_settingsService = settingsService;
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
this.Bind(o => o.IsBusy, (_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate));
Progress.Bind(
o => o.Current,
(_, _) => NotifyOfPropertyChange(() => IsProgressIndeterminate)
);
this.Bind(
o => o.SelectedGuild,
(_, _) =>
{
// Reset channels when the selected guild changes, to avoid jitter
// due to the channels being asynchronously loaded.
AvailableChannels = null;
SelectedChannels = null;
// Pull channels for the selected guild
// (ideally this should be called inside `PullGuilds()`,
// but Stylet doesn't support async commands)
PullChannels();
}
);
}
public void OnViewLoaded()
[RelayCommand]
private void Initialize()
{
if (!string.IsNullOrWhiteSpace(_settingsService.LastToken))
Token = _settingsService.LastToken;
}
public async void ShowSettings() =>
await _dialogManager.ShowDialogAsync(_viewModelFactory.CreateSettingsViewModel());
[RelayCommand]
private async Task ShowSettingsAsync() =>
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
public void ShowHelp() => ProcessEx.StartShellExecute(App.DocumentationUrl);
[RelayCommand]
private void ShowHelp() => ProcessEx.StartShellExecute(Program.DocumentationUrl);
public bool CanPullGuilds => !IsBusy && !string.IsNullOrWhiteSpace(Token);
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
public async void PullGuilds()
[RelayCommand(CanExecute = nameof(CanPullGuilds))]
private async Task PullGuildsAsync()
{
IsBusy = true;
var progress = _progressMuxer.CreateInput();
@@ -112,7 +121,7 @@ public class DashboardViewModel : PropertyChangedBase
AvailableGuilds = null;
SelectedGuild = null;
AvailableChannels = null;
SelectedChannels = null;
SelectedChannels.Clear();
_discord = new DiscordClient(token);
_settingsService.LastToken = token;
@@ -121,14 +130,16 @@ public class DashboardViewModel : PropertyChangedBase
AvailableGuilds = guilds;
SelectedGuild = guilds.FirstOrDefault();
await PullChannelsAsync();
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{
_eventAggregator.Publish(new NotificationMessage(ex.Message.TrimEnd('.')));
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
}
catch (Exception ex)
{
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
var dialog = _viewModelManager.CreateMessageBoxViewModel(
"Error pulling guilds",
ex.ToString()
);
@@ -142,9 +153,10 @@ public class DashboardViewModel : PropertyChangedBase
}
}
public bool CanPullChannels => !IsBusy && _discord is not null && SelectedGuild is not null;
private bool CanPullChannels() => !IsBusy && _discord is not null && SelectedGuild is not null;
public async void PullChannels()
[RelayCommand(CanExecute = nameof(CanPullChannels))]
private async Task PullChannelsAsync()
{
IsBusy = true;
var progress = _progressMuxer.CreateInput();
@@ -155,18 +167,13 @@ public class DashboardViewModel : PropertyChangedBase
return;
AvailableChannels = null;
SelectedChannels = null;
SelectedChannels.Clear();
var channels = new List<Channel>();
// Regular channels
await foreach (var channel in _discord.GetGuildChannelsAsync(SelectedGuild.Id))
{
if (channel.IsCategory)
continue;
channels.Add(channel);
}
// Threads
if (_settingsService.ThreadInclusionMode != ThreadInclusionMode.None)
@@ -182,16 +189,24 @@ public class DashboardViewModel : PropertyChangedBase
}
}
AvailableChannels = channels;
SelectedChannels = null;
// Build a hierarchy of channels
var channelTree = ChannelNode.BuildTree(
channels
.OrderByDescending(c => c.IsDirect ? c.LastMessageId : null)
.ThenBy(c => c.Position)
.ToArray()
);
AvailableChannels = channelTree;
SelectedChannels.Clear();
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{
_eventAggregator.Publish(new NotificationMessage(ex.Message.TrimEnd('.')));
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
}
catch (Exception ex)
{
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
var dialog = _viewModelManager.CreateMessageBoxViewModel(
"Error pulling channels",
ex.ToString()
);
@@ -205,30 +220,24 @@ public class DashboardViewModel : PropertyChangedBase
}
}
public bool CanExport =>
!IsBusy
&& _discord is not null
&& SelectedGuild is not null
&& SelectedChannels?.Any() is true;
private bool CanExport() =>
!IsBusy && _discord is not null && SelectedGuild is not null && SelectedChannels.Any();
public async void Export()
[RelayCommand(CanExecute = nameof(CanExport))]
private async Task ExportAsync()
{
IsBusy = true;
try
{
if (
_discord is null
|| SelectedGuild is null
|| SelectedChannels is null
|| !SelectedChannels.Any()
)
if (_discord is null || SelectedGuild is null || !SelectedChannels.Any())
return;
var dialog = _viewModelFactory.CreateExportSetupViewModel(
var dialog = _viewModelManager.CreateExportSetupViewModel(
SelectedGuild,
SelectedChannels
SelectedChannels.Select(c => c.Channel).ToArray()
);
if (await _dialogManager.ShowDialogAsync(dialog) != true)
return;
@@ -276,7 +285,7 @@ public class DashboardViewModel : PropertyChangedBase
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{
_eventAggregator.Publish(new NotificationMessage(ex.Message.TrimEnd('.')));
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
}
finally
{
@@ -288,16 +297,14 @@ public class DashboardViewModel : PropertyChangedBase
// Notify of the overall completion
if (successfulExportCount > 0)
{
_eventAggregator.Publish(
new NotificationMessage(
$"Successfully exported {successfulExportCount} channel(s)"
)
_snackbarManager.Notify(
$"Successfully exported {successfulExportCount} channel(s)"
);
}
}
catch (Exception ex)
{
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
var dialog = _viewModelManager.CreateMessageBoxViewModel(
"Error exporting channel(s)",
ex.ToString()
);
@@ -310,8 +317,20 @@ public class DashboardViewModel : PropertyChangedBase
}
}
public void OpenDiscord() => ProcessEx.StartShellExecute("https://discord.com/app");
[RelayCommand]
private void OpenDiscord() => ProcessEx.StartShellExecute("https://discord.com/app");
public void OpenDiscordDeveloperPortal() =>
[RelayCommand]
private void OpenDiscordDeveloperPortal() =>
ProcessEx.StartShellExecute("https://discord.com/developers/applications");
protected override void Dispose(bool disposing)
{
if (disposing)
{
_eventRoot.Dispose();
}
base.Dispose(disposing);
}
}
@@ -1,89 +1,111 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils.Extensions;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.ViewModels.Framework;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
public class ExportSetupViewModel : DialogScreen
public partial class ExportSetupViewModel(
DialogManager dialogManager,
SettingsService settingsService
) : DialogViewModelBase
{
private readonly DialogManager _dialogManager;
private readonly SettingsService _settingsService;
[ObservableProperty]
private Guild? _guild;
public Guild? Guild { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsSingleChannel))]
private IReadOnlyList<Channel>? _channels;
public IReadOnlyList<Channel>? Channels { get; set; }
[ObservableProperty]
private string? _outputPath;
[ObservableProperty]
private ExportFormat _selectedFormat;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAfterDateSet))]
[NotifyPropertyChangedFor(nameof(After))]
private DateTimeOffset? _afterDate;
[ObservableProperty]
private TimeSpan? _afterTime;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsBeforeDateSet))]
[NotifyPropertyChangedFor(nameof(Before))]
private DateTimeOffset? _beforeDate;
[ObservableProperty]
private TimeSpan? _beforeTime;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PartitionLimit))]
private string? _partitionLimitValue;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(MessageFilter))]
private string? _messageFilterValue;
[ObservableProperty]
private bool _shouldFormatMarkdown;
[ObservableProperty]
private bool _shouldDownloadAssets;
[ObservableProperty]
private bool _shouldReuseAssets;
[ObservableProperty]
private string? _assetsDirPath;
[ObservableProperty]
private bool _isAdvancedSectionDisplayed;
public bool IsSingleChannel => Channels?.Count == 1;
public string? OutputPath { get; set; }
public IReadOnlyList<ExportFormat> AvailableFormats { get; } = Enum.GetValues<ExportFormat>();
public ExportFormat SelectedFormat { get; set; }
// This date/time abomination is required because we use separate controls to set these
public DateTimeOffset? AfterDate { get; set; }
public bool IsAfterDateSet => AfterDate is not null;
public TimeSpan? AfterTime { get; set; }
public DateTimeOffset? After => AfterDate?.Add(AfterTime ?? TimeSpan.Zero);
public DateTimeOffset? BeforeDate { get; set; }
public bool IsBeforeDateSet => BeforeDate is not null;
public TimeSpan? BeforeTime { get; set; }
public DateTimeOffset? Before => BeforeDate?.Add(BeforeTime ?? TimeSpan.Zero);
public string? PartitionLimitValue { get; set; }
public PartitionLimit PartitionLimit =>
!string.IsNullOrWhiteSpace(PartitionLimitValue)
? PartitionLimit.Parse(PartitionLimitValue)
: PartitionLimit.Null;
public string? MessageFilterValue { get; set; }
public MessageFilter MessageFilter =>
!string.IsNullOrWhiteSpace(MessageFilterValue)
? MessageFilter.Parse(MessageFilterValue)
: MessageFilter.Null;
public bool ShouldFormatMarkdown { get; set; }
public bool ShouldDownloadAssets { get; set; }
public bool ShouldReuseAssets { get; set; }
public string? AssetsDirPath { get; set; }
public bool IsAdvancedSectionDisplayed { get; set; }
public ExportSetupViewModel(DialogManager dialogManager, SettingsService settingsService)
[RelayCommand]
private void Initialize()
{
_dialogManager = dialogManager;
_settingsService = settingsService;
// Persist preferences
SelectedFormat = _settingsService.LastExportFormat;
PartitionLimitValue = _settingsService.LastPartitionLimitValue;
MessageFilterValue = _settingsService.LastMessageFilterValue;
ShouldFormatMarkdown = _settingsService.LastShouldFormatMarkdown;
ShouldDownloadAssets = _settingsService.LastShouldDownloadAssets;
ShouldReuseAssets = _settingsService.LastShouldReuseAssets;
AssetsDirPath = _settingsService.LastAssetsDirPath;
SelectedFormat = settingsService.LastExportFormat;
PartitionLimitValue = settingsService.LastPartitionLimitValue;
MessageFilterValue = settingsService.LastMessageFilterValue;
ShouldFormatMarkdown = settingsService.LastShouldFormatMarkdown;
ShouldDownloadAssets = settingsService.LastShouldDownloadAssets;
ShouldReuseAssets = settingsService.LastShouldReuseAssets;
AssetsDirPath = settingsService.LastAssetsDirPath;
// Show the "advanced options" section by default if any
// of the advanced options are set to non-default values.
@@ -97,9 +119,8 @@ public class ExportSetupViewModel : DialogScreen
|| !string.IsNullOrWhiteSpace(AssetsDirPath);
}
public void ToggleAdvancedSection() => IsAdvancedSectionDisplayed = !IsAdvancedSectionDisplayed;
public void ShowOutputPathPrompt()
[RelayCommand]
private async Task ShowOutputPathPromptAsync()
{
if (IsSingleChannel)
{
@@ -112,33 +133,43 @@ public class ExportSetupViewModel : DialogScreen
);
var extension = SelectedFormat.GetFileExtension();
var filter = $"{extension.ToUpperInvariant()} files|*.{extension}";
var path = _dialogManager.PromptSaveFilePath(filter, defaultFileName);
var path = await dialogManager.PromptSaveFilePathAsync(
[
new FilePickerFileType($"{extension.ToUpperInvariant()} file")
{
Patterns = [$"*.{extension}"]
}
],
defaultFileName
);
if (!string.IsNullOrWhiteSpace(path))
OutputPath = path;
}
else
{
var path = _dialogManager.PromptDirectoryPath();
var path = await dialogManager.PromptDirectoryPathAsync();
if (!string.IsNullOrWhiteSpace(path))
OutputPath = path;
}
}
public void ShowAssetsDirPathPrompt()
[RelayCommand]
private async Task ShowAssetsDirPathPromptAsync()
{
var path = _dialogManager.PromptDirectoryPath();
var path = await dialogManager.PromptDirectoryPathAsync();
if (!string.IsNullOrWhiteSpace(path))
AssetsDirPath = path;
}
public void Confirm()
[RelayCommand]
private async Task ConfirmAsync()
{
// Prompt the output path if it's not set yet
// Prompt the output path if it hasn't been set yet
if (string.IsNullOrWhiteSpace(OutputPath))
{
ShowOutputPathPrompt();
await ShowOutputPathPromptAsync();
// If the output path is still not set, cancel the export
if (string.IsNullOrWhiteSpace(OutputPath))
@@ -146,31 +177,14 @@ public class ExportSetupViewModel : DialogScreen
}
// Persist preferences
_settingsService.LastExportFormat = SelectedFormat;
_settingsService.LastPartitionLimitValue = PartitionLimitValue;
_settingsService.LastMessageFilterValue = MessageFilterValue;
_settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown;
_settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
_settingsService.LastShouldReuseAssets = ShouldReuseAssets;
_settingsService.LastAssetsDirPath = AssetsDirPath;
settingsService.LastExportFormat = SelectedFormat;
settingsService.LastPartitionLimitValue = PartitionLimitValue;
settingsService.LastMessageFilterValue = MessageFilterValue;
settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown;
settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
settingsService.LastShouldReuseAssets = ShouldReuseAssets;
settingsService.LastAssetsDirPath = AssetsDirPath;
Close(true);
}
}
public static class ExportSetupViewModelExtensions
{
public static ExportSetupViewModel CreateExportSetupViewModel(
this IViewModelFactory factory,
Guild guild,
IReadOnlyList<Channel> channels
)
{
var viewModel = factory.CreateExportSetupViewModel();
viewModel.Guild = guild;
viewModel.Channels = channels;
return viewModel;
}
}
@@ -1,49 +1,29 @@
using DiscordChatExporter.Gui.ViewModels.Framework;
using CommunityToolkit.Mvvm.ComponentModel;
using DiscordChatExporter.Gui.Framework;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
public class MessageBoxViewModel : DialogScreen
public partial class MessageBoxViewModel : DialogViewModelBase
{
public string? Title { get; set; }
[ObservableProperty]
private string? _title = "Title";
public string? Message { get; set; }
[ObservableProperty]
private string? _message = "Message";
public bool IsOkButtonVisible { get; set; } = true;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDefaultButtonVisible))]
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
private string? _defaultButtonText = "OK";
public string? OkButtonText { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsCancelButtonVisible))]
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
private string? _cancelButtonText = "Cancel";
public bool IsCancelButtonVisible { get; set; }
public bool IsDefaultButtonVisible => !string.IsNullOrWhiteSpace(DefaultButtonText);
public string? CancelButtonText { get; set; }
public bool IsCancelButtonVisible => !string.IsNullOrWhiteSpace(CancelButtonText);
public int ButtonsCount => (IsOkButtonVisible ? 1 : 0) + (IsCancelButtonVisible ? 1 : 0);
}
public static class MessageBoxViewModelExtensions
{
public static MessageBoxViewModel CreateMessageBoxViewModel(
this IViewModelFactory factory,
string title,
string message,
string? okButtonText,
string? cancelButtonText
)
{
var viewModel = factory.CreateMessageBoxViewModel();
viewModel.Title = title;
viewModel.Message = message;
viewModel.IsOkButtonVisible = !string.IsNullOrWhiteSpace(okButtonText);
viewModel.OkButtonText = okButtonText;
viewModel.IsCancelButtonVisible = !string.IsNullOrWhiteSpace(cancelButtonText);
viewModel.CancelButtonText = cancelButtonText;
return viewModel;
}
public static MessageBoxViewModel CreateMessageBoxViewModel(
this IViewModelFactory factory,
string title,
string message
) => factory.CreateMessageBoxViewModel(title, message, "CLOSE", null);
public int ButtonsCount => (IsDefaultButtonVisible ? 1 : 0) + (IsCancelButtonVisible ? 1 : 0);
}
@@ -2,30 +2,43 @@
using System.Collections.Generic;
using System.Linq;
using DiscordChatExporter.Core.Utils.Extensions;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Models;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.ViewModels.Framework;
using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.Utils.Extensions;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
public class SettingsViewModel(SettingsService settingsService) : DialogScreen
public class SettingsViewModel : DialogViewModelBase
{
private readonly SettingsService _settingsService;
private readonly DisposableCollector _eventRoot = new();
public SettingsViewModel(SettingsService settingsService)
{
_settingsService = settingsService;
_eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged));
}
public bool IsAutoUpdateEnabled
{
get => settingsService.IsAutoUpdateEnabled;
set => settingsService.IsAutoUpdateEnabled = value;
get => _settingsService.IsAutoUpdateEnabled;
set => _settingsService.IsAutoUpdateEnabled = value;
}
public bool IsDarkModeEnabled
{
get => settingsService.IsDarkModeEnabled;
set => settingsService.IsDarkModeEnabled = value;
get => _settingsService.IsDarkModeEnabled;
set => _settingsService.IsDarkModeEnabled = value;
}
public bool IsTokenPersisted
{
get => settingsService.IsTokenPersisted;
set => settingsService.IsTokenPersisted = value;
get => _settingsService.IsTokenPersisted;
set => _settingsService.IsTokenPersisted = value;
}
public IReadOnlyList<ThreadInclusionMode> AvailableThreadInclusions { get; } =
@@ -33,13 +46,13 @@ public class SettingsViewModel(SettingsService settingsService) : DialogScreen
public ThreadInclusionMode ThreadInclusionMode
{
get => settingsService.ThreadInclusionMode;
set => settingsService.ThreadInclusionMode = value;
get => _settingsService.ThreadInclusionMode;
set => _settingsService.ThreadInclusionMode = value;
}
// These items have to be non-nullable because WPF ComboBox doesn't allow a null value to be selected
public IReadOnlyList<string> AvailableLocales { get; } = new[]
{
// These items have to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
public IReadOnlyList<string> AvailableLocales { get; } =
[
// Current locale (maps to null downstream)
"",
// Locales supported by the Discord app
@@ -72,25 +85,35 @@ public class SettingsViewModel(SettingsService settingsService) : DialogScreen
"ja-JP",
"zh-TW",
"ko-KR"
}.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
];
// This has to be non-nullable because WPF ComboBox doesn't allow a null value to be selected
// This has to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
public string Locale
{
get => settingsService.Locale ?? "";
get => _settingsService.Locale ?? "";
// Important to reduce empty strings to nulls, because empty strings don't correspond to valid cultures
set => settingsService.Locale = value.NullIfWhiteSpace();
set => _settingsService.Locale = value.NullIfWhiteSpace();
}
public bool IsUtcNormalizationEnabled
{
get => settingsService.IsUtcNormalizationEnabled;
set => settingsService.IsUtcNormalizationEnabled = value;
get => _settingsService.IsUtcNormalizationEnabled;
set => _settingsService.IsUtcNormalizationEnabled = value;
}
public int ParallelLimit
{
get => settingsService.ParallelLimit;
set => settingsService.ParallelLimit = Math.Clamp(value, 1, 10);
get => _settingsService.ParallelLimit;
set => _settingsService.ParallelLimit = Math.Clamp(value, 1, 10);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_eventRoot.Dispose();
}
base.Dispose(disposing);
}
}
@@ -1,72 +0,0 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MaterialDesignThemes.Wpf;
using Microsoft.Win32;
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Framework;
public class DialogManager(IViewManager viewManager) : IDisposable
{
private readonly SemaphoreSlim _dialogLock = new(1, 1);
public async ValueTask<T?> ShowDialogAsync<T>(DialogScreen<T> dialogScreen)
{
var view = viewManager.CreateAndBindViewForModelIfNecessary(dialogScreen);
void OnDialogOpened(object? openSender, DialogOpenedEventArgs openArgs)
{
void OnScreenClosed(object? closeSender, EventArgs closeArgs)
{
try
{
openArgs.Session.Close();
}
catch (InvalidOperationException)
{
// Race condition: dialog is already being closed
}
dialogScreen.Closed -= OnScreenClosed;
}
dialogScreen.Closed += OnScreenClosed;
}
await _dialogLock.WaitAsync();
try
{
await DialogHost.Show(view, OnDialogOpened);
return dialogScreen.DialogResult;
}
finally
{
_dialogLock.Release();
}
}
public string? PromptSaveFilePath(string filter = "All files|*.*", string defaultFilePath = "")
{
var dialog = new SaveFileDialog
{
Filter = filter,
AddExtension = true,
FileName = defaultFilePath,
DefaultExt = Path.GetExtension(defaultFilePath)
};
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
public string? PromptDirectoryPath(string defaultDirPath = "")
{
var dialog = new OpenFolderDialog { InitialDirectory = defaultDirPath };
return dialog.ShowDialog() == true ? dialog.FolderName : null;
}
public void Dispose()
{
_dialogLock.Dispose();
}
}
@@ -1,19 +0,0 @@
using System;
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels.Framework;
public abstract class DialogScreen<T> : PropertyChangedBase
{
public T? DialogResult { get; private set; }
public event EventHandler? Closed;
public void Close(T dialogResult)
{
DialogResult = dialogResult;
Closed?.Invoke(this, EventArgs.Empty);
}
}
public abstract class DialogScreen : DialogScreen<bool?>;
@@ -1,16 +0,0 @@
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
namespace DiscordChatExporter.Gui.ViewModels.Framework;
// Used to instantiate new view models while making use of dependency injection
public interface IViewModelFactory
{
DashboardViewModel CreateDashboardViewModel();
ExportSetupViewModel CreateExportSetupViewModel();
MessageBoxViewModel CreateMessageBoxViewModel();
SettingsViewModel CreateSettingsViewModel();
}
@@ -0,0 +1,127 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.Input;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.ViewModels.Components;
namespace DiscordChatExporter.Gui.ViewModels;
public partial class MainViewModel(
ViewModelManager viewModelManager,
DialogManager dialogManager,
SnackbarManager snackbarManager,
SettingsService settingsService,
UpdateService updateService
) : ViewModelBase
{
public string Title { get; } = $"{Program.Name} v{Program.VersionString}";
public DashboardViewModel Dashboard { get; } = viewModelManager.CreateDashboardViewModel();
private async Task ShowUkraineSupportMessageAsync()
{
if (!settingsService.IsUkraineSupportMessageEnabled)
return;
var dialog = viewModelManager.CreateMessageBoxViewModel(
"Thank you for supporting Ukraine!",
"""
As Russia wages a genocidal war against my country, I'm grateful to everyone who continues to stand with Ukraine in our fight for freedom.
Click LEARN MORE to find ways that you can help.
""",
"LEARN MORE",
"CLOSE"
);
// Disable this message in the future
settingsService.IsUkraineSupportMessageEnabled = false;
settingsService.Save();
if (await dialogManager.ShowDialogAsync(dialog) == true)
ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter");
}
private async Task CheckForUpdatesAsync()
{
try
{
var updateVersion = await updateService.CheckForUpdatesAsync();
if (updateVersion is null)
return;
snackbarManager.Notify($"Downloading update to {Program.Name} v{updateVersion}...");
await updateService.PrepareUpdateAsync(updateVersion);
snackbarManager.Notify(
"Update has been downloaded and will be installed when you exit",
"INSTALL NOW",
() =>
{
updateService.FinalizeUpdate(true);
if (Application.Current?.ApplicationLifetime?.TryShutdown(2) != true)
Environment.Exit(2);
}
);
}
catch
{
// Failure to update shouldn't crash the application
snackbarManager.Notify("Failed to perform application update");
}
}
[RelayCommand]
private async Task InitializeAsync()
{
// Reset settings (needed to resolve the default dark mode setting)
settingsService.Reset();
// Load settings
settingsService.Load();
// Set the correct theme
if (settingsService.IsDarkModeEnabled)
App.SetDarkTheme();
else
App.SetLightTheme();
await ShowUkraineSupportMessageAsync();
await CheckForUpdatesAsync();
// App has just been updated, display the changelog
if (
settingsService.LastAppVersion is not null
&& settingsService.LastAppVersion != Program.Version
)
{
snackbarManager.Notify(
$"Successfully updated to {Program.Name} v{Program.VersionString}",
"WHAT'S NEW",
() => ProcessEx.StartShellExecute(Program.LatestReleaseUrl)
);
settingsService.LastAppVersion = Program.Version;
settingsService.Save();
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
// Save settings
settingsService.Save();
// Finalize pending updates
updateService.FinalizeUpdate(false);
}
base.Dispose(disposing);
}
}
@@ -1,3 +0,0 @@
namespace DiscordChatExporter.Gui.ViewModels.Messages;
public record NotificationMessage(string Text);
@@ -1,147 +0,0 @@
using System;
using System.Threading.Tasks;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.ViewModels.Framework;
using DiscordChatExporter.Gui.ViewModels.Messages;
using MaterialDesignThemes.Wpf;
using Stylet;
namespace DiscordChatExporter.Gui.ViewModels;
public class RootViewModel : Screen, IHandle<NotificationMessage>, IDisposable
{
private readonly IViewModelFactory _viewModelFactory;
private readonly DialogManager _dialogManager;
private readonly SettingsService _settingsService;
private readonly UpdateService _updateService;
public SnackbarMessageQueue Notifications { get; } = new(TimeSpan.FromSeconds(5));
public DashboardViewModel Dashboard { get; }
public RootViewModel(
IViewModelFactory viewModelFactory,
IEventAggregator eventAggregator,
DialogManager dialogManager,
SettingsService settingsService,
UpdateService updateService
)
{
_viewModelFactory = viewModelFactory;
_dialogManager = dialogManager;
_settingsService = settingsService;
_updateService = updateService;
eventAggregator.Subscribe(this);
Dashboard = _viewModelFactory.CreateDashboardViewModel();
DisplayName = $"{App.Name} v{App.VersionString}";
}
private async Task ShowUkraineSupportMessageAsync()
{
if (!_settingsService.IsUkraineSupportMessageEnabled)
return;
var dialog = _viewModelFactory.CreateMessageBoxViewModel(
"Thank you for supporting Ukraine!",
"""
As Russia wages a genocidal war against my country, I'm grateful to everyone who continues to stand with Ukraine in our fight for freedom.
Click LEARN MORE to find ways that you can help.
""",
"LEARN MORE",
"CLOSE"
);
// Disable this message in the future
_settingsService.IsUkraineSupportMessageEnabled = false;
_settingsService.Save();
if (await _dialogManager.ShowDialogAsync(dialog) == true)
ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter");
}
private async ValueTask CheckForUpdatesAsync()
{
try
{
var updateVersion = await _updateService.CheckForUpdatesAsync();
if (updateVersion is null)
return;
Notifications.Enqueue($"Downloading update to {App.Name} v{updateVersion}...");
await _updateService.PrepareUpdateAsync(updateVersion);
Notifications.Enqueue(
"Update has been downloaded and will be installed when you exit",
"INSTALL NOW",
() =>
{
_updateService.FinalizeUpdate(true);
RequestClose();
}
);
}
catch
{
// Failure to update shouldn't crash the application
Notifications.Enqueue("Failed to perform application update");
}
}
public async void OnViewFullyLoaded()
{
await ShowUkraineSupportMessageAsync();
await CheckForUpdatesAsync();
}
protected override void OnViewLoaded()
{
base.OnViewLoaded();
_settingsService.Load();
// Sync the theme with settings
if (_settingsService.IsDarkModeEnabled)
{
App.SetDarkTheme();
}
else
{
App.SetLightTheme();
}
// App has just been updated, display the changelog
if (
_settingsService.LastAppVersion is not null
&& _settingsService.LastAppVersion != App.Version
)
{
Notifications.Enqueue(
$"Successfully updated to {App.Name} v{App.VersionString}",
"WHAT'S NEW",
() => ProcessEx.StartShellExecute(App.LatestReleaseUrl)
);
_settingsService.LastAppVersion = App.Version;
_settingsService.Save();
}
}
protected override void OnClose()
{
base.OnClose();
_settingsService.Save();
_updateService.FinalizeUpdate(false);
}
public void Handle(NotificationMessage message) => Notifications.Enqueue(message.Text);
public void Dispose() => Notifications.Dispose();
}
@@ -0,0 +1,363 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.Components.DashboardView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
xmlns:controls="clr-namespace:DiscordChatExporter.Gui.Views.Controls"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
x:Name="UserControl"
Loaded="UserControl_OnLoaded">
<Design.DataContext>
<components:DashboardViewModel />
</Design.DataContext>
<DockPanel>
<!-- Header -->
<StackPanel
Background="{DynamicResource MaterialDarkBackgroundBrush}"
DockPanel.Dock="Top"
Orientation="Vertical">
<Grid Margin="12,12,8,12" ColumnDefinitions="*,Auto">
<materialStyles:Card Grid.Column="0">
<!-- Token -->
<TextBox
x:Name="TokenValueTextBox"
FontSize="16"
PasswordChar="*"
RevealPassword="{Binding $self.IsFocused}"
Text="{Binding Token}"
Theme="{DynamicResource SoloTextBox}"
Watermark="Token">
<TextBox.InnerLeftContent>
<materialIcons:MaterialIcon
Grid.Column="0"
Width="24"
Height="24"
Margin="4,0,8,0"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Key" />
</TextBox.InnerLeftContent>
<TextBox.InnerRightContent>
<Button
Grid.Column="2"
Margin="8,0,0,0"
Padding="4"
Command="{Binding PullGuildsCommand}"
IsDefault="True"
Theme="{DynamicResource MaterialFlatButton}"
ToolTip.Tip="Pull available servers and channels (Enter)">
<materialIcons:MaterialIcon
Width="24"
Height="24"
Kind="ArrowRight" />
</Button>
</TextBox.InnerRightContent>
</TextBox>
</materialStyles:Card>
<!-- Settings button -->
<Button
Grid.Column="1"
Margin="8,0,0,0"
Padding="8"
VerticalAlignment="Center"
Command="{Binding ShowSettingsCommand}"
Foreground="{DynamicResource MaterialDarkForegroundBrush}"
Theme="{DynamicResource MaterialFlatButton}"
ToolTip.Tip="Settings">
<materialIcons:MaterialIcon
Width="24"
Height="24"
Kind="Settings" />
</Button>
</Grid>
<!-- Progress -->
<ProgressBar
Height="2"
Background="Transparent"
IsIndeterminate="{Binding IsProgressIndeterminate}"
Value="{Binding Progress.Current.Fraction, Mode=OneWay}" />
</StackPanel>
<!-- Body -->
<Panel
Background="{DynamicResource MaterialCardBackgroundBrush}"
DockPanel.Dock="Bottom"
IsEnabled="{Binding !IsBusy}">
<Panel.Styles>
<Style Selector="Panel">
<Style Selector="^:disabled">
<Setter Property="Opacity" Value="0.5" />
</Style>
</Style>
</Panel.Styles>
<!-- Placeholder / usage instructions -->
<Panel IsVisible="{Binding !AvailableGuilds.Count}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock
Margin="32,16"
FontSize="14"
FontWeight="Light"
LineHeight="23">
<!-- User token -->
<InlineUIContainer>
<materialIcons:MaterialIcon
Width="18"
Height="18"
Margin="0,-2,0,0"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
</InlineUIContainer>
<Run Text="" />
<Run
FontSize="16"
FontWeight="SemiBold"
Text="To get the token for your personal account:" />
<LineBreak />
<Run Text="* Automating user accounts is technically against TOS —" />
<Run FontWeight="SemiBold" Text="use at your own risk" /><Run Text="!" />
<LineBreak />
<Run Text="1. Open Discord in your" />
<controls:HyperLink Command="{Binding OpenDiscordCommand}" Text="web browser" />
<Run Text="and login" />
<LineBreak />
<Run Text="2. Open any server or direct message channel" />
<LineBreak />
<Run Text="3. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run FontWeight="SemiBold" Text="Network" />
<Run Text="tab" />
<LineBreak />
<Run Text="5. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+R" />
<Run Text="to reload" />
<LineBreak />
<Run Text="6. Switch between random channels to trigger network requests" />
<LineBreak />
<Run Text="7. Search for a request that starts with" />
<Run FontWeight="SemiBold" Text="messages" />
<LineBreak />
<Run Text="8. Select the" />
<Run FontWeight="SemiBold" Text="Headers" />
<Run Text="tab on the right" />
<LineBreak />
<Run Text="9. Scroll down to the" />
<Run FontWeight="SemiBold" Text="Request Headers" />
<Run Text="section" />
<LineBreak />
<Run Text="10. Copy the value of the" />
<Run FontWeight="SemiBold" Text="authorization" />
<Run Text="header" />
<LineBreak />
<LineBreak />
<!-- Bot token -->
<InlineUIContainer>
<materialIcons:MaterialIcon
Width="18"
Height="18"
Margin="0,-2,0,0"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" />
</InlineUIContainer>
<Run Text="" />
<Run
FontSize="16"
FontWeight="SemiBold"
Text="To get the token for your bot:" />
<LineBreak />
<Run Text="1. Open Discord" />
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="developer portal" />
<LineBreak />
<Run Text="2. Open your application's settings" />
<LineBreak />
<Run Text="3. Navigate to the" />
<Run FontWeight="SemiBold" Text="Bot" />
<Run Text="section on the left" />
<LineBreak />
<Run Text="4. Under" />
<Run FontWeight="SemiBold" Text="Token" />
<Run Text="click" />
<Run FontWeight="SemiBold" Text="Copy" />
<LineBreak />
<Run Text="* Your bot needs to have the" />
<Run FontWeight="SemiBold" Text="Message Content Intent" />
<Run Text="enabled to read messages" />
<LineBreak />
<LineBreak />
<Run Text="If you have questions or issues, please refer to the" />
<controls:HyperLink Command="{Binding ShowHelpCommand}" Text="documentation" />
</TextBlock>
</ScrollViewer>
</Panel>
<!-- Guilds and channels -->
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding !!AvailableGuilds.Count}">
<!-- Guilds -->
<Border
Grid.Column="0"
BorderBrush="{DynamicResource MaterialDividerBrush}"
BorderThickness="0,0,1,0">
<ListBox
x:Name="AvailableGuildsListBox"
ItemsSource="{Binding AvailableGuilds}"
ScrollViewer.VerticalScrollBarVisibility="Hidden"
SelectedItem="{Binding SelectedGuild}"
SelectionChanged="AvailableGuildsListBox_OnSelectionChanged"
SelectionMode="Single">
<ListBox.Styles>
<Style Selector="ListBox">
<Style Selector="^ ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Cursor" Value="Hand" />
</Style>
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate>
<Panel Background="Transparent" ToolTip.Tip="{Binding Name}">
<!-- Guild icon placeholder -->
<Ellipse
Width="48"
Height="48"
Margin="12"
Fill="{DynamicResource MaterialDividerBrush}" />
<!-- Guild icon -->
<Ellipse
Width="48"
Height="48"
Margin="12">
<Ellipse.Fill>
<ImageBrush asyncImageLoader:ImageBrushLoader.Source="{Binding IconUrl}" />
</Ellipse.Fill>
</Ellipse>
</Panel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Channels -->
<Border Grid.Column="1">
<TreeView
x:Name="AvailableChannelsTreeView"
AutoScrollToSelectedItem="False"
ItemsSource="{Binding AvailableChannels}"
SelectedItems="{Binding SelectedChannels}"
SelectionChanged="AvailableChannelsTreeView_OnSelectionChanged"
SelectionMode="Multiple"
TextSearch.Text="Name">
<TreeView.Styles>
<Style Selector="TreeView">
<Style Selector="^ TreeViewItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Cursor" Value="Hand" />
</Style>
</Style>
</TreeView.Styles>
<TreeView.ItemTemplate>
<TreeDataTemplate ItemsSource="{Binding Children}">
<Grid
Background="Transparent"
Classes.category="{Binding Channel.IsCategory}"
ColumnDefinitions="Auto,*,Auto">
<Grid.Styles>
<Style Selector="Grid">
<Style Selector="^:not(.category)">
<Setter Property="ToolTip.Tip">
<Template>
<TextBlock>
<Run Text="Last message sent:" />
<Run FontWeight="SemiBold" Text="{Binding Channel.LastMessageId, Converter={x:Static converters:SnowflakeToTimestampStringConverter.Instance}, TargetNullValue=never, Mode=OneWay}" />
</TextBlock>
</Template>
</Setter>
</Style>
</Style>
</Grid.Styles>
<!-- Channel icon -->
<materialIcons:MaterialIcon
Grid.Column="0"
Margin="0,0,4,0"
Classes.voice="{Binding Channel.IsVoice}"
IsVisible="{Binding !Channel.IsCategory}">
<materialIcons:MaterialIcon.Styles>
<Style Selector="materialIcons|MaterialIcon">
<Setter Property="Kind" Value="Pound" />
<Style Selector="^.voice">
<Setter Property="Kind" Value="VolumeHigh" />
</Style>
</Style>
</materialIcons:MaterialIcon.Styles>
</materialIcons:MaterialIcon>
<!-- Channel name -->
<TextBlock
Grid.Column="1"
Margin="0,12"
FontSize="14"
Text="{Binding Channel.Name, Mode=OneWay}" />
<!-- Checkmark -->
<materialIcons:MaterialIcon
Grid.Column="2"
Width="24"
Height="24"
Margin="16,0"
IsVisible="{Binding $parent[TreeViewItem].IsSelected}"
Kind="Check" />
</Grid>
</TreeDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</Border>
</Grid>
<!-- Export button -->
<Button
Width="56"
Height="56"
Margin="32,24"
Padding="0"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Background="{DynamicResource MaterialSecondaryMidBrush}"
Command="{Binding ExportCommand}"
Foreground="{DynamicResource MaterialSecondaryMidForegroundBrush}"
IsVisible="{Binding $self.IsEffectivelyEnabled}"
Theme="{DynamicResource MaterialIconButton}">
<materialIcons:MaterialIcon
Width="32"
Height="32"
Kind="Download" />
</Button>
</Panel>
</DockPanel>
</UserControl>
@@ -0,0 +1,37 @@
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.ViewModels.Components;
namespace DiscordChatExporter.Gui.Views.Components;
public partial class DashboardView : UserControl<DashboardViewModel>
{
public DashboardView() => InitializeComponent();
private void UserControl_OnLoaded(object? sender, RoutedEventArgs args)
{
DataContext.InitializeCommand.Execute(null);
TokenValueTextBox.Focus();
}
private void AvailableGuildsListBox_OnSelectionChanged(
object? sender,
SelectionChangedEventArgs args
) => DataContext.PullChannelsCommand.Execute(null);
private void AvailableChannelsTreeView_OnSelectionChanged(
object? sender,
SelectionChangedEventArgs args
)
{
// Hack: unselect categories because they cannot be exported
foreach (var item in args.AddedItems.OfType<ChannelNode>().Where(x => x.Channel.IsCategory))
{
if (AvailableChannelsTreeView.TreeContainerFromItem(item) is TreeViewItem container)
container.IsSelected = false;
}
}
}
@@ -1,440 +0,0 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.Components.DashboardView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:b="http://schemas.microsoft.com/xaml/behaviors"
xmlns:behaviors="clr-namespace:DiscordChatExporter.Gui.Behaviors"
xmlns:componentModel="clr-namespace:System.ComponentModel;assembly=WindowsBase"
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
xmlns:controls="clr-namespace:DiscordChatExporter.Gui.Views.Controls"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:data="clr-namespace:DiscordChatExporter.Core.Discord.Data;assembly=DiscordChatExporter.Core"
xmlns:globalization="clr-namespace:System.Globalization;assembly=System.Runtime"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
d:DataContext="{d:DesignInstance Type=components:DashboardViewModel}"
FocusManager.FocusedElement="{Binding ElementName=TokenValueTextBox}"
Loaded="{s:Action OnViewLoaded}"
mc:Ignorable="d">
<UserControl.Resources>
<!-- Collection view for DM channels -->
<CollectionViewSource x:Key="AvailableDirectChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
<CollectionViewSource.SortDescriptions>
<componentModel:SortDescription Direction="Descending" PropertyName="LastMessageId" />
<componentModel:SortDescription Direction="Ascending" PropertyName="Name" />
</CollectionViewSource.SortDescriptions>
</CollectionViewSource>
<!-- Collection view for guild channels -->
<CollectionViewSource x:Key="AvailableChannelsViewSource" Source="{Binding AvailableChannels, Mode=OneWay}">
<CollectionViewSource.GroupDescriptions>
<PropertyGroupDescription Converter="{x:Static converters:ChannelToGroupKeyConverter.Instance}" />
</CollectionViewSource.GroupDescriptions>
<CollectionViewSource.SortDescriptions>
<componentModel:SortDescription Direction="Ascending" PropertyName="IsThread" />
<componentModel:SortDescription Direction="Ascending" PropertyName="Position" />
<componentModel:SortDescription Direction="Ascending" PropertyName="Name" />
</CollectionViewSource.SortDescriptions>
</CollectionViewSource>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Toolbar -->
<Grid Grid.Row="0" Background="{DynamicResource MaterialDesignDarkBackground}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<materialDesign:Card
Grid.Row="0"
Grid.Column="0"
Margin="12,12,0,12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Token icon -->
<materialDesign:PackIcon
Grid.Column="0"
Width="24"
Height="24"
Margin="8"
VerticalAlignment="Center"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Key" />
<!-- Token -->
<controls:RevealablePasswordBox
x:Name="TokenValueTextBox"
Grid.Column="1"
Margin="0,6,6,8"
VerticalAlignment="Bottom"
materialDesign:HintAssist.Hint="Token"
BorderThickness="0"
FontFamily="Consolas"
FontSize="16"
Password="{Binding Token, UpdateSourceTrigger=PropertyChanged}">
<controls:RevealablePasswordBox.Style>
<Style TargetType="{x:Type controls:RevealablePasswordBox}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsMouseOver, RelativeSource={RelativeSource AncestorType=materialDesign:Card}}" Value="True">
<Setter Property="IsRevealed" Value="True" />
</DataTrigger>
<DataTrigger Binding="{Binding IsKeyboardFocusWithin, RelativeSource={RelativeSource AncestorType=materialDesign:Card}}" Value="True">
<Setter Property="IsRevealed" Value="True" />
</DataTrigger>
</Style.Triggers>
</Style>
</controls:RevealablePasswordBox.Style>
</controls:RevealablePasswordBox>
<!-- Pull guilds button -->
<Button
Grid.Column="2"
Margin="0,6,6,6"
Padding="4"
Command="{s:Action PullGuilds}"
IsDefault="True"
Style="{DynamicResource MaterialDesignFlatButton}"
ToolTip="Pull available guilds and channels (Enter)">
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="ArrowRight" />
</Button>
</Grid>
</materialDesign:Card>
<!-- Settings button -->
<Button
Grid.Column="1"
Margin="6"
Padding="4"
Command="{s:Action ShowSettings}"
Foreground="{DynamicResource MaterialDesignDarkForeground}"
Style="{DynamicResource MaterialDesignFlatButton}"
ToolTip="Settings">
<Button.Resources>
<SolidColorBrush x:Key="MaterialDesignFlatButtonClick" Color="#4C4C4C" />
</Button.Resources>
<materialDesign:PackIcon
Width="24"
Height="24"
Kind="Settings" />
</Button>
</Grid>
<!-- Progress bar -->
<ProgressBar
Grid.Row="1"
Background="{DynamicResource MaterialDesignDarkBackground}"
IsIndeterminate="{Binding IsProgressIndeterminate}"
Value="{Binding Progress.Current.Fraction, Mode=OneWay}" />
<!-- Content -->
<Grid
Grid.Row="2"
Background="{DynamicResource MaterialDesignCardBackground}"
IsEnabled="{Binding IsBusy, Converter={x:Static converters:InverseBoolConverter.Instance}}">
<!-- Placeholder / usage instructions -->
<Grid Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock
Margin="32,16"
FontSize="14"
FontWeight="Light"
LineHeight="20">
<!-- User token -->
<InlineUIContainer>
<materialDesign:PackIcon
Margin="0,0,2,-2"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
</InlineUIContainer>
<Run
FontSize="16"
FontWeight="SemiBold"
Text="To get the token for your personal account:" />
<LineBreak />
<Run Text="* Automating user accounts is technically against TOS —" />
<Run FontWeight="SemiBold" Text="use at your own risk" /><Run Text="!" />
<LineBreak />
<Run Text="1. Open Discord in your" />
<Hyperlink Command="{s:Action OpenDiscord}">
<Run Text="web browser" />
</Hyperlink>
<Run Text="and login" />
<LineBreak />
<Run Text="2. Open any server or direct message channel" />
<LineBreak />
<Run Text="3. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+Shift+I" />
<Run Text="to show developer tools" />
<LineBreak />
<Run Text="4. Navigate to the" />
<Run FontWeight="SemiBold" Text="Network" />
<Run Text="tab" />
<LineBreak />
<Run Text="5. Press" />
<Run FontWeight="SemiBold" Text="Ctrl+R" />
<Run Text="to reload" />
<LineBreak />
<Run Text="6. Switch between random channels to trigger network requests" />
<LineBreak />
<Run Text="7. Search for a request that starts with" />
<Run FontWeight="SemiBold" Text="messages" />
<LineBreak />
<Run Text="8. Select the" />
<Run FontWeight="SemiBold" Text="Headers" />
<Run Text="tab on the right" />
<LineBreak />
<Run Text="9. Scroll down to the" />
<Run FontWeight="SemiBold" Text="Request Headers" />
<Run Text="section" />
<LineBreak />
<Run Text="10. Copy the value of the" />
<Run FontWeight="SemiBold" Text="authorization" />
<Run Text="header" />
<LineBreak />
<LineBreak />
<!-- Bot token -->
<InlineUIContainer>
<materialDesign:PackIcon
Margin="0,0,2,-2"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" />
</InlineUIContainer>
<Run
FontSize="16"
FontWeight="SemiBold"
Text="To get the token for your bot:" />
<LineBreak />
<Run Text="1. Open Discord" />
<Hyperlink Command="{s:Action OpenDiscordDeveloperPortal}">
<Run Text="developer portal" />
</Hyperlink>
<LineBreak />
<Run Text="2. Open your application's settings" />
<LineBreak />
<Run Text="3. Navigate to the" />
<Run FontWeight="SemiBold" Text="Bot" />
<Run Text="section on the left" />
<LineBreak />
<Run Text="4. Under" />
<Run FontWeight="SemiBold" Text="Token" />
<Run Text="click" />
<Run FontWeight="SemiBold" Text="Copy" />
<LineBreak />
<Run Text="* Your bot needs to have the" />
<Run FontWeight="SemiBold" Text="Message Content Intent" />
<Run Text="enabled to read messages" />
<LineBreak />
<LineBreak />
<Run Text="If you have questions or issues, please refer to the" />
<Hyperlink Command="{s:Action ShowHelp}">documentation</Hyperlink>
</TextBlock>
</ScrollViewer>
</Grid>
<!-- Guilds and channels -->
<Grid Background="{DynamicResource MaterialDesignCardBackground}" Visibility="{Binding AvailableGuilds, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Guilds -->
<Border
Grid.Column="0"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,0,1,0">
<ListBox
ItemsSource="{Binding AvailableGuilds}"
ScrollViewer.VerticalScrollBarVisibility="Hidden"
SelectedItem="{Binding SelectedGuild}"
SelectionMode="Single">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid
Margin="-8"
Background="Transparent"
Cursor="Hand"
MouseLeftButtonUp="{s:Action PullChannels}"
ToolTip="{Binding Name}">
<!-- Guild icon placeholder -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4"
Fill="{DynamicResource MaterialDesignDivider}" />
<!-- Guild icon -->
<Ellipse
Width="48"
Height="48"
Margin="12,4,12,4"
Stroke="{DynamicResource MaterialDesignDivider}"
StrokeThickness="1">
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding IconUrl}" />
</Ellipse.Fill>
</Ellipse>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Channels -->
<Border Grid.Column="1">
<ListBox
HorizontalContentAlignment="Stretch"
SelectionMode="Extended"
TextSearch.TextPath="Name"
VirtualizingPanel.IsVirtualizingWhenGrouping="True">
<b:Interaction.Behaviors>
<behaviors:ChannelMultiSelectionListBoxBehavior SelectedItems="{Binding SelectedChannels}" />
</b:Interaction.Behaviors>
<ListBox.Style>
<Style BasedOn="{StaticResource {x:Type ListBox}}" TargetType="{x:Type ListBox}">
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedGuild.IsDirect}" Value="True">
<Setter Property="ItemsSource" Value="{Binding Source={StaticResource AvailableDirectChannelsViewSource}}" />
</DataTrigger>
<DataTrigger Binding="{Binding SelectedGuild.IsDirect}" Value="False">
<Setter Property="ItemsSource" Value="{Binding Source={StaticResource AvailableChannelsViewSource}}" />
</DataTrigger>
</Style.Triggers>
</Style>
</ListBox.Style>
<ListBox.GroupStyle>
<GroupStyle>
<GroupStyle.ContainerStyle>
<Style TargetType="{x:Type GroupItem}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate d:DataContext="{x:Type CollectionViewGroup}">
<Expander
Margin="0"
Padding="0"
Background="Transparent"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,0,0,1"
Header="{Binding Name}"
IsExpanded="False">
<ItemsPresenter />
</Expander>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</GroupStyle.ContainerStyle>
</GroupStyle>
</ListBox.GroupStyle>
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type data:Channel}">
<Grid Margin="-8" Background="Transparent">
<Grid.InputBindings>
<MouseBinding Command="{s:Action Export}" MouseAction="LeftDoubleClick" />
</Grid.InputBindings>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Grid.ToolTip>
<TextBlock>
<Run Text="Last message:" />
<Run FontWeight="SemiBold" Text="{Binding LastMessageId, Converter={x:Static converters:SnowflakeToDateTimeOffsetConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}, TargetNullValue=never}" />
</TextBlock>
</Grid.ToolTip>
<!-- Channel icon -->
<materialDesign:PackIcon
Grid.Column="0"
Margin="16,7,0,6"
VerticalAlignment="Center">
<materialDesign:PackIcon.Style>
<Style TargetType="{x:Type materialDesign:PackIcon}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsVoice}" Value="True">
<Setter Property="Kind" Value="VolumeHigh" />
</DataTrigger>
<DataTrigger Binding="{Binding IsVoice}" Value="False">
<Setter Property="Kind" Value="Pound" />
</DataTrigger>
</Style.Triggers>
</Style>
</materialDesign:PackIcon.Style>
</materialDesign:PackIcon>
<!-- Channel name -->
<TextBlock
Grid.Column="1"
Margin="3,8,8,8"
VerticalAlignment="Center"
FontSize="14"
Text="{Binding Name, Mode=OneWay}" />
<!-- Is selected checkmark -->
<materialDesign:PackIcon
Grid.Column="2"
Width="24"
Height="24"
Margin="8,0"
VerticalAlignment="Center"
Kind="Check"
Visibility="{Binding IsSelected, RelativeSource={RelativeSource AncestorType={x:Type ListBoxItem}}, Converter={x:Static s:BoolToVisibilityConverter.Instance}, Mode=OneWay}" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
</Grid>
<!-- Export button -->
<Button
Margin="32,24"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Command="{s:Action Export}"
Style="{DynamicResource MaterialDesignFloatingActionAccentButton}"
Visibility="{Binding CanExport, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<materialDesign:PackIcon
Width="32"
Height="32"
Kind="Download" />
</Button>
</Grid>
</Grid>
</UserControl>
@@ -1,9 +0,0 @@
namespace DiscordChatExporter.Gui.Views.Components;
public partial class DashboardView
{
public DashboardView()
{
InitializeComponent();
}
}
@@ -0,0 +1,19 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.Controls.HyperLink"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<TextBlock
x:Name="TextBlock"
Cursor="Hand"
Foreground="{DynamicResource MaterialSecondaryDarkBrush}"
PointerReleased="TextBlock_OnPointerReleased"
Text="{Binding $parent[UserControl].Text, Mode=OneWay}">
<TextBlock.Styles>
<Style Selector="TextBlock">
<Style Selector="^:pointerover">
<Setter Property="TextDecorations" Value="Underline" />
</Style>
</Style>
</TextBlock.Styles>
</TextBlock>
</UserControl>
@@ -0,0 +1,49 @@
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
namespace DiscordChatExporter.Gui.Views.Controls;
public partial class HyperLink : UserControl
{
public static readonly StyledProperty<string?> TextProperty =
TextBlock.TextProperty.AddOwner<HyperLink>();
public static readonly StyledProperty<ICommand?> CommandProperty =
Button.CommandProperty.AddOwner<HyperLink>();
public static readonly StyledProperty<object?> CommandParameterProperty =
Button.CommandParameterProperty.AddOwner<HyperLink>();
public HyperLink() => InitializeComponent();
public string? Text
{
get => GetValue(TextProperty);
set => SetValue(TextProperty, value);
}
public ICommand? Command
{
get => GetValue(CommandProperty);
set => SetValue(CommandProperty, value);
}
public object? CommandParameter
{
get => GetValue(CommandParameterProperty);
set => SetValue(CommandParameterProperty, value);
}
private void TextBlock_OnPointerReleased(object? sender, PointerReleasedEventArgs args)
{
if (Command is null)
return;
if (!Command.CanExecute(CommandParameter))
return;
Command.Execute(CommandParameter);
}
}
@@ -1,24 +0,0 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.Controls.RevealablePasswordBox"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
x:Name="Root"
mc:Ignorable="d">
<Grid>
<TextBox
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
BorderThickness="{Binding BorderThickness, ElementName=Root}"
Text="{Binding Password, ElementName=Root, UpdateSourceTrigger=PropertyChanged}"
Visibility="{Binding IsRevealed, ElementName=Root, Converter={x:Static s:BoolToVisibilityConverter.Instance}}" />
<PasswordBox
materialDesign:PasswordBoxAssist.Password="{Binding Password, ElementName=Root, UpdateSourceTrigger=PropertyChanged}"
materialDesign:TextFieldAssist.DecorationVisibility="Hidden"
BorderThickness="{Binding BorderThickness, ElementName=Root}"
IsEnabled="False"
Visibility="{Binding IsRevealed, ElementName=Root, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}" />
</Grid>
</UserControl>
@@ -1,40 +0,0 @@
using System.Windows;
namespace DiscordChatExporter.Gui.Views.Controls;
public partial class RevealablePasswordBox
{
public static readonly DependencyProperty PasswordProperty = DependencyProperty.Register(
nameof(Password),
typeof(string),
typeof(RevealablePasswordBox),
new FrameworkPropertyMetadata(
string.Empty,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault
)
);
public static readonly DependencyProperty IsRevealedProperty = DependencyProperty.Register(
nameof(IsRevealed),
typeof(bool),
typeof(RevealablePasswordBox),
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.None)
);
public string Password
{
get => (string)GetValue(PasswordProperty);
set => SetValue(PasswordProperty, value);
}
public bool IsRevealed
{
get => (bool)GetValue(IsRevealedProperty);
set => SetValue(IsRevealedProperty, value);
}
public RevealablePasswordBox()
{
InitializeComponent();
}
}
@@ -0,0 +1,343 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.Dialogs.ExportSetupView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles"
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
x:Name="UserControl"
Width="380"
Loaded="UserControl_OnLoaded">
<Design.DataContext>
<dialogs:ExportSetupViewModel />
</Design.DataContext>
<Grid RowDefinitions="Auto,*,Auto">
<!-- Guild/channel info -->
<Grid
Grid.Row="0"
Margin="16"
ColumnDefinitions="Auto,*">
<!-- Guild icon -->
<Ellipse
Grid.Column="0"
Width="32"
Height="32">
<Ellipse.Fill>
<ImageBrush asyncImageLoader:ImageBrushLoader.Source="{Binding Guild.IconUrl}" />
</Ellipse.Fill>
</Ellipse>
<!-- Channel count (for multiple channels) -->
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="19"
FontWeight="Light"
IsVisible="{Binding !IsSingleChannel}"
TextTrimming="CharacterEllipsis">
<Run Text="{Binding Channels.Count, Mode=OneWay}" />
<Run Text="channels selected" />
</TextBlock>
<!-- Category and channel name (for single channel) -->
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="19"
FontWeight="Light"
IsVisible="{Binding IsSingleChannel}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Channels[0], Converter={x:Static converters:ChannelToHierarchicalNameStringConverter.Instance}}">
<TextBlock IsVisible="{Binding !!Channels[0].Parent}">
<Run Text="{Binding Channels[0].Parent.Name, Mode=OneWay}" />
<Run Text="/" />
</TextBlock>
<Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
</TextBlock>
</Grid>
<Border
Grid.Row="1"
Padding="0,8"
BorderBrush="{DynamicResource MaterialDividerBrush}"
BorderThickness="0,1">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Orientation="Vertical">
<!-- Output path -->
<TextBox
Margin="16,8"
materialAssists:TextFieldAssist.Label="Output path"
Text="{Binding OutputPath}"
Theme="{DynamicResource FilledTextBox}">
<ToolTip.Tip>
<TextBlock>
<Run Text="Output file or directory path." />
<LineBreak />
<Run Text="If a directory is specified, file names will be generated automatically based on the channel names and export parameters." />
<LineBreak />
<Run Text="Directory paths must end with a slash to avoid ambiguity." />
<LineBreak />
<LineBreak />
<Run Text="Available template tokens:" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%g" />
<Run Text="— server ID" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%G" />
<Run Text="— server name" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%t" />
<Run Text="— category ID" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%T" />
<Run Text="— category name" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%c" />
<Run Text="— channel ID" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%C" />
<Run Text="— channel name" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%p" />
<Run Text="— channel position" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%P" />
<Run Text="— category position" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%a" />
<Run Text="— after date" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%b" />
<Run Text="— before date" />
<LineBreak />
<Run Text=" " />
<Run FontWeight="SemiBold" Text="%d" />
<Run Text="— current date" />
</TextBlock>
</ToolTip.Tip>
<TextBox.InnerRightContent>
<Button
Margin="8,8,8,6"
Padding="8"
VerticalAlignment="Center"
Command="{Binding ShowOutputPathPromptCommand}"
Theme="{DynamicResource MaterialFlatButton}">
<materialIcons:MaterialIcon
Width="20"
Height="20"
Kind="FolderOpen" />
</Button>
</TextBox.InnerRightContent>
</TextBox>
<!-- Format -->
<ComboBox
Margin="16,8"
materialAssists:ComboBoxAssist.Label="Format"
ItemsSource="{Binding AvailableFormats}"
SelectedItem="{Binding SelectedFormat}"
Theme="{DynamicResource MaterialFilledComboBox}"
ToolTip.Tip="Export format">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- Advanced section -->
<StackPanel IsVisible="{Binding IsAdvancedSectionDisplayed}" Orientation="Vertical">
<!-- Date limits -->
<Grid ColumnDefinitions="*,*" RowDefinitions="*,*">
<DatePicker
Grid.Row="0"
Grid.Column="0"
Margin="16,8,8,8"
materialAssists:TextFieldAssist.Label="After (date)"
SelectedDate="{Binding AfterDate}"
ToolTip.Tip="Only include messages sent after this date">
<DatePicker.Styles>
<Style Selector="DatePicker">
<Style Selector="^ /template/ TextBox#DisplayTextBox">
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
</Style>
</Style>
</DatePicker.Styles>
</DatePicker>
<DatePicker
Grid.Row="0"
Grid.Column="1"
Margin="8,8,16,8"
materialAssists:TextFieldAssist.Label="Before (date)"
SelectedDate="{Binding BeforeDate}"
ToolTip.Tip="Only include messages sent before this date">
<DatePicker.Styles>
<Style Selector="DatePicker">
<Style Selector="^ /template/ TextBox#DisplayTextBox">
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
</Style>
</Style>
</DatePicker.Styles>
</DatePicker>
<!-- Time limits -->
<TimePicker
Grid.Row="1"
Grid.Column="0"
Margin="16,8,8,8"
materialAssists:TextFieldAssist.Label="After (time)"
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
IsEnabled="{Binding IsAfterDateSet}"
SelectedTime="{Binding AfterTime}"
ToolTip.Tip="Only include messages sent after this time">
<TimePicker.Styles>
<Style Selector="TimePicker">
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
</Style>
</Style>
</TimePicker.Styles>
</TimePicker>
<TimePicker
Grid.Row="1"
Grid.Column="1"
Margin="8,8,16,8"
materialAssists:TextFieldAssist.Label="Before (time)"
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
IsEnabled="{Binding IsBeforeDateSet}"
SelectedTime="{Binding BeforeTime}"
ToolTip.Tip="Only include messages sent before this time">
<TimePicker.Styles>
<Style Selector="TimePicker">
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
<Setter Property="Theme" Value="{DynamicResource FilledTextBox}" />
</Style>
</Style>
</TimePicker.Styles>
</TimePicker>
</Grid>
<!-- Partitioning -->
<TextBox
Margin="16,8"
materialAssists:TextFieldAssist.Label="Partition limit"
Text="{Binding PartitionLimitValue}"
Theme="{DynamicResource FilledTextBox}"
ToolTip.Tip="Split the output into partitions, each limited to the specified number of messages (e.g. '100') or file size (e.g. '10mb')" />
<!-- Filtering -->
<TextBox
Margin="16,8"
materialAssists:TextFieldAssist.Label="Message filter"
Text="{Binding MessageFilterValue}"
Theme="{DynamicResource FilledTextBox}"
ToolTip.Tip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image'). See the documentation for more info." />
<!-- Markdown formatting -->
<DockPanel
Margin="16,8"
LastChildFill="False"
ToolTip.Tip="Process markdown, mentions, and other special tokens">
<TextBlock DockPanel.Dock="Left" Text="Format markdown" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldFormatMarkdown}" />
</DockPanel>
<!-- Download assets -->
<DockPanel
Margin="16,8"
LastChildFill="False"
ToolTip.Tip="Download assets referenced by the export (user avatars, attached files, embedded images, etc.)">
<TextBlock DockPanel.Dock="Left" Text="Download assets" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldDownloadAssets}" />
</DockPanel>
<!-- Reuse assets -->
<DockPanel
Margin="16,8"
IsEnabled="{Binding ShouldDownloadAssets}"
LastChildFill="False"
ToolTip.Tip="Reuse previously downloaded assets to avoid redundant requests">
<TextBlock DockPanel.Dock="Left" Text="Reuse assets" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldReuseAssets}" />
</DockPanel>
<!-- Assets path -->
<TextBox
Margin="16,8"
materialAssists:TextFieldAssist.Label="Assets directory path"
IsEnabled="{Binding ShouldDownloadAssets}"
Text="{Binding AssetsDirPath}"
Theme="{DynamicResource FilledTextBox}"
ToolTip.Tip="Download assets to this directory. If not specified, the asset directory path will be derived from the output path.">
<TextBox.InnerRightContent>
<Button
Margin="8,8,8,6"
Padding="8"
VerticalAlignment="Center"
Command="{Binding ShowAssetsDirPathPromptCommand}"
Theme="{DynamicResource MaterialFlatButton}">
<materialIcons:MaterialIcon
Width="20"
Height="20"
Kind="FolderOpen" />
</Button>
</TextBox.InnerRightContent>
</TextBox>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
<!-- Buttons -->
<Grid
Grid.Row="2"
Margin="16"
ColumnDefinitions="Auto,*,Auto,Auto">
<ToggleButton
Grid.Column="0"
IsChecked="{Binding IsAdvancedSectionDisplayed}"
Theme="{DynamicResource MaterialOutlineButton}"
ToolTip.Tip="Toggle advanced options">
<Button.Styles>
<Style Selector="ToggleButton">
<Setter Property="Content" Value="MORE" />
<Style Selector="^:checked">
<Setter Property="Content" Value="LESS" />
</Style>
</Style>
</Button.Styles>
</ToggleButton>
<Button
Grid.Column="2"
Command="{Binding ConfirmCommand}"
Content="EXPORT"
IsDefault="True"
Theme="{DynamicResource MaterialOutlineButton}" />
<Button
Grid.Column="3"
Margin="16,0,0,0"
Command="{Binding CloseCommand}"
Content="CANCEL"
IsCancel="True"
Theme="{DynamicResource MaterialOutlineButton}" />
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,13 @@
using Avalonia.Interactivity;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
namespace DiscordChatExporter.Gui.Views.Dialogs;
public partial class ExportSetupView : UserControl<ExportSetupViewModel>
{
public ExportSetupView() => InitializeComponent();
private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) =>
DataContext.InitializeCommand.Execute(null);
}
@@ -1,365 +0,0 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.Dialogs.ExportSetupView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:globalization="clr-namespace:System.Globalization;assembly=System.Runtime"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:s="https://github.com/canton7/Stylet"
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
Width="380"
d:DataContext="{d:DesignInstance Type=dialogs:ExportSetupViewModel}"
Style="{DynamicResource MaterialDesignRoot}"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- Guild/channel info -->
<Grid Grid.Row="0" Margin="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Guild icon -->
<Ellipse
Grid.Column="0"
Width="32"
Height="32">
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding Guild.IconUrl}" />
</Ellipse.Fill>
</Ellipse>
<!-- Channel count (for multiple channels) -->
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="19"
FontWeight="Light"
TextTrimming="CharacterEllipsis"
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.InverseInstance}}">
<Run Text="{Binding Channels.Count, Mode=OneWay}" />
<Run Text="channels selected" />
</TextBlock>
<!-- Category and channel name (for single channel) -->
<TextBlock
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="19"
FontWeight="Light"
TextTrimming="CharacterEllipsis"
Visibility="{Binding IsSingleChannel, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<TextBlock Visibility="{Binding Channels[0].Parent, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<Run Text="{Binding Channels[0].Parent.Name, Mode=OneWay}" ToolTip="{Binding Channels[0].Parent.Name, Mode=OneWay}" />
<Run Text="/" />
</TextBlock>
<Run
FontWeight="SemiBold"
Text="{Binding Channels[0].Name, Mode=OneWay}"
ToolTip="{Binding Channels[0].Name, Mode=OneWay}" />
</TextBlock>
</Grid>
<Border
Grid.Row="1"
Padding="0,8"
BorderBrush="{DynamicResource MaterialDesignDivider}"
BorderThickness="0,1">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel>
<!-- Output path -->
<Grid Margin="16,8">
<TextBox
Padding="16,16,42,16"
materialDesign:HintAssist.Hint="Output path"
materialDesign:HintAssist.IsFloating="True"
Style="{DynamicResource MaterialDesignOutlinedTextBox}"
Text="{Binding OutputPath}">
<TextBox.ToolTip>
<TextBlock>
<Run Text="Output file or directory path." />
<Run Text="If a directory is specified, file names will be generated automatically based on the channel names and export parameters." />
<Run Text="Directory paths must end with a slash to avoid ambiguity." />
<Run Text="Supports template tokens, see the documentation for more info." />
<LineBreak />
<LineBreak />
<Run Text="Available template tokens:" />
<LineBreak />
<Run FontWeight="SemiBold" Text="%g" />
<Run Text="— server ID" />
<LineBreak />
<Run FontWeight="SemiBold" Text="%G" />
<Run Text="— server name" />
<LineBreak />
<Run FontWeight="SemiBold" Text="%t" />
<Run Text="— category ID" />
<LineBreak />
<Run FontWeight="SemiBold" Text="%T" />
<Run Text="— category name" />
<LineBreak />
<Run FontWeight="SemiBold" Text="%c" />
<Run Text="— channel ID" />
<LineBreak />
<Run FontWeight="SemiBold" Text="%C" />
<Run Text="— channel name" />
<LineBreak />
<Run FontWeight="SemiBold" Text="%p" />
<Run Text="— channel position" />
<LineBreak />
<Run FontWeight="SemiBold" Text="%P" />
<Run Text="— category position" />
<LineBreak />
<Run FontWeight="SemiBold" Text="%a" />
<Run Text="— after date" />
<LineBreak />
<Run FontWeight="SemiBold" Text="%b" />
<Run Text="— before date" />
<LineBreak />
<Run FontWeight="SemiBold" Text="%d" />
<Run Text="— current date" />
</TextBlock>
</TextBox.ToolTip>
</TextBox>
<Button
Width="24"
Height="24"
Margin="0,0,12,0"
Padding="0"
HorizontalAlignment="Right"
Command="{s:Action ShowOutputPathPrompt}"
Style="{DynamicResource MaterialDesignToolForegroundButton}">
<materialDesign:PackIcon Kind="FolderOpen" />
</Button>
</Grid>
<!-- Format -->
<ComboBox
Margin="16,8"
materialDesign:HintAssist.Hint="Format"
materialDesign:HintAssist.IsFloating="True"
IsReadOnly="True"
ItemsSource="{Binding AvailableFormats}"
SelectedItem="{Binding SelectedFormat}"
Style="{DynamicResource MaterialDesignOutlinedComboBox}"
ToolTip="Export format">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- Advanced section -->
<StackPanel Visibility="{Binding IsAdvancedSectionDisplayed, Converter={x:Static s:BoolToVisibilityConverter.Instance}}">
<!-- Date limits -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<DatePicker
Grid.Row="0"
Grid.Column="0"
Margin="16,8,16,4"
materialDesign:HintAssist.Hint="After (date)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateEnd="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}"
SelectedDate="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}"
Style="{DynamicResource MaterialDesignOutlinedDatePicker}"
ToolTip="Only include messages sent after this date" />
<DatePicker
Grid.Row="0"
Grid.Column="1"
Margin="16,8,16,4"
materialDesign:HintAssist.Hint="Before (date)"
materialDesign:HintAssist.IsFloating="True"
DisplayDateStart="{Binding AfterDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}"
SelectedDate="{Binding BeforeDate, Converter={x:Static converters:DateTimeOffsetToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}"
Style="{DynamicResource MaterialDesignOutlinedDatePicker}"
ToolTip="Only include messages sent before this date" />
<materialDesign:TimePicker
Grid.Row="1"
Grid.Column="0"
Margin="16,4,16,8"
materialDesign:HintAssist.Hint="After (time)"
materialDesign:HintAssist.IsFloating="True"
Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
IsEnabled="{Binding IsAfterDateSet}"
SelectedTime="{Binding AfterTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}"
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
ToolTip="Only include messages sent after this time" />
<materialDesign:TimePicker
Grid.Row="1"
Grid.Column="1"
Margin="16,4,16,8"
materialDesign:HintAssist.Hint="Before (time)"
materialDesign:HintAssist.IsFloating="True"
Is24Hours="{x:Static utils:Internationalization.Is24Hours}"
IsEnabled="{Binding IsBeforeDateSet}"
SelectedTime="{Binding BeforeTime, Converter={x:Static converters:TimeSpanToDateTimeConverter.Instance}, ConverterCulture={x:Static globalization:CultureInfo.CurrentCulture}}"
Style="{DynamicResource MaterialDesignOutlinedTimePicker}"
ToolTip="Only include messages sent before this time" />
</Grid>
<!-- Partitioning -->
<TextBox
Margin="16,8"
materialDesign:HintAssist.Hint="Partition limit"
materialDesign:HintAssist.IsFloating="True"
Style="{DynamicResource MaterialDesignOutlinedTextBox}"
Text="{Binding PartitionLimitValue}"
ToolTip="Split the output into partitions, each limited to the specified number of messages (e.g. '100') or file size (e.g. '10mb')" />
<!-- Filtering -->
<TextBox
Margin="16,8"
materialDesign:HintAssist.Hint="Message filter"
materialDesign:HintAssist.IsFloating="True"
Style="{DynamicResource MaterialDesignOutlinedTextBox}"
Text="{Binding MessageFilterValue}"
ToolTip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image'). See the documentation for more info." />
<!-- Markdown formatting -->
<Grid Margin="16,8" ToolTip="Process markdown, mentions, and other special tokens">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
Text="Format markdown" />
<ToggleButton
Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center"
IsChecked="{Binding ShouldFormatMarkdown}" />
</Grid>
<!-- Download assets -->
<Grid Margin="16,8" ToolTip="Download assets referenced by the export (user avatars, attached files, embedded images, etc.)">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
Text="Download assets" />
<ToggleButton
Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center"
IsChecked="{Binding ShouldDownloadAssets}" />
</Grid>
<!-- Reuse assets -->
<Grid
Margin="16,8"
IsEnabled="{Binding ShouldDownloadAssets}"
ToolTip="Reuse previously downloaded assets to avoid redundant requests">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
Text="Reuse assets" />
<ToggleButton
Grid.Column="1"
HorizontalAlignment="Right"
VerticalAlignment="Center"
IsChecked="{Binding ShouldReuseAssets}" />
</Grid>
<!-- Assets path -->
<Grid
Margin="16,8"
IsEnabled="{Binding ShouldDownloadAssets}"
ToolTip="Download assets to this directory. If not specified, the asset directory path will be derived from the output path.">
<TextBox
Padding="16,16,42,16"
materialDesign:HintAssist.Hint="Assets directory path"
materialDesign:HintAssist.IsFloating="True"
Style="{DynamicResource MaterialDesignOutlinedTextBox}"
Text="{Binding AssetsDirPath}" />
<Button
Width="24"
Height="24"
Margin="0,0,12,0"
Padding="0"
HorizontalAlignment="Right"
Command="{s:Action ShowAssetsDirPathPrompt}"
Style="{DynamicResource MaterialDesignToolForegroundButton}">
<materialDesign:PackIcon Kind="FolderOpen" />
</Button>
</Grid>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
<!-- Buttons -->
<Grid Grid.Row="2" Margin="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button
Grid.Column="0"
Command="{s:Action ToggleAdvancedSection}"
IsDefault="True"
ToolTip="Toggle advanced options">
<Button.Style>
<Style BasedOn="{StaticResource MaterialDesignOutlinedButton}" TargetType="{x:Type Button}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsAdvancedSectionDisplayed}" Value="False">
<Setter Property="Content" Value="MORE" />
</DataTrigger>
<DataTrigger Binding="{Binding IsAdvancedSectionDisplayed}" Value="True">
<Setter Property="Content" Value="LESS" />
</DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button
Grid.Column="2"
Command="{s:Action Confirm}"
Content="EXPORT"
IsDefault="True"
Style="{DynamicResource MaterialDesignOutlinedButton}" />
<Button
Grid.Column="3"
Margin="8,0,0,0"
Command="{s:Action Close}"
Content="CANCEL"
IsCancel="True"
Style="{DynamicResource MaterialDesignOutlinedButton}" />
</Grid>
</Grid>
</UserControl>

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