mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc35f25293 | |||
| 0e3969ca2d | |||
| 91b7486f45 | |||
| b7548756a8 | |||
| 8f9c0f8660 | |||
| b12be796c7 | |||
| 7a69c87b56 | |||
| 9e7ad4d85c | |||
| 0fb4bea64d | |||
| d8abd011e5 | |||
| 9a5697b5b5 | |||
| 886b8442d3 | |||
| bedf7de8c6 | |||
| 5ef329b63c | |||
| 060e55a0d7 | |||
| 61444aad30 | |||
| 9b3421e8a5 | |||
| 5623c2ca34 | |||
| 8525917a4c | |||
| b9c1c47474 | |||
| 74f99b4e59 | |||
| 12b590d9f9 | |||
| 6d1e8c2e02 | |||
| 72f02cd9a0 | |||
| 47d560229d | |||
| 0e87098889 | |||
| 0139eeaeed |
+7
-7
@@ -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
|
||||
|
||||
@@ -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]
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Linux usage instructions
|
||||
|
||||
## Installing .NET Runtime
|
||||
|
||||
Please follow the [instructions provided here](Dotnet.md).
|
||||
|
||||
## Downloading and using DiscordChatExporter.Cli
|
||||
|
||||
1. Download [DiscordChatExporter.CLI.zip](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) and extract it to a folder.
|
||||
2. Open Terminal.
|
||||
3. `cd` into the extracted folder. You can do this in Terminal by typing `cd`, then press the SPACE key, drag and drop the extracted folder into the Terminal window, and press the ENTER key.
|
||||
4. Replace `TOKEN` and `CHANNEL`, then execute this command to export:
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t TOKEN -c CHANNEL
|
||||
```
|
||||
|
||||
If the above command throws a "Permission denied" error, execute this command to fix the permissions:
|
||||
|
||||
```console
|
||||
chmod 644 *.dll DiscordChatExporter.*
|
||||
```
|
||||
|
||||
> [How to get Token and Channel IDs](Token-and-IDs.md).
|
||||
|
||||
There's much more you can do with DCE.CLI! Read the [CLI explained](Using-the-CLI.md) page to get started.
|
||||
@@ -1,22 +0,0 @@
|
||||
# macOS usage instructions
|
||||
|
||||

|
||||
|
||||
## Installing .NET Runtime
|
||||
|
||||
Please follow the [instructions provided here](Dotnet.md).
|
||||
|
||||
## Downloading and using DiscordChatExporter.Cli
|
||||
|
||||
1. Download [DiscordChatExporter.CLI.zip](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest) and extract it to a folder.
|
||||
2. Search for `Terminal.app` in Spotlight (⌘+SPACE), then open it.
|
||||
3. In the Terminal window, type `cd` , press the SPACE key, then drag and drop the extracted folder into the window, then press the RETURN key.
|
||||
4. Execute the following command to export, replacing `TOKEN` and `CHANNEL` with your own values:
|
||||
|
||||
```console
|
||||
dotnet DiscordChatExporter.Cli.dll export -t TOKEN -c CHANNEL
|
||||
```
|
||||
|
||||
> [How to get Token and Channel IDs](Token-and-IDs.md).
|
||||
|
||||
There's much more you can do with DCE.CLI! Read the [CLI explained](Using-the-CLI.md) page to get started.
|
||||
@@ -89,17 +89,17 @@ from:96\-LB
|
||||
In most cases, you will need to enclose your filter in quotes (`"`) to escape characters that may have special meaning in your shell:
|
||||
|
||||
```console
|
||||
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
|
||||
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz has:image"
|
||||
```
|
||||
|
||||
If you need to include quotes inside the filter itself as well, use single quotes (`'`) for those instead:
|
||||
|
||||
```console
|
||||
DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
|
||||
$ ./DiscordChatExporter.Cli export [...] --filter "from:Tyrrrz 'hello world'"
|
||||
```
|
||||
|
||||
Additionally, negated filters (those that start with `-`) may cause parsing issues even when enclosed in quotes. To avoid this, use the tilde (`~`) character instead of the dash (`-`):
|
||||
|
||||
```console
|
||||
DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
|
||||
$ ./DiscordChatExporter.Cli export [...] --filter ~from:Tyrrrz
|
||||
```
|
||||
|
||||
@@ -2,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,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,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,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"
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
|
||||
3. Type
|
||||
|
||||
```console
|
||||
```js
|
||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
||||
```
|
||||
|
||||
@@ -118,7 +118,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
|
||||
1. Type
|
||||
|
||||
```console
|
||||
```js
|
||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
||||
```
|
||||
|
||||
|
||||
@@ -70,14 +70,7 @@ Check the following page: [Obtaining token](Token-and-IDs.md)
|
||||
|
||||
### When I open DCE a black window pops up quickly or nothing shows up
|
||||
|
||||
If you have [.NET Core Runtime correctly installed](Dotnet.md), you might have downloaded the CLi flavor, try [downloading the GUI](Getting-started.md#gui-or-cli) instead.
|
||||
|
||||
### How do I run DCE on macOS or Linux?
|
||||
|
||||
Check the following pages:
|
||||
|
||||
- [macOS usage instructions](MacOS.md)
|
||||
- [Linux usage instructions](Linux.md)
|
||||
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
@@ -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.
|
||||

|
||||
|
||||
**macOS** users can select the `.exe`, hit Command+I (⌘I), and copy what's after `Where:` to get the directory
|
||||

|
||||
**macOS** users can press Command+Option+C (⌘⌥C) while inside the folder (or selecting it) to copy its path to the clipboard.
|
||||
|
||||
You can also drag and drop the folder on **every platform**.
|
||||

|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -25,11 +25,10 @@ public static class Http
|
||||
private static bool IsRetryableException(Exception exception) =>
|
||||
exception
|
||||
.GetSelfAndChildren()
|
||||
.Any(
|
||||
ex =>
|
||||
ex is TimeoutException or SocketException or AuthenticationException
|
||||
|| ex is HttpRequestException hrex
|
||||
&& IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
|
||||
.Any(ex =>
|
||||
ex is TimeoutException or SocketException or AuthenticationException
|
||||
|| ex is HttpRequestException hrex
|
||||
&& IsRetryableStatusCode(hrex.StatusCode ?? HttpStatusCode.OK)
|
||||
);
|
||||
|
||||
public static ResiliencePipeline ResiliencePipeline { get; } =
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,543 +0,0 @@
|
||||
<Application
|
||||
x:Class="DiscordChatExporter.Gui.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:DiscordChatExporter.Gui"
|
||||
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
|
||||
xmlns:s="https://github.com/canton7/Stylet">
|
||||
<Application.Resources>
|
||||
<s:ApplicationLoader>
|
||||
<!-- Bootstrapper -->
|
||||
<s:ApplicationLoader.Bootstrapper>
|
||||
<local:Bootstrapper />
|
||||
</s:ApplicationLoader.Bootstrapper>
|
||||
|
||||
<!-- Merged dictionaries -->
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<!-- Colors are irrelevant because they are overriden in runtime -->
|
||||
<materialDesign:BundledTheme
|
||||
BaseTheme="Light"
|
||||
PrimaryColor="Blue"
|
||||
SecondaryColor="Blue" />
|
||||
|
||||
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<!-- Styles -->
|
||||
<Style x:Key="MaterialDesignRoot" TargetType="{x:Type Control}">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource MaterialDesignFont}" />
|
||||
<Setter Property="RenderOptions.BitmapScalingMode" Value="HighQuality" />
|
||||
<Setter Property="SnapsToDevicePixels" Value="True" />
|
||||
<Setter Property="TextElement.FontSize" Value="13" />
|
||||
<Setter Property="TextElement.FontWeight" Value="Regular" />
|
||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource MaterialDesignBody}" />
|
||||
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
|
||||
<Setter Property="TextOptions.TextRenderingMode" Value="Auto" />
|
||||
<Setter Property="UseLayoutRounding" Value="True" />
|
||||
</Style>
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignScrollBarMinimal}" TargetType="{x:Type ScrollBar}" />
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignLinearProgressBar}" TargetType="{x:Type ProgressBar}">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
|
||||
<Setter Property="Height" Value="2" />
|
||||
<Setter Property="Maximum" Value="1" />
|
||||
<Setter Property="Minimum" Value="0" />
|
||||
</Style>
|
||||
|
||||
<Style TargetType="{x:Type Hyperlink}">
|
||||
<Setter Property="TextDecorations" Value="Underline" />
|
||||
<Setter Property="FontWeight" Value="Regular" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignBody}" />
|
||||
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="True">
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
</Trigger>
|
||||
<MultiTrigger>
|
||||
<MultiTrigger.Conditions>
|
||||
<Condition Property="IsEnabled" Value="True" />
|
||||
<Condition Property="IsMouseOver" Value="True" />
|
||||
</MultiTrigger.Conditions>
|
||||
|
||||
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
|
||||
</MultiTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignTextBox}" TargetType="{x:Type TextBox}" />
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignComboBox}" TargetType="{x:Type ComboBox}" />
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignDatePicker}" TargetType="{x:Type DatePicker}" />
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignTimePicker}" TargetType="{x:Type materialDesign:TimePicker}" />
|
||||
|
||||
<Style TargetType="{x:Type materialDesign:Snackbar}">
|
||||
<Setter Property="Background" Value="{DynamicResource MaterialDesignDarkBackground}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignDarkForeground}" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="ActionButtonStyle">
|
||||
<Setter.Value>
|
||||
<Style BasedOn="{StaticResource MaterialDesignFlatButton}" TargetType="{x:Type Button}">
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Height" Value="36" />
|
||||
<Setter Property="Margin" Value="8,-10,-8,-10" />
|
||||
<Setter Property="Padding" Value="8" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource SecondaryHueMidBrush}" />
|
||||
<Setter Property="materialDesign:RippleAssist.Feedback" Value="{DynamicResource MaterialDesignSnackbarRipple}" />
|
||||
</Style>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style
|
||||
x:Key="MaterialDesignFlatActionToggleButton"
|
||||
BasedOn="{StaticResource MaterialDesignActionToggleButton}"
|
||||
TargetType="{x:Type ToggleButton}">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
|
||||
|
||||
<Style.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="True">
|
||||
<Setter Property="Background" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource MaterialDesignFlatButtonClick}" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<Style BasedOn="{StaticResource MaterialDesignContextMenu}" TargetType="{x:Type ContextMenu}">
|
||||
<Setter Property="Background" Value="{DynamicResource MaterialDesignPaper}" />
|
||||
</Style>
|
||||
|
||||
<!-- Default MD Expander is incredibly slow (https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit/issues/1307) -->
|
||||
<Style BasedOn="{StaticResource MaterialDesignExpander}" TargetType="{x:Type Expander}">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Expander">
|
||||
<Border BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}">
|
||||
<DockPanel Background="{TemplateBinding Background}">
|
||||
<ToggleButton
|
||||
Name="HeaderSite"
|
||||
BorderThickness="0"
|
||||
Content="{TemplateBinding Header}"
|
||||
ContentStringFormat="{TemplateBinding HeaderStringFormat}"
|
||||
ContentTemplate="{TemplateBinding HeaderTemplate}"
|
||||
ContentTemplateSelector="{TemplateBinding HeaderTemplateSelector}"
|
||||
Cursor="Hand"
|
||||
DockPanel.Dock="Top"
|
||||
Focusable="False"
|
||||
Foreground="{TemplateBinding Foreground}"
|
||||
IsChecked="{Binding Path=IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
|
||||
IsTabStop="False"
|
||||
Opacity=".87"
|
||||
Style="{StaticResource MaterialDesignExpanderToggleButton}"
|
||||
TextElement.FontSize="15" />
|
||||
<Border
|
||||
Name="ContentSite"
|
||||
DockPanel.Dock="Bottom"
|
||||
Visibility="Collapsed">
|
||||
<StackPanel
|
||||
Name="ContentPanel"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
|
||||
<ContentPresenter
|
||||
Name="PART_Content"
|
||||
ContentStringFormat="{TemplateBinding ContentStringFormat}"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
ContentTemplateSelector="{TemplateBinding ContentTemplateSelector}"
|
||||
Focusable="False" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsExpanded" Value="true">
|
||||
<Setter TargetName="ContentSite" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="ExpandDirection" Value="Right">
|
||||
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Left" />
|
||||
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Right" />
|
||||
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
|
||||
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
|
||||
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignVerticalHeaderStyle}" />
|
||||
</Trigger>
|
||||
|
||||
<Trigger Property="ExpandDirection" Value="Left">
|
||||
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Right" />
|
||||
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Left" />
|
||||
<Setter TargetName="ContentPanel" Property="Orientation" Value="Horizontal" />
|
||||
<Setter TargetName="ContentPanel" Property="Height" Value="Auto" />
|
||||
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignVerticalHeaderStyle}" />
|
||||
</Trigger>
|
||||
|
||||
<Trigger Property="ExpandDirection" Value="Up">
|
||||
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Bottom" />
|
||||
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Top" />
|
||||
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
|
||||
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
|
||||
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignHorizontalHeaderStyle}" />
|
||||
</Trigger>
|
||||
|
||||
<Trigger Property="ExpandDirection" Value="Down">
|
||||
<Setter TargetName="HeaderSite" Property="DockPanel.Dock" Value="Top" />
|
||||
<Setter TargetName="ContentSite" Property="DockPanel.Dock" Value="Bottom" />
|
||||
<Setter TargetName="ContentPanel" Property="Orientation" Value="Vertical" />
|
||||
<Setter TargetName="ContentPanel" Property="Width" Value="Auto" />
|
||||
<Setter TargetName="HeaderSite" Property="Style" Value="{StaticResource MaterialDesignHorizontalHeaderStyle}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- Use old MD style for slider, because the new one is too huge -->
|
||||
<Style x:Key="MaterialDesignHorizontalTrackRepeatButton" TargetType="{x:Type RepeatButton}">
|
||||
<Setter Property="OverridesDefaultStyle" Value="true" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Focusable" Value="false" />
|
||||
<Setter Property="IsTabStop" Value="false" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type RepeatButton}">
|
||||
<Canvas
|
||||
Width="{TemplateBinding Width}"
|
||||
Height="{TemplateBinding Height}"
|
||||
Background="Transparent">
|
||||
<Rectangle
|
||||
x:Name="PART_SelectionRange"
|
||||
Canvas.Top="8"
|
||||
Width="{TemplateBinding ActualWidth}"
|
||||
Height="2.0"
|
||||
Fill="{TemplateBinding Background}" />
|
||||
</Canvas>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="MaterialDesignVerticalTrackRepeatButton" TargetType="{x:Type RepeatButton}">
|
||||
<Setter Property="OverridesDefaultStyle" Value="true" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Focusable" Value="false" />
|
||||
<Setter Property="IsTabStop" Value="false" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type RepeatButton}">
|
||||
<Canvas
|
||||
Width="{TemplateBinding Width}"
|
||||
Height="{TemplateBinding Height}"
|
||||
Background="Transparent">
|
||||
<Rectangle
|
||||
x:Name="PART_SelectionRange"
|
||||
Canvas.Left="8"
|
||||
Width="2.0"
|
||||
Height="{TemplateBinding ActualHeight}"
|
||||
Fill="{TemplateBinding Background}" />
|
||||
</Canvas>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<ControlTemplate x:Key="MaterialDesignSliderThumb" TargetType="{x:Type Thumb}">
|
||||
<Grid
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
UseLayoutRounding="True">
|
||||
<Ellipse
|
||||
x:Name="shadow"
|
||||
Width="24"
|
||||
Height="24"
|
||||
Margin="-12"
|
||||
Fill="{TemplateBinding Foreground}"
|
||||
Opacity=".0"
|
||||
UseLayoutRounding="True" />
|
||||
<Ellipse
|
||||
x:Name="grip"
|
||||
Width="12"
|
||||
Height="12"
|
||||
Fill="{TemplateBinding Foreground}"
|
||||
RenderTransformOrigin=".5,.5"
|
||||
UseLayoutRounding="True">
|
||||
<Ellipse.RenderTransform>
|
||||
<ScaleTransform ScaleX="1" ScaleY="1" />
|
||||
</Ellipse.RenderTransform>
|
||||
</Ellipse>
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsMouseOver" Value="true">
|
||||
<Trigger.EnterActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation
|
||||
Storyboard.TargetName="shadow"
|
||||
Storyboard.TargetProperty="Opacity"
|
||||
To=".26"
|
||||
Duration="0:0:0.2" />
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</Trigger.EnterActions>
|
||||
<Trigger.ExitActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimation
|
||||
Storyboard.TargetName="shadow"
|
||||
Storyboard.TargetProperty="Opacity"
|
||||
To=".0"
|
||||
Duration="0:0:0.2" />
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</Trigger.ExitActions>
|
||||
</Trigger>
|
||||
<Trigger Property="IsDragging" Value="true">
|
||||
<Trigger.EnterActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleX">
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1" />
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1.5">
|
||||
<EasingDoubleKeyFrame.EasingFunction>
|
||||
<SineEase EasingMode="EaseInOut" />
|
||||
</EasingDoubleKeyFrame.EasingFunction>
|
||||
</EasingDoubleKeyFrame>
|
||||
</DoubleAnimationUsingKeyFrames>
|
||||
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleY">
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1" />
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1.5">
|
||||
<EasingDoubleKeyFrame.EasingFunction>
|
||||
<SineEase EasingMode="EaseInOut" />
|
||||
</EasingDoubleKeyFrame.EasingFunction>
|
||||
</EasingDoubleKeyFrame>
|
||||
</DoubleAnimationUsingKeyFrames>
|
||||
</Storyboard>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</Trigger.EnterActions>
|
||||
<Trigger.ExitActions>
|
||||
<BeginStoryboard>
|
||||
<Storyboard>
|
||||
<Storyboard>
|
||||
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleX">
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1.5" />
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1">
|
||||
<EasingDoubleKeyFrame.EasingFunction>
|
||||
<SineEase EasingMode="EaseInOut" />
|
||||
</EasingDoubleKeyFrame.EasingFunction>
|
||||
</EasingDoubleKeyFrame>
|
||||
</DoubleAnimationUsingKeyFrames>
|
||||
<DoubleAnimationUsingKeyFrames Storyboard.TargetName="grip" Storyboard.TargetProperty="RenderTransform.ScaleY">
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:0" Value="1.5" />
|
||||
<EasingDoubleKeyFrame KeyTime="0:0:0.1" Value="1">
|
||||
<EasingDoubleKeyFrame.EasingFunction>
|
||||
<SineEase EasingMode="EaseInOut" />
|
||||
</EasingDoubleKeyFrame.EasingFunction>
|
||||
</EasingDoubleKeyFrame>
|
||||
</DoubleAnimationUsingKeyFrames>
|
||||
</Storyboard>
|
||||
</Storyboard>
|
||||
</BeginStoryboard>
|
||||
</Trigger.ExitActions>
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="false">
|
||||
<Setter TargetName="grip" Property="Fill" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
|
||||
<Setter TargetName="grip" Property="Stroke" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
|
||||
<ControlTemplate x:Key="MaterialDesignSliderHorizontal" TargetType="{x:Type Slider}">
|
||||
<Border
|
||||
x:Name="border"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
SnapsToDevicePixels="True">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" MinHeight="{TemplateBinding MinHeight}" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<TickBar
|
||||
x:Name="TopTick"
|
||||
Grid.Row="0"
|
||||
Height="4"
|
||||
Margin="0,0,0,2"
|
||||
Fill="{TemplateBinding Foreground}"
|
||||
Placement="Top"
|
||||
Visibility="Collapsed" />
|
||||
<TickBar
|
||||
x:Name="BottomTick"
|
||||
Grid.Row="2"
|
||||
Height="4"
|
||||
Margin="0,2,0,0"
|
||||
Fill="{TemplateBinding Foreground}"
|
||||
Placement="Bottom"
|
||||
Visibility="Collapsed" />
|
||||
<Rectangle
|
||||
x:Name="PART_SelectionRange"
|
||||
Grid.Row="1"
|
||||
Height="4.0"
|
||||
Fill="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}"
|
||||
Visibility="Hidden" />
|
||||
<Track
|
||||
x:Name="PART_Track"
|
||||
Grid.Row="1"
|
||||
OpacityMask="{x:Null}">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton
|
||||
Background="{TemplateBinding Foreground}"
|
||||
Command="{x:Static Slider.DecreaseLarge}"
|
||||
Style="{StaticResource MaterialDesignHorizontalTrackRepeatButton}" />
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton
|
||||
x:Name="IncreaseRepeatButton"
|
||||
Background="{DynamicResource MaterialDesignCheckBoxOff}"
|
||||
Command="{x:Static Slider.IncreaseLarge}"
|
||||
Style="{StaticResource MaterialDesignHorizontalTrackRepeatButton}" />
|
||||
</Track.IncreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb
|
||||
x:Name="Thumb"
|
||||
Width="12"
|
||||
Height="18"
|
||||
VerticalAlignment="Center"
|
||||
Focusable="False"
|
||||
OverridesDefaultStyle="True"
|
||||
Template="{StaticResource MaterialDesignSliderThumb}" />
|
||||
</Track.Thumb>
|
||||
</Track>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="TickPlacement" Value="TopLeft">
|
||||
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="TickPlacement" Value="BottomRight">
|
||||
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="TickPlacement" Value="Both">
|
||||
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
|
||||
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelectionRangeEnabled" Value="true">
|
||||
<Setter TargetName="PART_SelectionRange" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="IncreaseRepeatButton" Property="Background" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
|
||||
<ControlTemplate x:Key="MaterialDesignSliderVertical" TargetType="{x:Type Slider}">
|
||||
<Border
|
||||
x:Name="border"
|
||||
Background="{TemplateBinding Background}"
|
||||
BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="{TemplateBinding BorderThickness}"
|
||||
SnapsToDevicePixels="True">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" MinWidth="{TemplateBinding MinWidth}" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TickBar
|
||||
x:Name="TopTick"
|
||||
Grid.Column="0"
|
||||
Width="4"
|
||||
Margin="0,0,2,0"
|
||||
Fill="{TemplateBinding Foreground}"
|
||||
Placement="Left"
|
||||
Visibility="Collapsed" />
|
||||
<TickBar
|
||||
x:Name="BottomTick"
|
||||
Grid.Column="2"
|
||||
Width="4"
|
||||
Margin="2,0,0,0"
|
||||
Fill="{TemplateBinding Foreground}"
|
||||
Placement="Right"
|
||||
Visibility="Collapsed" />
|
||||
<Rectangle
|
||||
x:Name="PART_SelectionRange"
|
||||
Grid.Column="1"
|
||||
Height="4.0"
|
||||
Fill="{DynamicResource {x:Static SystemColors.HighlightBrushKey}}"
|
||||
Visibility="Hidden" />
|
||||
<Track x:Name="PART_Track" Grid.Column="1">
|
||||
<Track.DecreaseRepeatButton>
|
||||
<RepeatButton
|
||||
Background="{TemplateBinding Foreground}"
|
||||
Command="{x:Static Slider.DecreaseLarge}"
|
||||
Style="{StaticResource MaterialDesignVerticalTrackRepeatButton}" />
|
||||
</Track.DecreaseRepeatButton>
|
||||
<Track.IncreaseRepeatButton>
|
||||
<RepeatButton
|
||||
x:Name="IncreaseRepeatButton"
|
||||
Background="{DynamicResource MaterialDesignCheckBoxOff}"
|
||||
Command="{x:Static Slider.IncreaseLarge}"
|
||||
Style="{StaticResource MaterialDesignVerticalTrackRepeatButton}" />
|
||||
</Track.IncreaseRepeatButton>
|
||||
<Track.Thumb>
|
||||
<Thumb
|
||||
x:Name="Thumb"
|
||||
Width="18"
|
||||
Height="12"
|
||||
VerticalAlignment="Top"
|
||||
Focusable="False"
|
||||
OverridesDefaultStyle="True"
|
||||
Template="{StaticResource MaterialDesignSliderThumb}" />
|
||||
</Track.Thumb>
|
||||
</Track>
|
||||
</Grid>
|
||||
</Border>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="TickPlacement" Value="TopLeft">
|
||||
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="TickPlacement" Value="BottomRight">
|
||||
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="TickPlacement" Value="Both">
|
||||
<Setter TargetName="TopTick" Property="Visibility" Value="Visible" />
|
||||
<Setter TargetName="BottomTick" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsSelectionRangeEnabled" Value="true">
|
||||
<Setter TargetName="PART_SelectionRange" Property="Visibility" Value="Visible" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="IncreaseRepeatButton" Property="Background" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
|
||||
<Style x:Key="MaterialDesignThinSlider" TargetType="{x:Type Slider}">
|
||||
<Setter Property="Stylus.IsPressAndHoldEnabled" Value="false" />
|
||||
<Setter Property="Background" Value="{x:Null}" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryHueMidBrush}" />
|
||||
<Setter Property="Template" Value="{StaticResource MaterialDesignSliderHorizontal}" />
|
||||
<Style.Triggers>
|
||||
<Trigger Property="Orientation" Value="Vertical">
|
||||
<Setter Property="Template" Value="{StaticResource MaterialDesignSliderVertical}" />
|
||||
</Trigger>
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter Property="Foreground" Value="{DynamicResource MaterialDesignCheckBoxDisabled}" />
|
||||
</Trigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</s:ApplicationLoader>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -1,52 +0,0 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using DiscordChatExporter.Gui.Utils;
|
||||
using MaterialDesignThemes.Wpf;
|
||||
|
||||
namespace DiscordChatExporter.Gui;
|
||||
|
||||
public partial class App
|
||||
{
|
||||
private static Assembly Assembly { get; } = typeof(App).Assembly;
|
||||
|
||||
public static string Name { get; } = Assembly.GetName().Name!;
|
||||
|
||||
public static Version Version { get; } = Assembly.GetName().Version!;
|
||||
|
||||
public static string VersionString { get; } = Version.ToString(3);
|
||||
|
||||
public static string ProjectUrl { get; } = "https://github.com/Tyrrrz/DiscordChatExporter";
|
||||
|
||||
public static string LatestReleaseUrl { get; } = ProjectUrl + "/releases/latest";
|
||||
|
||||
public static string DocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
|
||||
}
|
||||
|
||||
public partial class App
|
||||
{
|
||||
private static Theme LightTheme { get; } =
|
||||
Theme.Create(
|
||||
new MaterialDesignLightTheme(),
|
||||
MediaColor.FromHex("#343838"),
|
||||
MediaColor.FromHex("#F9A825")
|
||||
);
|
||||
|
||||
private static Theme DarkTheme { get; } =
|
||||
Theme.Create(
|
||||
new MaterialDesignDarkTheme(),
|
||||
MediaColor.FromHex("#E8E8E8"),
|
||||
MediaColor.FromHex("#F9A825")
|
||||
);
|
||||
|
||||
public static void SetLightTheme()
|
||||
{
|
||||
var paletteHelper = new PaletteHelper();
|
||||
paletteHelper.SetTheme(LightTheme);
|
||||
}
|
||||
|
||||
public static void SetDarkTheme()
|
||||
{
|
||||
var paletteHelper = new PaletteHelper();
|
||||
paletteHelper.SetTheme(DarkTheme);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Behaviors;
|
||||
|
||||
public class ChannelMultiSelectionListBoxBehavior : MultiSelectionListBoxBehavior<Channel>;
|
||||
@@ -1,104 +0,0 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using Microsoft.Xaml.Behaviors;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Behaviors;
|
||||
|
||||
public class MultiSelectionListBoxBehavior<T> : Behavior<ListBox>
|
||||
{
|
||||
public static readonly DependencyProperty SelectedItemsProperty = DependencyProperty.Register(
|
||||
nameof(SelectedItems),
|
||||
typeof(IList),
|
||||
typeof(MultiSelectionListBoxBehavior<T>),
|
||||
new FrameworkPropertyMetadata(
|
||||
null,
|
||||
FrameworkPropertyMetadataOptions.BindsTwoWayByDefault,
|
||||
OnSelectedItemsChanged
|
||||
)
|
||||
);
|
||||
|
||||
private static void OnSelectedItemsChanged(
|
||||
DependencyObject sender,
|
||||
DependencyPropertyChangedEventArgs args
|
||||
)
|
||||
{
|
||||
var behavior = (MultiSelectionListBoxBehavior<T>)sender;
|
||||
if (behavior._modelHandled)
|
||||
return;
|
||||
|
||||
if (behavior.AssociatedObject is null)
|
||||
return;
|
||||
|
||||
behavior._modelHandled = true;
|
||||
behavior.SelectItems();
|
||||
behavior._modelHandled = false;
|
||||
}
|
||||
|
||||
private bool _viewHandled;
|
||||
private bool _modelHandled;
|
||||
|
||||
public IList? SelectedItems
|
||||
{
|
||||
get => (IList?)GetValue(SelectedItemsProperty);
|
||||
set => SetValue(SelectedItemsProperty, value);
|
||||
}
|
||||
|
||||
// Propagate selected items from the model to the view
|
||||
private void SelectItems()
|
||||
{
|
||||
_viewHandled = true;
|
||||
|
||||
AssociatedObject.SelectedItems.Clear();
|
||||
if (SelectedItems is not null)
|
||||
{
|
||||
foreach (var item in SelectedItems)
|
||||
AssociatedObject.SelectedItems.Add(item);
|
||||
}
|
||||
|
||||
_viewHandled = false;
|
||||
}
|
||||
|
||||
// Propagate selected items from the view to the model
|
||||
private void OnListBoxSelectionChanged(object? sender, SelectionChangedEventArgs args)
|
||||
{
|
||||
if (_viewHandled)
|
||||
return;
|
||||
if (AssociatedObject.Items.SourceCollection is null)
|
||||
return;
|
||||
|
||||
SelectedItems = AssociatedObject.SelectedItems.Cast<T>().ToArray();
|
||||
}
|
||||
|
||||
private void OnListBoxItemsChanged(object? sender, NotifyCollectionChangedEventArgs args)
|
||||
{
|
||||
if (_viewHandled)
|
||||
return;
|
||||
if (AssociatedObject.Items.SourceCollection is null)
|
||||
return;
|
||||
SelectItems();
|
||||
}
|
||||
|
||||
protected override void OnAttached()
|
||||
{
|
||||
base.OnAttached();
|
||||
|
||||
AssociatedObject.SelectionChanged += OnListBoxSelectionChanged;
|
||||
((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged +=
|
||||
OnListBoxItemsChanged;
|
||||
}
|
||||
|
||||
protected override void OnDetaching()
|
||||
{
|
||||
base.OnDetaching();
|
||||
|
||||
if (AssociatedObject is not null)
|
||||
{
|
||||
AssociatedObject.SelectionChanged -= OnListBoxSelectionChanged;
|
||||
((INotifyCollectionChanged)AssociatedObject.Items).CollectionChanged -=
|
||||
OnListBoxItemsChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using DiscordChatExporter.Gui.Services;
|
||||
using DiscordChatExporter.Gui.ViewModels;
|
||||
using DiscordChatExporter.Gui.ViewModels.Framework;
|
||||
using Stylet;
|
||||
using StyletIoC;
|
||||
#if !DEBUG
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
#endif
|
||||
|
||||
namespace DiscordChatExporter.Gui;
|
||||
|
||||
public class Bootstrapper : Bootstrapper<RootViewModel>
|
||||
{
|
||||
protected override void OnStart()
|
||||
{
|
||||
base.OnStart();
|
||||
|
||||
// Set the default theme.
|
||||
// Preferred theme will be set later, once the settings are loaded.
|
||||
App.SetLightTheme();
|
||||
}
|
||||
|
||||
protected override void ConfigureIoC(IStyletIoCBuilder builder)
|
||||
{
|
||||
base.ConfigureIoC(builder);
|
||||
|
||||
builder.Bind<SettingsService>().ToSelf().InSingletonScope();
|
||||
builder.Bind<IViewModelFactory>().ToAbstractFactory();
|
||||
}
|
||||
|
||||
#if !DEBUG
|
||||
protected override void OnUnhandledException(DispatcherUnhandledExceptionEventArgs args)
|
||||
{
|
||||
base.OnUnhandledException(args);
|
||||
|
||||
MessageBox.Show(
|
||||
args.Exception.ToString(),
|
||||
"Error occured",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Error
|
||||
);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1,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;
|
||||
}
|
||||
+4
-5
@@ -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,
|
||||
+4
-5
@@ -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>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
|
||||
<PropertyChanged />
|
||||
</Weavers>
|
||||
@@ -1,74 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
|
||||
<xs:element name="Weavers">
|
||||
<xs:complexType>
|
||||
<xs:all>
|
||||
<xs:element name="PropertyChanged" minOccurs="0" maxOccurs="1">
|
||||
<xs:complexType>
|
||||
<xs:attribute name="InjectOnPropertyNameChanged" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if the On_PropertyName_Changed feature is enabled.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="TriggerDependentProperties" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if the Dependent properties feature is enabled.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="EnableIsChangedProperty" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if the IsChanged property feature is enabled.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="EventInvokerNames" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to change the name of the method that fires the notify event. This is a string that accepts multiple values in a comma separated form.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="CheckForEquality" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if equality checks should be inserted. If false, equality checking will be disabled for the project.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="CheckForEqualityUsingBaseEquals" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if equality checks should use the Equals method resolved from the base class.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="UseStaticEqualsFromBase" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to control if equality checks should use the static Equals method resolved from the base class.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="SuppressWarnings" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to turn off build warnings from this weaver.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="SuppressOnPropertyNameChangedWarning" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>Used to turn off build warnings about mismatched On_PropertyName_Changed methods.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:all>
|
||||
<xs:attribute name="VerifyAssembly" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
|
||||
<xs:annotation>
|
||||
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
<xs:attribute name="GenerateXsd" type="xs:boolean">
|
||||
<xs:annotation>
|
||||
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
|
||||
</xs:annotation>
|
||||
</xs:attribute>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user