Compare commits

...

7 Commits

Author SHA1 Message Date
tyrrrz b89dd4c924 Refactor 2026-07-25 21:18:39 +03:00
tyrrrz 427c6021ac Fix grammar 2026-07-25 20:28:29 +03:00
Kushida 91a8daf3a7 Fix reverse exports with an after boundary (#1569) 2026-07-25 20:28:06 +03:00
Cap LaBuff 0358045c94 Only display http(s) URLs as links in HTML export (#1568) 2026-07-14 12:09:42 +03:00
tyrrrz 31de8a5b06 Apply updated formatting rules 2026-07-04 18:51:27 +03:00
dependabot[bot] efd430ed29 Bump the actions group with 3 updates (#1564)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 18:49:28 +03:00
dependabot[bot] 623c688e1b Bump the nuget group with 14 updates (#1565)
Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 18:48:21 +03:00
30 changed files with 1273 additions and 1143 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ $ docker pull tyrrrz/discordchatexporter:stable
Note the `:stable` tag. DiscordChatExporter images are tagged according to the following patterns: 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. - `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. - `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). You can see all available tags [here](https://hub.docker.com/r/tyrrrz/discordchatexporter/tags?ordering=name).
+2 -2
View File
@@ -64,9 +64,9 @@ exit 0
- `tokenhere` with your [Token](Token-and-IDs.md). - `tokenhere` with your [Token](Token-and-IDs.md).
- `channelhere` with a [Channel ID](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. - `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. - `formathere` with one of the available export formats.
> **Note**: > **Note**:
+2 -2
View File
@@ -68,9 +68,9 @@ exit 0
- `tokenhere` with your [Token](Token-and-IDs.md) - `tokenhere` with your [Token](Token-and-IDs.md)
- `channelhere` with a [Channel ID](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 - `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 - `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:`. To quickly get file or folder paths, select the file/folder, then hit Command+I (⌘I) and copy what's after `Where:`.
+2 -2
View File
@@ -33,9 +33,9 @@ exit
- `tokenhere` with your [Token](Token-and-IDs.md) - `tokenhere` with your [Token](Token-and-IDs.md)
- `channelhere` with a [Channel ID](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 - `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 - `formathere` with one of the available export formats
Make sure not to delete the quotes (") Make sure not to delete the quotes (")
+4 -4
View File
@@ -77,7 +77,7 @@ format is `HtmlDark`.
#### Changing the output filename #### 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 ```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
@@ -186,21 +186,21 @@ locales. The default locale is `en-US`.
#### Date ranges #### Date ranges
**Messages sent before a date** **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 ```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18 ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
``` ```
**Messages sent after a date** **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 ```console
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" ./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
``` ```
**Messages sent in a date range** **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: September 17th, 2019 11:34 PM and September 18th:
```console ```console
+8 -8
View File
@@ -12,7 +12,7 @@
After extracting the `.zip`, run `DiscordChatExporter.exe` **(Windows)**, or `DiscordChatExporter` **(Linux)**. 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 cant be opened" error will be shown. If you skip these steps, the "DiscordChatExporter is damaged and cant 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"). 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 - **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. - **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**: > **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. > 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. - **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 ## Settings
- **Auto-update** - Perform automatic updates on every launch. - **Auto-update** - Perform automatic updates on every launch.
Default: Enabled Default: Enabled
> **Note**: > **Note**:
> Keep this option enabled to receive the latest features and bug fixes! > Keep this option enabled to receive the latest features and bug fixes!
- **Dark mode** - Use darker colors in the UI (User Interface). - **Dark mode** - Use darker colors in the UI (User Interface).
Default: Disabled Default: Disabled
- **Persist token** - Persist last used token between sessions. - **Persist token** - Persist last used token between sessions.
Default: Enabled Default: Enabled
- **Show threads** - Controls whether threads are shown in the channel list. - **Show threads** - Controls whether threads are shown in the channel list.
Default: none Default: none
- **Locale** - Customize how dates are formatted in the exported files. - **Locale** - Customize how dates are formatted in the exported files.
- **Date format** - Customize how dates are formatted in the exported files in the settings menu (). - **Date format** - Customize how dates are formatted in the exported files in the settings menu ().
- **Parallel limit** - The number of channels that will be exported at the same time. - **Parallel limit** - The number of channels that will be exported at the same time.
Default: 1 Default: 1
> **Note**: > **Note**:
> Try to keep this number low so that your account doesn't get flagged. > Try to keep this number low so that your account doesn't get flagged.
- **Normalize to UTC** - Convert all dates to UTC before exporting. - **Normalize to UTC** - Convert all dates to UTC before exporting.
+2 -2
View File
@@ -23,7 +23,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Docker Buildx - name: Install Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
@@ -55,7 +55,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Docker Buildx - name: Install Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
+7 -7
View File
@@ -26,10 +26,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
# Build the project separately to discern between build and format errors # Build the project separately to discern between build and format errors
- name: Build - name: Build
@@ -62,10 +62,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
- name: Run tests - name: Run tests
env: env:
@@ -81,7 +81,7 @@ jobs:
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
@@ -117,10 +117,10 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install .NET - name: Install .NET
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
- name: Publish app - name: Publish app
run: > run: >
+14 -14
View File
@@ -4,38 +4,38 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="AngleSharp" Version="1.4.0" /> <PackageVersion Include="AngleSharp" Version="1.5.1" />
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.8.0" /> <PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.8.0" />
<PackageVersion Include="AsyncKeyedLock" Version="8.0.2" /> <PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
<PackageVersion Include="Avalonia" Version="12.0.4" /> <PackageVersion Include="Avalonia" Version="12.0.5" />
<PackageVersion Include="Avalonia.Desktop" Version="12.0.4" /> <PackageVersion Include="Avalonia.Desktop" Version="12.0.5" />
<PackageVersion Include="CliFx" Version="3.0.0" /> <PackageVersion Include="CliFx" Version="3.0.0" />
<PackageVersion Include="Cogwheel" Version="2.1.1" /> <PackageVersion Include="Cogwheel" Version="2.1.1" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" /> <PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.6" /> <PackageVersion Include="CSharpier.MsBuild" Version="1.3.0" />
<PackageVersion Include="Deorcify" Version="2.0.1" /> <PackageVersion Include="Deorcify" Version="2.0.1" />
<PackageVersion Include="DialogHost.Avalonia" Version="0.12.2" /> <PackageVersion Include="DialogHost.Avalonia" Version="0.12.2" />
<PackageVersion Include="FluentAssertions" Version="8.10.0" /> <PackageVersion Include="FluentAssertions" Version="8.10.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.4" /> <PackageVersion Include="GitHubActionsTestLogger" Version="3.0.4" />
<PackageVersion Include="Gress" Version="2.2.0" /> <PackageVersion Include="Gress" Version="2.2.0" />
<PackageVersion Include="JsonExtensions" Version="1.2.0" /> <PackageVersion Include="JsonExtensions" Version="1.2.3" />
<PackageVersion Include="Markdig" Version="1.2.0" /> <PackageVersion Include="Markdig" Version="1.3.2" />
<PackageVersion Include="Material.Avalonia" Version="3.16.1" /> <PackageVersion Include="Material.Avalonia" Version="3.16.1" />
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.2" /> <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.9" />
<PackageVersion <PackageVersion
Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
Version="10.0.8" Version="10.0.9"
/> />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" /> <PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Onova" Version="2.6.13" /> <PackageVersion Include="Onova" Version="2.6.13" />
<PackageVersion Include="Polly" Version="8.6.6" /> <PackageVersion Include="Polly" Version="8.7.0" />
<PackageVersion Include="PowerKit" Version="2.0.1" /> <PackageVersion Include="PowerKit" Version="2.0.2" />
<PackageVersion Include="RazorBlade" Version="1.0.0" /> <PackageVersion Include="RazorBlade" Version="1.0.0" />
<PackageVersion Include="Spectre.Console" Version="0.55.2" /> <PackageVersion Include="Spectre.Console" Version="0.57.1" />
<PackageVersion Include="Superpower" Version="3.1.0" /> <PackageVersion Include="Superpower" Version="3.1.0" />
<PackageVersion Include="ThisAssembly.Project" Version="2.1.2" /> <PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
<PackageVersion Include="WebMarkupMin.Core" Version="2.21.0" /> <PackageVersion Include="WebMarkupMin.Core" Version="2.21.0" />
@@ -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] [Fact]
public async Task I_can_filter_the_export_to_only_include_messages_sent_before_the_specified_date() public async Task I_can_filter_the_export_to_only_include_messages_sent_before_the_specified_date()
{ {
@@ -30,7 +30,7 @@ public abstract class DiscordCommandBase : ICommand
[CommandOption( [CommandOption(
"respect-rate-limits", "respect-rate-limits",
Description = "Whether to respect advisory 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; public bool ShouldRespectRateLimits { get; set; } = true;
@@ -59,7 +59,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
"partition", "partition",
'p', 'p',
Description = "Split the output into partitions, each limited to the specified " 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; public PartitionLimit PartitionLimit { get; set; } = PartitionLimit.Null;
@@ -72,7 +72,7 @@ public partial record Embed
// Under the Discord API model, embeds can only have at most one image. // 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 // 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 // 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 // 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. // with this by merging related embeds at the end of the message parsing process.
@@ -10,14 +10,14 @@ namespace DiscordChatExporter.Core.Discord.Data;
public partial record Emoji( public partial record Emoji(
// Only present on custom emoji // Only present on custom emoji
Snowflake? Id, 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, string Name,
bool IsAnimated bool IsAnimated
) )
{ {
public bool IsCustomEmoji { get; } = Id is not null; 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 Code { get; } = Id is not null ? Name : EmojiIndex.TryGetCode(Name) ?? Name;
public string ImageUrl { get; } = public string ImageUrl { get; } =
+118 -121
View File
@@ -592,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( private async ValueTask<Message?> TryGetFirstMessageAsync(
Snowflake channelId, Snowflake channelId,
Snowflake? after = null, Snowflake? after = null,
@@ -634,59 +661,56 @@ public class DiscordClient(
return response.Value.EnumerateArray().Select(Message.Parse).LastOrDefault(); return response.Value.EnumerateArray().Select(Message.Parse).LastOrDefault();
} }
public async ValueTask<Message?> TryGetMessageAsync( private async IAsyncEnumerable<Message> GetMessagesAsync(
Snowflake channelId, Snowflake channelId,
Snowflake messageId, Snowflake? after,
CancellationToken cancellationToken = default Snowflake? before,
IProgress<Percentage>? progress,
bool isReverse,
[EnumeratorCancellation] CancellationToken cancellationToken
) )
{ {
// Use the regular message listing endpoint with the 'around' parameter instead of the // To keep the understanding of message history independent of the fetching direction,
// dedicated single-message endpoint, because the latter is not accessible to user tokens. // we'll refer to the two ends of the range as Alpha and Omega.
var url = new UrlBuilder() // Depending on the direction, these are either 'before' and 'after', or 'after' and 'before'.
.SetPath($"channels/{channelId}/messages") //
.SetQueryParameter("around", messageId.ToString()) // Chronological order:
.SetQueryParameter("limit", "1") // <after> Alpha [----->----->----] Omega <before>
.Build(); // Reverse chronological order:
// <after> Omega [-----<-----<----] Alpha <before>
// Can be null on channels that the user cannot access // Because Discord API doesn't allow us to provide both 'after' and 'before' parameters
var response = await TryGetJsonResponseAsync(url, cancellationToken); // at the same time, we have to establish at least one end of the boundary manually.
if (response is null) // To do that, we'll fetch the Omega message, which will be the terminal message in the range:
return null; // 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);
// The endpoint returns messages around the requested ID, so make sure to only return // If the Omega doesn't exist or falls outside of the range, then there are simply no messages
// the message that exactly matches it (it may be absent if it has been deleted). // satisfying the specified range.
return response if (
.Value.EnumerateArray() omegaMessage is null
.Select(Message.Parse) || (!isReverse && omegaMessage.Timestamp < after?.ToDate())
.FirstOrDefault(m => m.Id == messageId); || (isReverse && omegaMessage.Timestamp > before?.ToDate())
} )
{
public async IAsyncEnumerable<Message> GetMessagesAsync(
Snowflake channelId,
Snowflake? after = null,
Snowflake? before = null,
IProgress<Percentage>? progress = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
// 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())
yield break; yield break;
}
// Keep track of the first message in range in order to calculate the progress // Persist the Alpha message as soon as we fetch the initial batch of messages.
var firstMessage = default(Message); // 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) while (true)
{ {
var url = new UrlBuilder() var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages") .SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "100") .SetQueryParameter("limit", "100")
.SetQueryParameter("after", currentAfter.ToString()) .SetQueryParameter(!isReverse ? "after" : "before", currentBoundary?.ToString())
.Build(); .Build();
var response = await GetJsonResponseAsync(url, cancellationToken); var response = await GetJsonResponseAsync(url, cancellationToken);
@@ -694,8 +718,8 @@ public class DiscordClient(
var messages = response var messages = response
.EnumerateArray() .EnumerateArray()
.Select(Message.Parse) .Select(Message.Parse)
// Messages are returned from newest to oldest, so we need to reverse them // Messages in batches are always returned from newest to oldest, so reverse if needed
.Reverse() .Pipe(messages => isReverse ? messages : messages.Reverse())
.ToArray(); .ToArray();
// Break if there are no messages (can happen if messages are deleted during execution) // Break if there are no messages (can happen if messages are deleted during execution)
@@ -710,24 +734,31 @@ public class DiscordClient(
foreach (var message in messages) foreach (var message in messages)
{ {
firstMessage ??= message; // Ensure that we're still in range by checking against the Omega
if (!isReverse ? message.Id > omegaMessage.Id : message.Id < omegaMessage.Id)
// Ensure that the messages are in range {
if (message.Timestamp > lastMessage.Timestamp)
yield break; yield break;
}
alphaMessage ??= message;
// Report progress based on timestamps // Report progress based on timestamps
if (progress is not null) if (progress is not null)
{ {
var exportedDuration = (message.Timestamp - firstMessage.Timestamp).Duration(); var fetchedDuration = isReverse
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration(); ? alphaMessage.Timestamp - message.Timestamp
: message.Timestamp - alphaMessage.Timestamp;
var totalDuration = isReverse
? alphaMessage.Timestamp - omegaMessage.Timestamp
: omegaMessage.Timestamp - alphaMessage.Timestamp;
progress.Report( progress.Report(
Percentage.FromFraction( Percentage.FromFraction(
// Avoid division by zero if all messages have the exact same timestamp // Avoid division by zero if all messages have the exact same timestamp
// (which happens when there's only one message in the channel) // (which happens when there's only one message in the channel)
totalDuration > TimeSpan.Zero totalDuration > TimeSpan.Zero
? exportedDuration / totalDuration ? fetchedDuration / totalDuration
: 1 : 1
) )
); );
@@ -747,9 +778,34 @@ public class DiscordClient(
: null; : null;
yield return actualMessage ?? message; yield return actualMessage ?? message;
currentAfter = message.Id;
} }
// 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;
} }
} }
@@ -761,77 +817,18 @@ public class DiscordClient(
[EnumeratorCancellation] CancellationToken cancellationToken = default [EnumeratorCancellation] CancellationToken cancellationToken = default
) )
{ {
// Get the first message in the specified range, so we can later calculate the await foreach (
// progress based on the difference between message timestamps. var message in GetMessagesAsync(
// Snapshotting is not necessary here because new messages can't appear in the past. channelId,
var firstMessage = await TryGetFirstMessageAsync(channelId, after, cancellationToken); after,
if (firstMessage is null || firstMessage.Timestamp > before?.ToDate()) before,
yield break; progress,
true,
// Keep track of the last message in range in order to calculate the progress cancellationToken
var lastMessage = default(Message); )
)
var currentBefore = before;
while (true)
{ {
var url = new UrlBuilder() yield return message;
.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
)
);
}
// 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;
}
currentBefore = messages.Last().Id;
} }
} }
@@ -208,7 +208,7 @@ public partial class ExportRequest
// Determine whether the output path refers to a directory or a file. // Determine whether the output path refers to a directory or a file.
// The extension-based heuristic is evaluated on the original, unsubstituted path, // 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 // 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 period that would otherwise be mistaken for a file extension, incorrectly causing
// a directory path to be treated as a file. // a directory path to be treated as a file.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1502 // https://github.com/Tyrrrz/DiscordChatExporter/issues/1502
@@ -6,7 +6,7 @@ namespace DiscordChatExporter.Core.Markdown;
internal record EmojiNode( internal record EmojiNode(
// Only present on custom emoji // Only present on custom emoji
Snowflake? Id, 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, string Name,
bool IsAnimated bool IsAnimated
) : MarkdownNode ) : MarkdownNode
@@ -20,7 +20,7 @@ internal record EmojiNode(
public bool IsCustomEmoji => _emoji.IsCustomEmoji; 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 Code => _emoji.Code;
public string ImageUrl => _emoji.ImageUrl; public string ImageUrl => _emoji.ImageUrl;
@@ -2,7 +2,7 @@ using System.Collections.Generic;
namespace DiscordChatExporter.Core.Markdown; 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) internal record LinkNode(string Url, IReadOnlyList<MarkdownNode> Children)
: MarkdownNode, : MarkdownNode,
IContainerNode IContainerNode
@@ -209,7 +209,7 @@ internal static partial class MarkdownParser
new RegexMatcher<MarkdownContext, MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex( new Regex(
// Build a pattern from all known emoji, sorted longest-first so that compound // 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. // their individual components.
"(" "("
+ string.Join( + string.Join(
@@ -264,7 +264,7 @@ internal static partial class MarkdownParser
private static readonly IMatcher<MarkdownContext, MarkdownNode> MaskedLinkNodeMatcher = private static readonly IMatcher<MarkdownContext, MarkdownNode> MaskedLinkNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>( new RegexMatcher<MarkdownContext, MarkdownNode>(
// Capture [title](link) // 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]))) (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 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 = private static readonly IMatcher<MarkdownContext, MarkdownNode> MinimalNodeMatcher =
new AggregateMatcher<MarkdownContext, MarkdownNode>( new AggregateMatcher<MarkdownContext, MarkdownNode>(
// Mentions // Mentions
+2 -2
View File
@@ -20,9 +20,9 @@ public class UrlBuilder
return this; 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; return this;
var keyEncoded = Uri.EscapeDataString(key); var keyEncoded = Uri.EscapeDataString(key);
+146 -141
View File
@@ -1,160 +1,165 @@
<Application <Application
x:Class="DiscordChatExporter.Gui.App" x:Class="DiscordChatExporter.Gui.App"
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dialogHostAvalonia="clr-namespace:DialogHostAvalonia;assembly=DialogHost.Avalonia" xmlns:dialogHostAvalonia="clr-namespace:DialogHostAvalonia;assembly=DialogHost.Avalonia"
xmlns:framework="clr-namespace:DiscordChatExporter.Gui.Framework" xmlns:framework="clr-namespace:DiscordChatExporter.Gui.Framework"
xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles" xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles"
xmlns:materialControls="clr-namespace:Material.Styles.Controls;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:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles" xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
Name="DiscordChatExporter" Name="DiscordChatExporter"
ActualThemeVariantChanged="Application_OnActualThemeVariantChanged"> ActualThemeVariantChanged="Application_OnActualThemeVariantChanged"
<Application.DataTemplates> >
<framework:ViewManager /> <Application.DataTemplates>
</Application.DataTemplates> <framework:ViewManager />
</Application.DataTemplates>
<Application.Styles> <Application.Styles>
<!-- This theme is used as a stub to pre-load default resources, the actual colors are set through code --> <!-- This theme is used as a stub to pre-load default resources, the actual colors are set through code -->
<materialStyles:MaterialTheme <materialStyles:MaterialTheme
BaseTheme="Light" BaseTheme="Light"
PrimaryColor="Grey" PrimaryColor="Grey"
SecondaryColor="DeepOrange" /> SecondaryColor="DeepOrange"
<materialIcons:MaterialIconStyles /> />
<dialogHostAvalonia:DialogHostStyles /> <materialIcons:MaterialIconStyles />
<dialogHostAvalonia:DialogHostStyles />
<!-- Combo box --> <!-- Combo box -->
<Style Selector="ComboBox"> <Style Selector="ComboBox">
<Setter Property="FontSize" Value="14" /> <Setter Property="FontSize" Value="14" />
<Style Selector="^ /template/ Panel#PART_RootPanel"> <Style Selector="^ /template/ Panel#PART_RootPanel">
<Setter Property="Height" Value="22" /> <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>
<Style Selector="^ /template/ ToggleButton"> <Style Selector="^ Border#PART_Indicator">
<Style Selector="^:checked, ^:unchecked"> <Setter Property="Margin" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Style Selector="^ ContentPresenter#contentPresenter">
<Setter Property="Margin" Value="12,8" />
</Style>
</Style>
</Style> </Style>
</Style>
</Style>
</Style>
<Style Selector="^ /template/ Track#PART_Track">
<Style Selector="^:horizontal">
<Setter Property="Margin" Value="4,0" />
</Style> </Style>
<!-- Dialog host --> <Style Selector="^ Border#PART_HoverEffect">
<Style Selector="dialogHostAvalonia|DialogHost"> <Setter Property="Width" Value="24" />
<Setter Property="DialogMargin" Value="0" /> <Setter Property="Height" Value="24" />
</Style> </Style>
<Style Selector="dialogHostAvalonia|DialogOverlayPopupHost"> <Style Selector="^ Border#PART_ThumbGrip">
<Setter Property="Margin" Value="48" /> <Setter Property="Width" Value="12" />
<Setter Property="Background" Value="{DynamicResource MaterialPaperBrush}" /> <Setter Property="Height" Value="12" />
</Style> </Style>
</Style>
</Style>
<!-- Snack bar host --> <!-- Run -->
<Style Selector="materialControls|SnackbarHost"> <Style Selector="Run">
<Setter Property="SnackbarHorizontalAlignment" Value="Stretch" /> <Setter Property="BaselineAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" /> </Style>
<Style Selector="^ /template/ ItemsControl#PART_SnackbarHostItemsContainer materialControls|Card"> <!-- Text box -->
<Setter Property="Background" Value="{DynamicResource MaterialDarkBackgroundBrush}" /> <Style Selector="TextBox">
<Setter Property="Foreground" Value="{DynamicResource MaterialDarkForegroundBrush}" /> <Setter Property="FontSize" Value="14" />
</Style> </Style>
<Style Selector="^ /template/ ItemsControl#PART_SnackbarHostItemsContainer Button"> <!-- Toggle button -->
<Setter Property="Foreground" Value="{DynamicResource MaterialSecondaryMidBrush}" /> <Style Selector="ToggleButton">
</Style> <Setter Property="TextElement.FontWeight" Value="Medium" />
</Style> </Style>
<!-- Progress bar --> <!-- Toggle switch -->
<Style Selector="ProgressBar"> <Style Selector="ToggleSwitch">
<Setter Property="Minimum" Value="0" /> <Setter
<Setter Property="Maximum" Value="1" /> Property="materialAssists:ToggleSwitchAssist.SwitchThumbOffBackground"
<Setter Property="Foreground" Value="{DynamicResource MaterialSecondaryMidBrush}" /> Value="{DynamicResource ToggleBackgroundBrush}"
<Setter Property="materialAssists:TransitionAssist.DisableTransitions" Value="True" /> />
</Style>
<Style Selector="^:horizontal"> <!-- Tooltip -->
<Setter Property="MinHeight" Value="0" /> <Style Selector="ToolTip">
</Style> <Setter Property="TextElement.FontSize" Value="14" />
</Style> <Setter Property="TextElement.FontWeight" Value="Normal" />
<Setter Property="TextElement.FontStyle" Value="Normal" />
<Setter Property="TextElement.FontStretch" Value="Normal" />
</Style>
</Application.Styles>
<!-- Slider --> <Application.Resources>
<Style Selector="Slider"> <ResourceDictionary>
<Style Selector="^ /template/ ProgressBar#PART_ProgressLayer"> <ResourceDictionary.ThemeDictionaries>
<Style Selector="^:horizontal"> <ResourceDictionary x:Key="Default">
<Style Selector="^ Panel#PART_InnerPanel"> <SolidColorBrush x:Key="ToggleBackgroundBrush" Color="#FFFFFF" />
<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>
</ResourceDictionary> </ResourceDictionary>
</Application.Resources> <ResourceDictionary x:Key="Dark">
</Application> <SolidColorBrush x:Key="ToggleBackgroundBrush" Color="#8E8E8E" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
@@ -40,7 +40,7 @@ public class DialogManager : IDisposable
); );
// Yield to allow DialogHost to fully reset its state before // 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(); await Task.Yield();
return dialog.DialogResult; return dialog.DialogResult;
@@ -58,7 +58,7 @@ public partial class LocalizationManager
""", """,
[nameof(RateLimitPreferenceLabel)] = "Rate limit preference", [nameof(RateLimitPreferenceLabel)] = "Rate limit preference",
[nameof(RateLimitPreferenceTooltip)] = [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(ShowThreadsLabel)] = "Show threads",
[nameof(ShowThreadsTooltip)] = "Which types of threads to show in the channel list", [nameof(ShowThreadsTooltip)] = "Which types of threads to show in the channel list",
[nameof(LocaleLabel)] = "Locale", [nameof(LocaleLabel)] = "Locale",
@@ -102,10 +102,10 @@ public partial class LocalizationManager
[nameof(BeforeTimeTooltip)] = "Only include messages sent before this time", [nameof(BeforeTimeTooltip)] = "Only include messages sent before this time",
[nameof(PartitionLimitLabel)] = "Partition limit", [nameof(PartitionLimitLabel)] = "Partition limit",
[nameof(PartitionLimitTooltip)] = [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(MessageFilterLabel)] = "Message filter",
[nameof(MessageFilterTooltip)] = [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(ReverseMessageOrderLabel)] = "Reverse messages",
[nameof(ReverseMessageOrderTooltip)] = [nameof(ReverseMessageOrderTooltip)] =
"Export messages in reverse chronological order (newest first)", "Export messages in reverse chronological order (newest first)",
@@ -21,10 +21,10 @@ public partial class PublishMacOSBundleCommand : ICommand
[CommandOption("icons-file", Description = "Path to the .icns icons file.")] [CommandOption("icons-file", Description = "Path to the .icns icons file.")]
public required string IconsFilePath { get; set; } public required string IconsFilePath { get; set; }
[CommandOption("full-version", Description = "Full version string (e.g. '1.2.3.4').")] [CommandOption("full-version", Description = "Full version string (e.g., '1.2.3.4').")]
public required string FullVersion { get; set; } public required string FullVersion { get; set; }
[CommandOption("short-version", Description = "Short version string (e.g. '1.2.3').")] [CommandOption("short-version", Description = "Short version string (e.g., '1.2.3').")]
public required string ShortVersion { get; set; } public required string ShortVersion { get; set; }
public async ValueTask ExecuteAsync(IConsole console) public async ValueTask ExecuteAsync(IConsole console)
@@ -1,307 +1,323 @@
<UserControl <UserControl
x:Class="DiscordChatExporter.Gui.Views.Components.DashboardView" x:Class="DiscordChatExporter.Gui.Views.Components.DashboardView"
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components" xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters" xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles" xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
x:Name="UserControl" x:Name="UserControl"
x:DataType="components:DashboardViewModel" x:DataType="components:DashboardViewModel"
Loaded="UserControl_OnLoaded"> Loaded="UserControl_OnLoaded"
<DockPanel> >
<!-- Header --> <DockPanel>
<StackPanel <!-- Header -->
Background="{DynamicResource MaterialDarkBackgroundBrush}" <StackPanel
DockPanel.Dock="Top" Background="{DynamicResource MaterialDarkBackgroundBrush}"
Orientation="Vertical"> DockPanel.Dock="Top"
<Grid Margin="12,12,8,12" ColumnDefinitions="*,Auto"> Orientation="Vertical"
<materialStyles:Card Grid.Column="0"> >
<!-- Token --> <Grid Margin="12,12,8,12" ColumnDefinitions="*,Auto">
<TextBox <materialStyles:Card Grid.Column="0">
x:Name="TokenValueTextBox" <!-- Token -->
FontSize="16" <TextBox
PasswordChar="*" x:Name="TokenValueTextBox"
PlaceholderText="{Binding LocalizationManager.TokenPlaceholderText}" FontSize="16"
RevealPassword="{Binding $self.IsFocused}" PasswordChar="*"
Text="{Binding Token}" PlaceholderText="{Binding LocalizationManager.TokenPlaceholderText}"
Theme="{DynamicResource SoloTextBox}"> RevealPassword="{Binding $self.IsFocused}"
<TextBox.InnerLeftContent> Text="{Binding Token}"
<materialIcons:MaterialIcon Theme="{DynamicResource SoloTextBox}"
Grid.Column="0" >
Width="24" <TextBox.InnerLeftContent>
Height="24" <materialIcons:MaterialIcon
Margin="4,0,8,0" Grid.Column="0"
Foreground="{DynamicResource PrimaryHueMidBrush}" Width="24"
Kind="Key" /> Height="24"
</TextBox.InnerLeftContent> Margin="4,0,8,0"
<TextBox.InnerRightContent> Foreground="{DynamicResource PrimaryHueMidBrush}"
<Button Kind="Key"
Grid.Column="2" />
Margin="8,0,0,0" </TextBox.InnerLeftContent>
Padding="4" <TextBox.InnerRightContent>
Command="{Binding PullGuildsCommand}" <Button
IsDefault="True" Grid.Column="2"
Theme="{DynamicResource MaterialFlatButton}" Margin="8,0,0,0"
ToolTip.Tip="{Binding LocalizationManager.PullGuildsTooltip}"> Padding="4"
<materialIcons:MaterialIcon Command="{Binding PullGuildsCommand}"
Width="24" IsDefault="True"
Height="24" Theme="{DynamicResource MaterialFlatButton}"
Kind="ArrowRight" /> ToolTip.Tip="{Binding LocalizationManager.PullGuildsTooltip}"
</Button> >
</TextBox.InnerRightContent> <materialIcons:MaterialIcon Width="24" Height="24" Kind="ArrowRight" />
</TextBox> </Button>
</materialStyles:Card> </TextBox.InnerRightContent>
</TextBox>
</materialStyles:Card>
<!-- Settings button --> <!-- Settings button -->
<Button <Button
Grid.Column="1" Grid.Column="1"
Margin="8,0,0,0" Margin="8,0,0,0"
Padding="8" Padding="8"
VerticalAlignment="Center" VerticalAlignment="Center"
Command="{Binding ShowSettingsCommand}" Command="{Binding ShowSettingsCommand}"
Foreground="{DynamicResource MaterialDarkForegroundBrush}" Foreground="{DynamicResource MaterialDarkForegroundBrush}"
Theme="{DynamicResource MaterialFlatButton}" Theme="{DynamicResource MaterialFlatButton}"
ToolTip.Tip="{Binding LocalizationManager.SettingsTooltip}"> ToolTip.Tip="{Binding LocalizationManager.SettingsTooltip}"
<materialIcons:MaterialIcon >
Width="24" <materialIcons:MaterialIcon Width="24" Height="24" Kind="Settings" />
Height="24" </Button>
Kind="Settings" /> </Grid>
</Button>
</Grid>
<!-- Progress --> <!-- Progress -->
<ProgressBar <ProgressBar
Height="2" Height="2"
Background="Transparent" Background="Transparent"
IsIndeterminate="{Binding IsProgressIndeterminate}" IsIndeterminate="{Binding IsProgressIndeterminate}"
Value="{Binding Progress.Current.Fraction, Mode=OneWay}" /> Value="{Binding Progress.Current.Fraction, Mode=OneWay}"
</StackPanel> />
</StackPanel>
<!-- Body --> <!-- Body -->
<Panel <Panel
Background="{DynamicResource MaterialCardBackgroundBrush}" Background="{DynamicResource MaterialCardBackgroundBrush}"
DockPanel.Dock="Bottom" DockPanel.Dock="Bottom"
IsEnabled="{Binding !IsBusy}"> IsEnabled="{Binding !IsBusy}"
<Panel.Styles> >
<Style Selector="Panel"> <Panel.Styles>
<Style Selector="^:disabled"> <Style Selector="Panel">
<Setter Property="Opacity" Value="0.5" /> <Style Selector="^:disabled">
</Style> <Setter Property="Opacity" Value="0.5" />
</Style>
</Style>
</Panel.Styles>
<!-- Guilds and channels -->
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding !!AvailableGuilds.Count}">
<!-- Guilds -->
<Border
Grid.Column="0"
BorderBrush="{DynamicResource MaterialDividerBrush}"
BorderThickness="0,0,1,0"
>
<ListBox
x:Name="AvailableGuildsListBox"
ItemsSource="{Binding AvailableGuilds}"
ScrollViewer.VerticalScrollBarVisibility="Hidden"
SelectedItem="{Binding SelectedGuild}"
SelectionChanged="AvailableGuildsListBox_OnSelectionChanged"
SelectionMode="Single"
>
<ListBox.Styles>
<Style Selector="ListBox">
<Style Selector="^ ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Cursor" Value="Hand" />
</Style> </Style>
</Panel.Styles> </Style>
<!-- Guilds and channels --> </ListBox.Styles>
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding !!AvailableGuilds.Count}"> <ListBox.ItemTemplate>
<!-- Guilds --> <DataTemplate>
<Border <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" Grid.Column="0"
BorderBrush="{DynamicResource MaterialDividerBrush}" Margin="0,0,4,0"
BorderThickness="0,0,1,0"> Classes.voice="{Binding Channel.IsVoice}"
<ListBox IsVisible="{Binding !Channel.IsCategory}"
x:Name="AvailableGuildsListBox" >
ItemsSource="{Binding AvailableGuilds}" <materialIcons:MaterialIcon.Styles>
ScrollViewer.VerticalScrollBarVisibility="Hidden" <Style Selector="materialIcons|MaterialIcon">
SelectedItem="{Binding SelectedGuild}" <Setter Property="Kind" Value="Pound" />
SelectionChanged="AvailableGuildsListBox_OnSelectionChanged"
SelectionMode="Single">
<ListBox.Styles>
<Style Selector="ListBox">
<Style Selector="^ ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Cursor" Value="Hand" />
</Style>
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate>
<Panel Background="Transparent" ToolTip.Tip="{Binding Name}">
<!-- Guild icon placeholder -->
<Ellipse
Width="48"
Height="48"
Margin="12"
Fill="{DynamicResource MaterialDividerBrush}" />
<!-- Guild icon --> <Style Selector="^.voice">
<Ellipse <Setter Property="Kind" Value="VolumeHigh" />
Width="48" </Style>
Height="48" </Style>
Margin="12"> </materialIcons:MaterialIcon.Styles>
<Ellipse.Fill> </materialIcons:MaterialIcon>
<ImageBrush asyncImageLoader:ImageBrushLoader.Source="{Binding IconUrl}" />
</Ellipse.Fill>
</Ellipse>
</Panel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Channels --> <!-- Channel name -->
<Border Grid.Column="1"> <TextBlock
<TreeView Grid.Column="1"
x:Name="AvailableChannelsTreeView" Margin="0,12"
ItemsSource="{Binding AvailableChannels}" FontSize="14"
SelectedItems="{Binding SelectedChannels}" Text="{Binding Channel.Name, Mode=OneWay}"
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 --> <!-- Checkmark -->
<materialIcons:MaterialIcon <materialIcons:MaterialIcon
Grid.Column="0" Grid.Column="2"
Margin="0,0,4,0" Width="24"
Classes.voice="{Binding Channel.IsVoice}" Height="24"
IsVisible="{Binding !Channel.IsCategory}"> Margin="16,0"
<materialIcons:MaterialIcon.Styles> IsVisible="{Binding $parent[TreeViewItem].IsSelected}"
<Style Selector="materialIcons|MaterialIcon"> Kind="Check"
<Setter Property="Kind" Value="Pound" /> />
</Grid>
</TreeDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
</Border>
</Grid>
<Style Selector="^.voice"> <!-- Placeholder / usage instructions -->
<Setter Property="Kind" Value="VolumeHigh" /> <Panel IsVisible="{Binding !AvailableGuilds.Count}">
</Style> <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
</Style> <StackPanel Margin="32,16" Spacing="0">
</materialIcons:MaterialIcon.Styles> <!-- User token -->
</materialIcons:MaterialIcon> <TextBlock>
<InlineUIContainer>
<!-- 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}">
<materialIcons:MaterialIcon <materialIcons:MaterialIcon
Width="32" Width="18"
Height="32" Height="18"
Kind="Download" /> Foreground="{DynamicResource PrimaryHueMidBrush}"
</Button> Kind="Account"
</Panel> />
</DockPanel> </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> </UserControl>
@@ -1,20 +1,22 @@
<UserControl <UserControl
x:Class="DiscordChatExporter.Gui.Views.Controls.HyperLink" x:Class="DiscordChatExporter.Gui.Views.Controls.HyperLink"
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:DiscordChatExporter.Gui.Views.Controls"> xmlns:controls="clr-namespace:DiscordChatExporter.Gui.Views.Controls"
<TextBlock >
x:Name="TextBlock" <TextBlock
Cursor="Hand" x:Name="TextBlock"
Foreground="{DynamicResource MaterialSecondaryDarkBrush}" Cursor="Hand"
PointerReleased="TextBlock_OnPointerReleased" Foreground="{DynamicResource MaterialSecondaryDarkBrush}"
Text="{Binding $parent[controls:HyperLink].Text, Mode=OneWay}"> PointerReleased="TextBlock_OnPointerReleased"
<TextBlock.Styles> Text="{Binding $parent[controls:HyperLink].Text, Mode=OneWay}"
<Style Selector="TextBlock"> >
<Style Selector="^:pointerover"> <TextBlock.Styles>
<Setter Property="TextDecorations" Value="Underline" /> <Style Selector="TextBlock">
</Style> <Style Selector="^:pointerover">
</Style> <Setter Property="TextDecorations" Value="Underline" />
</TextBlock.Styles> </Style>
</TextBlock> </Style>
</UserControl> </TextBlock.Styles>
</TextBlock>
</UserControl>
@@ -1,285 +1,308 @@
<UserControl <UserControl
x:Class="DiscordChatExporter.Gui.Views.Dialogs.ExportSetupView" x:Class="DiscordChatExporter.Gui.Views.Dialogs.ExportSetupView"
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia" xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters" xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs" xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles" xmlns:materialAssists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles"
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils" xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
x:Name="UserControl" x:Name="UserControl"
Width="380" Width="380"
x:DataType="dialogs:ExportSetupViewModel"> x:DataType="dialogs:ExportSetupViewModel"
<Grid RowDefinitions="Auto,*,Auto"> >
<!-- Guild/channel info --> <Grid RowDefinitions="Auto,*,Auto">
<Grid <!-- Guild/channel info -->
Grid.Row="0" <Grid Grid.Row="0" Margin="16" ColumnDefinitions="Auto,*">
Margin="16" <!-- Guild icon -->
ColumnDefinitions="Auto,*"> <Ellipse Grid.Column="0" Width="32" Height="32">
<!-- Guild icon --> <Ellipse.Fill>
<Ellipse <ImageBrush asyncImageLoader:ImageBrushLoader.Source="{Binding Guild.IconUrl}" />
Grid.Column="0" </Ellipse.Fill>
Width="32" </Ellipse>
Height="32">
<Ellipse.Fill>
<ImageBrush asyncImageLoader:ImageBrushLoader.Source="{Binding Guild.IconUrl}" />
</Ellipse.Fill>
</Ellipse>
<!-- Channel count (for multiple channels) --> <!-- Channel count (for multiple channels) -->
<TextBlock <TextBlock
Grid.Column="1" Grid.Column="1"
Margin="8,0,0,0" Margin="8,0,0,0"
VerticalAlignment="Center" VerticalAlignment="Center"
FontSize="19" FontSize="19"
FontWeight="Light" FontWeight="Light"
IsVisible="{Binding !IsSingleChannel}" IsVisible="{Binding !IsSingleChannel}"
TextTrimming="CharacterEllipsis"> TextTrimming="CharacterEllipsis"
<Run Text="{Binding Channels.Count, FallbackValue=0, Mode=OneWay}" /> >
<Run Text="{Binding LocalizationManager.ChannelsSelectedText}" /> <Run Text="{Binding Channels.Count, FallbackValue=0, Mode=OneWay}" />
</TextBlock> <Run Text="{Binding LocalizationManager.ChannelsSelectedText}" />
</TextBlock>
<!-- Category and channel name (for single channel) --> <!-- Category and channel name (for single channel) -->
<TextBlock <TextBlock
Grid.Column="1" Grid.Column="1"
Margin="8,0,0,0" Margin="8,0,0,0"
VerticalAlignment="Center" VerticalAlignment="Center"
FontSize="19" FontSize="19"
FontWeight="Light" FontWeight="Light"
IsVisible="{Binding IsSingleChannel}" IsVisible="{Binding IsSingleChannel}"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Channels[0], Converter={x:Static converters:ChannelToHierarchicalNameStringConverter.Instance}}"> 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> <Run Text="{Binding Channels[0].Parent.Name, StringFormat='{}{0} / ', FallbackValue='', TargetNullValue='', Mode=OneWay}" />
</Grid> <Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
</TextBlock>
<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> </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> </UserControl>
@@ -1,72 +1,73 @@
<UserControl <UserControl
x:Class="DiscordChatExporter.Gui.Views.Dialogs.MessageBoxView" x:Class="DiscordChatExporter.Gui.Views.Dialogs.MessageBoxView"
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs" xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
xmlns:system="clr-namespace:System;assembly=System.Runtime" xmlns:system="clr-namespace:System;assembly=System.Runtime"
Width="500" Width="500"
x:DataType="dialogs:MessageBoxViewModel"> x:DataType="dialogs:MessageBoxViewModel"
<Grid RowDefinitions="Auto,*,Auto"> >
<!-- Title --> <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 <TextBlock
Grid.Row="0" Text="{Binding DefaultButtonText}"
Margin="16" TextAlignment="Center"
FontSize="19" TextTrimming="CharacterEllipsis"
FontWeight="Light" />
Text="{Binding Title}" </Button>
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Title}" />
<!-- Message --> <!-- Cancel -->
<Border <Button
Grid.Row="1" Margin="8"
Padding="0,8" HorizontalContentAlignment="Stretch"
BorderBrush="{DynamicResource MaterialDividerBrush}" Command="{Binding CloseCommand}"
BorderThickness="0,1"> IsCancel="True"
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> IsVisible="{Binding IsCancelButtonVisible}"
<TextBlock Theme="{DynamicResource MaterialOutlineButton}"
Margin="16,8" ToolTip.Tip="{Binding CancelButtonText}"
Text="{Binding Message}" >
TextWrapping="Wrap" /> <TextBlock
</ScrollViewer> Text="{Binding CancelButtonText}"
</Border> TextAlignment="Center"
TextTrimming="CharacterEllipsis"
<UniformGrid />
Grid.Row="2" </Button>
Margin="8" </UniformGrid>
Columns="{Binding ButtonsCount}"> </Grid>
<!-- OK --> </UserControl>
<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>
@@ -1,148 +1,187 @@
<UserControl <UserControl
x:Class="DiscordChatExporter.Gui.Views.Dialogs.SettingsView" x:Class="DiscordChatExporter.Gui.Views.Dialogs.SettingsView"
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters" xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs" xmlns:dialogs="clr-namespace:DiscordChatExporter.Gui.ViewModels.Dialogs"
Width="380" Width="380"
x:DataType="dialogs:SettingsViewModel"> x:DataType="dialogs:SettingsViewModel"
<Grid RowDefinitions="Auto,*,Auto"> >
<TextBlock <Grid RowDefinitions="Auto,*,Auto">
Grid.Row="0" <TextBlock
Margin="16" Grid.Row="0"
FontSize="19" Margin="16"
FontWeight="Light" FontSize="19"
Text="{Binding LocalizationManager.SettingsTitle}" /> FontWeight="Light"
Text="{Binding LocalizationManager.SettingsTitle}"
/>
<Border <Border
Grid.Row="1" Grid.Row="1"
Padding="0,8" Padding="0,8"
BorderBrush="{DynamicResource MaterialDividerBrush}" BorderBrush="{DynamicResource MaterialDividerBrush}"
BorderThickness="0,1"> BorderThickness="0,1"
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> >
<StackPanel <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
Margin="16,8" <StackPanel Margin="16,8" Orientation="Vertical" Spacing="16">
Orientation="Vertical" <!-- Theme -->
Spacing="16"> <DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.ThemeTooltip}">
<!-- Theme --> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ThemeLabel}" />
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.ThemeTooltip}"> <ComboBox
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ThemeLabel}" /> Width="150"
<ComboBox DockPanel.Dock="Right"
Width="150" ItemsSource="{Binding AvailableThemes}"
DockPanel.Dock="Right" SelectedItem="{Binding Theme}"
ItemsSource="{Binding AvailableThemes}" />
SelectedItem="{Binding Theme}" /> </DockPanel>
</DockPanel>
<!-- Language --> <!-- Language -->
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.LanguageTooltip}"> <DockPanel
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.LanguageLabel}" /> LastChildFill="False"
<ComboBox ToolTip.Tip="{Binding LocalizationManager.LanguageTooltip}"
Width="150" >
DockPanel.Dock="Right" <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.LanguageLabel}" />
ItemsSource="{Binding AvailableLanguages}" <ComboBox
SelectedItem="{Binding Language}" /> Width="150"
</DockPanel> DockPanel.Dock="Right"
ItemsSource="{Binding AvailableLanguages}"
SelectedItem="{Binding Language}"
/>
</DockPanel>
<!-- Auto-updates --> <!-- Auto-updates -->
<DockPanel <DockPanel
IsVisible="{Binding IsAutoUpdateAvailable}" IsVisible="{Binding IsAutoUpdateAvailable}"
LastChildFill="False" LastChildFill="False"
ToolTip.Tip="{Binding LocalizationManager.AutoUpdateTooltip}"> ToolTip.Tip="{Binding LocalizationManager.AutoUpdateTooltip}"
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.AutoUpdateLabel}" /> >
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsAutoUpdateEnabled}" /> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.AutoUpdateLabel}" />
</DockPanel> <ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsAutoUpdateEnabled}" />
</DockPanel>
<!-- Persist token --> <!-- Persist token -->
<DockPanel LastChildFill="False"> <DockPanel LastChildFill="False">
<ToolTip.Tip> <ToolTip.Tip>
<TextBlock <TextBlock
MaxWidth="400" MaxWidth="400"
Inlines="{Binding LocalizationManager.PersistTokenTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" Inlines="{Binding LocalizationManager.PersistTokenTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
TextWrapping="Wrap" /> TextWrapping="Wrap"
</ToolTip.Tip> />
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.PersistTokenLabel}" /> </ToolTip.Tip>
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" /> <TextBlock
</DockPanel> DockPanel.Dock="Left"
Text="{Binding LocalizationManager.PersistTokenLabel}"
/>
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
</DockPanel>
<!-- Rate limit preference --> <!-- Rate limit preference -->
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.RateLimitPreferenceTooltip}"> <DockPanel
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.RateLimitPreferenceLabel}" /> LastChildFill="False"
<ComboBox ToolTip.Tip="{Binding LocalizationManager.RateLimitPreferenceTooltip}"
Width="150" >
DockPanel.Dock="Right" <TextBlock
ItemsSource="{Binding AvailableRateLimitPreferences}" DockPanel.Dock="Left"
SelectedItem="{Binding RateLimitPreference}"> Text="{Binding LocalizationManager.RateLimitPreferenceLabel}"
<ComboBox.ItemTemplate> />
<DataTemplate> <ComboBox
<TextBlock Text="{Binding Converter={x:Static converters:RateLimitPreferenceToStringConverter.Instance}}" /> Width="150"
</DataTemplate> DockPanel.Dock="Right"
</ComboBox.ItemTemplate> ItemsSource="{Binding AvailableRateLimitPreferences}"
</ComboBox> SelectedItem="{Binding RateLimitPreference}"
</DockPanel> >
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={x:Static converters:RateLimitPreferenceToStringConverter.Instance}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</DockPanel>
<!-- Thread inclusion mode --> <!-- Thread inclusion mode -->
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.ShowThreadsTooltip}"> <DockPanel
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ShowThreadsLabel}" /> LastChildFill="False"
<ComboBox ToolTip.Tip="{Binding LocalizationManager.ShowThreadsTooltip}"
Width="150" >
DockPanel.Dock="Right" <TextBlock
ItemsSource="{Binding AvailableThreadInclusionModes}" DockPanel.Dock="Left"
SelectedItem="{Binding ThreadInclusionMode}" /> Text="{Binding LocalizationManager.ShowThreadsLabel}"
</DockPanel> />
<ComboBox
Width="150"
DockPanel.Dock="Right"
ItemsSource="{Binding AvailableThreadInclusionModes}"
SelectedItem="{Binding ThreadInclusionMode}"
/>
</DockPanel>
<!-- Locale --> <!-- Locale -->
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.LocaleTooltip}"> <DockPanel
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.LocaleLabel}" /> LastChildFill="False"
<ComboBox ToolTip.Tip="{Binding LocalizationManager.LocaleTooltip}"
Width="150" >
DockPanel.Dock="Right" <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.LocaleLabel}" />
ItemsSource="{Binding AvailableLocales}" <ComboBox
SelectedItem="{Binding Locale}"> Width="150"
<ComboBox.ItemTemplate> DockPanel.Dock="Right"
<DataTemplate> ItemsSource="{Binding AvailableLocales}"
<TextBlock Text="{Binding Converter={x:Static converters:LocaleToDisplayNameStringConverter.Instance}}" /> SelectedItem="{Binding Locale}"
</DataTemplate> >
</ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
</ComboBox> <DataTemplate>
</DockPanel> <TextBlock Text="{Binding Converter={x:Static converters:LocaleToDisplayNameStringConverter.Instance}}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</DockPanel>
<!-- UTC normalization --> <!-- UTC normalization -->
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.NormalizeToUtcTooltip}"> <DockPanel
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.NormalizeToUtcLabel}" /> LastChildFill="False"
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsUtcNormalizationEnabled}" /> ToolTip.Tip="{Binding LocalizationManager.NormalizeToUtcTooltip}"
</DockPanel> >
<TextBlock
DockPanel.Dock="Left"
Text="{Binding LocalizationManager.NormalizeToUtcLabel}"
/>
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsUtcNormalizationEnabled}" />
</DockPanel>
<!-- Parallel limit --> <!-- Parallel limit -->
<DockPanel LastChildFill="False" ToolTip.Tip="{Binding LocalizationManager.ParallelLimitTooltip}"> <DockPanel
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ParallelLimitLabel}" /> LastChildFill="False"
<StackPanel ToolTip.Tip="{Binding LocalizationManager.ParallelLimitTooltip}"
DockPanel.Dock="Right" >
Orientation="Horizontal" <TextBlock
Spacing="10"> DockPanel.Dock="Left"
<TextBlock Text="{Binding ParallelLimit}" /> Text="{Binding LocalizationManager.ParallelLimitLabel}"
<Slider />
Width="150" <StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="10">
IsSnapToTickEnabled="True" <TextBlock Text="{Binding ParallelLimit}" />
Maximum="10" <Slider
Minimum="1" Width="150"
TickFrequency="1" IsSnapToTickEnabled="True"
Value="{Binding ParallelLimit}" /> Maximum="10"
</StackPanel> Minimum="1"
</DockPanel> TickFrequency="1"
</StackPanel> Value="{Binding ParallelLimit}"
</ScrollViewer> />
</Border> </StackPanel>
</DockPanel>
</StackPanel>
</ScrollViewer>
</Border>
<!-- Close button --> <!-- Close button -->
<Button <Button
Grid.Row="2" Grid.Row="2"
Margin="16" Margin="16"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Command="{Binding CloseCommand}" Command="{Binding CloseCommand}"
Content="{Binding LocalizationManager.CloseButton}" Content="{Binding LocalizationManager.CloseButton}"
IsCancel="True" IsCancel="True"
IsDefault="True" IsDefault="True"
Theme="{DynamicResource MaterialOutlineButton}" /> Theme="{DynamicResource MaterialOutlineButton}"
</Grid> />
</Grid>
</UserControl> </UserControl>
+21 -20
View File
@@ -1,22 +1,23 @@
<Window <Window
x:Class="DiscordChatExporter.Gui.Views.MainView" x:Class="DiscordChatExporter.Gui.Views.MainView"
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:dialogHostAvalonia="clr-namespace:DialogHostAvalonia;assembly=DialogHost.Avalonia" xmlns:dialogHostAvalonia="clr-namespace:DialogHostAvalonia;assembly=DialogHost.Avalonia"
xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles" xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels" xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
Title="{Binding Title}" Title="{Binding Title}"
Width="625" Width="625"
Height="665" Height="665"
MinWidth="600" MinWidth="600"
MinHeight="400" MinHeight="400"
x:DataType="viewModels:MainViewModel" x:DataType="viewModels:MainViewModel"
Icon="/favicon.ico" Icon="/favicon.ico"
RenderOptions.BitmapInterpolationMode="HighQuality" RenderOptions.BitmapInterpolationMode="HighQuality"
WindowStartupLocation="CenterScreen"> WindowStartupLocation="CenterScreen"
<dialogHostAvalonia:DialogHost x:Name="DialogHost" CloseOnClickAway="False"> >
<materialStyles:SnackbarHost HostName="Root" SnackbarMaxCounts="3"> <dialogHostAvalonia:DialogHost x:Name="DialogHost" CloseOnClickAway="False">
<ContentControl Content="{Binding Dashboard}" /> <materialStyles:SnackbarHost HostName="Root" SnackbarMaxCounts="3">
</materialStyles:SnackbarHost> <ContentControl Content="{Binding Dashboard}" />
</dialogHostAvalonia:DialogHost> </materialStyles:SnackbarHost>
</dialogHostAvalonia:DialogHost>
</Window> </Window>