Compare commits

..

27 Commits

Author SHA1 Message Date
Tyrrrz dc35f25293 Suppress warnings more intelligently 2024-05-23 01:57:19 +03:00
Tyrrrz 0e3969ca2d Use compile-time serialization for settings 2024-05-22 21:41:22 +03:00
Oleksii Holub 91b7486f45 Publish trimmed builds (#1235) 2024-05-21 22:02:46 +03:00
Oleksii Holub b7548756a8 Use self-contained builds (#1224) 2024-05-16 20:42:15 +03:00
Tyrrrz 8f9c0f8660 Clean up 2024-05-15 23:16:14 +03:00
Tyrrrz b12be796c7 Simplify 2024-05-14 00:05:46 +03:00
Oleksii Holub 7a69c87b56 Use a 3-way theme switcher instead of a 2-way switcher (#1233) 2024-05-13 23:56:21 +03:00
Tyrrrz 9e7ad4d85c Fix naming inconsistency 2024-05-13 22:12:43 +03:00
Tyrrrz 0fb4bea64d Update issue forms 2024-05-05 20:00:28 +03:00
Tyrrrz d8abd011e5 Add helper link for OS/arch in readme 2024-05-02 02:13:17 +03:00
Oleksii Holub 9a5697b5b5 Skip invalid reactions instead of crashing (#1227) 2024-04-30 20:08:03 +03:00
LostXOR 886b8442d3 Improve documentation (#1225) 2024-04-30 00:07:29 +03:00
Tyrrrz bedf7de8c6 Update readme 2024-04-29 19:55:14 +03:00
Tyrrrz 5ef329b63c Remove unneeded attribute 2024-04-29 02:39:08 +03:00
Tyrrrz 060e55a0d7 Fix tiny issues in the readme 2024-04-28 19:33:01 +03:00
Oleksii Holub 61444aad30 Set correct file permissions for Linux/OSX release assets (#1223) 2024-04-28 18:53:36 +03:00
Tyrrrz 9b3421e8a5 Remove changelog notification 2024-04-28 03:06:02 +03:00
Tyrrrz 5623c2ca34 Update NuGet packages 2024-04-28 02:26:34 +03:00
Oleksii Holub 8525917a4c Make the GUI x-platform and adapt the CI/CD pipeline to accommodate that (#1222) 2024-04-28 02:24:23 +03:00
Oleksii Holub b9c1c47474 Migrate to Avalonia (#1220) 2024-04-27 04:17:46 +03:00
Mark Cilia Vincenti 74f99b4e59 Switched from SemaphoreSlim lock to AsyncNonKeyedLocker (#1184) 2024-04-21 18:32:57 +03:00
Tyrrrz 12b590d9f9 Use [] in place of Array.Empty<T>() 2024-03-28 03:22:51 +02:00
xs2237 6d1e8c2e02 Remove unnecessary whitespaces at the end of reactions in PlainText (#1213) 2024-03-21 17:29:14 +02:00
Tyrrrz 72f02cd9a0 Update docs with the easier way to run the CLI 2024-03-18 03:20:46 +02:00
Tyrrrz 47d560229d Set the executable permission on CLI entrypoint script before packaging for release 2024-03-18 03:12:31 +02:00
Tyrrrz 0e87098889 Make the DCE.CLI unix entrypoint script work regardless of current working directory 2024-03-05 19:39:06 +02:00
Oleksii Holub 0139eeaeed Add DiscordChatExporter.Cli.sh for simpler usage from shell (#1205) 2024-03-02 04:59:49 +02:00
113 changed files with 2710 additions and 3139 deletions
+7 -7
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).
@@ -68,8 +68,8 @@ 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
$ 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
-55
View File
@@ -1,55 +0,0 @@
# Install .NET runtime
**.NET 8.0 Runtime** is required by **DiscordChatExporter**.
> **Note**:
> Installing the **.NET Runtime** is not required if you running **DiscordChatExporter** using [Docker](Docker.md).
## Windows
Both the GUI and the CLI flavors of **DiscordChatExporter** for Windows come with a bootstrapper that should automatically install all the required prerequisites. Simply run the application and follow the presented instructions.
If, for some reason, you need to install the runtime manually, use one of the download links below:
- [.NET 8.0 Runtime for **Windows x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-windows-x86-installer)
- [.NET 8.0 Runtime for **Windows x86**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-windows-x64-installer)
- [.NET 8.0 Runtime for **Windows arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-windows-arm64-installer)
> **Note**:
> [How can I tell if my computer is running a 32-bit (x86) or a 64-bit (x64) version of Windows?](https://support.microsoft.com/help/15056/windows-32-64-bit-faq)
## macOS
Use one of the download links below:
- [.NET 8.0 Runtime for **macOS x64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-macos-x64-installer)
- [.NET 8.0 Runtime for **macOS arm64**](https://dotnet.microsoft.com/en-us/download/dotnet/thank-you/runtime-8.0.0-macos-arm64-installer)
> **Note**:
> [Is my Mac using an Intel processor (x64) or Apple Silicon (arm64)?](https://support.apple.com/HT211814)
## Linux
Installing the .NET Runtime works differently depending on which Linux distribution you use. Check out the instructions for your distro:
- [Ubuntu](https://docs.microsoft.com/dotnet/core/install/linux-ubuntu)
- [Debian](https://docs.microsoft.com/dotnet/core/install/linux-debian)
- [Fedora](https://docs.microsoft.com/dotnet/core/install/linux-fedora)
For other distros, please check the _'Install on Linux'_ menu on the left of [this page](https://docs.microsoft.com/dotnet/core/install/linux).
## Verify installation (optional)
You can check which version of **.NET Runtime** is installed by running the following command in a terminal:
```console
dotnet --info
```
If the **.NET Runtime** is correctly installed, the command will output something similar to the following:
```console
.NET runtimes installed:
Microsoft.NETCore.App 7.0.x [C:\path\to\dotnet\shared\Microsoft.NETCore.App]
Microsoft.WindowsDesktop.App 7.0.x [C:\path\to\dotnet\shared\Microsoft.WindowsDesktop.App]
```
+4 -7
View File
@@ -15,12 +15,9 @@ The information presented on this page is valid for **all** platforms.
**DCE** has two different versions:
- **Graphical User Interface** (**GUI**) - it's the preferred version for newcomers as it is easy to use.
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.zip` file.
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.*.zip` file.
- **Command-line Interface** (**CLI**) - offers greater flexibility and more features for advanced users, such as export scheduling, ID lists, and more specific date ranges.
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.CLI.zip` file.
If you're not comfortable with **Windows'** Command-line (cmd), please choose the GUI.
[**macOS**](MacOS.md), [**Linux**](Linux.md) and [**Docker**](Docker.md) users can only use the CLI version.
You can get it by [downloading](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) the `DiscordChatExporter.Cli.*.zip` file.
There are dedicated guides for each version:
@@ -38,13 +35,13 @@ You can open `.html` files with a web browser, such as Google Chrome.
> **Warning**:
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed.
> Export using the "Download referenced assets" (`--media`) option to avoid this.
> Export using the "Download assets" (`--media`) option to avoid this.
### Plain Text
<img src="https://i.imgur.com/PbUyRXD.png" height="400"/>
The Plain Text format is the best option for archiving due to its small size.
The Plain Text format formats messages as plain text, and has the smallest size.
You can open `.txt` files with a text editor, such as Notepad.
### JSON
-26
View File
@@ -1,26 +0,0 @@
# Linux usage instructions
## Installing .NET Runtime
Please follow the [instructions provided here](Dotnet.md).
## Downloading and using DiscordChatExporter.Cli
1. Download [DiscordChatExporter.CLI.zip](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) and extract it to a folder.
2. Open Terminal.
3. `cd` into the extracted folder. You can do this in Terminal by typing `cd`, then press the SPACE key, drag and drop the extracted folder into the Terminal window, and press the ENTER key.
4. Replace `TOKEN` and `CHANNEL`, then execute this command to export:
```console
dotnet DiscordChatExporter.Cli.dll export -t TOKEN -c CHANNEL
```
If the above command throws a "Permission denied" error, execute this command to fix the permissions:
```console
chmod 644 *.dll DiscordChatExporter.*
```
> [How to get Token and Channel IDs](Token-and-IDs.md).
There's much more you can do with DCE.CLI! Read the [CLI explained](Using-the-CLI.md) page to get started.
-22
View File
@@ -1,22 +0,0 @@
# macOS usage instructions
![Might look different depending on your macOS version](https://i.imgur.com/gA8jFjJ.png)
## Installing .NET Runtime
Please follow the [instructions provided here](Dotnet.md).
## Downloading and using DiscordChatExporter.Cli
1. Download [DiscordChatExporter.CLI.zip](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) and extract it to a folder.
2. Search for `Terminal.app` in Spotlight (⌘+SPACE), then open it.
3. In the Terminal window, type `cd` , press the SPACE key, then drag and drop the extracted folder into the window, then press the RETURN key.
4. Execute the following command to export, replacing `TOKEN` and `CHANNEL` with your own values:
```console
dotnet DiscordChatExporter.Cli.dll export -t TOKEN -c CHANNEL
```
> [How to get Token and Channel IDs](Token-and-IDs.md).
There's much more you can do with DCE.CLI! Read the [CLI explained](Using-the-CLI.md) page to get started.
+3 -3
View File
@@ -89,17 +89,17 @@ from:96\-LB
In most cases, you will need to enclose your filter in quotes (`"`) to escape characters that may have special meaning in your shell:
```console
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
```
If you need to include quotes inside the filter itself as well, use single quotes (`'`) for those instead:
```console
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
```
Additionally, negated filters (those that start with `-`) may cause parsing issues even when enclosed in quotes. To avoid this, use the tilde (`~`) character instead of the dash (`-`):
```console
DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
$ ./DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
```
-3
View File
@@ -2,8 +2,6 @@
## Installation & Usage
- [Get .NET Core Runtime](Dotnet.md) (Required for CLI; Installed automatically for GUI; Not required in Docker)
- [Windows](Getting-started.md#gui-or-cli) | [macOS](MacOS.md) | [Linux](Linux.md) | [Docker](Docker.md)
- Getting started:
- [Using the GUI](Using-the-GUI.md)
- [Using the CLI](Using-the-CLI.md)
@@ -29,6 +27,5 @@
- [General questions](Troubleshooting.md#general)
- [First steps help](Troubleshooting.md#first-steps)
- [It's crashing/failing](Troubleshooting.md#DCE-is-crashingfailing)
- [.NET Core Runtime is required](Troubleshooting.md#net-core-runtime-is-required)
- [Errors](Troubleshooting.md#errors)
- [**More help**](Troubleshooting.md)
+1 -3
View File
@@ -1,7 +1,5 @@
# Scheduling exports with Cron
Make sure you already have **DiscordChatExporter.CLI** and **.NET Core** properly installed ([instructions here](Linux.md)).
## Creating the script
1. Open Terminal and create a new text file with `nano /path/to/DiscordChatExporter/cron.sh`
@@ -45,7 +43,7 @@ fi
cd $DLLFOLDER || exit 1
# This will export your chat
dotnet DiscordChatExporter.Cli.dll export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
# This sets the current time to a variable
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")
+1 -5
View File
@@ -1,9 +1,5 @@
# Scheduling exports on macOS
Scheduling on macOS is a bit tricky, but it should work if you follow the instructions accordingly.
Make sure you already have **DiscordChatExporter.CLI** and **.NET Core** properly installed ([instructions here](MacOS.md)).
## Creating the script
1. Open TextEdit.app and create a new file
@@ -51,7 +47,7 @@ fi
cd $DLLFOLDER || exit 1
# This will export your chat
dotnet DiscordChatExporter.Cli.dll export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNELID -f $EXPORTFORMAT -o $FILENAME.tmp
# This sets the current time to a variable
CURRENTTIME=$(date +"%Y-%m-%d-%H-%M-%S")
+1 -3
View File
@@ -1,7 +1,5 @@
# Scheduling exports on Windows
We'll be using [DiscordChatExporter CLI](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest), PowerShell, and Task Scheduler.
## Creating the script
1. Open a text editor such as Notepad and paste:
@@ -19,7 +17,7 @@ $EXPORTFORMAT = "formathere"
cd $EXEPATH
.\DiscordChatExporter.Cli.exe export -t $TOKEN -c $CHANNEL -f $EXPORTFORMAT -o "$FILENAME.tmp"
./DiscordChatExporter.Cli export -t $TOKEN -c $CHANNEL -f $EXPORTFORMAT -o "$FILENAME.tmp"
$Date = Get-Date -Format "yyyy-MM-dd-HH-mm"
+2 -2
View File
@@ -25,7 +25,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
3. Type
```console
```js
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
```
@@ -118,7 +118,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
1. Type
```console
```js
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
```
+5 -30
View File
@@ -70,14 +70,7 @@ Check the following page: [Obtaining token](Token-and-IDs.md)
### When I open DCE a black window pops up quickly or nothing shows up
If you have [.NET Core Runtime correctly installed](Dotnet.md), you might have downloaded the CLi flavor, try [downloading the GUI](Getting-started.md#gui-or-cli) instead.
### How do I run DCE on macOS or Linux?
Check the following pages:
- [macOS usage instructions](MacOS.md)
- [Linux usage instructions](Linux.md)
You might have downloaded the CLI flavor of the app, which is meant to be run in a terminal. Try [downloading the GUI](Getting-started.md#gui-or-cli) instead if that's what you want.
### How can I set DCE to export automatically at certain times?
@@ -91,16 +84,6 @@ Check the following pages to learn how to schedule **DiscordChatExporter.CLI** r
Try opening it with a different program, try partitioning or use a different file format, like `PlainText`.
### DCE is crashing/failing
Check the following page: [Installing .NET Core Runtime](Dotnet.md)
If you already have .NET Core installed, please check if your problem is a [known issue](https://github.com/Tyrrrz/DiscordChatExporter/issues?q=is%3Aissue) before [opening a bug report](https://github.com/Tyrrrz/DiscordChatExporter/issues/new).
### .NET Core Runtime is required
Check the following page: [Installing .NET Core Runtime](Dotnet.md)
### I see messages in the export, but they have no content
Your bot is missing the 'Message Content Intent'. Go to the [Discord Developer Portal](https://discord.com/developers/applications), navigate to the 'Bot' section and enable it.
@@ -133,34 +116,26 @@ Make sure you're [copying the DM Channel ID](Token-and-IDs.md#how-to-get-a-direc
## Errors
```console
```yml
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Authentication token is invalid.
...
```
↳ Make sure the provided token is correct.
```console
```yml
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Requested resource does not exist.
```
↳ Check your channel ID, it might be invalid. [Read this if you need help](Token-and-IDs.md).
```console
```yml
DiscordChatExporter.Domain.Exceptions.DiscordChatExporterException: Access is forbidden.
```
↳ This means you don't have access to the channel.
```console
The application to execute does not exist:
```
↳ The `DiscordChatExporter.Cli.dll` file is missing. Keep the `.exe` and all the `.dll` files together. If you didn't move the files, try unzipping again.
```console
```yml
System.Net.WebException: Error: TrustFailure ... Invalid certificate received from server.
...
```
↳ Try running cert-sync.
+31 -38
View File
@@ -1,24 +1,17 @@
# Using the CLI
## Guide
> **Note**:
> Make sure you have [.NET Core installed](Dotnet.md) before attempting to run the commands below.
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
## Step 1
After extracting the `.zip`, open Command Prompt, aka `cmd` (`Terminal` on **macOS** and **Linux**).
## Step 2
Change the current directory to DCE's folder with `cd C:\path\to\directory`, then press ENTER to run the command.
Change the current directory to DCE's folder with `cd C:\path\to\DiscordChatExporter` (`cd /path/to/DiscordChatExporter` on **MacOS** and **Linux**), then press ENTER to run the command.
**Windows** users can quickly get the directory's path by clicking the address bar while inside the folder.
**Windows** users can quickly get the folder's path by clicking the address bar while inside the folder.
![Copy path from Explorer](https://i.imgur.com/XncnhC2.gif)
**macOS** users can select the `.exe`, hit Command+I (⌘I), and copy what's after `Where:` to get the directory
![Mac info panel](https://camo.githubusercontent.com/3c51a904b0099c9f68a4797461d4a7914902fc04/68747470733a2f2f692e696d6775722e636f6d2f323975364e79782e706e67)
**macOS** users can press Command+Option+C (⌘⌥C) while inside the folder (or selecting it) to copy its path to the clipboard.
You can also drag and drop the folder on **every platform**.
![Drag and drop folder](https://i.imgur.com/sOpZQAb.gif)
@@ -28,10 +21,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 +42,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 +61,7 @@ dotnet DiscordChatExporter.Cli.dll export --help
You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`.
```console
dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555
$ ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
```
#### Changing the format
@@ -77,7 +70,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 +78,7 @@ dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 -f Json
You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:
```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 +88,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 +98,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 +106,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 +133,13 @@ You can use partitioning to split files after a given number of messages or file
For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
```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 +150,7 @@ downloaded when using the plain text (TXT) export format.
A folder containing the assets will be created along with the exported chat. They must be kept together.
```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 +159,7 @@ Previously downloaded assets can be reused to skip redundant downloads as long a
same folder. Using this option can speed up future exports. This option requires the `--media` option.
```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 +168,7 @@ By default, the media directory is created alongside the exported chat. You can
providing a path that ends with a slash. All of the exported media will be stored in this directory.
```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 +177,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 +186,14 @@ dotnet DiscordChatExporter.Cli.dll export -t "mfa.Ifrn" -c 53555 --locale "de-DE
Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019:
```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 +201,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 +215,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 +225,7 @@ specifying which threads should be included. It has possible values of `none`, `
threads should be included. To include both active and archived threads, use `--include-threads all`.
```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 +235,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 +243,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 +251,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 +259,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 +267,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 +275,5 @@ dotnet DiscordChatExporter.Cli.dll dm -t "mfa.Ifrn"
To list all servers accessible by the current account, use the `guilds` command:
```console
dotnet DiscordChatExporter.Cli.dll guilds -t "mfa.Ifrn" > C:\path\to\output.txt
$ ./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
```
+11 -18
View File
@@ -10,7 +10,7 @@
### Step 1
After extracting the `.zip`, open `DiscordChatExporter.exe`
After extracting the `.zip`, run `DiscordChatExporter.exe` (Windows), or `DiscordChatExporter` (Mac OS and Linux).
### Step 2
@@ -61,37 +61,30 @@ In this screen you can customize the following:
## Settings
- **Auto-update**
Perform automatic updates on every launch.
- **Auto-update** - Perform automatic updates on every launch.
Default: Enabled
> **Note**:
> Keep this option enabled to receive the latest features and bug fixes!
- **Dark mode**
Use darker colors in the UI (User Interface).
- **Dark mode** - Use darker colors in the UI (User Interface).
Default: Disabled
- **Persist token**
Persist last used token between sessions.
- **Persist token** - Persist last used token between sessions.
Default: Enabled
- **Show threads**
Controls whether threads are shown in the channel list.
- **Show threads** - Controls whether threads are shown in the channel list.
Default: none
- **Locale**
Customize how dates are formatted in the exported files.
- **Locale** - Customize how dates are formatted in the exported files.
- **Date format**
Customize how dates are formatted in the exported files in the settings menu ().
- **Date format** - Customize how dates are formatted in the exported files in the settings menu ().
- **Parallel limit**
The number of channels that will be exported at the same time.
- **Parallel limit** - The number of channels that will be exported at the same time.
Default: 1
- **Normalize to UTC**
Convert all dates to UTC before exporting.
> **Note**:
> Try to keep this number low so that your account doesn't get flagged.
- **Normalize to UTC** - Convert all dates to UTC before exporting.
+2
View File
@@ -100,6 +100,8 @@ body:
required: true
- label: I have sponsored this project
required: false
- label: I have not read any of the above and just checked all the boxes to submit the issue
required: false
- type: markdown
attributes:
+1 -2
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
+40 -8
View File
@@ -18,7 +18,7 @@ env:
jobs:
format:
runs-on: windows-latest
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -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:
@@ -82,8 +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
runs-on: windows-latest
runs-on: ${{ startsWith(matrix.rid, 'win-') && 'windows-latest' || startsWith(matrix.rid, 'osx-') && 'macos-latest' || 'ubuntu-latest' }}
timeout-minutes: 10
permissions:
@@ -106,11 +122,13 @@ jobs:
-p:CSharpier_Bypass=true
--output ${{ matrix.app }}/bin/publish/
--configuration Release
--runtime ${{ matrix.rid }}
--self-contained
- name: Upload artifacts
uses: actions/upload-artifact@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 +165,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
@@ -165,20 +193,24 @@ jobs:
- name: Download artifacts
uses: actions/download-artifact@6b208ae046db98c579e8a3aa621ab581ff575935 # v4.1.1
with:
name: ${{ matrix.app }}
name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/
- name: Set permissions
if: ${{ !startsWith(matrix.rid, 'win') }}
run: chmod +x ${{ matrix.app }}/${{ matrix.asset }}
- name: Create package
# Change into the artifacts directory to avoid including the directory itself in the zip archive
working-directory: ${{ matrix.app }}/
run: zip -r ../${{ matrix.asset }}.zip .
run: zip -r ../${{ matrix.asset }}.${{ matrix.rid }}.zip .
- name: Upload release asset
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: >
gh release upload ${{ github.ref_name }}
${{ matrix.asset }}.zip
${{ matrix.asset }}.${{ matrix.rid }}.zip
--repo ${{ github.event.repository.full_name }}
notify:
+1
View File
@@ -8,6 +8,7 @@
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
</PropertyGroup>
</Project>
@@ -11,19 +11,18 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.0.7" />
<PackageReference Include="coverlet.collector" Version="6.0.0" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
<PackageReference Include="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.10.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();
@@ -205,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();
@@ -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)
{
@@ -3,15 +3,16 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
<PublishTrimmed>true</PublishTrimmed>
<NoWarn>$(NoWarn);IL2104</NoWarn>
</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="Gress" Version="2.1.1" />
<PackageReference Include="Spectre.Console" Version="0.48.0" />
<PackageReference Include="Spectre.Console" Version="0.49.1" />
</ItemGroup>
<ItemGroup>
+36 -2
View File
@@ -1,3 +1,37 @@
using CliFx;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CliFx;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Commands.Converters;
return await new CliApplicationBuilder().AddCommandsFromThisAssembly().Build().RunAsync(args);
namespace DiscordChatExporter.Cli;
public static class Program
{
// Explicit references because CliFx relies on reflection and we're publishing with trimming enabled
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportAllCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportDirectMessagesCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportGuildCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetDirectChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetGuildsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GuideCommand))]
[DynamicDependency(
DynamicallyAccessedMemberTypes.All,
typeof(ThreadInclusionModeBindingConverter)
)]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TruthyBooleanBindingConverter))]
public static async Task<int> Main(string[] args) =>
await new CliApplicationBuilder()
.AddCommand<ExportAllCommand>()
.AddCommand<ExportChannelsCommand>()
.AddCommand<ExportDirectMessagesCommand>()
.AddCommand<ExportGuildCommand>()
.AddCommand<GetChannelsCommand>()
.AddCommand<GetDirectChannelsCommand>()
.AddCommand<GetGuildsCommand>()
.AddCommand<GuideCommand>()
.Build()
.RunAsync(args);
}
@@ -0,0 +1,21 @@
using System.Collections.Generic;
using System.Linq;
namespace DiscordChatExporter.Core.Discord.Data;
public record ChannelNode(Channel Channel, IReadOnlyList<ChannelNode> Children)
{
public static IReadOnlyList<ChannelNode> BuildTree(IReadOnlyList<Channel> channels)
{
IReadOnlyList<ChannelNode> GetChildren(Channel parent) =>
channels
.Where(c => c.Parent?.Id == parent.Id)
.Select(c => new ChannelNode(c, GetChildren(c)))
.ToArray();
return channels
.Where(c => c.Parent is null)
.Select(c => new ChannelNode(c, GetChildren(c)))
.ToArray();
}
}
@@ -63,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);
@@ -78,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);
@@ -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
)
@@ -671,10 +676,14 @@ public class DiscordClient(string token)
.SetQueryParameter("after", currentAfter.ToString())
.Build();
var response = await GetJsonResponseAsync(url, cancellationToken);
// Can be null on reactions with an emoji that has been deleted (?)
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1226
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
yield break;
var count = 0;
foreach (var userJson in response.EnumerateArray())
foreach (var userJson in response.Value.EnumerateArray())
{
var user = User.Parse(userJson);
yield return user;
@@ -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.4.0" />
<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.16" />
</ItemGroup>
</Project>
@@ -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();
@@ -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();
+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; } =
+153
View File
@@ -0,0 +1,153 @@
<Application
x:Class="DiscordChatExporter.Gui.App"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dialogHostAvalonia="clr-namespace:DialogHostAvalonia;assembly=DialogHost.Avalonia"
xmlns:framework="clr-namespace:DiscordChatExporter.Gui.Framework"
xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles"
xmlns:materialControls="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
ActualThemeVariantChanged="Application_OnActualThemeVariantChanged">
<Application.DataTemplates>
<framework:ViewManager />
</Application.DataTemplates>
<Application.Styles>
<!-- This theme is used as a stub to pre-load default resources, the actual colors are set through code -->
<materialStyles:MaterialTheme
BaseTheme="Light"
PrimaryColor="Grey"
SecondaryColor="DeepOrange" />
<materialIcons:MaterialIconStyles />
<dialogHostAvalonia:DialogHostStyles />
<!-- Combo box -->
<Style Selector="ComboBox">
<Setter Property="FontSize" Value="14" />
<Style Selector="^ /template/ Panel#PART_RootPanel">
<Setter Property="Height" Value="22" />
</Style>
<Style Selector="^ /template/ ToggleButton">
<Style Selector="^:checked, ^:unchecked">
<Setter Property="Margin" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Style Selector="^ ContentPresenter#contentPresenter">
<Setter Property="Margin" Value="12,8" />
</Style>
</Style>
</Style>
</Style>
<!-- Dialog host -->
<Style Selector="dialogHostAvalonia|DialogHost">
<Setter Property="DialogMargin" Value="0" />
</Style>
<Style Selector="dialogHostAvalonia|DialogOverlayPopupHost">
<Setter Property="Margin" Value="48" />
</Style>
<!-- Snack bar host -->
<Style Selector="materialControls|SnackbarHost">
<Setter Property="SnackbarHorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Style Selector="^ /template/ ItemsControl#PART_SnackbarHostItemsContainer materialControls|Card">
<Setter Property="Background" Value="{DynamicResource MaterialDarkBackgroundBrush}" />
<Setter Property="Foreground" Value="{DynamicResource MaterialDarkForegroundBrush}" />
</Style>
<Style Selector="^ /template/ ItemsControl#PART_SnackbarHostItemsContainer Button">
<Setter Property="Foreground" Value="{DynamicResource MaterialSecondaryMidBrush}" />
</Style>
</Style>
<!-- Progress bar -->
<Style Selector="ProgressBar">
<Setter Property="Minimum" Value="0" />
<Setter Property="Maximum" Value="1" />
<Setter Property="Foreground" Value="{DynamicResource MaterialSecondaryMidBrush}" />
<Setter Property="materialAssists:TransitionAssist.DisableTransitions" Value="True" />
<Style Selector="^:horizontal">
<Setter Property="MinHeight" Value="0" />
</Style>
</Style>
<!-- Slider -->
<Style Selector="Slider">
<Style Selector="^ /template/ ProgressBar#PART_ProgressLayer">
<Style Selector="^:horizontal">
<Style Selector="^ Panel#PART_InnerPanel">
<Setter Property="Height" Value="2" />
<Style Selector="^ Border#PART_InactiveState">
<Setter Property="Margin" Value="0" />
<Setter Property="Height" Value="2" />
</Style>
<Style Selector="^ Border#PART_Indicator">
<Setter Property="Margin" Value="0" />
</Style>
</Style>
</Style>
</Style>
<Style Selector="^ /template/ Track#PART_Track">
<Style Selector="^:horizontal">
<Setter Property="Margin" Value="4,0" />
</Style>
<Style Selector="^ Border#PART_HoverEffect">
<Setter Property="Width" Value="24" />
<Setter Property="Height" Value="24" />
</Style>
<Style Selector="^ Border#PART_ThumbGrip">
<Setter Property="Width" Value="12" />
<Setter Property="Height" Value="12" />
</Style>
</Style>
</Style>
<!-- Text box -->
<Style Selector="TextBox">
<Setter Property="FontSize" Value="14" />
</Style>
<!-- Toggle button -->
<Style Selector="ToggleButton">
<Setter Property="TextElement.FontWeight" Value="Medium" />
</Style>
<!-- Toggle switch -->
<Style Selector="ToggleSwitch">
<Setter Property="materialAssists:ToggleSwitchAssist.SwitchThumbOffBackground" Value="{DynamicResource ToggleBackgroundBrush}" />
</Style>
<!-- Tooltip -->
<Style Selector="ToolTip">
<Setter Property="TextElement.FontSize" Value="14" />
<Setter Property="TextElement.FontWeight" Value="Normal" />
<Setter Property="TextElement.FontStyle" Value="Normal" />
<Setter Property="TextElement.FontStretch" Value="Normal" />
</Style>
</Application.Styles>
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Default">
<SolidColorBrush x:Key="ToggleBackgroundBrush" Color="#FFFFFF" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="ToggleBackgroundBrush" Color="#8E8E8E" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
+122
View File
@@ -0,0 +1,122 @@
using System;
using System.Net;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Platform;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils;
using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.Views;
using Material.Styles.Themes;
using Microsoft.Extensions.DependencyInjection;
namespace DiscordChatExporter.Gui;
public class App : Application, IDisposable
{
private readonly DisposableCollector _eventRoot = new();
private readonly ServiceProvider _services;
private readonly SettingsService _settingsService;
private readonly MainViewModel _mainViewModel;
public App()
{
var services = new ServiceCollection();
// Framework
services.AddSingleton<DialogManager>();
services.AddSingleton<SnackbarManager>();
services.AddSingleton<ViewManager>();
services.AddSingleton<ViewModelManager>();
// Services
services.AddSingleton<SettingsService>();
services.AddSingleton<UpdateService>();
// View models
services.AddTransient<MainViewModel>();
services.AddTransient<DashboardViewModel>();
services.AddTransient<ExportSetupViewModel>();
services.AddTransient<MessageBoxViewModel>();
services.AddTransient<SettingsViewModel>();
_services = services.BuildServiceProvider(true);
_settingsService = _services.GetRequiredService<SettingsService>();
_mainViewModel = _services.GetRequiredService<ViewModelManager>().CreateMainViewModel();
// Re-initialize the theme when the user changes it
_eventRoot.Add(
_settingsService.WatchProperty(
o => o.Theme,
() =>
{
RequestedThemeVariant = _settingsService.Theme switch
{
ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light,
ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark,
_ => Avalonia.Styling.ThemeVariant.Default
};
InitializeTheme();
},
false
)
);
}
public override void Initialize()
{
base.Initialize();
// Increase maximum concurrent connections
ServicePointManager.DefaultConnectionLimit = 20;
AvaloniaXamlLoader.Load(this);
}
private void InitializeTheme()
{
var actualTheme = RequestedThemeVariant?.Key switch
{
"Light" => PlatformThemeVariant.Light,
"Dark" => PlatformThemeVariant.Dark,
_ => PlatformSettings?.GetColorValues().ThemeVariant ?? PlatformThemeVariant.Light
};
this.LocateMaterialTheme<MaterialThemeBase>().CurrentTheme =
actualTheme == PlatformThemeVariant.Light
? Theme.Create(Theme.Light, Color.Parse("#343838"), Color.Parse("#F9A825"))
: Theme.Create(Theme.Dark, Color.Parse("#E8E8E8"), Color.Parse("#F9A825"));
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
desktop.MainWindow = new MainView { DataContext = _mainViewModel };
base.OnFrameworkInitializationCompleted();
// Set up custom theme colors
InitializeTheme();
// Load settings
_settingsService.Load();
}
private void Application_OnActualThemeVariantChanged(object? sender, EventArgs args) =>
// Re-initialize the theme when the system theme changes
InitializeTheme();
public void Dispose()
{
_eventRoot.Dispose();
_services.Dispose();
}
}
-543
View File
@@ -1,543 +0,0 @@
<Application
x:Class="DiscordChatExporter.Gui.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:s="https://github.com/canton7/Stylet">
<Application.Resources>
<s:ApplicationLoader>
<!-- Bootstrapper -->
<s:ApplicationLoader.Bootstrapper>
<local:Bootstrapper />
</s:ApplicationLoader.Bootstrapper>
<!-- Merged dictionaries -->
<ResourceDictionary.MergedDictionaries>
<!-- Colors are irrelevant because they are overriden in runtime -->
<materialDesign:BundledTheme
BaseTheme="Light"
PrimaryColor="Blue"
SecondaryColor="Blue" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
</ResourceDictionary.MergedDictionaries>
<!-- Styles -->
<Style x:Key="MaterialDesignRoot" TargetType="{x:Type Control}">
<Setter Property="FontFamily" Value="{DynamicResource MaterialDesignFont}" />
<Setter Property="RenderOptions.BitmapScalingMode" Value="HighQuality" />
<Setter Property="SnapsToDevicePixels" Value="True" />
<Setter Property="TextElement.FontSize" Value="13" />
<Setter Property="TextElement.FontWeight" Value="Regular" />
<Setter Property="TextElement.Foreground" Value="{DynamicResource MaterialDesignBody}" />
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
<Setter Property="TextOptions.TextRenderingMode" Value="Auto" />
<Setter Property="UseLayoutRounding" Value="True" />
</Style>
<Style BasedOn="{StaticResource MaterialDesignScrollBarMinimal}" TargetType="{x:Type ScrollBar}" />
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
<Setter Property="Height" Value="2" />
<Setter Property="Maximum" Value="1" />
<Setter Property="Minimum" Value="0" />
</Style>
<Style TargetType="{x:Type Hyperlink}">
<Setter Property="TextDecorations" Value="Underline" />
<Setter Property="FontWeight" Value="Regular" />
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignBody}" />
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
<Trigger Property="IsEnabled" Value="True">
<Setter Property="Cursor" Value="Hand" />
</Trigger>
<MultiTrigger>
<MultiTrigger.Conditions>
<Condition Property="IsEnabled" Value="True" />
<Condition Property="IsMouseOver" Value="True" />
</MultiTrigger.Conditions>
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
</MultiTrigger>
</Style.Triggers>
</Style>
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}" />
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}" />
<Style TargetType="{x:Type materialDesign:Snackbar}">
<Setter Property="Background" Value="{DynamicResource MaterialDesignDarkBackground}" />
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignDarkForeground}" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="ActionButtonStyle">
<Setter.Value>
<Style BasedOn="{StaticResource MaterialDesignFlatButton}" TargetType="{x:Type Button}">
<Setter Property="VerticalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Height" Value="36" />
<Setter Property="Margin" Value="8,-10,-8,-10" />
<Setter Property="Padding" Value="8" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
<Setter Property="materialDesign:RippleAssist.Feedback" Value="{DynamicResource MaterialDesignSnackbarRipple}" />
</Style>
</Setter.Value>
</Setter>
</Style>
<Style
x:Key="MaterialDesignFlatActionToggleButton"
BasedOn="{StaticResource MaterialDesignActionToggleButton}"
TargetType="{x:Type ToggleButton}">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
<Setter Property="BorderBrush" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
</Trigger>
</Style.Triggers>
</Style>
<Style BasedOn="{StaticResource MaterialDesignContextMenu}" TargetType="{x:Type ContextMenu}">
<Setter Property="Background" Value="{DynamicResource MaterialDesignPaper}" />
</Style>
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Expander">
<Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}">
<DockPanel Background="{TemplateBinding Background}">
<ToggleButton
Name="HeaderSite"
BorderThickness="0"
Content="{TemplateBinding Header}"
ContentStringFormat="{TemplateBinding HeaderStringFormat}"
ContentTemplate="{TemplateBinding HeaderTemplate}"
ContentTemplateSelector="{TemplateBinding HeaderTemplateSelector}"
Cursor="Hand"
DockPanel.Dock="Top"
Focusable="False"
Foreground="{TemplateBinding Foreground}"
IsChecked="{Binding Path=IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
IsTabStop="False"
Opacity=".87"
Style="{StaticResource MaterialDesignExpanderToggleButton}"
TextElement.FontSize="15" />
<Border
Name="ContentSite"
DockPanel.Dock="Bottom"
Visibility="Collapsed">
<StackPanel
Name="ContentPanel"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
<ContentPresenter
Name="PART_Content"
ContentStringFormat="{TemplateBinding ContentStringFormat}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"
Focusable="False" />
</StackPanel>
</Border>
</DockPanel>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="true">
<Setter TargetName="ContentSite" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Right">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Left" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Right" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignVerticalHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Left">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Right" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Left" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignVerticalHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Up">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Bottom" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Top" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignHorizontalHeaderStyle}" />
</Trigger>
<Trigger Property="ExpandDirection" Value="Down">
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Top" />
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Bottom" />
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignHorizontalHeaderStyle}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Use old MD style for slider, because the new one is too huge -->
<Style x:Key="MaterialDesignHorizontalTrackRepeatButton" TargetType="{x:Type RepeatButton}">
<Setter Property="OverridesDefaultStyle" Value="true" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Focusable" Value="false" />
<Setter Property="IsTabStop" Value="false" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type RepeatButton}">
<Canvas
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
Background="Transparent">
<Rectangle
x:Name="PART_SelectionRange"
Canvas.Top="8"
Width="{TemplateBinding ActualWidth}"
Height="2.0"
Fill="{TemplateBinding Background}" />
</Canvas>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="MaterialDesignVerticalTrackRepeatButton" TargetType="{x:Type RepeatButton}">
<Setter Property="OverridesDefaultStyle" Value="true" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Focusable" Value="false" />
<Setter Property="IsTabStop" Value="false" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type RepeatButton}">
<Canvas
Width="{TemplateBinding Width}"
Height="{TemplateBinding Height}"
Background="Transparent">
<Rectangle
x:Name="PART_SelectionRange"
Canvas.Left="8"
Width="2.0"
Height="{TemplateBinding ActualHeight}"
Fill="{TemplateBinding Background}" />
</Canvas>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<ControlTemplate x:Key="MaterialDesignSliderThumb" TargetType="{x:Type Thumb}">
<Grid
HorizontalAlignment="Center"
VerticalAlignment="Center"
UseLayoutRounding="True">
<Ellipse
x:Name="shadow"
Width="24"
Height="24"
Margin="-12"
Fill="{TemplateBinding Foreground}"
Opacity=".0"
UseLayoutRounding="True" />
<Ellipse
x:Name="grip"
Width="12"
Height="12"
Fill="{TemplateBinding Foreground}"
RenderTransformOrigin=".5,.5"
UseLayoutRounding="True">
<Ellipse.RenderTransform>
<ScaleTransform ScaleX="1" ScaleY="1" />
</Ellipse.RenderTransform>
</Ellipse>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="true">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="shadow"
Storyboard.TargetProperty="Opacity"
To=".26"
Duration="0:0:0.2" />
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<DoubleAnimation
Storyboard.TargetName="shadow"
Storyboard.TargetProperty="Opacity"
To=".0"
Duration="0:0:0.2" />
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsDragging" Value="true">
<Trigger.EnterActions>
<BeginStoryboard>
<Storyboard>
<Storyboard>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleX">
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1" />
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1.5">
<EasingDoubleKeyFrame.EasingFunction>
<SineEase EasingMode="EaseInOut" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleY">
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1" />
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1.5">
<EasingDoubleKeyFrame.EasingFunction>
<SineEase EasingMode="EaseInOut" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</Storyboard>
</BeginStoryboard>
</Trigger.EnterActions>
<Trigger.ExitActions>
<BeginStoryboard>
<Storyboard>
<Storyboard>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleX">
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1.5" />
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1">
<EasingDoubleKeyFrame.EasingFunction>
<SineEase EasingMode="EaseInOut" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleY">
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1.5" />
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1">
<EasingDoubleKeyFrame.EasingFunction>
<SineEase EasingMode="EaseInOut" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</Storyboard>
</BeginStoryboard>
</Trigger.ExitActions>
</Trigger>
<Trigger Property="IsEnabled" Value="false">
<Setter TargetName="grip" Property="Fill" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
<Setter TargetName="grip" Property="Stroke" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<ControlTemplate x:Key="MaterialDesignSliderHorizontal" TargetType="{x:Type Slider}">
<Border
x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
SnapsToDevicePixels="True">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" MinHeight="{TemplateBinding MinHeight}" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TickBar
x:Name="TopTick"
Grid.Row="0"
Height="4"
Margin="0,0,0,2"
Fill="{TemplateBinding Foreground}"
Placement="Top"
Visibility="Collapsed" />
<TickBar
x:Name="BottomTick"
Grid.Row="2"
Height="4"
Margin="0,2,0,0"
Fill="{TemplateBinding Foreground}"
Placement="Bottom"
Visibility="Collapsed" />
<Rectangle
x:Name="PART_SelectionRange"
Grid.Row="1"
Height="4.0"
Fill="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}"
Visibility="Hidden" />
<Track
x:Name="PART_Track"
Grid.Row="1"
OpacityMask="{x:Null}">
<Track.DecreaseRepeatButton>
<RepeatButton
Background="{TemplateBinding Foreground}"
Command="{x:Static Slider.DecreaseLarge}"
Style="{StaticResource MaterialDesignHorizontalTrackRepeatButton}" />
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton
x:Name="IncreaseRepeatButton"
Background="{DynamicResource MaterialDesignCheckBoxOff}"
Command="{x:Static Slider.IncreaseLarge}"
Style="{StaticResource MaterialDesignHorizontalTrackRepeatButton}" />
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb
x:Name="Thumb"
Width="12"
Height="18"
VerticalAlignment="Center"
Focusable="False"
OverridesDefaultStyle="True"
Template="{StaticResource MaterialDesignSliderThumb}" />
</Track.Thumb>
</Track>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="TickPlacement" Value="TopLeft">
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="TickPlacement" Value="BottomRight">
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="TickPlacement" Value="Both">
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsSelectionRangeEnabled" Value="true">
<Setter TargetName="PART_SelectionRange" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="IncreaseRepeatButton" Property="Background" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<ControlTemplate x:Key="MaterialDesignSliderVertical" TargetType="{x:Type Slider}">
<Border
x:Name="border"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
SnapsToDevicePixels="True">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" MinWidth="{TemplateBinding MinWidth}" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TickBar
x:Name="TopTick"
Grid.Column="0"
Width="4"
Margin="0,0,2,0"
Fill="{TemplateBinding Foreground}"
Placement="Left"
Visibility="Collapsed" />
<TickBar
x:Name="BottomTick"
Grid.Column="2"
Width="4"
Margin="2,0,0,0"
Fill="{TemplateBinding Foreground}"
Placement="Right"
Visibility="Collapsed" />
<Rectangle
x:Name="PART_SelectionRange"
Grid.Column="1"
Height="4.0"
Fill="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}"
Visibility="Hidden" />
<Track x:Name="PART_Track" Grid.Column="1">
<Track.DecreaseRepeatButton>
<RepeatButton
Background="{TemplateBinding Foreground}"
Command="{x:Static Slider.DecreaseLarge}"
Style="{StaticResource MaterialDesignVerticalTrackRepeatButton}" />
</Track.DecreaseRepeatButton>
<Track.IncreaseRepeatButton>
<RepeatButton
x:Name="IncreaseRepeatButton"
Background="{DynamicResource MaterialDesignCheckBoxOff}"
Command="{x:Static Slider.IncreaseLarge}"
Style="{StaticResource MaterialDesignVerticalTrackRepeatButton}" />
</Track.IncreaseRepeatButton>
<Track.Thumb>
<Thumb
x:Name="Thumb"
Width="18"
Height="12"
VerticalAlignment="Top"
Focusable="False"
OverridesDefaultStyle="True"
Template="{StaticResource MaterialDesignSliderThumb}" />
</Track.Thumb>
</Track>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="TickPlacement" Value="TopLeft">
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="TickPlacement" Value="BottomRight">
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="TickPlacement" Value="Both">
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsSelectionRangeEnabled" Value="true">
<Setter TargetName="PART_SelectionRange" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="IncreaseRepeatButton" Property="Background" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
<Style x:Key="MaterialDesignThinSlider" TargetType="{x:Type Slider}">
<Setter Property="Stylus.IsPressAndHoldEnabled" Value="false" />
<Setter Property="Background" Value="{x:Null}" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
<Setter Property="Template" Value="{StaticResource MaterialDesignSliderHorizontal}" />
<Style.Triggers>
<Trigger Property="Orientation" Value="Vertical">
<Setter Property="Template" Value="{StaticResource MaterialDesignSliderVertical}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
</Trigger>
</Style.Triggers>
</Style>
</s:ApplicationLoader>
</Application.Resources>
</Application>
-52
View File
@@ -1,52 +0,0 @@
using System;
using System.Reflection;
using DiscordChatExporter.Gui.Utils;
using MaterialDesignThemes.Wpf;
namespace DiscordChatExporter.Gui;
public partial class App
{
private static Assembly Assembly { get; } = typeof(App).Assembly;
public static string Name { get; } = Assembly.GetName().Name!;
public static Version Version { get; } = Assembly.GetName().Version!;
public static string VersionString { get; } = Version.ToString(3);
public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter";
public static string LatestReleaseUrl { get; } = ProjectUrl + "/releases/latest";
public static string DocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
}
public partial class App
{
private static Theme LightTheme { get; } =
Theme.Create(
new MaterialDesignLightTheme(),
MediaColor.FromHex("#343838"),
MediaColor.FromHex("#F9A825")
);
private static Theme DarkTheme { get; } =
Theme.Create(
new MaterialDesignDarkTheme(),
MediaColor.FromHex("#E8E8E8"),
MediaColor.FromHex("#F9A825")
);
public static void SetLightTheme()
{
var paletteHelper = new PaletteHelper();
paletteHelper.SetTheme(LightTheme);
}
public static void SetDarkTheme()
{
var paletteHelper = new PaletteHelper();
paletteHelper.SetTheme(DarkTheme);
}
}
@@ -1,5 +0,0 @@
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Gui.Behaviors;
public class ChannelMultiSelectionListBoxBehavior : MultiSelectionListBoxBehavior<Channel>;
@@ -1,104 +0,0 @@
using System.Collections;
using System.Collections.Specialized;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using Microsoft.Xaml.Behaviors;
namespace DiscordChatExporter.Gui.Behaviors;
public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
{
public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register(
nameof(SelectedItems),
typeof(IList),
typeof(MultiSelectionListBoxBehavior<T>),
new FrameworkPropertyMetadata(
null,
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
OnSelectedItemsChanged
)
);
private static void OnSelectedItemsChanged(
DependencyObject sender,
DependencyPropertyChangedEventArgs args
)
{
var behavior = (MultiSelectionListBoxBehavior<T>)sender;
if (behavior._modelHandled)
return;
if (behavior.AssociatedObject is null)
return;
behavior._modelHandled = true;
behavior.SelectItems();
behavior._modelHandled = false;
}
private bool _viewHandled;
private bool _modelHandled;
public IList? SelectedItems
{
get => (IList?)GetValue(SelectedItemsProperty);
set => SetValue(SelectedItemsProperty, value);
}
// Propagate selected items from the model to the view
private void SelectItems()
{
_viewHandled = true;
AssociatedObject.SelectedItems.Clear();
if (SelectedItems is not null)
{
foreach (var item in SelectedItems)
AssociatedObject.SelectedItems.Add(item);
}
_viewHandled = false;
}
// Propagate selected items from the view to the model
private void OnListBoxSelectionChanged(object? sender, SelectionChangedEventArgs args)
{
if (_viewHandled)
return;
if (AssociatedObject.Items.SourceCollection is null)
return;
SelectedItems = AssociatedObject.SelectedItems.Cast<T>().ToArray();
}
private void OnListBoxItemsChanged(object? sender, NotifyCollectionChangedEventArgs args)
{
if (_viewHandled)
return;
if (AssociatedObject.Items.SourceCollection is null)
return;
SelectItems();
}
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.SelectionChanged += OnListBoxSelectionChanged;
((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged +=
OnListBoxItemsChanged;
}
protected override void OnDetaching()
{
base.OnDetaching();
if (AssociatedObject is not null)
{
AssociatedObject.SelectionChanged -= OnListBoxSelectionChanged;
((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged -=
OnListBoxItemsChanged;
}
}
}
-45
View File
@@ -1,45 +0,0 @@
using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Framework;
using Stylet;
using StyletIoC;
#if !DEBUG
using System.Windows;
using System.Windows.Threading;
#endif
namespace DiscordChatExporter.Gui;
public class Bootstrapper : Bootstrapper<RootViewModel>
{
protected override void OnStart()
{
base.OnStart();
// Set the default theme.
// Preferred theme will be set later, once the settings are loaded.
App.SetLightTheme();
}
protected override void ConfigureIoC(IStyletIoCBuilder builder)
{
base.ConfigureIoC(builder);
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
builder.Bind<IViewModelFactory>().ToAbstractFactory();
}
#if !DEBUG
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args)
{
base.OnUnhandledException(args);
MessageBox.Show(
args.Exception.ToString(),
"Error occured",
MessageBoxButton.OK,
MessageBoxImage.Error
);
}
#endif
}
@@ -1,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,18 +1,17 @@
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)
? CultureInfo.GetCultureInfo(locale).DisplayName
: "System default";
: "System";
public object ConvertBack(
object? value,
@@ -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,31 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>$(TargetFramework)-windows</TargetFramework>
<AssemblyName>DiscordChatExporter</AssemblyName>
<UseWPF>true</UseWPF>
<ApplicationIcon>../favicon.ico</ApplicationIcon>
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
<PublishTrimmed>true</PublishTrimmed>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<Resource Include="../favicon.ico" />
<AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Cogwheel" Version="2.0.4" />
<PackageReference Include="CSharpier.MsBuild" Version="0.26.7" PrivateAssets="all" />
<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.1.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageReference Include="CSharpier.MsBuild" Version="0.28.2" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
<PackageReference Include="DotnetRuntimeBootstrapper" Version="2.5.2" PrivateAssets="all" />
<PackageReference Include="DialogHost.Avalonia" Version="0.7.7" />
<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.6.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,8 @@
namespace DiscordChatExporter.Gui.Framework;
public enum ThemeVariant
{
System,
Light,
Dark
}
@@ -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,43 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.Views;
using DiscordChatExporter.Gui.Views.Components;
using DiscordChatExporter.Gui.Views.Dialogs;
namespace DiscordChatExporter.Gui.Framework;
public partial class ViewManager
{
private Control? TryCreateView(ViewModelBase viewModel) =>
viewModel switch
{
MainViewModel => new MainView(),
DashboardViewModel => new DashboardView(),
ExportSetupViewModel => new ExportSetupView(),
MessageBoxViewModel => new MessageBoxView(),
SettingsViewModel => new SettingsView(),
_ => null
};
public Control? TryBindView(ViewModelBase viewModel)
{
var view = TryCreateView(viewModel);
if (view is null)
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;
}
}
+49
View File
@@ -0,0 +1,49 @@
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 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,134 @@
using System;
using System.IO;
using System.Text.Json.Serialization;
using Cogwheel;
using CommunityToolkit.Mvvm.ComponentModel;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Models;
using Microsoft.Win32;
namespace DiscordChatExporter.Gui.Services;
// Can't use [ObservableProperty] here because System.Text.Json's source generator doesn't see
// the generated properties.
[INotifyPropertyChanged]
public partial class SettingsService()
: SettingsBase(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.dat"))
: SettingsBase(
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Settings.dat"),
SerializerContext.Default
)
{
public bool IsUkraineSupportMessageEnabled { get; set; } = true;
private bool _isUkraineSupportMessageEnabled = true;
public bool IsUkraineSupportMessageEnabled
{
get => _isUkraineSupportMessageEnabled;
set => SetProperty(ref _isUkraineSupportMessageEnabled, value);
}
public bool IsAutoUpdateEnabled { get; set; } = true;
private ThemeVariant _theme;
public ThemeVariant Theme
{
get => _theme;
set => SetProperty(ref _theme, value);
}
public bool IsDarkModeEnabled { get; set; } = IsDarkModeEnabledByDefault();
private bool _isAutoUpdateEnabled = true;
public bool IsAutoUpdateEnabled
{
get => _isAutoUpdateEnabled;
set => SetProperty(ref _isAutoUpdateEnabled, value);
}
public bool IsTokenPersisted { get; set; } = true;
private bool _isTokenPersisted = true;
public bool IsTokenPersisted
{
get => _isTokenPersisted;
set => SetProperty(ref _isTokenPersisted, value);
}
public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None;
private ThreadInclusionMode _threadInclusionMode;
public ThreadInclusionMode ThreadInclusionMode
{
get => _threadInclusionMode;
set => SetProperty(ref _threadInclusionMode, value);
}
public string? Locale { get; set; }
private string? _locale;
public string? Locale
{
get => _locale;
set => SetProperty(ref _locale, value);
}
public bool IsUtcNormalizationEnabled { get; set; }
private bool _isUtcNormalizationEnabled;
public bool IsUtcNormalizationEnabled
{
get => _isUtcNormalizationEnabled;
set => SetProperty(ref _isUtcNormalizationEnabled, value);
}
public int ParallelLimit { get; set; } = 1;
private int _parallelLimit = 1;
public int ParallelLimit
{
get => _parallelLimit;
set => SetProperty(ref _parallelLimit, value);
}
public Version? LastAppVersion { get; set; }
private string? _lastToken;
public string? LastToken
{
get => _lastToken;
set => SetProperty(ref _lastToken, value);
}
public string? LastToken { get; set; }
private ExportFormat _lastExportFormat = ExportFormat.HtmlDark;
public ExportFormat LastExportFormat
{
get => _lastExportFormat;
set => SetProperty(ref _lastExportFormat, value);
}
public ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
private string? _lastPartitionLimitValue;
public string? LastPartitionLimitValue
{
get => _lastPartitionLimitValue;
set => SetProperty(ref _lastPartitionLimitValue, value);
}
public string? LastPartitionLimitValue { get; set; }
private string? _lastMessageFilterValue;
public string? LastMessageFilterValue
{
get => _lastMessageFilterValue;
set => SetProperty(ref _lastMessageFilterValue, value);
}
public string? LastMessageFilterValue { get; set; }
private bool _lastShouldFormatMarkdown = true;
public bool LastShouldFormatMarkdown
{
get => _lastShouldFormatMarkdown;
set => SetProperty(ref _lastShouldFormatMarkdown, value);
}
public bool LastShouldFormatMarkdown { get; set; } = true;
private bool _lastShouldDownloadAssets;
public bool LastShouldDownloadAssets
{
get => _lastShouldDownloadAssets;
set => SetProperty(ref _lastShouldDownloadAssets, value);
}
public bool LastShouldDownloadAssets { get; set; }
private bool _lastShouldReuseAssets;
public bool LastShouldReuseAssets
{
get => _lastShouldReuseAssets;
set => SetProperty(ref _lastShouldReuseAssets, value);
}
public bool LastShouldReuseAssets { get; set; }
public string? LastAssetsDirPath { get; set; }
private string? _lastAssetsDirPath;
public string? LastAssetsDirPath
{
get => _lastAssetsDirPath;
set => SetProperty(ref _lastAssetsDirPath, value);
}
public override void Save()
{
@@ -59,21 +145,6 @@ public partial class SettingsService()
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;
}
}
[JsonSerializable(typeof(SettingsService))]
private partial class SerializerContext : JsonSerializerContext;
}
@@ -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,33 @@
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);
}
@@ -1,45 +1,59 @@
using System;
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
{
public bool IsAutoUpdateEnabled
private readonly SettingsService _settingsService;
private readonly DisposableCollector _eventRoot = new();
public SettingsViewModel(SettingsService settingsService)
{
get => settingsService.IsAutoUpdateEnabled;
set => settingsService.IsAutoUpdateEnabled = value;
_settingsService = settingsService;
_eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged));
}
public bool IsDarkModeEnabled
public IReadOnlyList<ThemeVariant> AvailableThemes { get; } = Enum.GetValues<ThemeVariant>();
public ThemeVariant Theme
{
get => settingsService.IsDarkModeEnabled;
set => settingsService.IsDarkModeEnabled = value;
get => _settingsService.Theme;
set => _settingsService.Theme = value;
}
public bool IsAutoUpdateEnabled
{
get => _settingsService.IsAutoUpdateEnabled;
set => _settingsService.IsAutoUpdateEnabled = value;
}
public bool IsTokenPersisted
{
get => settingsService.IsTokenPersisted;
set => settingsService.IsTokenPersisted = value;
get => _settingsService.IsTokenPersisted;
set => _settingsService.IsTokenPersisted = value;
}
public IReadOnlyList<ThreadInclusionMode> AvailableThreadInclusions { get; } =
public IReadOnlyList<ThreadInclusionMode> AvailableThreadInclusionModes { get; } =
Enum.GetValues<ThreadInclusionMode>();
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 +86,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,99 @@
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()
{
await ShowUkraineSupportMessageAsync();
await CheckForUpdatesAsync();
}
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,359 @@
<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"
x:DataType="components:DashboardViewModel"
Loaded="UserControl_OnLoaded">
<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>
<!-- 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"
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>
<!-- 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>
<!-- 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,20 @@
<UserControl
x:Class="DiscordChatExporter.Gui.Views.Controls.HyperLink"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:DiscordChatExporter.Gui.Views.Controls">
<TextBlock
x:Name="TextBlock"
Cursor="Hand"
Foreground="{DynamicResource MaterialSecondaryDarkBrush}"
PointerReleased="TextBlock_OnPointerReleased"
Text="{Binding $parent[controls:HyperLink].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,340 @@
<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"
x:DataType="dialogs:ExportSetupViewModel"
Loaded="UserControl_OnLoaded">
<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, FallbackValue=0, 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