mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-08-27 22:34:49 +02:00
Compare commits
21 Commits
5632d6c386
..
2.48
| Author | SHA1 | Date | |
|---|---|---|---|
| 905489b01b | |||
| 33c9e77524 | |||
| b049fb2919 | |||
| f6865c8216 | |||
| 15b2dc0e52 | |||
| 79edf0ee3c | |||
| b89dd4c924 | |||
| 427c6021ac | |||
| 91a8daf3a7 | |||
| 0358045c94 | |||
| 31de8a5b06 | |||
| efd430ed29 | |||
| 623c688e1b | |||
| 05f8df51e9 | |||
| e2c633b004 | |||
| b11a57a825 | |||
| f502e577c2 | |||
| 8bc9fe7c72 | |||
| 97485c280b | |||
| 5a57c4202d | |||
| acac8f7bbb |
+1
-1
@@ -16,7 +16,7 @@ $ docker pull tyrrrz/discordchatexporter:stable
|
||||
Note the `:stable` tag. DiscordChatExporter images are tagged according to the following patterns:
|
||||
|
||||
- `stable` — latest stable version release. This tag is updated with each release of a new project version. Recommended for personal use.
|
||||
- `x.y.z` (e.g. `2.30.1`) — specific stable version release. This tag is pushed when the corresponding version is released and never updated thereafter. Recommended for use in automation scenarios.
|
||||
- `x.y.z` (e.g., `2.30.1`) — specific stable version release. This tag is pushed when the corresponding version is released and never updated thereafter. Recommended for use in automation scenarios.
|
||||
- `latest` — latest (potentially unstable) build. This tag is updated with each new commit to the `prime` branch. Not recommended, unless you want to test a new feature that has not been released in a stable version yet.
|
||||
|
||||
You can see all available tags [here](https://hub.docker.com/r/tyrrrz/discordchatexporter/tags?ordering=name).
|
||||
|
||||
@@ -64,9 +64,9 @@ exit 0
|
||||
|
||||
- `tokenhere` with your [Token](Token-and-IDs.md).
|
||||
- `channelhere` with a [Channel ID](Token-and-IDs.md).
|
||||
- `dceFOLDERpathhere` with DCE's **directory path** (e.g. `/path/to/folder`, NOT `/path/to/folder/DiscordChatExporter.dll`).
|
||||
- `dceFOLDERpathhere` with DCE's **directory path** (e.g., `/path/to/folder`, NOT `/path/to/folder/DiscordChatExporter.dll`).
|
||||
- `filenamehere` with the exported channel's filename, without spaces.
|
||||
- `dirhere` with the export directory (e.g. /home/user/Documents/Discord\ Exports).
|
||||
- `dirhere` with the export directory (e.g., /home/user/Documents/Discord\ Exports).
|
||||
- `formathere` with one of the available export formats.
|
||||
|
||||
> **Note**:
|
||||
|
||||
@@ -68,9 +68,9 @@ exit 0
|
||||
|
||||
- `tokenhere` with your [Token](Token-and-IDs.md)
|
||||
- `channelhere` with a [Channel ID](Token-and-IDs.md)
|
||||
- `dceFOLDERpathhere` with DCE's **directory's path** (e.g. `/Users/user/Desktop/DiscordChatExporterFolder`, NOT `/Users/user/Desktop/DiscordChatExporterFolder/DiscordChatExporter.DLL`)
|
||||
- `dceFOLDERpathhere` with DCE's **directory's path** (e.g., `/Users/user/Desktop/DiscordChatExporterFolder`, NOT `/Users/user/Desktop/DiscordChatExporterFolder/DiscordChatExporter.DLL`)
|
||||
- `filenamehere` with the exported channel's filename, without spaces
|
||||
- `dirhere` with the directory you want the files to be saved at (e.g. `/Users/user/Documents/Discord\ Exports`)
|
||||
- `dirhere` with the directory you want the files to be saved at (e.g., `/Users/user/Documents/Discord\ Exports`)
|
||||
- `formathere` with one of the available export formats
|
||||
|
||||
To quickly get file or folder paths, select the file/folder, then hit Command+I (⌘I) and copy what's after `Where:`.
|
||||
|
||||
@@ -33,9 +33,9 @@ exit
|
||||
|
||||
- `tokenhere` with your [Token](Token-and-IDs.md)
|
||||
- `channelhere` with a [Channel ID](Token-and-IDs.md)
|
||||
- `exefolderhere` with the .exe **directory's path** (e.g. C:\Users\User\Desktop\DiscordChatExporter)
|
||||
- `exefolderhere` with the .exe **directory's path** (e.g., C:\Users\User\Desktop\DiscordChatExporter)
|
||||
- `filenamehere` with a filename without spaces
|
||||
- `dirhere` with the export directory (e.g. C:\Users\User\Documents\Exports)
|
||||
- `dirhere` with the export directory (e.g., C:\Users\User\Documents\Exports)
|
||||
- `formathere` with one of the available export formats
|
||||
|
||||
Make sure not to delete the quotes (")
|
||||
|
||||
@@ -77,7 +77,7 @@ format is `HtmlDark`.
|
||||
|
||||
#### Changing the output filename
|
||||
|
||||
You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:
|
||||
You can change the filename by using `-o name.ext`. e.g., for the `HTML` format:
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
|
||||
@@ -186,21 +186,21 @@ locales. The default locale is `en-US`.
|
||||
#### Date ranges
|
||||
|
||||
**Messages sent before a date**
|
||||
Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019:
|
||||
Use `--before` to export messages sent before the provided date. e.g., messages sent before September 18th, 2019:
|
||||
|
||||
```console
|
||||
./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:
|
||||
Use `--after` to export messages sent after the provided date. e.g., messages sent after September 17th, 2019 11:34 PM:
|
||||
|
||||
```console
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
|
||||
```
|
||||
|
||||
**Messages sent in a date range**
|
||||
Use `--before` and `--after` to export messages sent during the provided date range. E.g. messages sent between
|
||||
Use `--before` and `--after` to export messages sent during the provided date range. e.g., messages sent between
|
||||
September 17th, 2019 11:34 PM and September 18th:
|
||||
|
||||
```console
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
After extracting the `.zip`, run `DiscordChatExporter.exe` **(Windows)**, or `DiscordChatExporter` **(Linux)**.
|
||||
|
||||
If you're using **macOS**, you'll need to manually grant permission for the app to run.
|
||||
If you're using **macOS**, you'll need to manually grant permission for the app to run.
|
||||
If you skip these steps, the "DiscordChatExporter is damaged and can’t be opened" error will be shown.
|
||||
|
||||
1. Open Terminal.app. You can search for it in Spotlight (press <kbd>⌘</kbd> + <kbd>Space</kbd> and type "Terminal").
|
||||
@@ -56,10 +56,11 @@ In this screen you can customize the following:
|
||||
- **Export format** - HTML (Dark), HTML (Light), TXT, CSV and JSON
|
||||
|
||||
- **Date range (after/before)** (Optional) - If set, only messages sent in the provided date range will be exported. Only one value (either after or before) is required if you want to use this option.
|
||||
|
||||
> **Note**:
|
||||
> Please note that the time defaults to **12:00 AM** (midnight/00:00). This means that if you choose to export between Sep 17th and Sep 18th, messages from Sep 18th won't be exported.
|
||||
|
||||
- **Partition limit** (Optional) - Split output into partitions, each limited to this number of messages (e.g. 100) or file size (e.g. 10mb). For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
|
||||
- **Partition limit** (Optional) - Split output into partitions, each limited to this number of messages (e.g., 100) or file size (e.g., 10mb). For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
|
||||
|
||||
- **Message Filter** (Optional) - Special notation for filtering the messages that get included in the export. See [Message filters](Message-filters.md) for more info.
|
||||
|
||||
@@ -77,29 +78,28 @@ In this screen you can customize the following:
|
||||
## Settings
|
||||
|
||||
- **Auto-update** - Perform automatic updates on every launch.
|
||||
Default: Enabled
|
||||
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).
|
||||
Default: Disabled
|
||||
Default: Disabled
|
||||
|
||||
- **Persist token** - Persist last used token between sessions.
|
||||
Default: Enabled
|
||||
Default: Enabled
|
||||
|
||||
- **Show threads** - Controls whether threads are shown in the channel list.
|
||||
Default: none
|
||||
Default: none
|
||||
|
||||
- **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 ().
|
||||
|
||||
- **Parallel limit** - The number of channels that will be exported at the same time.
|
||||
Default: 1
|
||||
Default: 1
|
||||
|
||||
> **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.
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Build image
|
||||
run: >
|
||||
@@ -55,10 +55,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
run: >
|
||||
|
||||
@@ -26,10 +26,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
|
||||
# Build the project separately to discern between build and format errors
|
||||
- name: Build
|
||||
@@ -62,10 +62,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -117,10 +117,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
|
||||
- name: Publish app
|
||||
run: >
|
||||
@@ -128,12 +128,16 @@ jobs:
|
||||
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||
-p:CSharpier_Bypass=true
|
||||
-p:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }}
|
||||
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
|
||||
--output ${{ matrix.app }}/bin/publish/
|
||||
--configuration Release
|
||||
--runtime ${{ matrix.rid }}
|
||||
--self-contained
|
||||
|
||||
- name: Rename macOS bundle
|
||||
if: ${{ startsWith(matrix.rid, 'osx-') && matrix.app == 'DiscordChatExporter.Gui' }}
|
||||
run: >
|
||||
mv ${{ matrix.app }}/bin/publish/DCE.app ${{ matrix.app }}/bin/publish/${{ matrix.asset }}.app
|
||||
|
||||
- name: Upload app binaries
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
+18
-17
@@ -4,38 +4,39 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageVersion Include="AngleSharp" Version="1.7.0" />
|
||||
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.8.0" />
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
|
||||
<PackageVersion Include="Avalonia" Version="12.0.4" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageVersion Include="Avalonia" Version="12.1.1" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="12.1.1" />
|
||||
<PackageVersion Include="CliFx" Version="3.0.0" />
|
||||
<PackageVersion Include="Cogwheel" Version="2.1.1" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
||||
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.6" />
|
||||
<PackageVersion Include="Deorcify" Version="1.1.0" />
|
||||
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.2" />
|
||||
<PackageVersion Include="CSharpier.MsBuild" Version="1.3.0" />
|
||||
<PackageVersion Include="Deorcify" Version="2.0.1" />
|
||||
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.3" />
|
||||
<PackageVersion Include="FluentAssertions" Version="8.10.0" />
|
||||
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.4" />
|
||||
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.5" />
|
||||
<PackageVersion Include="Gress" Version="2.2.0" />
|
||||
<PackageVersion Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageVersion Include="Markdig" Version="1.2.0" />
|
||||
<PackageVersion Include="JsonExtensions" Version="1.2.3" />
|
||||
<PackageVersion Include="MacBundle" Version="1.0.0" />
|
||||
<PackageVersion Include="Markdig" Version="1.3.2" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.16.1" />
|
||||
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
|
||||
<PackageVersion
|
||||
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
||||
Version="10.0.8"
|
||||
Version="10.0.10"
|
||||
/>
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageVersion Include="Onova" Version="2.6.13" />
|
||||
<PackageVersion Include="Polly" Version="8.6.6" />
|
||||
<PackageVersion Include="PowerKit" Version="1.2.0" />
|
||||
<PackageVersion Include="Polly" Version="8.7.0" />
|
||||
<PackageVersion Include="PowerKit" Version="2.3.1" />
|
||||
<PackageVersion Include="RazorBlade" Version="1.0.0" />
|
||||
<PackageVersion Include="Spectre.Console" Version="0.55.2" />
|
||||
<PackageVersion Include="Spectre.Console" Version="0.57.2" />
|
||||
<PackageVersion Include="Superpower" Version="3.1.0" />
|
||||
<PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
|
||||
<PackageVersion Include="WebMarkupMin.Core" Version="2.21.0" />
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" />
|
||||
<PackageReference Include="JsonExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="PowerKit" PrivateAssets="all" />
|
||||
<PackageReference Include="PowerKit" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -22,6 +22,8 @@ public static class ChannelIds
|
||||
|
||||
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
||||
|
||||
public static Snowflake PollTestCases { get; } = Snowflake.Parse("1296445312759627786");
|
||||
|
||||
public static Snowflake ReplyTestCases { get; } = Snowflake.Parse("866459871934677052");
|
||||
|
||||
public static Snowflake SelfContainedTestCases { get; } = Snowflake.Parse("887441432678379560");
|
||||
|
||||
@@ -60,6 +60,52 @@ public class DateRangeSpecs
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_filter_the_reverse_export_to_only_include_messages_sent_after_the_specified_date()
|
||||
{
|
||||
// Arrange
|
||||
var after = new DateTimeOffset(2021, 07, 24, 0, 0, 0, TimeSpan.Zero);
|
||||
using var file = TempFile.Create();
|
||||
|
||||
// Act
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
After = Snowflake.FromDate(after),
|
||||
IsReverseMessageOrder = true,
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
// Assert
|
||||
var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||
.ToArray();
|
||||
|
||||
timestamps.All(t => t > after).Should().BeTrue();
|
||||
timestamps.Should().BeInDescendingOrder();
|
||||
|
||||
timestamps
|
||||
.Should()
|
||||
.BeEquivalentTo(
|
||||
[
|
||||
new DateTimeOffset(2021, 09, 08, 14, 26, 35, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 40, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 39, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 14, 52, 38, TimeSpan.Zero),
|
||||
new DateTimeOffset(2021, 07, 24, 13, 49, 13, TimeSpan.Zero),
|
||||
],
|
||||
o =>
|
||||
o.Using<DateTimeOffset>(ctx =>
|
||||
ctx.Subject.Should().BeCloseTo(ctx.Expectation, TimeSpan.FromSeconds(1))
|
||||
)
|
||||
.WhenTypeIs<DateTimeOffset>()
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_filter_the_export_to_only_include_messages_sent_before_the_specified_date()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using FluentAssertions;
|
||||
using PowerKit.Extensions;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
|
||||
public class HtmlPollSpecs
|
||||
{
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_message_with_a_poll()
|
||||
{
|
||||
// Act
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
ChannelIds.PollTestCases,
|
||||
Snowflake.Parse("1298034559048487016")
|
||||
);
|
||||
|
||||
// Assert
|
||||
message
|
||||
.Text()
|
||||
.Should()
|
||||
.ContainAll(
|
||||
"Poll question",
|
||||
"No emoji",
|
||||
"1 vote",
|
||||
"20%",
|
||||
"Default emoji",
|
||||
"3 votes",
|
||||
"60%",
|
||||
"Custom emoji",
|
||||
"1 vote",
|
||||
"5 votes"
|
||||
);
|
||||
|
||||
message
|
||||
.QuerySelectorAll("img")
|
||||
.Select(e => e.GetAttribute("title"))
|
||||
.Should()
|
||||
.Contain("heart")
|
||||
.And.Contain("dce");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_message_with_a_poll_result()
|
||||
{
|
||||
// Act
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
ChannelIds.PollTestCases,
|
||||
Snowflake.Parse("1298396967742996641")
|
||||
);
|
||||
|
||||
// Assert
|
||||
message
|
||||
.Text()
|
||||
.Should()
|
||||
.ContainAll("Poll question", "Default emoji", "Winning answer", "60%");
|
||||
|
||||
message.Text().ReplaceWhiteSpace(' ').Should().Contain("10/22/2024 9:26 PM");
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ public abstract class DiscordCommandBase : ICommand
|
||||
[CommandOption(
|
||||
"respect-rate-limits",
|
||||
Description = "Whether to respect advisory rate limits. "
|
||||
+ "If disabled, only hard rate limits (i.e. 429 responses) will be respected."
|
||||
+ "If disabled, only hard rate limits (i.e., 429 responses) will be respected."
|
||||
)]
|
||||
public bool ShouldRespectRateLimits { get; set; } = true;
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
"partition",
|
||||
'p',
|
||||
Description = "Split the output into partitions, each limited to the specified "
|
||||
+ "number of messages (e.g. '100') or file size (e.g. '10mb')."
|
||||
+ "number of messages (e.g., '100') or file size (e.g., '10mb')."
|
||||
)]
|
||||
public PartitionLimit PartitionLimit { get; set; } = PartitionLimit.Null;
|
||||
|
||||
@@ -165,6 +165,37 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
throw new CommandException("Option --media-dir cannot be used without --media.");
|
||||
}
|
||||
|
||||
// Make sure the user does not try to export multiple channels into one file.
|
||||
// Output path must either be a directory or contain template tokens for this to work.
|
||||
// Validate this up-front, before fetching threads, because thread fetching can take a
|
||||
// long time and it's frustrating to fail only after it completes.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1549
|
||||
var mayExportMultipleChannels =
|
||||
// Multiple channels were provided explicitly
|
||||
channels.Count > 1
|
||||
// Thread inclusion can add more channels to the export
|
||||
|| ThreadInclusionMode != ThreadInclusionMode.None;
|
||||
|
||||
var isValidOutputPath =
|
||||
// Anything is valid when exporting a single channel
|
||||
!mayExportMultipleChannels
|
||||
// When using template tokens, assume the user knows what they're doing
|
||||
|| OutputPath.Contains('%')
|
||||
// Otherwise, require an existing directory or an unambiguous directory path
|
||||
|| Directory.Exists(OutputPath)
|
||||
|| Path.EndsInDirectorySeparator(OutputPath);
|
||||
|
||||
if (!isValidOutputPath)
|
||||
{
|
||||
throw new CommandException(
|
||||
"Attempted to export multiple channels, but the output path is neither a directory nor a template. "
|
||||
+ "If the provided output path is meant to be treated as a directory, make sure it ends with a slash. "
|
||||
+ $"Provided output path: '{OutputPath}'."
|
||||
);
|
||||
}
|
||||
|
||||
var unwrappedChannels = new List<Channel>(channels);
|
||||
|
||||
// Unwrap threads
|
||||
@@ -205,28 +236,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
||||
}
|
||||
|
||||
// Make sure the user does not try to export multiple channels into one file.
|
||||
// Output path must either be a directory or contain template tokens for this to work.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
||||
var isValidOutputPath =
|
||||
// Anything is valid when exporting a single channel
|
||||
unwrappedChannels.Count <= 1
|
||||
// When using template tokens, assume the user knows what they're doing
|
||||
|| OutputPath.Contains('%')
|
||||
// Otherwise, require an existing directory or an unambiguous directory path
|
||||
|| Directory.Exists(OutputPath)
|
||||
|| Path.EndsInDirectorySeparator(OutputPath);
|
||||
|
||||
if (!isValidOutputPath)
|
||||
{
|
||||
throw new CommandException(
|
||||
"Attempted to export multiple channels, but the output path is neither a directory nor a template. "
|
||||
+ "If the provided output path is meant to be treated as a directory, make sure it ends with a slash. "
|
||||
+ $"Provided output path: '{OutputPath}'."
|
||||
);
|
||||
}
|
||||
|
||||
// Export
|
||||
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
|
||||
var warningsByChannel = new ConcurrentDictionary<Channel, string>();
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" />
|
||||
<PackageReference Include="Gress" />
|
||||
<PackageReference Include="PowerKit" PrivateAssets="all" />
|
||||
<PackageReference Include="PowerKit" />
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ public partial record Embed(
|
||||
// but the client can render multiple images in some cases.
|
||||
public EmbedImage? Image => Images.FirstOrDefault();
|
||||
|
||||
public EmbedField? TryGetField(string name) =>
|
||||
Fields.FirstOrDefault(f => string.Equals(f.Name, name, StringComparison.Ordinal));
|
||||
|
||||
public PollResultEmbedProjection? TryGetPollResult() =>
|
||||
PollResultEmbedProjection.TryResolve(this);
|
||||
|
||||
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() =>
|
||||
SpotifyTrackEmbedProjection.TryResolve(this);
|
||||
|
||||
@@ -47,6 +53,7 @@ public partial record Embed
|
||||
var kind =
|
||||
json.GetPropertyOrNull("type")
|
||||
?.GetStringOrNull()
|
||||
?.Replace("_", "")
|
||||
.Pipe(s => Enum.ParseOrNull<EmbedKind>(s, true))
|
||||
?? EmbedKind.Rich;
|
||||
|
||||
@@ -72,7 +79,7 @@ public partial record Embed
|
||||
|
||||
// Under the Discord API model, embeds can only have at most one image.
|
||||
// Because of that, embeds that are rendered with multiple images on the client
|
||||
// (e.g. tweet embeds), are exposed from the API as multiple separate embeds.
|
||||
// (e.g., tweet embeds), are exposed from the API as multiple separate embeds.
|
||||
// Our embed model is consistent with the user-facing side of Discord, so images
|
||||
// are stored as an array. The API will only ever return one image, but we deal
|
||||
// with this by merging related embeds at the end of the message parsing process.
|
||||
|
||||
@@ -8,4 +8,5 @@ public enum EmbedKind
|
||||
Video,
|
||||
Gifv,
|
||||
Link,
|
||||
PollResult,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Globalization;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
|
||||
// https://docs.discord.com/developers/resources/message#embed-fields-by-embed-type-poll-result-embed-fields
|
||||
public partial record PollResultEmbedProjection(
|
||||
string Question,
|
||||
int TotalVoteCount,
|
||||
int WinningVoteCount,
|
||||
int? WinningAnswerId,
|
||||
string? WinningAnswerText,
|
||||
Emoji? WinningAnswerEmoji
|
||||
)
|
||||
{
|
||||
public double WinningVoteShare { get; } =
|
||||
TotalVoteCount > 0 ? (double)WinningVoteCount / TotalVoteCount : 0;
|
||||
}
|
||||
|
||||
public partial record PollResultEmbedProjection
|
||||
{
|
||||
private static Emoji? TryParseWinningAnswerEmoji(Embed embed)
|
||||
{
|
||||
var name = embed.TryGetField("victor_answer_emoji_name")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
|
||||
var id = embed
|
||||
.TryGetField("victor_answer_emoji_id")
|
||||
?.Value?.Pipe(v => Snowflake.TryParse(v));
|
||||
|
||||
var isAnimated =
|
||||
embed.TryGetField("victor_answer_emoji_animated")?.Value?.Pipe(bool.ParseOrNull)
|
||||
?? false;
|
||||
|
||||
return new Emoji(id, name, isAnimated);
|
||||
}
|
||||
|
||||
public static PollResultEmbedProjection? TryResolve(Embed embed)
|
||||
{
|
||||
if (embed.Kind != EmbedKind.PollResult)
|
||||
return null;
|
||||
|
||||
var question = embed.TryGetField("poll_question_text")?.Value ?? "";
|
||||
|
||||
var totalVoteCount =
|
||||
embed
|
||||
.TryGetField("total_votes")
|
||||
?.Value?.Pipe(v => int.ParseOrNull(v, CultureInfo.InvariantCulture))
|
||||
?? 0;
|
||||
|
||||
var winningVoteCount =
|
||||
embed
|
||||
.TryGetField("victor_answer_votes")
|
||||
?.Value?.Pipe(v => int.ParseOrNull(v, CultureInfo.InvariantCulture))
|
||||
?? 0;
|
||||
|
||||
var winningAnswerId = embed
|
||||
.TryGetField("victor_answer_id")
|
||||
?.Value?.Pipe(v => int.ParseOrNull(v, CultureInfo.InvariantCulture));
|
||||
|
||||
var winningAnswerText = embed.TryGetField("victor_answer_text")?.Value;
|
||||
var winningAnswerEmoji = TryParseWinningAnswerEmoji(embed);
|
||||
|
||||
return new PollResultEmbedProjection(
|
||||
question,
|
||||
totalVoteCount,
|
||||
winningVoteCount,
|
||||
winningAnswerId,
|
||||
winningAnswerText,
|
||||
winningAnswerEmoji
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,14 @@ namespace DiscordChatExporter.Core.Discord.Data;
|
||||
public partial record Emoji(
|
||||
// Only present on custom emoji
|
||||
Snowflake? Id,
|
||||
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
|
||||
// Name of a custom emoji (e.g., LUL) or actual representation of a standard emoji (e.g., 🙂)
|
||||
string Name,
|
||||
bool IsAnimated
|
||||
)
|
||||
{
|
||||
public bool IsCustomEmoji { get; } = Id is not null;
|
||||
|
||||
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
|
||||
// Name of a custom emoji (e.g., LUL) or name of a standard emoji (e.g., slight_smile)
|
||||
public string Code { get; } = Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
|
||||
|
||||
public string ImageUrl { get; } =
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Common;
|
||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
using DiscordChatExporter.Core.Discord.Data.Polls;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
@@ -28,17 +29,21 @@ public partial record Message(
|
||||
MessageReference? Reference,
|
||||
Message? ReferencedMessage,
|
||||
MessageSnapshot? ForwardedMessage,
|
||||
Interaction? Interaction
|
||||
Interaction? Interaction,
|
||||
Poll? Poll
|
||||
) : IHasId
|
||||
{
|
||||
public bool IsEmpty { get; } =
|
||||
string.IsNullOrWhiteSpace(Content)
|
||||
&& !Attachments.Any()
|
||||
&& !Embeds.Any()
|
||||
&& !Stickers.Any();
|
||||
&& !Stickers.Any()
|
||||
&& Poll is null;
|
||||
|
||||
public bool IsSystemNotification { get; } =
|
||||
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
||||
Kind
|
||||
is (>= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated)
|
||||
or MessageKind.PollResult;
|
||||
|
||||
public bool IsReply { get; } = Kind == MessageKind.Reply;
|
||||
|
||||
@@ -187,6 +192,8 @@ public partial record Message
|
||||
|
||||
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
|
||||
|
||||
var poll = json.GetPropertyOrNull("poll")?.Pipe(Poll.Parse);
|
||||
|
||||
return new Message(
|
||||
id,
|
||||
kind,
|
||||
@@ -205,7 +212,8 @@ public partial record Message
|
||||
messageReference,
|
||||
referencedMessage,
|
||||
forwardedMessage,
|
||||
interaction
|
||||
interaction,
|
||||
poll
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,6 @@ public enum MessageKind
|
||||
GuildMemberJoin = 7,
|
||||
ThreadCreated = 18,
|
||||
Reply = 19,
|
||||
ThreadStarterMessage = 21,
|
||||
PollResult = 46,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Polls;
|
||||
|
||||
// https://discord.com/developers/docs/resources/poll#poll-object
|
||||
public record Poll(
|
||||
string Question,
|
||||
IReadOnlyList<PollAnswer> Answers,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
bool AllowsMultipleAnswers,
|
||||
PollResults? Results
|
||||
)
|
||||
{
|
||||
public static Poll Parse(JsonElement json)
|
||||
{
|
||||
var question =
|
||||
json.GetProperty("question").GetPropertyOrNull("text")?.GetStringOrNull() ?? "";
|
||||
|
||||
var answers =
|
||||
json.GetPropertyOrNull("answers")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(PollAnswer.Parse)
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var expiresAt = json.GetPropertyOrNull("expiry")?.GetDateTimeOffsetOrNull();
|
||||
|
||||
var allowsMultipleAnswers =
|
||||
json.GetPropertyOrNull("allow_multiselect")?.GetBooleanOrNull() ?? false;
|
||||
|
||||
var results = json.GetPropertyOrNull("results")?.Pipe(PollResults.Parse);
|
||||
|
||||
return new Poll(question, answers, expiresAt, allowsMultipleAnswers, results);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
using PowerKit.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Polls;
|
||||
|
||||
// https://discord.com/developers/docs/resources/poll#poll-answer-object
|
||||
public record PollAnswer(int Id, string Text, Emoji? Emoji)
|
||||
{
|
||||
public static PollAnswer Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("answer_id").GetInt32();
|
||||
|
||||
var text =
|
||||
json.GetPropertyOrNull("poll_media")?.GetPropertyOrNull("text")?.GetStringOrNull()
|
||||
?? "";
|
||||
|
||||
var emoji = json.GetPropertyOrNull("poll_media")
|
||||
?.GetPropertyOrNull("emoji")
|
||||
?.Pipe(Emoji.Parse);
|
||||
|
||||
return new PollAnswer(id, text, emoji);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Polls;
|
||||
|
||||
// https://discord.com/developers/docs/resources/poll#poll-answer-count-object
|
||||
public record PollAnswerResult(int Id, int Count, bool DidCurrentUserVote)
|
||||
{
|
||||
public static PollAnswerResult Parse(JsonElement json)
|
||||
{
|
||||
var id = json.GetProperty("id").GetInt32();
|
||||
var count = json.GetProperty("count").GetInt32();
|
||||
var didCurrentUserVote = json.GetPropertyOrNull("me_voted")?.GetBooleanOrNull() ?? false;
|
||||
|
||||
return new PollAnswerResult(id, count, didCurrentUserVote);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data.Polls;
|
||||
|
||||
// https://discord.com/developers/docs/resources/poll#poll-results-object
|
||||
public record PollResults(bool IsFinalized, IReadOnlyList<PollAnswerResult> Answers)
|
||||
{
|
||||
public int TotalVoteCount { get; } = Answers.Sum(a => a.Count);
|
||||
|
||||
public int WinningVoteCount { get; } = Answers.Select(a => a.Count).DefaultIfEmpty().Max();
|
||||
|
||||
public PollAnswerResult? TryGetAnswerResult(int answerId) =>
|
||||
Answers.FirstOrDefault(a => a.Id == answerId);
|
||||
|
||||
public static PollResults Parse(JsonElement json)
|
||||
{
|
||||
var isFinalized = json.GetPropertyOrNull("is_finalized")?.GetBooleanOrNull() ?? false;
|
||||
|
||||
var answers =
|
||||
json.GetPropertyOrNull("answer_counts")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(PollAnswerResult.Parse)
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
return new PollResults(isFinalized, answers);
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,8 @@ public class DiscordClient(
|
||||
string url,
|
||||
TokenKind tokenKind,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
return await Http.ResponseResiliencePipeline.ExecuteAsync(
|
||||
) =>
|
||||
await Http.ResponseResiliencePipeline.ExecuteAsync(
|
||||
async innerCancellationToken =>
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, new Uri(_baseUri, url));
|
||||
@@ -91,7 +90,6 @@ public class DiscordClient(
|
||||
},
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
private async ValueTask<TokenKind> ResolveTokenKindAsync(
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -364,6 +362,7 @@ public class DiscordClient(
|
||||
$"guilds/{guildId}/members/{memberId}",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return response?.Pipe(j => Member.Parse(j, guildId));
|
||||
}
|
||||
|
||||
@@ -412,14 +411,12 @@ public class DiscordClient(
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
|
||||
Channel? parent = null;
|
||||
if (parentId is not null)
|
||||
{
|
||||
// It's possible for the parent channel to be inaccessible, despite the
|
||||
// child channel being accessible.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
||||
parent = await TryGetChannelAsync(parentId.Value, cancellationToken);
|
||||
}
|
||||
// It's possible for the parent channel to be inaccessible, despite the
|
||||
// child channel being accessible.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
||||
var parent = parentId is not null
|
||||
? await TryGetChannelAsync(parentId.Value, cancellationToken)
|
||||
: null;
|
||||
|
||||
return Channel.Parse(response.Value, parent);
|
||||
}
|
||||
@@ -595,6 +592,33 @@ public class DiscordClient(
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Message?> TryGetMessageAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake messageId,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
// Use the regular message listing endpoint with the 'around' parameter instead of the
|
||||
// dedicated single-message endpoint, because the latter is not accessible to user tokens.
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
.SetQueryParameter("around", messageId.ToString())
|
||||
.SetQueryParameter("limit", "1")
|
||||
.Build();
|
||||
|
||||
// Can be null on channels that the user cannot access
|
||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||
if (response is null)
|
||||
return null;
|
||||
|
||||
// The endpoint returns messages around the requested ID, so make sure to only return
|
||||
// the message that exactly matches it (it may be absent if it has been deleted).
|
||||
return response
|
||||
.Value.EnumerateArray()
|
||||
.Select(Message.Parse)
|
||||
.FirstOrDefault(m => m.Id == messageId);
|
||||
}
|
||||
|
||||
private async ValueTask<Message?> TryGetFirstMessageAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? after = null,
|
||||
@@ -607,8 +631,12 @@ public class DiscordClient(
|
||||
.SetQueryParameter("after", (after ?? Snowflake.Zero).ToString())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||
var message = response.EnumerateArray().Select(Message.Parse).FirstOrDefault();
|
||||
// Can be null on channels that the user cannot access
|
||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||
if (response is null)
|
||||
return null;
|
||||
|
||||
var message = response.Value.EnumerateArray().Select(Message.Parse).FirstOrDefault();
|
||||
|
||||
return message;
|
||||
}
|
||||
@@ -625,36 +653,64 @@ public class DiscordClient(
|
||||
.SetQueryParameter("before", before?.ToString())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||
return response.EnumerateArray().Select(Message.Parse).LastOrDefault();
|
||||
// Can be null on channels that the user cannot access
|
||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||
if (response is null)
|
||||
return null;
|
||||
|
||||
return response.Value.EnumerateArray().Select(Message.Parse).LastOrDefault();
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Message> GetMessagesAsync(
|
||||
private async IAsyncEnumerable<Message> GetMessagesAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? after = null,
|
||||
Snowflake? before = null,
|
||||
IProgress<Percentage>? progress = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
Snowflake? after,
|
||||
Snowflake? before,
|
||||
IProgress<Percentage>? progress,
|
||||
bool isReverse,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
// Get the last message in the specified range, so we can later calculate the
|
||||
// progress based on the difference between message timestamps.
|
||||
// This also snapshots the boundaries, which means that messages posted after
|
||||
// the export started will not appear in the output.
|
||||
var lastMessage = await TryGetLastMessageAsync(channelId, before, cancellationToken);
|
||||
if (lastMessage is null || lastMessage.Timestamp < after?.ToDate())
|
||||
// To keep the understanding of message history independent of the fetching direction,
|
||||
// we'll refer to the two ends of the range as Alpha and Omega.
|
||||
// Depending on the direction, these are either 'before' and 'after', or 'after' and 'before'.
|
||||
//
|
||||
// Chronological order:
|
||||
// <after> Alpha [----->----->----] Omega <before>
|
||||
// Reverse chronological order:
|
||||
// <after> Omega [-----<-----<----] Alpha <before>
|
||||
|
||||
// Because Discord API doesn't allow us to provide both 'after' and 'before' parameters
|
||||
// at the same time, we have to establish at least one end of the boundary manually.
|
||||
// To do that, we'll fetch the Omega message, which will be the terminal message in the range:
|
||||
// last message in chronological order, or first message in reverse chronological order.
|
||||
// This snapshotting also has the side benefit of allowing us to calculate progress by comparing
|
||||
// the timestamps of the Alpha message, Omega message, and the message being currently processed.
|
||||
var omegaMessage = !isReverse
|
||||
? await TryGetLastMessageAsync(channelId, before, cancellationToken)
|
||||
: await TryGetFirstMessageAsync(channelId, after, cancellationToken);
|
||||
|
||||
// If the Omega doesn't exist or falls outside of the range, then there are simply no messages
|
||||
// satisfying the specified range.
|
||||
if (
|
||||
omegaMessage is null
|
||||
|| (!isReverse && omegaMessage.Timestamp < after?.ToDate())
|
||||
|| (isReverse && omegaMessage.Timestamp > before?.ToDate())
|
||||
)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Keep track of the first message in range in order to calculate the progress
|
||||
var firstMessage = default(Message);
|
||||
// Persist the Alpha message as soon as we fetch the initial batch of messages.
|
||||
// This is only used for calculating progress.
|
||||
var alphaMessage = default(Message);
|
||||
|
||||
var currentAfter = after ?? Snowflake.Zero;
|
||||
var currentBoundary = !isReverse ? after ?? Snowflake.Zero : before;
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
.SetQueryParameter("limit", "100")
|
||||
.SetQueryParameter("after", currentAfter.ToString())
|
||||
.SetQueryParameter(!isReverse ? "after" : "before", currentBoundary?.ToString())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||
@@ -662,8 +718,8 @@ public class DiscordClient(
|
||||
var messages = response
|
||||
.EnumerateArray()
|
||||
.Select(Message.Parse)
|
||||
// Messages are returned from newest to oldest, so we need to reverse them
|
||||
.Reverse()
|
||||
// Messages in batches are always returned from newest to oldest, so reverse if needed
|
||||
.Pipe(messages => isReverse ? messages : messages.Reverse())
|
||||
.ToArray();
|
||||
|
||||
// Break if there are no messages (can happen if messages are deleted during execution)
|
||||
@@ -678,32 +734,78 @@ public class DiscordClient(
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
firstMessage ??= message;
|
||||
|
||||
// Ensure that the messages are in range
|
||||
if (message.Timestamp > lastMessage.Timestamp)
|
||||
// Ensure that we're still in range by checking against the Omega
|
||||
if (!isReverse ? message.Id > omegaMessage.Id : message.Id < omegaMessage.Id)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
alphaMessage ??= message;
|
||||
|
||||
// Report progress based on timestamps
|
||||
if (progress is not null)
|
||||
{
|
||||
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration();
|
||||
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
||||
var fetchedDuration = isReverse
|
||||
? alphaMessage.Timestamp - message.Timestamp
|
||||
: message.Timestamp - alphaMessage.Timestamp;
|
||||
|
||||
var totalDuration = isReverse
|
||||
? alphaMessage.Timestamp - omegaMessage.Timestamp
|
||||
: omegaMessage.Timestamp - alphaMessage.Timestamp;
|
||||
|
||||
progress.Report(
|
||||
Percentage.FromFraction(
|
||||
// Avoid division by zero if all messages have the exact same timestamp
|
||||
// (which happens when there's only one message in the channel)
|
||||
totalDuration > TimeSpan.Zero
|
||||
? exportedDuration / totalDuration
|
||||
? fetchedDuration / totalDuration
|
||||
: 1
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
yield return message;
|
||||
currentAfter = message.Id;
|
||||
// Some messages, for example thread starter messages, are returned by the API as content-less references.
|
||||
// Try to resolve them to the actual message so that they appear as they do in the Discord client.
|
||||
var actualMessage =
|
||||
message.Kind == MessageKind.ThreadStarterMessage
|
||||
&& message.Reference?.ChannelId is { } referencedChannelId
|
||||
&& message.Reference?.MessageId is { } referencedMessageId
|
||||
? await TryGetMessageAsync(
|
||||
referencedChannelId,
|
||||
referencedMessageId,
|
||||
cancellationToken
|
||||
)
|
||||
: null;
|
||||
|
||||
yield return actualMessage ?? message;
|
||||
}
|
||||
|
||||
// The new boundary is always determined by the last message in the batch,
|
||||
// because we order the messages based on fetching direction.
|
||||
currentBoundary = messages.Last().Id;
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Message> GetMessagesAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? after = null,
|
||||
Snowflake? before = null,
|
||||
IProgress<Percentage>? progress = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
await foreach (
|
||||
var message in GetMessagesAsync(
|
||||
channelId,
|
||||
after,
|
||||
before,
|
||||
progress,
|
||||
false,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
yield return message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,64 +817,18 @@ public class DiscordClient(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
// Get the first message in the specified range, so we can later calculate the
|
||||
// progress based on the difference between message timestamps.
|
||||
// Snapshotting is not necessary here because new messages can't appear in the past.
|
||||
var firstMessage = await TryGetFirstMessageAsync(channelId, after, cancellationToken);
|
||||
if (firstMessage is null || firstMessage.Timestamp > before?.ToDate())
|
||||
yield break;
|
||||
|
||||
// Keep track of the last message in range in order to calculate the progress
|
||||
var lastMessage = default(Message);
|
||||
|
||||
var currentBefore = before;
|
||||
while (true)
|
||||
await foreach (
|
||||
var message in GetMessagesAsync(
|
||||
channelId,
|
||||
after,
|
||||
before,
|
||||
progress,
|
||||
true,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
.SetQueryParameter("limit", "100")
|
||||
.SetQueryParameter("before", currentBefore?.ToString())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||
|
||||
var messages = response.EnumerateArray().Select(Message.Parse).ToArray();
|
||||
|
||||
// Break if there are no messages (can happen if messages are deleted during execution)
|
||||
if (!messages.Any())
|
||||
yield break;
|
||||
|
||||
// If all messages are empty, make sure that it's not because the bot account doesn't
|
||||
// have the MESSAGE_CONTENT intent enabled.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||
if (messages.All(m => m.IsEmpty))
|
||||
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
lastMessage ??= message;
|
||||
|
||||
// Report progress based on timestamps
|
||||
if (progress is not null)
|
||||
{
|
||||
var exportedDuration = (lastMessage.Timestamp - message.Timestamp).Duration();
|
||||
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
||||
|
||||
progress.Report(
|
||||
Percentage.FromFraction(
|
||||
// Avoid division by zero if all messages have the exact same timestamp
|
||||
// (which happens when there's only one message in the channel)
|
||||
totalDuration > TimeSpan.Zero
|
||||
? exportedDuration / totalDuration
|
||||
: 1
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
yield return message;
|
||||
}
|
||||
|
||||
currentBefore = messages.Last().Id;
|
||||
yield return message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<PackageReference Include="Gress" />
|
||||
<PackageReference Include="JsonExtensions" />
|
||||
<PackageReference Include="Polly" />
|
||||
<PackageReference Include="PowerKit" PrivateAssets="all" />
|
||||
<PackageReference Include="PowerKit" />
|
||||
<PackageReference Include="RazorBlade" />
|
||||
<PackageReference Include="Superpower" />
|
||||
<PackageReference Include="WebMarkupMin.Core" />
|
||||
|
||||
@@ -38,23 +38,28 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
return _previousPathsByUrl[url] = filePath;
|
||||
|
||||
// Check for a file cached by the legacy naming scheme (5-char hash) and rename it
|
||||
// to the new naming scheme to preserve backwards compatibility with existing exports
|
||||
// to the new naming scheme to preserve backwards compatibility with existing exports.
|
||||
// This will catch both the 5-char lowercase hash and the 5-char uppercase hash variants.
|
||||
if (reuse)
|
||||
{
|
||||
var legacyFilePath = Path.Combine(workingDirPath, GetLegacyFileNameFromUrl(url));
|
||||
if (File.Exists(legacyFilePath))
|
||||
var legacyFileNames = GetLegacyFileNamesFromUrl(url);
|
||||
foreach (var legacyFileName in legacyFileNames)
|
||||
{
|
||||
// Overwrite in case the destination file was created concurrently between our
|
||||
// earlier existence check and this move operation
|
||||
try
|
||||
var legacyFilePath = Path.Combine(workingDirPath, legacyFileName);
|
||||
if (File.Exists(legacyFilePath))
|
||||
{
|
||||
File.Move(legacyFilePath, filePath, overwrite: true);
|
||||
return _previousPathsByUrl[url] = filePath;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// The legacy file was moved or deleted concurrently or something else happened.
|
||||
// Upgrading old files is not crucial, so we can just move on.
|
||||
// Overwrite in case the destination file was created concurrently between our
|
||||
// earlier existence check and this move operation
|
||||
try
|
||||
{
|
||||
File.Move(legacyFilePath, filePath, true);
|
||||
return _previousPathsByUrl[url] = filePath;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// The legacy file was moved or deleted concurrently or something else happened.
|
||||
// Upgrading old files is not crucial, so we can just move on.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,10 +92,16 @@ internal partial class ExportAssetDownloader
|
||||
{
|
||||
private static string NormalizeUrl(string url)
|
||||
{
|
||||
// Remove signature parameters from Discord CDN URLs to normalize them
|
||||
// Remove signature parameters from Discord CDN/media URLs to normalize them
|
||||
var uri = new Uri(url);
|
||||
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
if (
|
||||
!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(uri.Host, "media.discordapp.net", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||
query.Remove("ex");
|
||||
@@ -137,13 +148,16 @@ internal partial class ExportAssetDownloader
|
||||
);
|
||||
|
||||
// Legacy naming used a 5-char hash, kept for backwards compatibility with existing exports
|
||||
private static string GetLegacyFileNameFromUrl(string url) =>
|
||||
GetFileNameFromUrl(
|
||||
url,
|
||||
SHA256
|
||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||
.Pipe(Convert.ToHexStringLower)
|
||||
// 5 chars = 20 bits, reaches 1% collision probability at ~145 files
|
||||
.Truncate(5)
|
||||
);
|
||||
private static IReadOnlyList<string> GetLegacyFileNamesFromUrl(string url)
|
||||
{
|
||||
var hashData = SHA256.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)));
|
||||
|
||||
return
|
||||
[
|
||||
// Lowercase variant (introduced in 2.46.1)
|
||||
GetFileNameFromUrl(url, Convert.ToHexStringLower(hashData).Truncate(5)),
|
||||
// Uppercase variant (original)
|
||||
GetFileNameFromUrl(url, Convert.ToHexString(hashData).Truncate(5)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,10 +206,15 @@ public partial class ExportRequest
|
||||
{
|
||||
var actualOutputPath = FormatPath(outputPath, guild, channel, after, before);
|
||||
|
||||
// Output is a directory
|
||||
// Determine whether the output path refers to a directory or a file.
|
||||
// The extension-based heuristic is evaluated on the original, unsubstituted path,
|
||||
// because the value of a template token (e.g., a guild or channel name) may contain
|
||||
// a period that would otherwise be mistaken for a file extension, incorrectly causing
|
||||
// a directory path to be treated as a file.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1502
|
||||
if (
|
||||
Directory.Exists(actualOutputPath)
|
||||
|| string.IsNullOrWhiteSpace(Path.GetExtension(actualOutputPath))
|
||||
|| string.IsNullOrWhiteSpace(Path.GetExtension(outputPath))
|
||||
)
|
||||
{
|
||||
var fileName = GetDefaultOutputFileName(guild, channel, format, after, before);
|
||||
|
||||
@@ -28,5 +28,7 @@ internal class ContainsMessageFilter(string text) : MessageFilter
|
||||
|| IsMatch(e.Description)
|
||||
|| IsMatch(e.Footer?.Text)
|
||||
|| e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
|
||||
);
|
||||
)
|
||||
|| IsMatch(message.Poll?.Question)
|
||||
|| message.Poll?.Answers.Any(a => IsMatch(a.Text)) == true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@using System
|
||||
@using System.Collections.Generic
|
||||
@using System.Globalization
|
||||
@using System.Linq
|
||||
@using System.Threading.Tasks
|
||||
@using RazorBlade
|
||||
@@ -46,6 +47,11 @@
|
||||
? message.Author.DisplayName
|
||||
: authorMember?.DisplayName ?? message.Author.DisplayName;
|
||||
|
||||
var pollResult = message.Embeds
|
||||
.Select(e => e.TryGetPollResult())
|
||||
.WhereNotNull()
|
||||
.FirstOrDefault();
|
||||
|
||||
<div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
|
||||
<div class="chatlog__message">
|
||||
@* System notification *@
|
||||
@@ -63,6 +69,7 @@
|
||||
MessageKind.ChannelPinnedMessage => "pin-icon",
|
||||
MessageKind.GuildMemberJoin => "join-icon",
|
||||
MessageKind.ThreadCreated => "thread-icon",
|
||||
MessageKind.PollResult => "poll-icon",
|
||||
_ => "pencil-icon"
|
||||
};
|
||||
}
|
||||
@@ -75,8 +82,11 @@
|
||||
@* Author name *@
|
||||
<span class="chatlog__system-notification-author" style="@(authorColor is not null ? $"color: rgb({authorColor.Value.R}, {authorColor.Value.G}, {authorColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@authorDisplayName</span>
|
||||
|
||||
@* Space out the content *@
|
||||
<span> </span>
|
||||
@* Space out the content (poll-result text starts with an apostrophe) *@
|
||||
@if (message.Kind != MessageKind.PollResult)
|
||||
{
|
||||
<span> </span>
|
||||
}
|
||||
|
||||
@* System notification content *@
|
||||
<span class="chatlog__system-notification-content">
|
||||
@@ -126,6 +136,32 @@
|
||||
{
|
||||
<span>joined the server.</span>
|
||||
}
|
||||
else if (message.Kind == MessageKind.PollResult && pollResult is not null)
|
||||
{
|
||||
@if (!string.IsNullOrWhiteSpace(pollResult.Question))
|
||||
{
|
||||
<span>'s poll </span>
|
||||
|
||||
@if (message.Reference?.MessageId is not null)
|
||||
{
|
||||
<a class="chatlog__system-notification-link" href="#chatlog__message-container-@message.Reference.MessageId">@pollResult.Question</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="chatlog__system-notification-link">@pollResult.Question</span>
|
||||
}
|
||||
|
||||
<span> has closed.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>'s poll has closed.</span>
|
||||
}
|
||||
}
|
||||
else if (message.Kind == MessageKind.PollResult)
|
||||
{
|
||||
<span>'s poll has closed.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@message.Content.ToLowerInvariant()</span>
|
||||
@@ -136,6 +172,48 @@
|
||||
<span class="chatlog__system-notification-timestamp" title="@FormatDate(message.Timestamp, "f")">
|
||||
<a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
|
||||
</span>
|
||||
|
||||
@if (message.Kind == MessageKind.PollResult && pollResult is not null)
|
||||
{
|
||||
<div class="chatlog__poll-result">
|
||||
<div class="chatlog__poll-result-answer">
|
||||
@if (pollResult.WinningAnswerEmoji is not null)
|
||||
{
|
||||
<div class="chatlog__poll-result-emoji-container">
|
||||
<img class="chatlog__poll-result-emoji" alt="@pollResult.WinningAnswerEmoji.Name" title="@pollResult.WinningAnswerEmoji.Code" src="@await ResolveAssetUrlAsync(pollResult.WinningAnswerEmoji.ImageUrl)" loading="lazy">
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="chatlog__poll-result-answer-info">
|
||||
<div class="chatlog__poll-result-answer-text">
|
||||
@(pollResult.WinningAnswerId is not null ? pollResult.WinningAnswerText : "Poll closed")
|
||||
|
||||
@if (pollResult.WinningAnswerId is not null)
|
||||
{
|
||||
<span class="chatlog__poll-result-winner" title="Winning answer"><span aria-hidden="true">✓</span><span class="chatlog__poll-accessible-label">Winning answer</span></span>
|
||||
}
|
||||
</div>
|
||||
<div class="chatlog__poll-result-answer-details">
|
||||
@if (pollResult.WinningAnswerId is not null)
|
||||
{
|
||||
<span>Winning answer</span>
|
||||
<span aria-hidden="true"> • </span>
|
||||
<span>@pollResult.WinningVoteShare.ToString("P0", Context.Request.CultureInfo)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@pollResult.TotalVoteCount.ToString("N0", Context.Request.CultureInfo) @(pollResult.TotalVoteCount == 1 ? "vote" : "votes")</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (message.Reference?.MessageId is not null)
|
||||
{
|
||||
<a class="chatlog__poll-result-button" href="#chatlog__message-container-@message.Reference.MessageId">View Poll</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
// Regular message
|
||||
@@ -401,6 +479,84 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Poll *@
|
||||
@if (message.Poll is { } poll)
|
||||
{
|
||||
<div class="chatlog__poll">
|
||||
<div class="chatlog__poll-question">@poll.Question</div>
|
||||
|
||||
<div class="chatlog__poll-answers">
|
||||
@foreach (var answer in poll.Answers)
|
||||
{
|
||||
var answerResult = poll.Results?.TryGetAnswerResult(answer.Id);
|
||||
|
||||
var isSelected = answerResult?.DidCurrentUserVote == true;
|
||||
|
||||
var isWinning =
|
||||
poll.Results is { IsFinalized: true, WinningVoteCount: > 0 }
|
||||
&& answerResult?.Count >= poll.Results.WinningVoteCount;
|
||||
|
||||
var answerVoteShare = poll.Results?.TotalVoteCount > 0
|
||||
? (double)(answerResult?.Count ?? 0) / poll.Results.TotalVoteCount
|
||||
: 0;
|
||||
|
||||
<div class="chatlog__poll-answer @(isWinning ? "chatlog__poll-answer--winning" : poll.Results?.IsFinalized != true && isSelected ? "chatlog__poll-answer--selected" : null)" style="--poll-answer-fill: @answerVoteShare.ToString("P2", CultureInfo.InvariantCulture).Replace(" ", "")">
|
||||
<div class="chatlog__poll-answer-content">
|
||||
@if (answer.Emoji is not null)
|
||||
{
|
||||
<img class="chatlog__poll-answer-emoji" alt="@answer.Emoji.Name" title="@answer.Emoji.Code" src="@await ResolveAssetUrlAsync(answer.Emoji.ImageUrl)" loading="lazy">
|
||||
}
|
||||
<span class="chatlog__poll-answer-text">@answer.Text</span>
|
||||
</div>
|
||||
|
||||
<div class="chatlog__poll-answer-details">
|
||||
@if (poll.Results is not null)
|
||||
{
|
||||
var answerVoteCount = answerResult?.Count ?? 0;
|
||||
|
||||
<span class="chatlog__poll-answer-count">@answerVoteCount.ToString("N0", Context.Request.CultureInfo) @(answerVoteCount == 1 ? "vote" : "votes")</span>
|
||||
<span class="chatlog__poll-answer-percentage">@answerVoteShare.ToString("P0", Context.Request.CultureInfo)</span>
|
||||
}
|
||||
|
||||
@if (isSelected)
|
||||
{
|
||||
<span class="chatlog__poll-answer-selected" title="Selected"><span aria-hidden="true">✓</span><span class="chatlog__poll-accessible-label">Selected</span></span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (poll.Results is not null || poll.AllowsMultipleAnswers || poll.ExpiresAt is not null)
|
||||
{
|
||||
<div class="chatlog__poll-footer">
|
||||
@if (poll.Results is not null)
|
||||
{
|
||||
<span class="chatlog__poll-footer-item">@poll.Results.TotalVoteCount.ToString("N0", Context.Request.CultureInfo) @(poll.Results.TotalVoteCount == 1 ? "vote" : "votes")</span>
|
||||
}
|
||||
|
||||
@if (poll.AllowsMultipleAnswers)
|
||||
{
|
||||
<span class="chatlog__poll-footer-item">Multiple answers allowed</span>
|
||||
}
|
||||
|
||||
@if (poll.Results?.IsFinalized == true)
|
||||
{
|
||||
<span class="chatlog__poll-footer-item">Poll closed</span>
|
||||
}
|
||||
|
||||
@if (poll.ExpiresAt is not null && poll.Results?.IsFinalized != true)
|
||||
{
|
||||
<span class="chatlog__poll-footer-item">
|
||||
Ends
|
||||
<time datetime="@poll.ExpiresAt.Value.ToString("O")" title="@FormatDate(poll.ExpiresAt.Value, "f")">@FormatDate(poll.ExpiresAt.Value)</time>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Invites *@
|
||||
@{
|
||||
var inviteCodes = MarkdownParser
|
||||
@@ -766,4 +922,4 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,15 @@ internal static class PlainTextMessageExtensions
|
||||
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
||||
MessageKind.ThreadCreated => "Started a thread.",
|
||||
MessageKind.GuildMemberJoin => "Joined the server.",
|
||||
MessageKind.PollResult => message
|
||||
.Embeds.Select(e => e.TryGetPollResult())
|
||||
.WhereNotNull()
|
||||
.FirstOrDefault()
|
||||
is { } pollResult
|
||||
? string.IsNullOrWhiteSpace(pollResult.Question)
|
||||
? $"{message.Author.DisplayName}'s poll has closed."
|
||||
: $"{message.Author.DisplayName}'s poll {pollResult.Question} has closed."
|
||||
: "A poll has closed.",
|
||||
|
||||
_ => message.Content,
|
||||
};
|
||||
|
||||
@@ -431,6 +431,229 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chatlog__poll {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 32rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.chatlog__poll-question {
|
||||
margin-bottom: 0.6rem;
|
||||
color: @Themed("#f2f3f5", "#060607");
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.chatlog__poll-answers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.chatlog__poll-answer {
|
||||
display: flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border: 1px solid @Themed("#4e5058", "#c4c9ce");
|
||||
border-radius: 0.5rem;
|
||||
background-color: @Themed("#2b2d31", "#f2f3f5");
|
||||
color: @Themed("#dbdee1", "#2e3338");
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chatlog__poll-answer::before {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--poll-answer-fill, 0%);
|
||||
background-color: @Themed("#3a3c42", "#dfe1e5");
|
||||
content: "";
|
||||
}
|
||||
|
||||
.chatlog__poll-answer--winning {
|
||||
border-color: #23a559;
|
||||
}
|
||||
|
||||
.chatlog__poll-answer--winning::before {
|
||||
background-color: @Themed("#2f5142", "#d8f3e3");
|
||||
}
|
||||
|
||||
.chatlog__poll-answer--selected {
|
||||
border-color: #5865f2;
|
||||
}
|
||||
|
||||
.chatlog__poll-answer--selected::before {
|
||||
background-color: @Themed("#3c4164", "#e1e4ff");
|
||||
}
|
||||
|
||||
.chatlog__poll-answer-content {
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chatlog__poll-answer-details {
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.chatlog__poll-answer-selected {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 50%;
|
||||
background-color: @Themed("#f2f3f5", "#313338");
|
||||
color: @Themed("#313338", "#f2f3f5");
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.chatlog__poll-answer-emoji {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.45rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.chatlog__poll-answer-text {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chatlog__poll-answer-count,
|
||||
.chatlog__poll-answer-percentage {
|
||||
color: @Themed("#b5bac1", "#5c646c");
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chatlog__poll-answer-percentage {
|
||||
color: @Themed("#f2f3f5", "#2e3338");
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.chatlog__poll-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.45rem;
|
||||
color: @Themed("#b5bac1", "#5c646c");
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.chatlog__poll-footer-item + .chatlog__poll-footer-item::before {
|
||||
margin: 0 0.35rem;
|
||||
content: "•";
|
||||
}
|
||||
|
||||
.chatlog__poll-accessible-label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chatlog__poll-result {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 25rem;
|
||||
min-height: 3.5rem;
|
||||
margin-top: 0.45rem;
|
||||
padding: 0.45rem;
|
||||
border: 1px solid @Themed("#3f4147", "#d5d8dc");
|
||||
border-radius: 0.5rem;
|
||||
background-color: @Themed("#2b2d31", "#f2f3f5");
|
||||
}
|
||||
|
||||
.chatlog__poll-result-answer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chatlog__poll-result-emoji-container {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
margin-right: 0.55rem;
|
||||
border-radius: 0.4rem;
|
||||
background-color: @Themed("#383a40", "#e3e5e8");
|
||||
}
|
||||
|
||||
.chatlog__poll-result-emoji {
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
}
|
||||
|
||||
.chatlog__poll-result-answer-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chatlog__poll-result-answer-text {
|
||||
color: @Themed("#f2f3f5", "#060607");
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chatlog__poll-result-winner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
margin-left: 0.15rem;
|
||||
border-radius: 50%;
|
||||
background-color: #23a559;
|
||||
color: #ffffff;
|
||||
font-size: 0.65rem;
|
||||
vertical-align: 0.05rem;
|
||||
}
|
||||
|
||||
.chatlog__poll-result-answer-details {
|
||||
margin-top: 0.1rem;
|
||||
color: @Themed("#b5bac1", "#5c646c");
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.chatlog__poll-result-button {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.75rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: @Themed("#4e5058", "#d6d9dc");
|
||||
color: @Themed("#ffffff", "#1e1f22");
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chatlog__poll-result-button:hover {
|
||||
background-color: @Themed("#6d6f78", "#c4c8cc");
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.chatlog__attachment {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
@@ -1039,6 +1262,9 @@
|
||||
<symbol id="pin-icon" viewBox="0 0 18 18">
|
||||
<path fill="#b9bbbe" d="m16.908 8.39684-8.29587-8.295827-1.18584 1.184157 1.18584 1.18584-4.14834 4.1475v.00167l-1.18583-1.18583-1.185 1.18583 3.55583 3.55502-4.740831 4.74 1.185001 1.185 4.74083-4.74 3.55581 3.555 1.185-1.185-1.185-1.185 4.1475-4.14836h.0009l1.185 1.185z" />
|
||||
</symbol>
|
||||
<symbol id="poll-icon" viewBox="0 0 18 18">
|
||||
<path fill="#b9bbbe" d="M2 3h2v2H2V3Zm4 0h10v2H6V3ZM2 8h2v2H2V8Zm4 0h10v2H6V8ZM2 13h2v2H2v-2Zm4 0h10v2H6v-2Z" />
|
||||
</symbol>
|
||||
<symbol id="channel-icon" viewBox="0 0 24 24">
|
||||
<path fill="#b9bbbe" d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41045 9L8.35045 15H14.3504L15.4104 9H9.41045Z" />
|
||||
</symbol>
|
||||
@@ -1090,4 +1316,4 @@
|
||||
@* Preamble cuts off at this point *@
|
||||
<!--wmm:ignore-->
|
||||
<div class="chatlog">
|
||||
<!--/wmm:ignore-->
|
||||
<!--/wmm:ignore-->
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace DiscordChatExporter.Core.Markdown;
|
||||
internal record EmojiNode(
|
||||
// Only present on custom emoji
|
||||
Snowflake? Id,
|
||||
// Name of a custom emoji (e.g. LUL) or actual representation of a standard emoji (e.g. 🙂)
|
||||
// Name of a custom emoji (e.g., LUL) or actual representation of a standard emoji (e.g., 🙂)
|
||||
string Name,
|
||||
bool IsAnimated
|
||||
) : MarkdownNode
|
||||
@@ -20,7 +20,7 @@ internal record EmojiNode(
|
||||
|
||||
public bool IsCustomEmoji => _emoji.IsCustomEmoji;
|
||||
|
||||
// Name of a custom emoji (e.g. LUL) or name of a standard emoji (e.g. slight_smile)
|
||||
// Name of a custom emoji (e.g., LUL) or name of a standard emoji (e.g., slight_smile)
|
||||
public string Code => _emoji.Code;
|
||||
|
||||
public string ImageUrl => _emoji.ImageUrl;
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Core.Markdown;
|
||||
|
||||
// Named links can contain child nodes (e.g. [**bold URL**](https://test.com))
|
||||
// Named links can contain child nodes (e.g., [**bold URL**](https://test.com))
|
||||
internal record LinkNode(string Url, IReadOnlyList<MarkdownNode> Children)
|
||||
: MarkdownNode,
|
||||
IContainerNode
|
||||
|
||||
@@ -209,7 +209,7 @@ internal static partial class MarkdownParser
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
new Regex(
|
||||
// Build a pattern from all known emoji, sorted longest-first so that compound
|
||||
// emoji (e.g. sequences with ZWJ or skin-tone modifiers) are matched before
|
||||
// emoji (e.g., sequences with ZWJ or skin-tone modifiers) are matched before
|
||||
// their individual components.
|
||||
"("
|
||||
+ string.Join(
|
||||
@@ -264,7 +264,7 @@ internal static partial class MarkdownParser
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> MaskedLinkNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Capture [title](link)
|
||||
new Regex(@"\[(.+?)\]\((.+?)\)", DefaultRegexOptions),
|
||||
new Regex(@"\[(.+?)\]\((https?://\S*[^\.,:;""'\s])\)", DefaultRegexOptions),
|
||||
(c, s, m) => new LinkNode(m.Groups[2].Value, Parse(c, s.Relocate(m.Groups[1])))
|
||||
);
|
||||
|
||||
@@ -408,7 +408,7 @@ internal static partial class MarkdownParser
|
||||
TimestampNodeMatcher
|
||||
);
|
||||
|
||||
// Minimal set of matchers for non-multimedia formats (e.g. plain text)
|
||||
// Minimal set of matchers for non-multimedia formats (e.g., plain text)
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> MinimalNodeMatcher =
|
||||
new AggregateMatcher<MarkdownContext, MarkdownNode>(
|
||||
// Mentions
|
||||
|
||||
@@ -20,9 +20,9 @@ public class UrlBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public UrlBuilder SetQueryParameter(string key, string? value, bool ignoreUnsetValue = true)
|
||||
public UrlBuilder SetQueryParameter(string key, string? value, bool ignoreIfEmptyValue = true)
|
||||
{
|
||||
if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value))
|
||||
if (ignoreIfEmptyValue && string.IsNullOrWhiteSpace(value))
|
||||
return this;
|
||||
|
||||
var keyEncoded = Uri.EscapeDataString(key);
|
||||
|
||||
+146
-141
@@ -1,160 +1,165 @@
|
||||
<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"
|
||||
Name="DiscordChatExporter"
|
||||
ActualThemeVariantChanged="Application_OnActualThemeVariantChanged">
|
||||
<Application.DataTemplates>
|
||||
<framework:ViewManager />
|
||||
</Application.DataTemplates>
|
||||
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"
|
||||
Name="DiscordChatExporter"
|
||||
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 />
|
||||
<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" />
|
||||
<!-- Combo box -->
|
||||
<Style Selector="ComboBox">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
|
||||
<Style Selector="^ /template/ Panel#PART_RootPanel">
|
||||
<Setter Property="Height" Value="22" />
|
||||
<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" />
|
||||
<Setter Property="Background" Value="{DynamicResource MaterialPaperBrush}" />
|
||||
</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="^ /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 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>
|
||||
|
||||
<!-- Dialog host -->
|
||||
<Style Selector="dialogHostAvalonia|DialogHost">
|
||||
<Setter Property="DialogMargin" Value="0" />
|
||||
<Style Selector="^ Border#PART_HoverEffect">
|
||||
<Setter Property="Width" Value="24" />
|
||||
<Setter Property="Height" Value="24" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="dialogHostAvalonia|DialogOverlayPopupHost">
|
||||
<Setter Property="Margin" Value="48" />
|
||||
<Setter Property="Background" Value="{DynamicResource MaterialPaperBrush}" />
|
||||
<Style Selector="^ Border#PART_ThumbGrip">
|
||||
<Setter Property="Width" Value="12" />
|
||||
<Setter Property="Height" Value="12" />
|
||||
</Style>
|
||||
</Style>
|
||||
</Style>
|
||||
|
||||
<!-- Snack bar host -->
|
||||
<Style Selector="materialControls|SnackbarHost">
|
||||
<Setter Property="SnackbarHorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<!-- Run -->
|
||||
<Style Selector="Run">
|
||||
<Setter Property="BaselineAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^ /template/ ItemsControl#PART_SnackbarHostItemsContainer materialControls|Card">
|
||||
<Setter Property="Background" Value="{DynamicResource MaterialDarkBackgroundBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource MaterialDarkForegroundBrush}" />
|
||||
</Style>
|
||||
<!-- Text box -->
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="^ /template/ ItemsControl#PART_SnackbarHostItemsContainer Button">
|
||||
<Setter Property="Foreground" Value="{DynamicResource MaterialSecondaryMidBrush}" />
|
||||
</Style>
|
||||
</Style>
|
||||
<!-- Toggle button -->
|
||||
<Style Selector="ToggleButton">
|
||||
<Setter Property="TextElement.FontWeight" Value="Medium" />
|
||||
</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" />
|
||||
<!-- Toggle switch -->
|
||||
<Style Selector="ToggleSwitch">
|
||||
<Setter
|
||||
Property="materialAssists:ToggleSwitchAssist.SwitchThumbOffBackground"
|
||||
Value="{DynamicResource ToggleBackgroundBrush}"
|
||||
/>
|
||||
</Style>
|
||||
|
||||
<Style Selector="^:horizontal">
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Run -->
|
||||
<Style Selector="Run">
|
||||
<Setter Property="BaselineAlignment" Value="Center" />
|
||||
</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>
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
<ResourceDictionary x:Key="Default">
|
||||
<SolidColorBrush x:Key="ToggleBackgroundBrush" Color="#FFFFFF" />
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<SolidColorBrush x:Key="ToggleBackgroundBrush" Color="#8E8E8E" />
|
||||
</ResourceDictionary>
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<MacOSBundleIdentifier>me.tyrrrz.discordchatexporter</MacOSBundleIdentifier>
|
||||
<MacOSBundleName>DCE</MacOSBundleName>
|
||||
<MacOSBundleDisplayName>DiscordChatExporter</MacOSBundleDisplayName>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<EncryptionSalt>HimalayanPinkSalt</EncryptionSalt>
|
||||
</PropertyGroup>
|
||||
@@ -16,10 +22,6 @@
|
||||
<ProjectProperty Include="EncryptionSalt" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<PublishMacOSBundle>false</PublishMacOSBundle>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="../favicon.ico" Link="favicon.ico" />
|
||||
</ItemGroup>
|
||||
@@ -31,26 +33,20 @@
|
||||
<PackageReference Include="Cogwheel" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" />
|
||||
<PackageReference Include="DialogHost.Avalonia" />
|
||||
<PackageReference Include="Gress" />
|
||||
<PackageReference Include="MacBundle" />
|
||||
<PackageReference Include="Markdig" />
|
||||
<PackageReference Include="Material.Avalonia" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Onova" />
|
||||
<PackageReference Include="PowerKit" PrivateAssets="all" />
|
||||
<PackageReference Include="PowerKit" />
|
||||
<PackageReference Include="ThisAssembly.Project" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../DiscordChatExporter.Core/DiscordChatExporter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PublishMacOSBundle" AfterTargets="Publish" Condition="$(PublishMacOSBundle)">
|
||||
<Exec
|
||||
Command="dotnet run --file "$(ProjectDir)PublishMacOSBundle.csx" -- --publish-dir "$(PublishDir)" --icons-file "$(ProjectDir)../favicon.icns" --full-version $(Version) --short-version $(AssemblyVersion)"
|
||||
LogStandardErrorAsError="true"
|
||||
/>
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -40,7 +40,7 @@ public class DialogManager : IDisposable
|
||||
);
|
||||
|
||||
// Yield to allow DialogHost to fully reset its state before
|
||||
// another dialog is shown (e.g. when dialogs are shown sequentially).
|
||||
// another dialog is shown (e.g., when dialogs are shown sequentially).
|
||||
await Task.Yield();
|
||||
|
||||
return dialog.DialogResult;
|
||||
|
||||
@@ -58,7 +58,7 @@ public partial class LocalizationManager
|
||||
""",
|
||||
[nameof(RateLimitPreferenceLabel)] = "Rate limit preference",
|
||||
[nameof(RateLimitPreferenceTooltip)] =
|
||||
"Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.",
|
||||
"Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e., 429 responses) will be respected.",
|
||||
[nameof(ShowThreadsLabel)] = "Show threads",
|
||||
[nameof(ShowThreadsTooltip)] = "Which types of threads to show in the channel list",
|
||||
[nameof(LocaleLabel)] = "Locale",
|
||||
@@ -102,10 +102,10 @@ public partial class LocalizationManager
|
||||
[nameof(BeforeTimeTooltip)] = "Only include messages sent before this time",
|
||||
[nameof(PartitionLimitLabel)] = "Partition limit",
|
||||
[nameof(PartitionLimitTooltip)] =
|
||||
"Split the output into partitions, each limited to the specified number of messages (e.g. '100') or file size (e.g. '10mb')",
|
||||
"Split the output into partitions, each limited to the specified number of messages (e.g., '100') or file size (e.g., '10mb')",
|
||||
[nameof(MessageFilterLabel)] = "Message filter",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image'). See the documentation for more info.",
|
||||
"Only include messages that satisfy this filter (e.g., 'from:foo#1234' or 'has:image'). See the documentation for more info.",
|
||||
[nameof(ReverseMessageOrderLabel)] = "Reverse messages",
|
||||
[nameof(ReverseMessageOrderTooltip)] =
|
||||
"Export messages in reverse chronological order (newest first)",
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
#!/usr/bin/dotnet --
|
||||
#:package CliFx
|
||||
|
||||
using CliFx;
|
||||
using CliFx.Binding;
|
||||
using CliFx.Infrastructure;
|
||||
|
||||
[Command(Description = "Publishes the GUI app as a macOS .app bundle.")]
|
||||
public partial class PublishMacOSBundleCommand : ICommand
|
||||
{
|
||||
private const string BundleName = "DiscordChatExporter.app";
|
||||
private const string AppName = "DiscordChatExporter";
|
||||
private const string AppCopyright = "© Oleksii Holub";
|
||||
private const string AppIdentifier = "me.Tyrrrz.DiscordChatExporter";
|
||||
private const string AppSpokenName = "Discord Chat Exporter";
|
||||
private const string AppIconName = "AppIcon";
|
||||
|
||||
[CommandOption("publish-dir", Description = "Path to the publish output directory.")]
|
||||
public required string PublishDirPath { get; set; }
|
||||
|
||||
[CommandOption("icons-file", Description = "Path to the .icns icons file.")]
|
||||
public required string IconsFilePath { get; set; }
|
||||
|
||||
[CommandOption("full-version", Description = "Full version string (e.g. '1.2.3.4').")]
|
||||
public required string FullVersion { get; set; }
|
||||
|
||||
[CommandOption("short-version", Description = "Short version string (e.g. '1.2.3').")]
|
||||
public required string ShortVersion { get; set; }
|
||||
|
||||
public async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
// Set up paths
|
||||
var publishDirPath = Path.GetFullPath(PublishDirPath);
|
||||
var tempDirPath = Path.GetFullPath(
|
||||
Path.Combine(publishDirPath, "../publish-macos-app-temp")
|
||||
);
|
||||
|
||||
// Ensure the temporary directory is clean before use in case a previous run crashed
|
||||
if (Directory.Exists(tempDirPath))
|
||||
Directory.Delete(tempDirPath, true);
|
||||
|
||||
var bundleDirPath = Path.Combine(tempDirPath, BundleName);
|
||||
var contentsDirPath = Path.Combine(bundleDirPath, "Contents");
|
||||
|
||||
try
|
||||
{
|
||||
// Copy icons into the .app's Resources folder
|
||||
Directory.CreateDirectory(Path.Combine(contentsDirPath, "Resources"));
|
||||
File.Copy(
|
||||
IconsFilePath,
|
||||
Path.Combine(contentsDirPath, "Resources", "AppIcon.icns"),
|
||||
true
|
||||
);
|
||||
|
||||
// Generate the Info.plist metadata file with the app information
|
||||
// lang=xml
|
||||
var plistContent = $"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>{AppName}</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>{AppName}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>{AppName}</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>{AppCopyright}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>{AppIdentifier}</string>
|
||||
<key>CFBundleSpokenName</key>
|
||||
<string>{AppSpokenName}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>{AppIconName}</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>{AppIconName}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>{FullVersion}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>{ShortVersion}</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true />
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
</dict>
|
||||
</plist>
|
||||
""";
|
||||
|
||||
await File.WriteAllTextAsync(Path.Combine(contentsDirPath, "Info.plist"), plistContent);
|
||||
|
||||
// Delete the previous bundle if it exists
|
||||
var existingBundlePath = Path.Combine(publishDirPath, BundleName);
|
||||
if (Directory.Exists(existingBundlePath))
|
||||
Directory.Delete(existingBundlePath, true);
|
||||
|
||||
// Move all files from the publish directory into the MacOS directory
|
||||
Directory.CreateDirectory(Path.Combine(contentsDirPath, "MacOS"));
|
||||
foreach (var entryPath in Directory.GetFileSystemEntries(publishDirPath))
|
||||
{
|
||||
var destinationPath = Path.Combine(
|
||||
contentsDirPath,
|
||||
"MacOS",
|
||||
Path.GetFileName(entryPath)
|
||||
);
|
||||
|
||||
if (Directory.Exists(entryPath))
|
||||
Directory.Move(entryPath, destinationPath);
|
||||
else
|
||||
File.Move(entryPath, destinationPath);
|
||||
}
|
||||
|
||||
// Move the final bundle into the publish directory for upload
|
||||
Directory.Move(bundleDirPath, Path.Combine(publishDirPath, BundleName));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up the temporary directory
|
||||
if (Directory.Exists(tempDirPath))
|
||||
Directory.Delete(tempDirPath, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,307 +1,323 @@
|
||||
<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: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="*"
|
||||
PlaceholderText="{Binding LocalizationManager.TokenPlaceholderText}"
|
||||
RevealPassword="{Binding $self.IsFocused}"
|
||||
Text="{Binding Token}"
|
||||
Theme="{DynamicResource SoloTextBox}">
|
||||
<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="{Binding LocalizationManager.PullGuildsTooltip}">
|
||||
<materialIcons:MaterialIcon
|
||||
Width="24"
|
||||
Height="24"
|
||||
Kind="ArrowRight" />
|
||||
</Button>
|
||||
</TextBox.InnerRightContent>
|
||||
</TextBox>
|
||||
</materialStyles:Card>
|
||||
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: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="*"
|
||||
PlaceholderText="{Binding LocalizationManager.TokenPlaceholderText}"
|
||||
RevealPassword="{Binding $self.IsFocused}"
|
||||
Text="{Binding Token}"
|
||||
Theme="{DynamicResource SoloTextBox}"
|
||||
>
|
||||
<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="{Binding LocalizationManager.PullGuildsTooltip}"
|
||||
>
|
||||
<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="{Binding LocalizationManager.SettingsTooltip}">
|
||||
<materialIcons:MaterialIcon
|
||||
Width="24"
|
||||
Height="24"
|
||||
Kind="Settings" />
|
||||
</Button>
|
||||
</Grid>
|
||||
<!-- 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="{Binding LocalizationManager.SettingsTooltip}"
|
||||
>
|
||||
<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>
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
</Panel.Styles>
|
||||
<!-- Guilds and channels -->
|
||||
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding !!AvailableGuilds.Count}">
|
||||
<!-- Guilds -->
|
||||
<Border
|
||||
</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"
|
||||
DoubleTapped="ChannelGrid_OnDoubleTapped"
|
||||
>
|
||||
<Grid.Styles>
|
||||
<Style Selector="Grid">
|
||||
<Style Selector="^:not(.category)">
|
||||
<Setter Property="ToolTip.Tip">
|
||||
<Template>
|
||||
<TextBlock>
|
||||
<Run Text="{Binding #UserControl.DataContext.LocalizationManager.LastMessageSentTooltip}" />
|
||||
<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"
|
||||
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}" />
|
||||
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" />
|
||||
|
||||
<!-- 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>
|
||||
<Style Selector="^.voice">
|
||||
<Setter Property="Kind" Value="VolumeHigh" />
|
||||
</Style>
|
||||
</Style>
|
||||
</materialIcons:MaterialIcon.Styles>
|
||||
</materialIcons:MaterialIcon>
|
||||
|
||||
<!-- 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"
|
||||
DoubleTapped="ChannelGrid_OnDoubleTapped">
|
||||
<Grid.Styles>
|
||||
<Style Selector="Grid">
|
||||
<Style Selector="^:not(.category)">
|
||||
<Setter Property="ToolTip.Tip">
|
||||
<Template>
|
||||
<TextBlock>
|
||||
<Run Text="{Binding #UserControl.DataContext.LocalizationManager.LastMessageSentTooltip}" />
|
||||
<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 name -->
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
Margin="0,12"
|
||||
FontSize="14"
|
||||
Text="{Binding Channel.Name, Mode=OneWay}"
|
||||
/>
|
||||
|
||||
<!-- 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" />
|
||||
<!-- 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>
|
||||
|
||||
<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">
|
||||
<StackPanel Margin="32,16" Spacing="0">
|
||||
<!-- User token -->
|
||||
<TextBlock>
|
||||
<InlineUIContainer>
|
||||
<materialIcons:MaterialIcon
|
||||
Width="18"
|
||||
Height="18"
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Account" />
|
||||
</InlineUIContainer>
|
||||
<Run Text="" />
|
||||
<Run
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding LocalizationManager.TokenPersonalHeader}" />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenPersonalTosWarning, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenPersonalInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<!-- Bot token -->
|
||||
<TextBlock Margin="0,12,0,0">
|
||||
<InlineUIContainer>
|
||||
<materialIcons:MaterialIcon
|
||||
Width="18"
|
||||
Height="18"
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Robot" />
|
||||
</InlineUIContainer>
|
||||
<Run Text="" />
|
||||
<Run
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding LocalizationManager.TokenBotHeader}" />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenBotInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock
|
||||
Margin="0,12,0,0"
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenHelpText, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</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}">
|
||||
<!-- Placeholder / usage instructions -->
|
||||
<Panel IsVisible="{Binding !AvailableGuilds.Count}">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="32,16" Spacing="0">
|
||||
<!-- User token -->
|
||||
<TextBlock>
|
||||
<InlineUIContainer>
|
||||
<materialIcons:MaterialIcon
|
||||
Width="32"
|
||||
Height="32"
|
||||
Kind="Download" />
|
||||
</Button>
|
||||
</Panel>
|
||||
</DockPanel>
|
||||
Width="18"
|
||||
Height="18"
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Account"
|
||||
/>
|
||||
</InlineUIContainer>
|
||||
<Run Text="" />
|
||||
<Run
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding LocalizationManager.TokenPersonalHeader}"
|
||||
/>
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenPersonalTosWarning, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap"
|
||||
/>
|
||||
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenPersonalInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap"
|
||||
/>
|
||||
|
||||
<!-- Bot token -->
|
||||
<TextBlock Margin="0,12,0,0">
|
||||
<InlineUIContainer>
|
||||
<materialIcons:MaterialIcon
|
||||
Width="18"
|
||||
Height="18"
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Robot"
|
||||
/>
|
||||
</InlineUIContainer>
|
||||
<Run Text="" />
|
||||
<Run
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="{Binding LocalizationManager.TokenBotHeader}"
|
||||
/>
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenBotInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap"
|
||||
/>
|
||||
|
||||
<TextBlock
|
||||
Margin="0,12,0,0"
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenHelpText, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap"
|
||||
/>
|
||||
</StackPanel>
|
||||
</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>
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
<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>
|
||||
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>
|
||||
|
||||
@@ -1,285 +1,308 @@
|
||||
<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">
|
||||
<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>
|
||||
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"
|
||||
>
|
||||
<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="{Binding LocalizationManager.ChannelsSelectedText}" />
|
||||
</TextBlock>
|
||||
<!-- 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="{Binding LocalizationManager.ChannelsSelectedText}" />
|
||||
</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}}">
|
||||
<Run Text="{Binding Channels[0].Parent.Name, StringFormat='{}{0} / ', FallbackValue='', TargetNullValue='', Mode=OneWay}" /><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="{Binding LocalizationManager.OutputPathLabel}"
|
||||
Text="{Binding OutputPath}"
|
||||
Theme="{DynamicResource FilledTextBox}">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock
|
||||
MaxWidth="400"
|
||||
Inlines="{Binding LocalizationManager.OutputPathTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
TextWrapping="Wrap" />
|
||||
</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="{Binding LocalizationManager.FormatLabel}"
|
||||
ItemsSource="{Binding AvailableFormats}"
|
||||
SelectedItem="{Binding SelectedFormat}"
|
||||
Theme="{DynamicResource MaterialFilledComboBox}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.FormatTooltip}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!-- Advanced section -->
|
||||
<StackPanel
|
||||
Margin="16,8"
|
||||
IsVisible="{Binding IsAdvancedSectionDisplayed}"
|
||||
Orientation="Vertical"
|
||||
Spacing="16">
|
||||
<!-- Date limits -->
|
||||
<Grid
|
||||
ColumnDefinitions="*,*"
|
||||
ColumnSpacing="16"
|
||||
RowDefinitions="*,*"
|
||||
RowSpacing="16">
|
||||
<DatePicker
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AfterDateLabel}"
|
||||
SelectedDate="{Binding AfterDate}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.AfterDateTooltip}">
|
||||
<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"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.BeforeDateLabel}"
|
||||
SelectedDate="{Binding BeforeDate}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.BeforeDateTooltip}">
|
||||
<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"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AfterTimeLabel}"
|
||||
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
|
||||
IsEnabled="{Binding IsAfterDateSet}"
|
||||
SelectedTime="{Binding AfterTime}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.AfterTimeTooltip}">
|
||||
<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"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.BeforeTimeLabel}"
|
||||
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
|
||||
IsEnabled="{Binding IsBeforeDateSet}"
|
||||
SelectedTime="{Binding BeforeTime}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.BeforeTimeTooltip}">
|
||||
<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
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.PartitionLimitLabel}"
|
||||
Text="{Binding PartitionLimitValue}"
|
||||
Theme="{DynamicResource FilledTextBox}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.PartitionLimitTooltip}" />
|
||||
|
||||
<!-- Filtering -->
|
||||
<TextBox
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.MessageFilterLabel}"
|
||||
Text="{Binding MessageFilterValue}"
|
||||
Theme="{DynamicResource FilledTextBox}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.MessageFilterTooltip}" />
|
||||
|
||||
<!-- Reverse message order -->
|
||||
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.ReverseMessageOrderTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ReverseMessageOrderLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsReverseMessageOrder}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Markdown formatting -->
|
||||
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.FormatMarkdownTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.FormatMarkdownLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldFormatMarkdown}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Download assets -->
|
||||
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.DownloadAssetsTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.DownloadAssetsLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldDownloadAssets}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Reuse assets -->
|
||||
<DockPanel
|
||||
IsEnabled="{Binding ShouldDownloadAssets}"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.ReuseAssetsTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ReuseAssetsLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldReuseAssets}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Assets path -->
|
||||
<TextBox
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AssetsDirPathLabel}"
|
||||
IsEnabled="{Binding ShouldDownloadAssets}"
|
||||
Text="{Binding AssetsDirPath}"
|
||||
Theme="{DynamicResource FilledTextBox}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.AssetsDirPathTooltip}">
|
||||
<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"
|
||||
ColumnSpacing="8">
|
||||
<ToggleButton
|
||||
Grid.Column="0"
|
||||
IsChecked="{Binding IsAdvancedSectionDisplayed}"
|
||||
Theme="{DynamicResource MaterialOutlineButton}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.AdvancedOptionsTooltip}">
|
||||
<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="{Binding LocalizationManager.ExportButton}"
|
||||
IsDefault="True"
|
||||
Theme="{DynamicResource MaterialOutlineButton}" />
|
||||
<Button
|
||||
Grid.Column="3"
|
||||
Command="{Binding CloseCommand}"
|
||||
Content="{Binding LocalizationManager.CancelButton}"
|
||||
IsCancel="True"
|
||||
Theme="{DynamicResource MaterialOutlineButton}" />
|
||||
</Grid>
|
||||
<!-- 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}}"
|
||||
>
|
||||
<Run Text="{Binding Channels[0].Parent.Name, StringFormat='{}{0} / ', FallbackValue='', TargetNullValue='', Mode=OneWay}" />
|
||||
<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="{Binding LocalizationManager.OutputPathLabel}"
|
||||
Text="{Binding OutputPath}"
|
||||
Theme="{DynamicResource FilledTextBox}"
|
||||
>
|
||||
<ToolTip.Tip>
|
||||
<TextBlock
|
||||
MaxWidth="400"
|
||||
Inlines="{Binding LocalizationManager.OutputPathTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
TextWrapping="Wrap"
|
||||
/>
|
||||
</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="{Binding LocalizationManager.FormatLabel}"
|
||||
ItemsSource="{Binding AvailableFormats}"
|
||||
SelectedItem="{Binding SelectedFormat}"
|
||||
Theme="{DynamicResource MaterialFilledComboBox}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.FormatTooltip}"
|
||||
>
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!-- Advanced section -->
|
||||
<StackPanel
|
||||
Margin="16,8"
|
||||
IsVisible="{Binding IsAdvancedSectionDisplayed}"
|
||||
Orientation="Vertical"
|
||||
Spacing="16"
|
||||
>
|
||||
<!-- Date limits -->
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="16" RowDefinitions="*,*" RowSpacing="16">
|
||||
<DatePicker
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AfterDateLabel}"
|
||||
SelectedDate="{Binding AfterDate}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.AfterDateTooltip}"
|
||||
>
|
||||
<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"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.BeforeDateLabel}"
|
||||
SelectedDate="{Binding BeforeDate}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.BeforeDateTooltip}"
|
||||
>
|
||||
<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"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AfterTimeLabel}"
|
||||
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
|
||||
IsEnabled="{Binding IsAfterDateSet}"
|
||||
SelectedTime="{Binding AfterTime}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.AfterTimeTooltip}"
|
||||
>
|
||||
<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"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.BeforeTimeLabel}"
|
||||
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
|
||||
IsEnabled="{Binding IsBeforeDateSet}"
|
||||
SelectedTime="{Binding BeforeTime}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.BeforeTimeTooltip}"
|
||||
>
|
||||
<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
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.PartitionLimitLabel}"
|
||||
Text="{Binding PartitionLimitValue}"
|
||||
Theme="{DynamicResource FilledTextBox}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.PartitionLimitTooltip}"
|
||||
/>
|
||||
|
||||
<!-- Filtering -->
|
||||
<TextBox
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.MessageFilterLabel}"
|
||||
Text="{Binding MessageFilterValue}"
|
||||
Theme="{DynamicResource FilledTextBox}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.MessageFilterTooltip}"
|
||||
/>
|
||||
|
||||
<!-- Reverse message order -->
|
||||
<DockPanel
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.ReverseMessageOrderTooltip}"
|
||||
>
|
||||
<TextBlock
|
||||
DockPanel.Dock="Left"
|
||||
Text="{Binding LocalizationManager.ReverseMessageOrderLabel}"
|
||||
/>
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsReverseMessageOrder}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Markdown formatting -->
|
||||
<DockPanel
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.FormatMarkdownTooltip}"
|
||||
>
|
||||
<TextBlock
|
||||
DockPanel.Dock="Left"
|
||||
Text="{Binding LocalizationManager.FormatMarkdownLabel}"
|
||||
/>
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldFormatMarkdown}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Download assets -->
|
||||
<DockPanel
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.DownloadAssetsTooltip}"
|
||||
>
|
||||
<TextBlock
|
||||
DockPanel.Dock="Left"
|
||||
Text="{Binding LocalizationManager.DownloadAssetsLabel}"
|
||||
/>
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldDownloadAssets}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Reuse assets -->
|
||||
<DockPanel
|
||||
IsEnabled="{Binding ShouldDownloadAssets}"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.ReuseAssetsTooltip}"
|
||||
>
|
||||
<TextBlock
|
||||
DockPanel.Dock="Left"
|
||||
Text="{Binding LocalizationManager.ReuseAssetsLabel}"
|
||||
/>
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldReuseAssets}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Assets path -->
|
||||
<TextBox
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AssetsDirPathLabel}"
|
||||
IsEnabled="{Binding ShouldDownloadAssets}"
|
||||
Text="{Binding AssetsDirPath}"
|
||||
Theme="{DynamicResource FilledTextBox}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.AssetsDirPathTooltip}"
|
||||
>
|
||||
<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" ColumnSpacing="8">
|
||||
<ToggleButton
|
||||
Grid.Column="0"
|
||||
IsChecked="{Binding IsAdvancedSectionDisplayed}"
|
||||
Theme="{DynamicResource MaterialOutlineButton}"
|
||||
ToolTip.Tip="{Binding LocalizationManager.AdvancedOptionsTooltip}"
|
||||
>
|
||||
<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="{Binding LocalizationManager.ExportButton}"
|
||||
IsDefault="True"
|
||||
Theme="{DynamicResource MaterialOutlineButton}"
|
||||
/>
|
||||
<Button
|
||||
Grid.Column="3"
|
||||
Command="{Binding CloseCommand}"
|
||||
Content="{Binding LocalizationManager.CancelButton}"
|
||||
IsCancel="True"
|
||||
Theme="{DynamicResource MaterialOutlineButton}"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,72 +1,73 @@
|
||||
<UserControl
|
||||
x:Class="DiscordChatExporter.Gui.Views.Dialogs.MessageBoxView"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
|
||||
xmlns:system="clr-namespace:System;assembly=System.Runtime"
|
||||
Width="500"
|
||||
x:DataType="dialogs:MessageBoxViewModel">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- Title -->
|
||||
x:Class="DiscordChatExporter.Gui.Views.Dialogs.MessageBoxView"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
|
||||
xmlns:system="clr-namespace:System;assembly=System.Runtime"
|
||||
Width="500"
|
||||
x:DataType="dialogs:MessageBoxViewModel"
|
||||
>
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<!-- Title -->
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Margin="16"
|
||||
FontSize="19"
|
||||
FontWeight="Light"
|
||||
Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Title}"
|
||||
/>
|
||||
|
||||
<!-- Message -->
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Padding="0,8"
|
||||
BorderBrush="{DynamicResource MaterialDividerBrush}"
|
||||
BorderThickness="0,1"
|
||||
>
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock Margin="16,8" Text="{Binding Message}" TextWrapping="Wrap" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<UniformGrid Grid.Row="2" Margin="8" Columns="{Binding ButtonsCount}">
|
||||
<!-- OK -->
|
||||
<Button
|
||||
Margin="8"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
Command="{Binding CloseCommand}"
|
||||
IsDefault="True"
|
||||
IsVisible="{Binding IsDefaultButtonVisible}"
|
||||
Theme="{DynamicResource MaterialOutlineButton}"
|
||||
ToolTip.Tip="{Binding DefaultButtonText}"
|
||||
>
|
||||
<Button.CommandParameter>
|
||||
<system:Boolean>True</system:Boolean>
|
||||
</Button.CommandParameter>
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Margin="16"
|
||||
FontSize="19"
|
||||
FontWeight="Light"
|
||||
Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Title}" />
|
||||
Text="{Binding DefaultButtonText}"
|
||||
TextAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<!-- Message -->
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Padding="0,8"
|
||||
BorderBrush="{DynamicResource MaterialDividerBrush}"
|
||||
BorderThickness="0,1">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock
|
||||
Margin="16,8"
|
||||
Text="{Binding Message}"
|
||||
TextWrapping="Wrap" />
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<UniformGrid
|
||||
Grid.Row="2"
|
||||
Margin="8"
|
||||
Columns="{Binding ButtonsCount}">
|
||||
<!-- OK -->
|
||||
<Button
|
||||
Margin="8"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
Command="{Binding CloseCommand}"
|
||||
IsDefault="True"
|
||||
IsVisible="{Binding IsDefaultButtonVisible}"
|
||||
Theme="{DynamicResource MaterialOutlineButton}"
|
||||
ToolTip.Tip="{Binding DefaultButtonText}">
|
||||
<Button.CommandParameter>
|
||||
<system:Boolean>True</system:Boolean>
|
||||
</Button.CommandParameter>
|
||||
<TextBlock
|
||||
Text="{Binding DefaultButtonText}"
|
||||
TextAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Button>
|
||||
|
||||
<!-- Cancel -->
|
||||
<Button
|
||||
Margin="8"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
Command="{Binding CloseCommand}"
|
||||
IsCancel="True"
|
||||
IsVisible="{Binding IsCancelButtonVisible}"
|
||||
Theme="{DynamicResource MaterialOutlineButton}"
|
||||
ToolTip.Tip="{Binding CancelButtonText}">
|
||||
<TextBlock
|
||||
Text="{Binding CancelButtonText}"
|
||||
TextAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Button>
|
||||
</UniformGrid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
<!-- Cancel -->
|
||||
<Button
|
||||
Margin="8"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
Command="{Binding CloseCommand}"
|
||||
IsCancel="True"
|
||||
IsVisible="{Binding IsCancelButtonVisible}"
|
||||
Theme="{DynamicResource MaterialOutlineButton}"
|
||||
ToolTip.Tip="{Binding CancelButtonText}"
|
||||
>
|
||||
<TextBlock
|
||||
Text="{Binding CancelButtonText}"
|
||||
TextAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
/>
|
||||
</Button>
|
||||
</UniformGrid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,148 +1,187 @@
|
||||
<UserControl
|
||||
x:Class="DiscordChatExporter.Gui.Views.Dialogs.SettingsView"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
|
||||
Width="380"
|
||||
x:DataType="dialogs:SettingsViewModel">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Margin="16"
|
||||
FontSize="19"
|
||||
FontWeight="Light"
|
||||
Text="{Binding LocalizationManager.SettingsTitle}" />
|
||||
x:Class="DiscordChatExporter.Gui.Views.Dialogs.SettingsView"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
|
||||
Width="380"
|
||||
x:DataType="dialogs:SettingsViewModel"
|
||||
>
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Margin="16"
|
||||
FontSize="19"
|
||||
FontWeight="Light"
|
||||
Text="{Binding LocalizationManager.SettingsTitle}"
|
||||
/>
|
||||
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Padding="0,8"
|
||||
BorderBrush="{DynamicResource MaterialDividerBrush}"
|
||||
BorderThickness="0,1">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel
|
||||
Margin="16,8"
|
||||
Orientation="Vertical"
|
||||
Spacing="16">
|
||||
<!-- Theme -->
|
||||
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.ThemeTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ThemeLabel}" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
ItemsSource="{Binding AvailableThemes}"
|
||||
SelectedItem="{Binding Theme}" />
|
||||
</DockPanel>
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Padding="0,8"
|
||||
BorderBrush="{DynamicResource MaterialDividerBrush}"
|
||||
BorderThickness="0,1"
|
||||
>
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Margin="16,8" Orientation="Vertical" Spacing="16">
|
||||
<!-- Theme -->
|
||||
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.ThemeTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ThemeLabel}" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
ItemsSource="{Binding AvailableThemes}"
|
||||
SelectedItem="{Binding Theme}"
|
||||
/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Language -->
|
||||
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.LanguageTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.LanguageLabel}" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
ItemsSource="{Binding AvailableLanguages}"
|
||||
SelectedItem="{Binding Language}" />
|
||||
</DockPanel>
|
||||
<!-- Language -->
|
||||
<DockPanel
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.LanguageTooltip}"
|
||||
>
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.LanguageLabel}" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
ItemsSource="{Binding AvailableLanguages}"
|
||||
SelectedItem="{Binding Language}"
|
||||
/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Auto-updates -->
|
||||
<DockPanel
|
||||
IsVisible="{Binding IsAutoUpdateAvailable}"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.AutoUpdateTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.AutoUpdateLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsAutoUpdateEnabled}" />
|
||||
</DockPanel>
|
||||
<!-- Auto-updates -->
|
||||
<DockPanel
|
||||
IsVisible="{Binding IsAutoUpdateAvailable}"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.AutoUpdateTooltip}"
|
||||
>
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.AutoUpdateLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsAutoUpdateEnabled}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Persist token -->
|
||||
<DockPanel LastChildFill="False">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock
|
||||
MaxWidth="400"
|
||||
Inlines="{Binding LocalizationManager.PersistTokenTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
TextWrapping="Wrap" />
|
||||
</ToolTip.Tip>
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.PersistTokenLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
|
||||
</DockPanel>
|
||||
<!-- Persist token -->
|
||||
<DockPanel LastChildFill="False">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock
|
||||
MaxWidth="400"
|
||||
Inlines="{Binding LocalizationManager.PersistTokenTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
TextWrapping="Wrap"
|
||||
/>
|
||||
</ToolTip.Tip>
|
||||
<TextBlock
|
||||
DockPanel.Dock="Left"
|
||||
Text="{Binding LocalizationManager.PersistTokenLabel}"
|
||||
/>
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Rate limit preference -->
|
||||
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.RateLimitPreferenceTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.RateLimitPreferenceLabel}" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
ItemsSource="{Binding AvailableRateLimitPreferences}"
|
||||
SelectedItem="{Binding RateLimitPreference}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={x:Static converters:RateLimitPreferenceToStringConverter.Instance}}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</DockPanel>
|
||||
<!-- Rate limit preference -->
|
||||
<DockPanel
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.RateLimitPreferenceTooltip}"
|
||||
>
|
||||
<TextBlock
|
||||
DockPanel.Dock="Left"
|
||||
Text="{Binding LocalizationManager.RateLimitPreferenceLabel}"
|
||||
/>
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
ItemsSource="{Binding AvailableRateLimitPreferences}"
|
||||
SelectedItem="{Binding RateLimitPreference}"
|
||||
>
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={x:Static converters:RateLimitPreferenceToStringConverter.Instance}}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Thread inclusion mode -->
|
||||
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.ShowThreadsTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ShowThreadsLabel}" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
ItemsSource="{Binding AvailableThreadInclusionModes}"
|
||||
SelectedItem="{Binding ThreadInclusionMode}" />
|
||||
</DockPanel>
|
||||
<!-- Thread inclusion mode -->
|
||||
<DockPanel
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.ShowThreadsTooltip}"
|
||||
>
|
||||
<TextBlock
|
||||
DockPanel.Dock="Left"
|
||||
Text="{Binding LocalizationManager.ShowThreadsLabel}"
|
||||
/>
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
ItemsSource="{Binding AvailableThreadInclusionModes}"
|
||||
SelectedItem="{Binding ThreadInclusionMode}"
|
||||
/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- Locale -->
|
||||
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.LocaleTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.LocaleLabel}" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
ItemsSource="{Binding AvailableLocales}"
|
||||
SelectedItem="{Binding Locale}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={x:Static converters:LocaleToDisplayNameStringConverter.Instance}}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</DockPanel>
|
||||
<!-- Locale -->
|
||||
<DockPanel
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.LocaleTooltip}"
|
||||
>
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.LocaleLabel}" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
ItemsSource="{Binding AvailableLocales}"
|
||||
SelectedItem="{Binding Locale}"
|
||||
>
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={x:Static converters:LocaleToDisplayNameStringConverter.Instance}}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</DockPanel>
|
||||
|
||||
<!-- UTC normalization -->
|
||||
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.NormalizeToUtcTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.NormalizeToUtcLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsUtcNormalizationEnabled}" />
|
||||
</DockPanel>
|
||||
<!-- UTC normalization -->
|
||||
<DockPanel
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.NormalizeToUtcTooltip}"
|
||||
>
|
||||
<TextBlock
|
||||
DockPanel.Dock="Left"
|
||||
Text="{Binding LocalizationManager.NormalizeToUtcLabel}"
|
||||
/>
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsUtcNormalizationEnabled}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Parallel limit -->
|
||||
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.ParallelLimitTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ParallelLimitLabel}" />
|
||||
<StackPanel
|
||||
DockPanel.Dock="Right"
|
||||
Orientation="Horizontal"
|
||||
Spacing="10">
|
||||
<TextBlock Text="{Binding ParallelLimit}" />
|
||||
<Slider
|
||||
Width="150"
|
||||
IsSnapToTickEnabled="True"
|
||||
Maximum="10"
|
||||
Minimum="1"
|
||||
TickFrequency="1"
|
||||
Value="{Binding ParallelLimit}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
<!-- Parallel limit -->
|
||||
<DockPanel
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.ParallelLimitTooltip}"
|
||||
>
|
||||
<TextBlock
|
||||
DockPanel.Dock="Left"
|
||||
Text="{Binding LocalizationManager.ParallelLimitLabel}"
|
||||
/>
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="10">
|
||||
<TextBlock Text="{Binding ParallelLimit}" />
|
||||
<Slider
|
||||
Width="150"
|
||||
IsSnapToTickEnabled="True"
|
||||
Maximum="10"
|
||||
Minimum="1"
|
||||
TickFrequency="1"
|
||||
Value="{Binding ParallelLimit}"
|
||||
/>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- Close button -->
|
||||
<Button
|
||||
Grid.Row="2"
|
||||
Margin="16"
|
||||
HorizontalAlignment="Stretch"
|
||||
Command="{Binding CloseCommand}"
|
||||
Content="{Binding LocalizationManager.CloseButton}"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
Theme="{DynamicResource MaterialOutlineButton}" />
|
||||
</Grid>
|
||||
<!-- Close button -->
|
||||
<Button
|
||||
Grid.Row="2"
|
||||
Margin="16"
|
||||
HorizontalAlignment="Stretch"
|
||||
Command="{Binding CloseCommand}"
|
||||
Content="{Binding LocalizationManager.CloseButton}"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
Theme="{DynamicResource MaterialOutlineButton}"
|
||||
/>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
<Window
|
||||
x:Class="DiscordChatExporter.Gui.Views.MainView"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:dialogHostAvalonia="clr-namespace:DialogHostAvalonia;assembly=DialogHost.Avalonia"
|
||||
xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
||||
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
|
||||
Title="{Binding Title}"
|
||||
Width="625"
|
||||
Height="665"
|
||||
MinWidth="600"
|
||||
MinHeight="400"
|
||||
x:DataType="viewModels:MainViewModel"
|
||||
Icon="/favicon.ico"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<dialogHostAvalonia:DialogHost x:Name="DialogHost" CloseOnClickAway="False">
|
||||
<materialStyles:SnackbarHost HostName="Root" SnackbarMaxCounts="3">
|
||||
<ContentControl Content="{Binding Dashboard}" />
|
||||
</materialStyles:SnackbarHost>
|
||||
</dialogHostAvalonia:DialogHost>
|
||||
x:Class="DiscordChatExporter.Gui.Views.MainView"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:dialogHostAvalonia="clr-namespace:DialogHostAvalonia;assembly=DialogHost.Avalonia"
|
||||
xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
||||
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
|
||||
Title="{Binding Title}"
|
||||
Width="625"
|
||||
Height="665"
|
||||
MinWidth="600"
|
||||
MinHeight="400"
|
||||
x:DataType="viewModels:MainViewModel"
|
||||
Icon="/favicon.ico"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
>
|
||||
<dialogHostAvalonia:DialogHost x:Name="DialogHost" CloseOnClickAway="False">
|
||||
<materialStyles:SnackbarHost HostName="Root" SnackbarMaxCounts="3">
|
||||
<ContentControl Content="{Binding Dashboard}" />
|
||||
</materialStyles:SnackbarHost>
|
||||
</dialogHostAvalonia:DialogHost>
|
||||
</Window>
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user