mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1181a35f08 | |||
| 4a62f9c34f | |||
| b6e1f92989 | |||
| 0d3d2ddcf0 | |||
| 9afecd47bd | |||
| 5cec162b4e | |||
| 6647f90ced | |||
| b9a3001c11 | |||
| dadfe5b8ef | |||
| 4f29fa63d0 | |||
| 54b0174bde | |||
| 3be5bc6a21 | |||
| c9f152773a | |||
| 5e39611395 | |||
| 40491bdc0f | |||
| 03dd272eaf | |||
| 98e294f7e4 | |||
| b22e786449 | |||
| 951c1e4613 | |||
| 675d910ea3 | |||
| 28f26e45fb | |||
| 4674c517e3 | |||
| b660edfe78 | |||
| 532470e3db | |||
| b533e08d74 | |||
| d490fd0a38 | |||
| 21c2398e2a | |||
| 7ff2347df1 | |||
| 3391e755fc | |||
| f0e9d3be1b | |||
| eef0fc742d | |||
| 2e47c73388 | |||
| 2d49146c74 | |||
| c59374598a | |||
| 01d0203445 | |||
| c4bfb3424e | |||
| 522caba420 | |||
| ad1170b42e | |||
| d2c0e2169b | |||
| e6562de1f5 | |||
| 5f8054e2f0 | |||
| 12d98e9ab0 | |||
| 18086aa209 | |||
| dd7196b6a5 |
+2
-2
@@ -17,7 +17,7 @@ Note the `:stable` tag. DiscordChatExporter images are tagged according to the f
|
||||
|
||||
- `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.
|
||||
- `latest` — latest (potentially unstable) build. This tag is updated with each new commit to the `master` 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).
|
||||
|
||||
@@ -57,7 +57,7 @@ You can also use the current working directory as the output directory by specif
|
||||
- `-v $PWD:/out` in Bash
|
||||
- `-v $pwd.Path:/out` in PowerShell
|
||||
|
||||
For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/DiscordChatExporter.Cli.dockerfile) and [Docker documentation](https://docs.docker.com/engine/reference/run).
|
||||
For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/DiscordChatExporter.Cli.dockerfile) and [Docker documentation](https://docs.docker.com/engine/reference/run).
|
||||
|
||||
To get your Token and Channel IDs, please refer to [this page](Token-and-IDs.md).
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||
|
||||
TOKEN=tokenhere
|
||||
CHANNELID=channelhere
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||
|
||||
TOKEN=tokenhere
|
||||
CHANNELID=channelhere
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
1. Open a text editor such as Notepad and paste:
|
||||
|
||||
```console
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
||||
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||
|
||||
$TOKEN = "tokenhere"
|
||||
$CHANNEL = "channelhere"
|
||||
|
||||
@@ -27,7 +27,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
3. Type
|
||||
|
||||
```js
|
||||
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.$8&&x.exports.LP&&x.exports.gK){m=x;break}}}]);m&&console.log("Token:",m.exports.LP());
|
||||
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.getToken){m=x;break}}}]);m&&console.log("Token:",m.exports.getToken());
|
||||
```
|
||||
|
||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||
@@ -120,7 +120,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
1. Type
|
||||
|
||||
```js
|
||||
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.$8&&x.exports.LP&&x.exports.gK){m=x;break}}}]);m&&console.log("Token:",m.exports.LP());
|
||||
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.getToken){m=x;break}}}]);m&&console.log("Token:",m.exports.getToken());
|
||||
```
|
||||
|
||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||
|
||||
+11
-12
@@ -29,21 +29,20 @@ Type the following command in your terminal of choice, then press ENTER to run i
|
||||
> **Note**:
|
||||
> On Windows, if you're using the default Command Prompt (`cmd`), omit the leading `./` at the start of the command.
|
||||
|
||||
|
||||
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
|
||||
|
||||
## CLI commands
|
||||
|
||||
| Command | Description |
|
||||
|-------------------------|------------------------------------------------------|
|
||||
| export | Exports a channel |
|
||||
| exportdm | Exports all direct message channels |
|
||||
| exportguild | Exports all channels within the specified server |
|
||||
| exportall | Exports all accessible channels |
|
||||
| channels | Outputs the list of channels in the given server |
|
||||
| dm | Outputs the list of direct message channels |
|
||||
| guilds | Outputs the list of accessible servers |
|
||||
| guide | Explains how to obtain token, server, and channel ID |
|
||||
| Command | Description |
|
||||
| ----------- | ---------------------------------------------------- |
|
||||
| export | Exports a channel |
|
||||
| exportdm | Exports all direct message channels |
|
||||
| exportguild | Exports all channels within the specified server |
|
||||
| exportall | Exports all accessible channels |
|
||||
| channels | Outputs the list of channels in the given server |
|
||||
| dm | Outputs the list of direct message channels |
|
||||
| guilds | Outputs the list of accessible servers |
|
||||
| guide | Explains how to obtain token, server, and channel ID |
|
||||
|
||||
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `./DiscordChatExporter.Cli guide`.
|
||||
|
||||
@@ -222,7 +221,7 @@ Use `--filter` to filter what messages are included in the export.
|
||||
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --filter "from:Tyrrrz has:image"
|
||||
```
|
||||
|
||||
Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Message-filters.md).
|
||||
Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs/Message-filters.md).
|
||||
|
||||
### Export channels from a specific server
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ⚠ Feature request
|
||||
url: https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md
|
||||
url: https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md
|
||||
about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.
|
||||
- name: 📖 Documentation
|
||||
url: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
|
||||
url: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
|
||||
about: Find usage guides and frequently asked questions.
|
||||
- name: 🗨 Discussions
|
||||
url: https://github.com/Tyrrrz/DiscordChatExporter/discussions/new
|
||||
|
||||
@@ -4,12 +4,12 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- prime
|
||||
tags:
|
||||
- "*"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- prime
|
||||
|
||||
jobs:
|
||||
# Outputs from this job aren't really used, but it's here to verify that the Dockerfile builds correctly
|
||||
@@ -37,15 +37,15 @@ jobs:
|
||||
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
||||
|
||||
- name: Upload image
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: DiscordChatExporter.Cli.Docker
|
||||
path: DiscordChatExporter.Cli.Docker.tar
|
||||
if-no-files-found: error
|
||||
|
||||
deploy:
|
||||
# Deploy to DockerHub only on tag push or master branch push
|
||||
if: ${{ github.ref_type == 'tag' || github.ref_type == 'branch' && github.ref_name == 'master' }}
|
||||
# Deploy to DockerHub only on tag push or prime branch push
|
||||
if: ${{ github.ref_type == 'tag' || github.ref_type == 'branch' && github.ref_name == 'prime' }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -4,12 +4,12 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- prime
|
||||
tags:
|
||||
- "*"
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- prime
|
||||
|
||||
env:
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
|
||||
@@ -127,6 +127,7 @@ jobs:
|
||||
dotnet publish ${{ matrix.app }}
|
||||
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||
-p:CSharpier_Bypass=true
|
||||
-p:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }}
|
||||
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
|
||||
--output ${{ matrix.app }}/bin/publish/
|
||||
--configuration Release
|
||||
@@ -134,7 +135,7 @@ jobs:
|
||||
--self-contained
|
||||
|
||||
- name: Upload app binaries
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/bin/publish/
|
||||
@@ -199,7 +200,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download app binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
|
||||
with:
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.6.0" />
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
|
||||
<PackageVersion Include="Avalonia" Version="11.3.12" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.12" />
|
||||
<PackageVersion Include="CliFx" Version="2.3.6" />
|
||||
<PackageVersion Include="Cogwheel" Version="2.1.0" />
|
||||
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageVersion Include="coverlet.collector" Version="8.0.0" />
|
||||
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.6" />
|
||||
<PackageVersion Include="Deorcify" Version="1.1.0" />
|
||||
<PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
|
||||
<PackageVersion Include="DialogHost.Avalonia" Version="0.10.4" />
|
||||
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
|
||||
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.1" />
|
||||
<PackageVersion Include="Gress" Version="2.1.1" />
|
||||
<PackageVersion Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageVersion Include="Markdig" Version="1.0.1" />
|
||||
<PackageVersion Include="Material.Avalonia" Version="3.9.2" />
|
||||
<PackageVersion Include="Material.Icons.Avalonia" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
|
||||
<PackageVersion Include="Onova" Version="2.6.13" />
|
||||
<PackageVersion Include="Polly" Version="8.6.5" />
|
||||
<PackageVersion Include="RazorBlade" Version="0.11.0" />
|
||||
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
|
||||
<PackageVersion Include="Superpower" Version="3.1.0" />
|
||||
<PackageVersion Include="WebMarkupMin.Core" Version="2.20.2" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||
<PackageVersion Include="YoutubeExplode" Version="6.5.7" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -10,21 +10,18 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="3.0.1" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
|
||||
<PackageReference
|
||||
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
||||
Version="10.0.2"
|
||||
/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
|
||||
<PackageReference Include="AngleSharp" />
|
||||
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -16,6 +16,8 @@ public static class ChannelIds
|
||||
|
||||
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
||||
|
||||
public static Snowflake ForwardTestCases { get; } = Snowflake.Parse("1455202357204877477");
|
||||
|
||||
public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521");
|
||||
|
||||
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
@@ -44,4 +49,41 @@ public class HtmlContentSpecs
|
||||
"Yeet"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_in_the_HTML_format_in_the_reverse_order()
|
||||
{
|
||||
// Arrange
|
||||
using var file = TempFile.Create();
|
||||
|
||||
// Act
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.HtmlDark,
|
||||
OutputPath = file.Path,
|
||||
Locale = "en-US",
|
||||
IsUtcNormalizationEnabled = true,
|
||||
IsReverseMessageOrder = true,
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
var document = Html.Parse(await File.ReadAllTextAsync(file.Path));
|
||||
var messages = document.QuerySelectorAll("[data-message-id]").ToArray();
|
||||
|
||||
// Assert
|
||||
messages
|
||||
.Select(e => e.GetAttribute("data-message-id"))
|
||||
.Should()
|
||||
.Equal(
|
||||
"885169254029213696",
|
||||
"868505973294268457",
|
||||
"868505969821364245",
|
||||
"868505966528835604",
|
||||
"868490009366396958",
|
||||
"866732113319428096",
|
||||
"866710679758045195",
|
||||
"866674314627121232"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp.Dom;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
|
||||
public class HtmlForwardSpecs
|
||||
{
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||
{
|
||||
// Act
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
ChannelIds.ForwardTestCases,
|
||||
Snowflake.Parse("1455202427115536514")
|
||||
);
|
||||
|
||||
// Assert
|
||||
message
|
||||
.Text()
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.ContainAll("Forwarded", @"¯\_(ツ)_/¯", "12/29/2025 2:14 PM");
|
||||
}
|
||||
}
|
||||
@@ -61,4 +61,17 @@ public class HtmlMentionSpecs
|
||||
// Assert
|
||||
message.Text().Should().Contain("Role mention: @Role 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_message_with_a_thread_mention()
|
||||
{
|
||||
// Act
|
||||
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||
ChannelIds.MentionTestCases,
|
||||
Snowflake.Parse("1474874276828938290")
|
||||
);
|
||||
|
||||
// Assert
|
||||
message.Text().Should().Contain("Thread mention: #Thread starting message");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
using System.Linq;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using FluentAssertions;
|
||||
using JsonExtensions;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
@@ -43,4 +49,43 @@ public class JsonContentSpecs
|
||||
"Yeet"
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_in_the_JSON_format_in_the_reverse_order()
|
||||
{
|
||||
// Arrange
|
||||
using var file = TempFile.Create();
|
||||
|
||||
// Act
|
||||
await new ExportChannelsCommand
|
||||
{
|
||||
Token = Secrets.DiscordToken,
|
||||
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||
ExportFormat = ExportFormat.Json,
|
||||
OutputPath = file.Path,
|
||||
Locale = "en-US",
|
||||
IsUtcNormalizationEnabled = true,
|
||||
IsReverseMessageOrder = true,
|
||||
}.ExecuteAsync(new FakeConsole());
|
||||
|
||||
var messages = Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||
.GetProperty("messages")
|
||||
.EnumerateArray()
|
||||
.ToArray();
|
||||
|
||||
// Assert
|
||||
messages
|
||||
.Select(j => j.GetProperty("id").GetString())
|
||||
.Should()
|
||||
.Equal(
|
||||
"885169254029213696",
|
||||
"868505973294268457",
|
||||
"868505969821364245",
|
||||
"868505966528835604",
|
||||
"868490009366396958",
|
||||
"866732113319428096",
|
||||
"866710679758045195",
|
||||
"866674314627121232"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
|
||||
public class JsonForwardSpecs
|
||||
{
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||
{
|
||||
// Act
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
ChannelIds.ForwardTestCases,
|
||||
Snowflake.Parse("1455202427115536514")
|
||||
);
|
||||
|
||||
// Assert
|
||||
var reference = message.GetProperty("reference");
|
||||
reference.GetProperty("type").GetString().Should().Be("Forward");
|
||||
reference.GetProperty("guildId").GetString().Should().Be("869237470565392384");
|
||||
|
||||
var forwardedMessage = message.GetProperty("forwardedMessage");
|
||||
forwardedMessage.GetProperty("content").GetString().Should().Contain(@"¯\_(ツ)_/¯");
|
||||
forwardedMessage
|
||||
.GetProperty("timestamp")
|
||||
.GetString()
|
||||
.Should()
|
||||
.StartWith("2025-12-28T22:52:42.175+00:00");
|
||||
}
|
||||
}
|
||||
@@ -75,4 +75,21 @@ public class JsonMentionSpecs
|
||||
// Assert
|
||||
message.GetProperty("content").GetString().Should().Be("Role mention: @Role 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_message_with_a_thread_mention()
|
||||
{
|
||||
// Act
|
||||
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||
ChannelIds.MentionTestCases,
|
||||
Snowflake.Parse("1474874276828938290")
|
||||
);
|
||||
|
||||
// Assert
|
||||
message
|
||||
.GetProperty("content")
|
||||
.GetString()
|
||||
.Should()
|
||||
.Be("Thread mention: #Thread starting message");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Threading.Tasks;
|
||||
using DiscordChatExporter.Cli.Tests.Infra;
|
||||
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||
|
||||
public class PlainTextForwardSpecs
|
||||
{
|
||||
[Fact]
|
||||
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||
{
|
||||
// Act
|
||||
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.ForwardTestCases);
|
||||
|
||||
// Assert
|
||||
document
|
||||
.ReplaceWhiteSpace()
|
||||
.Should()
|
||||
.ContainAll("{Forwarded Message}", @"¯\_(ツ)_/¯", "12/28/2025 10:52 PM");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ WORKDIR /tmp/app
|
||||
COPY favicon.ico .
|
||||
COPY NuGet.config .
|
||||
COPY Directory.Build.props .
|
||||
COPY Directory.Packages.props .
|
||||
COPY DiscordChatExporter.Core DiscordChatExporter.Core
|
||||
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
||||
|
||||
|
||||
@@ -83,6 +83,12 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public int ParallelLimit { get; init; } = 1;
|
||||
|
||||
[CommandOption(
|
||||
"reverse",
|
||||
Description = "Export messages in reverse chronological order (newest first)."
|
||||
)]
|
||||
public bool IsReverseMessageOrder { get; init; }
|
||||
|
||||
[CommandOption(
|
||||
"markdown",
|
||||
Description = "Process markdown, mentions, and other special tokens."
|
||||
@@ -267,6 +273,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
Before,
|
||||
PartitionLimit,
|
||||
MessageFilter,
|
||||
IsReverseMessageOrder,
|
||||
ShouldFormatMarkdown,
|
||||
ShouldDownloadAssets,
|
||||
ShouldReuseAssets,
|
||||
|
||||
@@ -74,7 +74,7 @@ public class GuideCommand : ICommand
|
||||
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
|
||||
{
|
||||
console.Output.WriteLine(
|
||||
"https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"
|
||||
"https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.3.6" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.54.0" />
|
||||
<PackageReference Include="CliFx" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" />
|
||||
<PackageReference Include="Spectre.Console" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -8855,6 +8855,8 @@ internal static class EmojiIndex
|
||||
["united_nations"] = "🇺🇳",
|
||||
};
|
||||
|
||||
public static IReadOnlyCollection<string> GetAllNames() => _toCodes.Keys;
|
||||
|
||||
public static string? TryGetCode(string name) => _toCodes.GetValueOrDefault(name);
|
||||
|
||||
public static string? TryGetName(string code) => _fromCodes.GetValueOrDefault(code);
|
||||
|
||||
@@ -27,9 +27,16 @@ public partial record Message(
|
||||
IReadOnlyList<User> MentionedUsers,
|
||||
MessageReference? Reference,
|
||||
Message? ReferencedMessage,
|
||||
MessageSnapshot? ForwardedMessage,
|
||||
Interaction? Interaction
|
||||
) : IHasId
|
||||
{
|
||||
public bool IsEmpty { get; } =
|
||||
string.IsNullOrWhiteSpace(Content)
|
||||
&& !Attachments.Any()
|
||||
&& !Embeds.Any()
|
||||
&& !Stickers.Any();
|
||||
|
||||
public bool IsSystemNotification { get; } =
|
||||
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
||||
|
||||
@@ -38,11 +45,7 @@ public partial record Message(
|
||||
// App interactions are rendered as replies in the Discord client, but they are not actually replies
|
||||
public bool IsReplyLike => IsReply || Interaction is not null;
|
||||
|
||||
public bool IsEmpty { get; } =
|
||||
string.IsNullOrWhiteSpace(Content)
|
||||
&& !Attachments.Any()
|
||||
&& !Embeds.Any()
|
||||
&& !Stickers.Any();
|
||||
public bool IsForwarded { get; } = Reference?.Kind == MessageReferenceKind.Forward;
|
||||
|
||||
public IEnumerable<User> GetReferencedUsers()
|
||||
{
|
||||
@@ -171,7 +174,17 @@ public partial record Message
|
||||
|
||||
var messageReference = json.GetPropertyOrNull("message_reference")
|
||||
?.Pipe(MessageReference.Parse);
|
||||
|
||||
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
||||
|
||||
// Currently Discord only supports 1 snapshot per forward
|
||||
var forwardedMessage = json.GetPropertyOrNull("message_snapshots")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(j => j.GetPropertyOrNull("message"))
|
||||
.WhereNotNull()
|
||||
.Select(MessageSnapshot.Parse)
|
||||
.FirstOrDefault();
|
||||
|
||||
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
|
||||
|
||||
return new Message(
|
||||
@@ -191,6 +204,7 @@ public partial record Message
|
||||
mentionedUsers,
|
||||
messageReference,
|
||||
referencedMessage,
|
||||
forwardedMessage,
|
||||
interaction
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,19 @@ using JsonExtensions.Reading;
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
|
||||
public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowflake? GuildId)
|
||||
public record MessageReference(
|
||||
MessageReferenceKind Kind,
|
||||
Snowflake? MessageId,
|
||||
Snowflake? ChannelId,
|
||||
Snowflake? GuildId
|
||||
)
|
||||
{
|
||||
public static MessageReference Parse(JsonElement json)
|
||||
{
|
||||
var kind =
|
||||
json.GetPropertyOrNull("type")?.GetInt32OrNull()?.Pipe(t => (MessageReferenceKind)t)
|
||||
?? MessageReferenceKind.Default;
|
||||
|
||||
var messageId = json.GetPropertyOrNull("message_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
@@ -21,6 +30,6 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
|
||||
return new MessageReference(messageId, channelId, guildId);
|
||||
return new MessageReference(kind, messageId, channelId, guildId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#message-reference-types
|
||||
public enum MessageReferenceKind
|
||||
{
|
||||
Default = 0,
|
||||
Forward = 1,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||
using JsonExtensions.Reading;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord.Data;
|
||||
|
||||
// https://docs.discord.com/developers/resources/message#message-snapshot-object
|
||||
public record MessageSnapshot(
|
||||
DateTimeOffset Timestamp,
|
||||
DateTimeOffset? EditedTimestamp,
|
||||
string Content,
|
||||
IReadOnlyList<Attachment> Attachments,
|
||||
IReadOnlyList<Embed> Embeds,
|
||||
IReadOnlyList<Sticker> Stickers
|
||||
)
|
||||
{
|
||||
public static MessageSnapshot Parse(JsonElement json)
|
||||
{
|
||||
var timestamp =
|
||||
json.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull()
|
||||
?? DateTimeOffset.MinValue;
|
||||
|
||||
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffsetOrNull();
|
||||
|
||||
var content = json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "";
|
||||
|
||||
var attachments =
|
||||
json.GetPropertyOrNull("attachments")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Attachment.Parse)
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var embeds =
|
||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||
?? [];
|
||||
|
||||
var stickers =
|
||||
json.GetPropertyOrNull("sticker_items")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Sticker.Parse)
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
return new MessageSnapshot(
|
||||
timestamp,
|
||||
editedTimestamp,
|
||||
content,
|
||||
attachments,
|
||||
embeds,
|
||||
stickers
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,23 @@ public class DiscordClient(
|
||||
return Application.Parse(response);
|
||||
}
|
||||
|
||||
private async ValueTask EnsureMessageContentIntentAsync(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (await ResolveTokenKindAsync(cancellationToken) != TokenKind.Bot)
|
||||
return;
|
||||
|
||||
var application = await GetApplicationAsync(cancellationToken);
|
||||
if (application.IsMessageContentIntentEnabled)
|
||||
return;
|
||||
|
||||
throw new DiscordChatExporterException(
|
||||
"Provided bot account is missing the MESSAGE_CONTENT privileged intent.",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
public async ValueTask<User?> TryGetUserAsync(
|
||||
Snowflake userId,
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -371,21 +388,40 @@ public class DiscordClient(
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
|
||||
try
|
||||
{
|
||||
var parent = parentId is not null
|
||||
? await GetChannelAsync(parentId.Value, cancellationToken)
|
||||
: null;
|
||||
|
||||
return Channel.Parse(response, parent);
|
||||
}
|
||||
// It's possible for the parent channel to be inaccessible, despite the
|
||||
// child channel being accessible.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
||||
catch (DiscordChatExporterException)
|
||||
var parent = parentId is not null
|
||||
? await TryGetChannelAsync(parentId.Value, cancellationToken)
|
||||
: null;
|
||||
|
||||
return Channel.Parse(response, parent);
|
||||
}
|
||||
|
||||
public async ValueTask<Channel?> TryGetChannelAsync(
|
||||
Snowflake channelId,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var response = await TryGetJsonResponseAsync($"channels/{channelId}", cancellationToken);
|
||||
if (response is null)
|
||||
return null;
|
||||
|
||||
var parentId = response
|
||||
.Value.GetPropertyOrNull("parent_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse);
|
||||
|
||||
Channel? parent = null;
|
||||
if (parentId is not null)
|
||||
{
|
||||
return Channel.Parse(response);
|
||||
// It's possible for the parent channel to be inaccessible, despite the
|
||||
// child channel being accessible.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
||||
parent = await TryGetChannelAsync(parentId.Value, cancellationToken);
|
||||
}
|
||||
|
||||
return Channel.Parse(response.Value, parent);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Channel> GetChannelThreadsAsync(
|
||||
@@ -410,6 +446,11 @@ public class DiscordClient(
|
||||
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
||||
.ToArray();
|
||||
|
||||
// Track yielded thread IDs to avoid duplicates that can occur when a thread transitions
|
||||
// from active to archived between the two separate API calls used to fetch threads.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1433
|
||||
var seenThreadIds = new HashSet<Snowflake>();
|
||||
|
||||
// User accounts can only fetch threads using the search endpoint
|
||||
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
||||
{
|
||||
@@ -453,7 +494,9 @@ public class DiscordClient(
|
||||
break;
|
||||
}
|
||||
|
||||
yield return thread;
|
||||
if (seenThreadIds.Add(thread.Id))
|
||||
yield return thread;
|
||||
|
||||
currentOffset++;
|
||||
}
|
||||
|
||||
@@ -492,7 +535,12 @@ public class DiscordClient(
|
||||
.Pipe(parentsById.GetValueOrDefault);
|
||||
|
||||
if (filteredChannels.Contains(parent))
|
||||
yield return Channel.Parse(threadJson, parent);
|
||||
{
|
||||
var thread = Channel.Parse(threadJson, parent);
|
||||
|
||||
if (seenThreadIds.Add(thread.Id))
|
||||
yield return thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,12 +576,14 @@ public class DiscordClient(
|
||||
)
|
||||
{
|
||||
var thread = Channel.Parse(threadJson, channel);
|
||||
yield return thread;
|
||||
|
||||
currentBefore = threadJson
|
||||
.GetProperty("thread_metadata")
|
||||
.GetProperty("archive_timestamp")
|
||||
.GetString();
|
||||
|
||||
if (seenThreadIds.Add(thread.Id))
|
||||
yield return thread;
|
||||
}
|
||||
|
||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||
@@ -545,6 +595,24 @@ public class DiscordClient(
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<Message?> TryGetFirstMessageAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? after = null,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
.SetQueryParameter("limit", "1")
|
||||
.SetQueryParameter("after", (after ?? Snowflake.Zero).ToString())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||
var message = response.EnumerateArray().Select(Message.Parse).FirstOrDefault();
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? before = null,
|
||||
@@ -603,22 +671,10 @@ public class DiscordClient(
|
||||
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.
|
||||
// have the MESSAGE_CONTENT intent enabled.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||
if (
|
||||
messages.All(m => m.IsEmpty)
|
||||
&& await ResolveTokenKindAsync(cancellationToken) == TokenKind.Bot
|
||||
)
|
||||
{
|
||||
var application = await GetApplicationAsync(cancellationToken);
|
||||
if (!application.IsMessageContentIntentEnabled)
|
||||
{
|
||||
throw new DiscordChatExporterException(
|
||||
"Provided bot account does not have the Message Content Intent enabled.",
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
if (messages.All(m => m.IsEmpty))
|
||||
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
@@ -651,6 +707,75 @@ public class DiscordClient(
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Message> GetMessagesInReverseAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? after = null,
|
||||
Snowflake? before = null,
|
||||
IProgress<Percentage>? progress = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
// Get the first message in the specified range, so we can later calculate the
|
||||
// progress based on the difference between message timestamps.
|
||||
// Snapshotting is not necessary here because new messages can't appear in the past.
|
||||
var firstMessage = await TryGetFirstMessageAsync(channelId, after, cancellationToken);
|
||||
if (firstMessage is null || firstMessage.Timestamp > before?.ToDate())
|
||||
yield break;
|
||||
|
||||
// Keep track of the last message in range in order to calculate the progress
|
||||
var lastMessage = default(Message);
|
||||
|
||||
var currentBefore = before;
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channelId}/messages")
|
||||
.SetQueryParameter("limit", "100")
|
||||
.SetQueryParameter("before", currentBefore?.ToString())
|
||||
.Build();
|
||||
|
||||
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||
|
||||
var messages = response.EnumerateArray().Select(Message.Parse).ToArray();
|
||||
|
||||
// Break if there are no messages (can happen if messages are deleted during execution)
|
||||
if (!messages.Any())
|
||||
yield break;
|
||||
|
||||
// If all messages are empty, make sure that it's not because the bot account doesn't
|
||||
// have the MESSAGE_CONTENT intent enabled.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||
if (messages.All(m => m.IsEmpty))
|
||||
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
lastMessage ??= message;
|
||||
|
||||
// Report progress based on timestamps
|
||||
if (progress is not null)
|
||||
{
|
||||
var exportedDuration = (lastMessage.Timestamp - message.Timestamp).Duration();
|
||||
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
||||
|
||||
progress.Report(
|
||||
Percentage.FromFraction(
|
||||
// Avoid division by zero if all messages have the exact same timestamp
|
||||
// (which happens when there's only one message in the channel)
|
||||
totalDuration > TimeSpan.Zero
|
||||
? exportedDuration / totalDuration
|
||||
: 1
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
yield return message;
|
||||
}
|
||||
|
||||
currentBefore = messages.Last().Id;
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<User> GetMessageReactionsAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake messageId,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageReference Include="AsyncKeyedLock" Version="8.0.1" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Polly" Version="8.6.5" />
|
||||
<PackageReference Include="RazorBlade" Version="0.11.0" />
|
||||
<PackageReference Include="Superpower" Version="3.1.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.5.6" />
|
||||
<PackageReference Include="AngleSharp" />
|
||||
<PackageReference Include="AsyncKeyedLock" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" />
|
||||
<PackageReference Include="JsonExtensions" />
|
||||
<PackageReference Include="Polly" />
|
||||
<PackageReference Include="RazorBlade" />
|
||||
<PackageReference Include="Superpower" />
|
||||
<PackageReference Include="WebMarkupMin.Core" />
|
||||
<PackageReference Include="YoutubeExplode" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -64,15 +64,23 @@ public class ChannelExporter(DiscordClient discord)
|
||||
);
|
||||
}
|
||||
|
||||
await foreach (
|
||||
var message in discord.GetMessagesAsync(
|
||||
var messages = !request.IsReverseMessageOrder
|
||||
? discord.GetMessagesAsync(
|
||||
request.Channel.Id,
|
||||
request.After,
|
||||
request.Before,
|
||||
progress,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
: discord.GetMessagesInReverseAsync(
|
||||
request.Channel.Id,
|
||||
request.After,
|
||||
request.Before,
|
||||
progress,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await foreach (var message in messages)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
||||
@@ -37,6 +37,28 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
if (reuse && File.Exists(filePath))
|
||||
return _previousPathsByUrl[url] = filePath;
|
||||
|
||||
// Check for a file cached by the legacy naming scheme (5-char hash) and rename it
|
||||
// to the new naming scheme to preserve backwards compatibility with existing exports
|
||||
if (reuse)
|
||||
{
|
||||
var legacyFilePath = Path.Combine(workingDirPath, GetLegacyFileNameFromUrl(url));
|
||||
if (File.Exists(legacyFilePath))
|
||||
{
|
||||
// Overwrite in case the destination file was created concurrently between our
|
||||
// earlier existence check and this move operation
|
||||
try
|
||||
{
|
||||
File.Move(legacyFilePath, filePath, overwrite: true);
|
||||
return _previousPathsByUrl[url] = filePath;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// The legacy file was moved or deleted concurrently or something else happened.
|
||||
// Upgrading old files is not crucial, so we can just move on.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(workingDirPath);
|
||||
|
||||
await Http.ResiliencePipeline.ExecuteAsync(
|
||||
@@ -56,34 +78,23 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
||||
|
||||
internal partial class ExportAssetDownloader
|
||||
{
|
||||
private static string GetUrlHash(string url)
|
||||
private static string NormalizeUrl(string url)
|
||||
{
|
||||
// Remove signature parameters from Discord CDN URLs to normalize them
|
||||
static string NormalizeUrl(string url)
|
||||
{
|
||||
var uri = new Uri(url);
|
||||
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
||||
return url;
|
||||
var uri = new Uri(url);
|
||||
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
||||
return url;
|
||||
|
||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||
query.Remove("ex");
|
||||
query.Remove("is");
|
||||
query.Remove("hm");
|
||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||
query.Remove("ex");
|
||||
query.Remove("is");
|
||||
query.Remove("hm");
|
||||
|
||||
return uri.GetLeftPart(UriPartial.Path) + query;
|
||||
}
|
||||
|
||||
return SHA256
|
||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||
.Pipe(Convert.ToHexStringLower)
|
||||
// 5 chars ought to be enough for anybody
|
||||
.Truncate(5);
|
||||
return uri.GetLeftPart(UriPartial.Path) + query;
|
||||
}
|
||||
|
||||
private static string GetFileNameFromUrl(string url)
|
||||
private static string GetFileNameFromUrl(string url, string urlHash)
|
||||
{
|
||||
var urlHash = GetUrlHash(url);
|
||||
|
||||
// Try to extract the file name from URL
|
||||
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||
|
||||
@@ -107,4 +118,25 @@ internal partial class ExportAssetDownloader
|
||||
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
|
||||
);
|
||||
}
|
||||
|
||||
private static string GetFileNameFromUrl(string url) =>
|
||||
GetFileNameFromUrl(
|
||||
url,
|
||||
// 16 chars = 64 bits, reaches 1% collision probability at ~609 million files
|
||||
SHA256
|
||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||
.Pipe(Convert.ToHexStringLower)
|
||||
.Truncate(16)
|
||||
);
|
||||
|
||||
// Legacy naming used a 5-char hash, kept for backwards compatibility with existing exports
|
||||
private static string GetLegacyFileNameFromUrl(string url) =>
|
||||
GetFileNameFromUrl(
|
||||
url,
|
||||
SHA256
|
||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||
.Pipe(Convert.ToHexStringLower)
|
||||
// 5 chars = 20 bits, reaches 1% collision probability at ~145 files
|
||||
.Truncate(5)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
{
|
||||
private readonly Dictionary<Snowflake, Member?> _membersById = new();
|
||||
private readonly Dictionary<Snowflake, Channel> _channelsById = new();
|
||||
private readonly Dictionary<Snowflake, Channel?> _channelsById = new();
|
||||
private readonly Dictionary<Snowflake, Role> _rolesById = new();
|
||||
|
||||
private readonly ExportAssetDownloader _assetDownloader = new(
|
||||
@@ -51,6 +51,21 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
}
|
||||
}
|
||||
|
||||
// Threads are not preloaded, so we resolve them on demand
|
||||
public async ValueTask PopulateChannelAsync(
|
||||
Snowflake id,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (_channelsById.ContainsKey(id))
|
||||
return;
|
||||
|
||||
var channel = await Discord.TryGetChannelAsync(id, cancellationToken);
|
||||
|
||||
// Store the result even if it's null, to avoid re-fetching non-existing channels
|
||||
_channelsById[id] = channel;
|
||||
}
|
||||
|
||||
// Because members cannot be pulled in bulk, we need to populate them on demand
|
||||
private async ValueTask PopulateMemberAsync(
|
||||
Snowflake id,
|
||||
|
||||
@@ -33,6 +33,8 @@ public partial class ExportRequest
|
||||
|
||||
public MessageFilter MessageFilter { get; }
|
||||
|
||||
public bool IsReverseMessageOrder { get; }
|
||||
|
||||
public bool ShouldFormatMarkdown { get; }
|
||||
|
||||
public bool ShouldDownloadAssets { get; }
|
||||
@@ -55,6 +57,7 @@ public partial class ExportRequest
|
||||
Snowflake? before,
|
||||
PartitionLimit partitionLimit,
|
||||
MessageFilter messageFilter,
|
||||
bool isReverseMessageOrder,
|
||||
bool shouldFormatMarkdown,
|
||||
bool shouldDownloadAssets,
|
||||
bool shouldReuseAssets,
|
||||
@@ -69,6 +72,7 @@ public partial class ExportRequest
|
||||
Before = before;
|
||||
PartitionLimit = partitionLimit;
|
||||
MessageFilter = messageFilter;
|
||||
IsReverseMessageOrder = isReverseMessageOrder;
|
||||
ShouldFormatMarkdown = shouldFormatMarkdown;
|
||||
ShouldDownloadAssets = shouldDownloadAssets;
|
||||
ShouldReuseAssets = shouldReuseAssets;
|
||||
|
||||
@@ -270,6 +270,12 @@ internal partial class HtmlMarkdownVisitor(
|
||||
}
|
||||
else if (mention.Kind == MentionKind.Channel)
|
||||
{
|
||||
// Channel/thread mentions may reference threads that are not preloaded,
|
||||
// so we resolve them on demand.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1261
|
||||
if (mention.TargetId is not null)
|
||||
await context.PopulateChannelAsync(mention.TargetId.Value, cancellationToken);
|
||||
|
||||
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
||||
var symbol = channel?.IsVoice == true ? "🔊" : "#";
|
||||
var name = channel?.Name ?? "deleted-channel";
|
||||
|
||||
@@ -17,6 +17,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
||||
private readonly HtmlMinifier _minifier = new();
|
||||
private readonly List<Message> _messageGroup = [];
|
||||
|
||||
// Note: in reverse order, last message appears earlier than the first message
|
||||
private bool CanJoinGroup(Message message)
|
||||
{
|
||||
// If the group is empty, any message can join it
|
||||
|
||||
@@ -119,6 +119,24 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async ValueTask WriteAttachmentAsync(
|
||||
Attachment attachment,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", attachment.Id.ToString());
|
||||
_writer.WriteString(
|
||||
"url",
|
||||
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
|
||||
);
|
||||
_writer.WriteString("fileName", attachment.FileName);
|
||||
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private async ValueTask WriteEmbedAuthorAsync(
|
||||
EmbedAuthor embedAuthor,
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -335,6 +353,24 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
await _writer.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async ValueTask WriteStickerAsync(
|
||||
Sticker sticker,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", sticker.Id.ToString());
|
||||
_writer.WriteString("name", sticker.Name);
|
||||
_writer.WriteString("format", sticker.Format.ToString());
|
||||
_writer.WriteString(
|
||||
"sourceUrl",
|
||||
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
|
||||
);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
|
||||
public override async ValueTask WritePreambleAsync(
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
@@ -437,19 +473,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
_writer.WriteStartArray("attachments");
|
||||
|
||||
foreach (var attachment in message.Attachments)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", attachment.Id.ToString());
|
||||
_writer.WriteString(
|
||||
"url",
|
||||
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
|
||||
);
|
||||
_writer.WriteString("fileName", attachment.FileName);
|
||||
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
await WriteAttachmentAsync(attachment, cancellationToken);
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
@@ -465,19 +489,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
_writer.WriteStartArray("stickers");
|
||||
|
||||
foreach (var sticker in message.Stickers)
|
||||
{
|
||||
_writer.WriteStartObject();
|
||||
|
||||
_writer.WriteString("id", sticker.Id.ToString());
|
||||
_writer.WriteString("name", sticker.Name);
|
||||
_writer.WriteString("format", sticker.Format.ToString());
|
||||
_writer.WriteString(
|
||||
"sourceUrl",
|
||||
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
|
||||
);
|
||||
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
await WriteStickerAsync(sticker, cancellationToken);
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
@@ -527,12 +539,58 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
if (message.Reference is not null)
|
||||
{
|
||||
_writer.WriteStartObject("reference");
|
||||
_writer.WriteString("type", message.Reference.Kind.ToString());
|
||||
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
|
||||
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
|
||||
_writer.WriteString("guildId", message.Reference.GuildId?.ToString());
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
|
||||
// Forwarded message
|
||||
if (message.ForwardedMessage is not null)
|
||||
{
|
||||
_writer.WriteStartObject("forwardedMessage");
|
||||
|
||||
_writer.WriteString(
|
||||
"timestamp",
|
||||
Context.NormalizeDate(message.ForwardedMessage.Timestamp)
|
||||
);
|
||||
|
||||
_writer.WriteString(
|
||||
"timestampEdited",
|
||||
message.ForwardedMessage.EditedTimestamp?.Pipe(Context.NormalizeDate)
|
||||
);
|
||||
|
||||
_writer.WriteString(
|
||||
"content",
|
||||
await FormatMarkdownAsync(message.ForwardedMessage.Content, cancellationToken)
|
||||
);
|
||||
|
||||
// Forwarded attachments
|
||||
_writer.WriteStartArray("attachments");
|
||||
|
||||
foreach (var attachment in message.ForwardedMessage.Attachments)
|
||||
await WriteAttachmentAsync(attachment, cancellationToken);
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
// Forwarded embeds
|
||||
_writer.WriteStartArray("embeds");
|
||||
foreach (var embed in message.ForwardedMessage.Embeds)
|
||||
await WriteEmbedAsync(embed, cancellationToken);
|
||||
_writer.WriteEndArray();
|
||||
|
||||
// Forwarded stickers
|
||||
_writer.WriteStartArray("stickers");
|
||||
|
||||
foreach (var sticker in message.ForwardedMessage.Stickers)
|
||||
await WriteStickerAsync(sticker, cancellationToken);
|
||||
|
||||
_writer.WriteEndArray();
|
||||
|
||||
_writer.WriteEndObject();
|
||||
}
|
||||
|
||||
// Interaction
|
||||
if (message.Interaction is not null)
|
||||
{
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
}
|
||||
|
||||
<div class="chatlog__message-group">
|
||||
@foreach (var (message, i) in Messages.WithIndex())
|
||||
@foreach (var (i, message) in Messages.Index())
|
||||
{
|
||||
var isFirst = i == 0;
|
||||
|
||||
@@ -262,6 +262,95 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Forwarded message *@
|
||||
@if (message is { IsForwarded: true, ForwardedMessage: not null })
|
||||
{
|
||||
<div class="chatlog__forwarded">
|
||||
<div class="chatlog__forwarded-header">
|
||||
<svg class="chatlog__forwarded-icon">
|
||||
<use href="#forward-icon"></use>
|
||||
</svg>
|
||||
<em>Forwarded</em>
|
||||
</div>
|
||||
|
||||
@* Forwarded content *@
|
||||
@if (!string.IsNullOrWhiteSpace(message.ForwardedMessage.Content))
|
||||
{
|
||||
<div class="chatlog__forwarded-content chatlog__markdown">
|
||||
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.ForwardedMessage.Content))<!--/wmm:ignore--></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Forwarded attachments *@
|
||||
@if (message.ForwardedMessage.Attachments.Any())
|
||||
{
|
||||
<div class="chatlog__forwarded-attachments">
|
||||
@foreach (var attachment in message.ForwardedMessage.Attachments)
|
||||
{
|
||||
@if (attachment.IsImage)
|
||||
{
|
||||
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||
<img class="chatlog__forwarded-attachment" src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
|
||||
</a>
|
||||
}
|
||||
else if (attachment.IsVideo)
|
||||
{
|
||||
<video class="chatlog__forwarded-attachment" controls>
|
||||
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
|
||||
</video>
|
||||
}
|
||||
else if (attachment.IsAudio)
|
||||
{
|
||||
<audio class="chatlog__forwarded-attachment" controls>
|
||||
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
|
||||
</audio>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="chatlog__attachment-generic">
|
||||
<svg class="chatlog__attachment-generic-icon">
|
||||
<use href="#attachment-icon"/>
|
||||
</svg>
|
||||
<div class="chatlog__attachment-generic-name">
|
||||
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||
@attachment.FileName
|
||||
</a>
|
||||
</div>
|
||||
<div class="chatlog__attachment-generic-size">
|
||||
@attachment.FileSize
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Forwarded stickers *@
|
||||
@foreach (var sticker in message.ForwardedMessage.Stickers)
|
||||
{
|
||||
<div class="chatlog__sticker" title="@sticker.Name">
|
||||
@if (sticker.IsImage)
|
||||
{
|
||||
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
|
||||
}
|
||||
else if (sticker.Format == StickerFormat.Lottie)
|
||||
{
|
||||
<div class="chatlog__sticker--media" data-source="@await ResolveAssetUrlAsync(sticker.SourceUrl)"></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Forwarded timestamp *@
|
||||
<div class="chatlog__forwarded-timestamp">
|
||||
<span title="@FormatDate(message.ForwardedMessage.Timestamp, "f")">Originally sent: @FormatDate(message.ForwardedMessage.Timestamp)</span>
|
||||
@if (message.ForwardedMessage.EditedTimestamp is not null)
|
||||
{
|
||||
<span title="@FormatDate(message.ForwardedMessage.EditedTimestamp.Value, "f")"> (edited)</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Attachments *@
|
||||
@foreach (var attachment in message.Attachments)
|
||||
{
|
||||
|
||||
@@ -57,6 +57,12 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
|
||||
}
|
||||
else if (mention.Kind == MentionKind.Channel)
|
||||
{
|
||||
// Channel/thread mentions may reference threads that are not preloaded,
|
||||
// so we resolve them on demand.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1261
|
||||
if (mention.TargetId is not null)
|
||||
await context.PopulateChannelAsync(mention.TargetId.Value, cancellationToken);
|
||||
|
||||
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
||||
var name = channel?.Name ?? "deleted-channel";
|
||||
|
||||
|
||||
@@ -23,13 +23,11 @@ internal static class PlainTextMessageExtensions
|
||||
: "Removed a recipient.",
|
||||
|
||||
MessageKind.Call =>
|
||||
$"Started a call that lasted {
|
||||
message
|
||||
$"Started a call that lasted {message
|
||||
.CallEndedTimestamp?
|
||||
.Pipe(t => t - message.Timestamp)
|
||||
.Pipe(t => t.TotalMinutes)
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||
} minutes.",
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"} minutes.",
|
||||
|
||||
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
|
||||
? $"Changed the channel name: {message.Content}"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -173,7 +174,7 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
await _writer.WriteLineAsync("{Reactions}");
|
||||
|
||||
foreach (var (reaction, i) in reactions.WithIndex())
|
||||
foreach (var (i, reaction) in reactions.Index())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -224,6 +225,31 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
||||
await _writer.WriteLineAsync();
|
||||
}
|
||||
|
||||
private async ValueTask WriteForwardedMessageAsync(
|
||||
MessageSnapshot forwardedMessage,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
await _writer.WriteLineAsync("{Forwarded Message}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(forwardedMessage.Content))
|
||||
{
|
||||
await _writer.WriteLineAsync(
|
||||
await FormatMarkdownAsync(forwardedMessage.Content, cancellationToken)
|
||||
);
|
||||
}
|
||||
|
||||
await _writer.WriteLineAsync(
|
||||
$"Originally sent: {Context.FormatDate(forwardedMessage.Timestamp)}"
|
||||
);
|
||||
|
||||
await WriteAttachmentsAsync(forwardedMessage.Attachments, cancellationToken);
|
||||
await WriteEmbedsAsync(forwardedMessage.Embeds, cancellationToken);
|
||||
await WriteStickersAsync(forwardedMessage.Stickers, cancellationToken);
|
||||
|
||||
await _writer.WriteLineAsync();
|
||||
}
|
||||
|
||||
public override async ValueTask WriteMessageAsync(
|
||||
Message message,
|
||||
CancellationToken cancellationToken = default
|
||||
@@ -248,6 +274,12 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
||||
|
||||
await _writer.WriteLineAsync();
|
||||
|
||||
// Forwarded message content
|
||||
if (message.ForwardedMessage is not null)
|
||||
{
|
||||
await WriteForwardedMessageAsync(message.ForwardedMessage, cancellationToken);
|
||||
}
|
||||
|
||||
// Attachments, embeds, reactions, etc.
|
||||
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
||||
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
||||
|
||||
@@ -304,6 +304,52 @@
|
||||
unicode-bidi: bidi-override;
|
||||
}
|
||||
|
||||
.chatlog__forwarded {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 0.15rem;
|
||||
padding: 0.5rem;
|
||||
border-left: 4px solid @Themed("#4f545c", "#c7ccd1");
|
||||
border-radius: 4px;
|
||||
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
|
||||
}
|
||||
|
||||
.chatlog__forwarded-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
color: @Themed("#b5b6b8", "#5f5f60");
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chatlog__forwarded-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.chatlog__forwarded-content {
|
||||
color: @Themed("#dcddde", "#2e3338");
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.chatlog__forwarded-attachments {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.chatlog__forwarded-attachment {
|
||||
max-width: 300px;
|
||||
max-height: 200px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chatlog__forwarded-timestamp {
|
||||
margin-top: 0.25rem;
|
||||
color: @Themed("#a3a6aa", "#5e6772");
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.chatlog__system-notification-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
@@ -999,6 +1045,9 @@
|
||||
<path fill="#b9bbbe" d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.40991 9H3.55274C3.47819 9 3.42168 8.93274 3.43453 8.85931L3.74253 7.09931C3.75258 7.04189 3.80244 7 3.86074 7H7.75991L8.45234 3.09903C8.46251 3.04174 8.51231 3 8.57049 3H10.3267C10.4014 3 10.4579 3.06746 10.4449 3.14097L9.75991 7H15.7599L16.4523 3.09903C16.4625 3.04174 16.5123 3 16.5705 3H18.3267C18.4014 3 18.4579 3.06746 18.4449 3.14097L17.7599 7H21.6171C21.6916 7 21.7481 7.06725 21.7353 7.14069L21.4273 8.90069C21.4172 8.95811 21.3674 9 21.3091 9H17.4099L17.0495 11.04H15.05L15.4104 9H9.41035L8.35035 15H10.5599V17H7.99991L7.30749 20.901C7.29732 20.9583 7.24752 21 7.18934 21H5.43309Z" />
|
||||
<path fill="#b9bbbe" d="M13.4399 12.96C12.9097 12.96 12.4799 13.3898 12.4799 13.92V20.2213C12.4799 20.7515 12.9097 21.1813 13.4399 21.1813H14.3999C14.5325 21.1813 14.6399 21.2887 14.6399 21.4213V23.4597C14.6399 23.6677 14.8865 23.7773 15.0408 23.6378L17.4858 21.4289C17.6622 21.2695 17.8916 21.1813 18.1294 21.1813H22.5599C23.0901 21.1813 23.5199 20.7515 23.5199 20.2213V13.92C23.5199 13.3898 23.0901 12.96 22.5599 12.96H13.4399Z" />
|
||||
</symbol>
|
||||
<symbol id="forward-icon" viewBox="0 0 24 24">
|
||||
<path fill="#b9bbbe" d="M13 4L21 12L13 20V15C8 15 4 17 1 22C2 16 6 10 13 9V4Z" />
|
||||
</symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
</head>
|
||||
|
||||
@@ -14,7 +14,7 @@ internal static class MatcherExtensions
|
||||
this IMatcher<TContext, TValue> matcher,
|
||||
TContext context,
|
||||
StringSegment segment,
|
||||
Func<TContext, StringSegment, TValue> transformFallback
|
||||
Func<TContext, StringSegment, TValue> fallbackTransform
|
||||
)
|
||||
{
|
||||
// Loop through segments divided by individual matches
|
||||
@@ -40,7 +40,7 @@ internal static class MatcherExtensions
|
||||
|
||||
yield return new ParsedMatch<TValue>(
|
||||
fallbackSegment,
|
||||
transformFallback(context, fallbackSegment)
|
||||
fallbackTransform(context, fallbackSegment)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ internal static class MatcherExtensions
|
||||
|
||||
yield return new ParsedMatch<TValue>(
|
||||
fallbackSegment,
|
||||
transformFallback(context, fallbackSegment)
|
||||
fallbackTransform(context, fallbackSegment)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,53 +208,18 @@ internal static partial class MarkdownParser
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
|
||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||
new Regex(
|
||||
@"("
|
||||
+
|
||||
// Country flag emoji (two regional indicator surrogate pairs)
|
||||
@"(?:\uD83C[\uDDE6-\uDDFF]){2}|"
|
||||
+
|
||||
// Digit emoji (digit followed by enclosing mark)
|
||||
@"\d\p{Me}|"
|
||||
+
|
||||
// Surrogate pair
|
||||
@"\p{Cs}{2}|"
|
||||
+
|
||||
// Miscellaneous characters
|
||||
@"["
|
||||
+ @"\u2600-\u2604"
|
||||
+ @"\u260E\u2611"
|
||||
+ @"\u2614-\u2615"
|
||||
+ @"\u2618\u261D\u2620"
|
||||
+ @"\u2622-\u2623"
|
||||
+ @"\u2626\u262A"
|
||||
+ @"\u262E-\u262F"
|
||||
+ @"\u2638-\u263A"
|
||||
+ @"\u2640\u2642"
|
||||
+ @"\u2648-\u2653"
|
||||
+ @"\u265F-\u2660"
|
||||
+ @"\u2663"
|
||||
+ @"\u2665-\u2666"
|
||||
+ @"\u2668\u267B"
|
||||
+ @"\u267E-\u267F"
|
||||
+ @"\u2692-\u2697"
|
||||
+ @"\u2699"
|
||||
+ @"\u269B-\u269C"
|
||||
+ @"\u26A0-\u26A1"
|
||||
+ @"\u26A7"
|
||||
+ @"\u26AA-\u26AB"
|
||||
+ @"\u26B0-\u26B1"
|
||||
+ @"\u26BD-\u26BE"
|
||||
+ @"\u26C4-\u26C5"
|
||||
+ @"\u26C8"
|
||||
+ @"\u26CE-\u26CF"
|
||||
+ @"\u26D1"
|
||||
+ @"\u26D3-\u26D4"
|
||||
+ @"\u26E9-\u26EA"
|
||||
+ @"\u26F0-\u26F5"
|
||||
+ @"\u26F7-\u26FA"
|
||||
+ @"\u26FD"
|
||||
+ @"]"
|
||||
+ @")",
|
||||
// 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
|
||||
// their individual components.
|
||||
"("
|
||||
+ string.Join(
|
||||
"|",
|
||||
EmojiIndex
|
||||
.GetAllNames()
|
||||
.OrderByDescending(e => e.Length)
|
||||
.Select(Regex.Escape)
|
||||
)
|
||||
+ ")",
|
||||
DefaultRegexOptions
|
||||
),
|
||||
(_, _, m) => new EmojiNode(m.Groups[1].Value)
|
||||
@@ -310,7 +275,7 @@ internal static partial class MarkdownParser
|
||||
// Capture the shrug kaomoji.
|
||||
// This escapes it from matching for formatting.
|
||||
@"¯\_(ツ)_/¯",
|
||||
(s, _) => new TextNode(s.ToString())
|
||||
(_, s) => new TextNode(s.ToString())
|
||||
);
|
||||
|
||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
|
||||
|
||||
@@ -14,5 +14,5 @@ internal readonly record struct StringSegment(string Source, int StartIndex, int
|
||||
|
||||
public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length);
|
||||
|
||||
public override string ToString() => Source.Substring(StartIndex, Length);
|
||||
public override string ToString() => Source[StartIndex..EndIndex];
|
||||
}
|
||||
|
||||
@@ -12,16 +12,6 @@ public static class CollectionExtensions
|
||||
}
|
||||
}
|
||||
|
||||
extension<T>(IEnumerable<T> source)
|
||||
{
|
||||
public IEnumerable<(T value, int index)> WithIndex()
|
||||
{
|
||||
var i = 0;
|
||||
foreach (var o in source)
|
||||
yield return (o, i++);
|
||||
}
|
||||
}
|
||||
|
||||
extension<T>(IEnumerable<T?> source)
|
||||
where T : class
|
||||
{
|
||||
@@ -34,4 +24,17 @@ public static class CollectionExtensions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension<T>(IEnumerable<T?> source)
|
||||
where T : struct
|
||||
{
|
||||
public IEnumerable<T> WhereNotNull()
|
||||
{
|
||||
foreach (var o in source)
|
||||
{
|
||||
if (o is not null)
|
||||
yield return o.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,11 @@
|
||||
</Style>
|
||||
</Style>
|
||||
|
||||
<!-- Run -->
|
||||
<Style Selector="Run">
|
||||
<Setter Property="BaselineAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<!-- Text box -->
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
|
||||
@@ -5,6 +5,7 @@ using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using DiscordChatExporter.Gui.Framework;
|
||||
using DiscordChatExporter.Gui.Localization;
|
||||
using DiscordChatExporter.Gui.Services;
|
||||
using DiscordChatExporter.Gui.Utils;
|
||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||
@@ -25,6 +26,8 @@ public class App : Application, IDisposable
|
||||
private readonly SettingsService _settingsService;
|
||||
private readonly MainViewModel _mainViewModel;
|
||||
|
||||
private bool _isDisposed;
|
||||
|
||||
public App()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
@@ -39,6 +42,9 @@ public class App : Application, IDisposable
|
||||
services.AddSingleton<SettingsService>();
|
||||
services.AddSingleton<UpdateService>();
|
||||
|
||||
// Localization
|
||||
services.AddSingleton<LocalizationManager>();
|
||||
|
||||
// View models
|
||||
services.AddTransient<MainViewModel>();
|
||||
services.AddTransient<DashboardViewModel>();
|
||||
@@ -94,8 +100,24 @@ public class App : Application, IDisposable
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainView { DataContext = _mainViewModel };
|
||||
|
||||
void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs args)
|
||||
{
|
||||
if (sender is IControlledApplicationLifetime lifetime)
|
||||
lifetime.Exit -= OnExit;
|
||||
|
||||
Dispose();
|
||||
}
|
||||
|
||||
// Although `App.Dispose()` is invoked from `Program.Main(...)`, on some platforms
|
||||
// it may be called too late in the shutdown lifecycle. Attach an exit
|
||||
// handler to ensure timely disposal as a safeguard.
|
||||
// https://github.com/Tyrrrz/YoutubeDownloader/issues/795
|
||||
desktop.Exit += OnExit;
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
|
||||
// Set up custom theme colors
|
||||
@@ -111,6 +133,11 @@ public class App : Application, IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed)
|
||||
return;
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
_eventRoot.Dispose();
|
||||
_services.Dispose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||
using DiscordChatExporter.Gui.Views.Controls;
|
||||
using Markdig;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
using MarkdownInline = Markdig.Syntax.Inlines.Inline;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Converters;
|
||||
|
||||
public class MarkdownToInlinesConverter : IValueConverter
|
||||
{
|
||||
public static readonly MarkdownToInlinesConverter Instance = new();
|
||||
|
||||
private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder()
|
||||
.UseEmphasisExtras()
|
||||
.Build();
|
||||
|
||||
private static void ProcessInline(
|
||||
InlineCollection inlines,
|
||||
MarkdownInline markdownInline,
|
||||
FontWeight? fontWeight = null,
|
||||
FontStyle? fontStyle = null,
|
||||
TextDecorationCollection? textDecorations = null
|
||||
)
|
||||
{
|
||||
switch (markdownInline)
|
||||
{
|
||||
case LiteralInline literal:
|
||||
{
|
||||
var run = new Run(literal.Content.ToString());
|
||||
|
||||
if (fontWeight is not null)
|
||||
run.FontWeight = fontWeight.Value;
|
||||
if (fontStyle is not null)
|
||||
run.FontStyle = fontStyle.Value;
|
||||
if (textDecorations is not null)
|
||||
run.TextDecorations = textDecorations;
|
||||
|
||||
inlines.Add(run);
|
||||
break;
|
||||
}
|
||||
|
||||
case LineBreakInline:
|
||||
{
|
||||
inlines.Add(new LineBreak());
|
||||
break;
|
||||
}
|
||||
|
||||
case EmphasisInline emphasis:
|
||||
{
|
||||
var newWeight = fontWeight;
|
||||
var newStyle = fontStyle;
|
||||
var newDecorations = textDecorations;
|
||||
|
||||
switch (emphasis.DelimiterChar)
|
||||
{
|
||||
case '*' or '_' when emphasis.DelimiterCount == 2:
|
||||
newWeight = FontWeight.SemiBold;
|
||||
break;
|
||||
case '*' or '_':
|
||||
newStyle = FontStyle.Italic;
|
||||
break;
|
||||
case '~':
|
||||
newDecorations = TextDecorations.Strikethrough;
|
||||
break;
|
||||
case '+':
|
||||
newDecorations = TextDecorations.Underline;
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (var child in emphasis)
|
||||
ProcessInline(inlines, child, newWeight, newStyle, newDecorations);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case LinkInline link:
|
||||
{
|
||||
inlines.Add(new HyperLink { Text = link.GetInnerText(), Url = link.Url });
|
||||
break;
|
||||
}
|
||||
|
||||
case ContainerInline container:
|
||||
{
|
||||
foreach (var child in container)
|
||||
ProcessInline(inlines, child, fontWeight, fontStyle, textDecorations);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var inlines = new InlineCollection();
|
||||
if (value is not string { Length: > 0 } text)
|
||||
return inlines;
|
||||
|
||||
var isFirst = true;
|
||||
|
||||
foreach (var block in Markdown.Parse(text, MarkdownPipeline))
|
||||
{
|
||||
switch (block)
|
||||
{
|
||||
case ParagraphBlock { Inline: not null } paragraph:
|
||||
{
|
||||
if (!isFirst)
|
||||
{
|
||||
// Insert a blank line between paragraphs
|
||||
inlines.Add(new LineBreak());
|
||||
inlines.Add(new LineBreak());
|
||||
}
|
||||
|
||||
isFirst = false;
|
||||
|
||||
foreach (var markdownInline in paragraph.Inline!)
|
||||
ProcessInline(inlines, markdownInline);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case ListBlock list:
|
||||
{
|
||||
var itemOrder = 1;
|
||||
if (list.IsOrdered && int.TryParse(list.OrderedStart, out var startNum))
|
||||
itemOrder = startNum;
|
||||
|
||||
foreach (var listItem in list.OfType<ListItemBlock>())
|
||||
{
|
||||
if (!isFirst)
|
||||
inlines.Add(new LineBreak());
|
||||
isFirst = false;
|
||||
|
||||
var prefix = list.IsOrdered ? $"{itemOrder++}. " : $"{list.BulletType} ";
|
||||
|
||||
inlines.Add(new Run(prefix));
|
||||
|
||||
foreach (var subBlock in listItem.OfType<ParagraphBlock>())
|
||||
{
|
||||
if (subBlock is { Inline: not null })
|
||||
{
|
||||
foreach (var markdownInline in subBlock.Inline)
|
||||
ProcessInline(inlines, markdownInline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inlines;
|
||||
}
|
||||
|
||||
public object? ConvertBack(
|
||||
object? value,
|
||||
Type targetType,
|
||||
object? parameter,
|
||||
CultureInfo culture
|
||||
) => throw new NotSupportedException();
|
||||
}
|
||||
@@ -8,6 +8,15 @@
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<EncryptionSalt>HimalayanPinkSalt</EncryptionSalt>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Expose this property in code -->
|
||||
<ProjectProperty Include="EncryptionSalt" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<PublishMacOSBundle>false</PublishMacOSBundle>
|
||||
</PropertyGroup>
|
||||
@@ -23,24 +32,22 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.5.0" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.11" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.11" />
|
||||
<PackageReference
|
||||
Include="Avalonia.Diagnostics"
|
||||
Version="11.3.11"
|
||||
Condition="'$(Configuration)' == 'Debug'"
|
||||
/>
|
||||
<PackageReference Include="Cogwheel" Version="2.1.0" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||
<PackageReference Include="DialogHost.Avalonia" Version="0.10.4" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="Material.Avalonia" Version="3.9.2" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||
<PackageReference Include="Onova" Version="2.6.13" />
|
||||
<PackageReference Include="AsyncImageLoader.Avalonia" />
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Condition="'$(Configuration)' == 'Debug'" />
|
||||
<PackageReference Include="Cogwheel" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||
<PackageReference Include="DialogHost.Avalonia" />
|
||||
<PackageReference Include="Gress" />
|
||||
<PackageReference Include="Markdig" />
|
||||
<PackageReference Include="Material.Avalonia" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Onova" />
|
||||
<PackageReference Include="ThisAssembly.Project" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
using Avalonia;
|
||||
using Avalonia.Platform.Storage;
|
||||
using DialogHostAvalonia;
|
||||
@@ -13,11 +13,12 @@ namespace DiscordChatExporter.Gui.Framework;
|
||||
|
||||
public class DialogManager : IDisposable
|
||||
{
|
||||
private readonly AsyncNonKeyedLocker _dialogLock = new();
|
||||
private readonly SemaphoreSlim _dialogLock = new(1, 1);
|
||||
|
||||
public async Task<T?> ShowDialogAsync<T>(DialogViewModelBase<T> dialog)
|
||||
{
|
||||
using (await _dialogLock.LockAsync())
|
||||
await _dialogLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
await DialogHost.Show(
|
||||
dialog,
|
||||
@@ -38,8 +39,16 @@ public class DialogManager : IDisposable
|
||||
}
|
||||
);
|
||||
|
||||
// Yield to allow DialogHost to fully reset its state before
|
||||
// another dialog is shown (e.g. when dialogs are shown sequentially).
|
||||
await Task.Yield();
|
||||
|
||||
return dialog.DialogResult;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_dialogLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> PromptSaveFilePathAsync(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DiscordChatExporter.Gui.Localization;
|
||||
|
||||
public enum Language
|
||||
{
|
||||
System,
|
||||
English,
|
||||
Ukrainian,
|
||||
German,
|
||||
French,
|
||||
Spanish,
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Localization;
|
||||
|
||||
public partial class LocalizationManager
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> EnglishLocalization =
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
// Dashboard
|
||||
[nameof(PullGuildsTooltip)] = "Pull available servers and channels (Enter)",
|
||||
[nameof(SettingsTooltip)] = "Settings",
|
||||
[nameof(LastMessageSentTooltip)] = "Last message sent:",
|
||||
[nameof(TokenWatermark)] = "Token",
|
||||
// Token instructions (personal account)
|
||||
[nameof(TokenPersonalHeader)] = "To get the token for your personal account:",
|
||||
[nameof(TokenPersonalTosWarning)] =
|
||||
"* Automating user accounts is technically against TOS — **use at your own risk**!",
|
||||
[nameof(TokenPersonalInstructions)] = """
|
||||
1. Open Discord in your web browser and login
|
||||
2. Open any server or direct message channel
|
||||
3. Press **Ctrl+Shift+I** to show developer tools
|
||||
4. Navigate to the **Network** tab
|
||||
5. Press **Ctrl+R** to reload
|
||||
6. Switch between random channels to trigger network requests
|
||||
7. Search for a request that starts with **messages**
|
||||
8. Select the **Headers** tab on the right
|
||||
9. Scroll down to the **Request Headers** section
|
||||
10. Copy the value of the **authorization** header
|
||||
""",
|
||||
// Token instructions (bot)
|
||||
[nameof(TokenBotHeader)] = "To get the token for your bot:",
|
||||
[nameof(TokenBotInstructions)] = """
|
||||
The token is generated during bot creation. If you lost it, generate a new one:
|
||||
|
||||
1. Open Discord [developer portal](https://discord.com/developers/applications)
|
||||
2. Open your application's settings
|
||||
3. Navigate to the **Bot** section on the left
|
||||
4. Under **Token** click **Reset Token**
|
||||
5. Click **Yes, do it!** and authenticate to confirm
|
||||
* Integrations using the previous token will stop working until updated
|
||||
* Your bot needs to have the **Message Content Intent** enabled to read messages
|
||||
""",
|
||||
[nameof(TokenHelpText)] =
|
||||
"If you have questions or issues, please refer to the [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
|
||||
// Settings
|
||||
[nameof(SettingsTitle)] = "Settings",
|
||||
[nameof(ThemeLabel)] = "Theme",
|
||||
[nameof(ThemeTooltip)] = "Preferred user interface theme",
|
||||
[nameof(LanguageLabel)] = "Language",
|
||||
[nameof(LanguageTooltip)] = "Preferred user interface language",
|
||||
[nameof(AutoUpdateLabel)] = "Auto-update",
|
||||
[nameof(AutoUpdateTooltip)] = "Perform automatic updates on every launch",
|
||||
[nameof(PersistTokenLabel)] = "Persist token",
|
||||
[nameof(PersistTokenTooltip)] = """
|
||||
Save the last used token to a file so that it can be persisted between sessions.
|
||||
**Warning**: although the token is stored with encryption, it may still be recovered by an attacker who has access to your system.
|
||||
""",
|
||||
[nameof(RateLimitPreferenceLabel)] = "Rate limit preference",
|
||||
[nameof(RateLimitPreferenceTooltip)] =
|
||||
"Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.",
|
||||
[nameof(ShowThreadsLabel)] = "Show threads",
|
||||
[nameof(ShowThreadsTooltip)] = "Which types of threads to show in the channel list",
|
||||
[nameof(LocaleLabel)] = "Locale",
|
||||
[nameof(LocaleTooltip)] = "Locale to use when formatting dates and numbers",
|
||||
[nameof(NormalizeToUtcLabel)] = "Normalize to UTC",
|
||||
[nameof(NormalizeToUtcTooltip)] = "Normalize all timestamps to UTC+0",
|
||||
[nameof(ParallelLimitLabel)] = "Parallel limit",
|
||||
[nameof(ParallelLimitTooltip)] = "How many channels can be exported at the same time",
|
||||
// Export Setup
|
||||
[nameof(ChannelsSelectedText)] = "channels selected",
|
||||
[nameof(OutputPathLabel)] = "Output path",
|
||||
[nameof(OutputPathTooltip)] = """
|
||||
Output file or directory path.
|
||||
|
||||
If a directory is specified, file names will be generated automatically based on the channel names and export parameters.
|
||||
|
||||
Directory paths must end with a slash to avoid ambiguity.
|
||||
|
||||
Available template tokens:
|
||||
**%g** — server ID
|
||||
**%G** — server name
|
||||
**%t** — category ID
|
||||
**%T** — category name
|
||||
**%c** — channel ID
|
||||
**%C** — channel name
|
||||
**%p** — channel position
|
||||
**%P** — category position
|
||||
**%a** — after date
|
||||
**%b** — before date
|
||||
**%d** — current date
|
||||
""",
|
||||
[nameof(FormatLabel)] = "Format",
|
||||
[nameof(FormatTooltip)] = "Export format",
|
||||
[nameof(AfterDateLabel)] = "After (date)",
|
||||
[nameof(AfterDateTooltip)] = "Only include messages sent after this date",
|
||||
[nameof(BeforeDateLabel)] = "Before (date)",
|
||||
[nameof(BeforeDateTooltip)] = "Only include messages sent before this date",
|
||||
[nameof(AfterTimeLabel)] = "After (time)",
|
||||
[nameof(AfterTimeTooltip)] = "Only include messages sent after this time",
|
||||
[nameof(BeforeTimeLabel)] = "Before (time)",
|
||||
[nameof(BeforeTimeTooltip)] = "Only include messages sent before this time",
|
||||
[nameof(PartitionLimitLabel)] = "Partition limit",
|
||||
[nameof(PartitionLimitTooltip)] =
|
||||
"Split the output into partitions, each limited to the specified number of messages (e.g. '100') or file size (e.g. '10mb')",
|
||||
[nameof(MessageFilterLabel)] = "Message filter",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image'). See the documentation for more info.",
|
||||
[nameof(ReverseMessageOrderLabel)] = "Reverse messages",
|
||||
[nameof(ReverseMessageOrderTooltip)] =
|
||||
"Export messages in reverse chronological order (newest first)",
|
||||
[nameof(FormatMarkdownLabel)] = "Format markdown",
|
||||
[nameof(FormatMarkdownTooltip)] =
|
||||
"Process markdown, mentions, and other special tokens",
|
||||
[nameof(DownloadAssetsLabel)] = "Download assets",
|
||||
[nameof(DownloadAssetsTooltip)] =
|
||||
"Download assets referenced by the export (user avatars, attached files, embedded images, etc.)",
|
||||
[nameof(ReuseAssetsLabel)] = "Reuse assets",
|
||||
[nameof(ReuseAssetsTooltip)] =
|
||||
"Reuse previously downloaded assets to avoid redundant requests",
|
||||
[nameof(AssetsDirPathLabel)] = "Assets directory path",
|
||||
[nameof(AssetsDirPathTooltip)] =
|
||||
"Download assets to this directory. If not specified, the asset directory path will be derived from the output path.",
|
||||
[nameof(AdvancedOptionsTooltip)] = "Toggle advanced options",
|
||||
[nameof(ExportButton)] = "EXPORT",
|
||||
// Common buttons
|
||||
[nameof(CloseButton)] = "CLOSE",
|
||||
[nameof(CancelButton)] = "CANCEL",
|
||||
// Dialog messages
|
||||
[nameof(UkraineSupportTitle)] = "Thank you for supporting Ukraine!",
|
||||
[nameof(UkraineSupportMessage)] = """
|
||||
As Russia wages a genocidal war against my country, I'm grateful to everyone who continues to stand with Ukraine in our fight for freedom.
|
||||
|
||||
Click LEARN MORE to find ways that you can help.
|
||||
""",
|
||||
[nameof(LearnMoreButton)] = "LEARN MORE",
|
||||
[nameof(UnstableBuildTitle)] = "Unstable build warning",
|
||||
[nameof(UnstableBuildMessage)] = """
|
||||
You're using a development build of {0}. These builds are not thoroughly tested and may contain bugs.
|
||||
|
||||
Auto-updates are disabled for development builds.
|
||||
|
||||
Click SEE RELEASES if you want to download a stable release instead.
|
||||
""",
|
||||
[nameof(SeeReleasesButton)] = "SEE RELEASES",
|
||||
[nameof(UpdateDownloadingMessage)] = "Downloading update to {0} v{1}...",
|
||||
[nameof(UpdateReadyMessage)] =
|
||||
"Update has been downloaded and will be installed when you exit",
|
||||
[nameof(UpdateInstallNowButton)] = "INSTALL NOW",
|
||||
[nameof(UpdateFailedMessage)] = "Failed to perform application update",
|
||||
[nameof(ErrorPullingGuildsTitle)] = "Error pulling servers",
|
||||
[nameof(ErrorPullingChannelsTitle)] = "Error pulling channels",
|
||||
[nameof(ErrorExportingTitle)] = "Error exporting channel(s)",
|
||||
[nameof(SuccessfulExportMessage)] = "Successfully exported {0} channel(s)",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Localization;
|
||||
|
||||
public partial class LocalizationManager
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> FrenchLocalization = new Dictionary<
|
||||
string,
|
||||
string
|
||||
>
|
||||
{
|
||||
// Dashboard
|
||||
[nameof(PullGuildsTooltip)] = "Charger les serveurs et canaux disponibles (Entrée)",
|
||||
[nameof(SettingsTooltip)] = "Paramètres",
|
||||
[nameof(LastMessageSentTooltip)] = "Dernier message envoyé :",
|
||||
[nameof(TokenWatermark)] = "Token",
|
||||
// Token instructions (personal account)
|
||||
[nameof(TokenPersonalHeader)] = "Obtenir le token pour votre compte personnel :",
|
||||
[nameof(TokenPersonalTosWarning)] =
|
||||
"* L'automatisation des comptes est techniquement contraire aux CGU — **à vos risques et périls**!",
|
||||
[nameof(TokenPersonalInstructions)] = """
|
||||
1. Ouvrez Discord dans votre navigateur web et connectez-vous
|
||||
2. Ouvrez n'importe quel serveur ou canal de message direct
|
||||
3. Appuyez sur **Ctrl+Shift+I** pour afficher les outils de développement
|
||||
4. Naviguez vers l'onglet **Network**
|
||||
5. Appuyez sur **Ctrl+R** pour recharger
|
||||
6. Changez de canal pour déclencher des requêtes réseau
|
||||
7. Cherchez une requête commençant par **messages**
|
||||
8. Sélectionnez l'onglet **Headers** à droite
|
||||
9. Faites défiler jusqu'à la section **Request Headers**
|
||||
10. Copiez la valeur de l'en-tête **authorization**
|
||||
""",
|
||||
// Token instructions (bot)
|
||||
[nameof(TokenBotHeader)] = "Obtenir le token pour votre bot :",
|
||||
[nameof(TokenBotInstructions)] = """
|
||||
Le token est généré lors de la création du bot. Si vous l'avez perdu, générez-en un nouveau :
|
||||
|
||||
1. Ouvrez Discord [portail développeur](https://discord.com/developers/applications)
|
||||
2. Ouvrez les paramètres de votre application
|
||||
3. Naviguez vers la section **Bot** à gauche
|
||||
4. Sous **Token**, cliquez sur **Reset Token**
|
||||
5. Cliquez sur **Yes, do it!** et confirmez
|
||||
* Les intégrations utilisant l'ancien token cesseront de fonctionner jusqu'à leur mise à jour
|
||||
* Votre bot doit avoir l'option **Message Content Intent** activée pour lire les messages
|
||||
""",
|
||||
[nameof(TokenHelpText)] =
|
||||
"Pour les questions ou problèmes, veuillez consulter la [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
|
||||
// Settings
|
||||
[nameof(SettingsTitle)] = "Paramètres",
|
||||
[nameof(ThemeLabel)] = "Thème",
|
||||
[nameof(ThemeTooltip)] = "Thème d'interface préféré",
|
||||
[nameof(LanguageLabel)] = "Langue",
|
||||
[nameof(LanguageTooltip)] = "Langue d'interface préférée",
|
||||
[nameof(AutoUpdateLabel)] = "Mise à jour automatique",
|
||||
[nameof(AutoUpdateTooltip)] = "Effectuer des mises à jour automatiques à chaque lancement",
|
||||
[nameof(PersistTokenLabel)] = "Conserver le token",
|
||||
[nameof(PersistTokenTooltip)] = """
|
||||
Enregistrer le dernier token utilisé dans un fichier pour le conserver entre les sessions.
|
||||
**Avertissement** : bien que le token soit stocké avec chiffrement, il peut toujours être récupéré par un attaquant ayant accès à votre système.
|
||||
""",
|
||||
[nameof(RateLimitPreferenceLabel)] = "Préférence de limite de débit",
|
||||
[nameof(RateLimitPreferenceTooltip)] =
|
||||
"Indique s'il faut respecter les limites de débit recommandées. Si désactivé, seules les limites strictes (réponses 429) seront respectées.",
|
||||
[nameof(ShowThreadsLabel)] = "Afficher les fils",
|
||||
[nameof(ShowThreadsTooltip)] = "Quels types de fils afficher dans la liste des canaux",
|
||||
[nameof(LocaleLabel)] = "Locale",
|
||||
[nameof(LocaleTooltip)] = "Locale à utiliser pour le formatage des dates et des nombres",
|
||||
[nameof(NormalizeToUtcLabel)] = "Normaliser en UTC",
|
||||
[nameof(NormalizeToUtcTooltip)] = "Normaliser tous les horodatages en UTC+0",
|
||||
[nameof(ParallelLimitLabel)] = "Limite parallèle",
|
||||
[nameof(ParallelLimitTooltip)] = "Combien de canaux peuvent être exportés simultanément",
|
||||
// Export Setup
|
||||
[nameof(ChannelsSelectedText)] = "canaux sélectionnés",
|
||||
[nameof(OutputPathLabel)] = "Chemin de sortie",
|
||||
[nameof(OutputPathTooltip)] = """
|
||||
Chemin du fichier ou répertoire de sortie.
|
||||
|
||||
Si un répertoire est spécifié, les noms de fichiers seront générés automatiquement en fonction des noms de canaux et des paramètres d'exportation.
|
||||
|
||||
Les chemins de répertoire doivent se terminer par un slash pour éviter toute ambiguïté.
|
||||
|
||||
Jetons de modèle disponibles :
|
||||
**%g** — ID du serveur
|
||||
**%G** — nom du serveur
|
||||
**%t** — ID de la catégorie
|
||||
**%T** — nom de la catégorie
|
||||
**%c** — ID du canal
|
||||
**%C** — nom du canal
|
||||
**%p** — position du canal
|
||||
**%P** — position de la catégorie
|
||||
**%a** — date après
|
||||
**%b** — date avant
|
||||
**%d** — date actuelle
|
||||
""",
|
||||
[nameof(FormatLabel)] = "Format",
|
||||
[nameof(FormatTooltip)] = "Format d'exportation",
|
||||
[nameof(AfterDateLabel)] = "Après (date)",
|
||||
[nameof(AfterDateTooltip)] = "Inclure uniquement les messages envoyés après cette date",
|
||||
[nameof(BeforeDateLabel)] = "Avant (date)",
|
||||
[nameof(BeforeDateTooltip)] = "Inclure uniquement les messages envoyés avant cette date",
|
||||
[nameof(AfterTimeLabel)] = "Après (heure)",
|
||||
[nameof(AfterTimeTooltip)] = "Inclure uniquement les messages envoyés après cette heure",
|
||||
[nameof(BeforeTimeLabel)] = "Avant (heure)",
|
||||
[nameof(BeforeTimeTooltip)] = "Inclure uniquement les messages envoyés avant cette heure",
|
||||
[nameof(PartitionLimitLabel)] = "Limite de partition",
|
||||
[nameof(PartitionLimitTooltip)] =
|
||||
"Diviser la sortie en partitions, chacune limitée au nombre de messages spécifié (ex. '100') ou à la taille de fichier (ex. '10mb')",
|
||||
[nameof(MessageFilterLabel)] = "Filtre de messages",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"Inclure uniquement les messages satisfaisant ce filtre (ex. 'from:foo#1234' ou 'has:image'). Voir la documentation pour plus d'informations.",
|
||||
[nameof(ReverseMessageOrderLabel)] = "Inverser l'ordre des messages",
|
||||
[nameof(ReverseMessageOrderTooltip)] =
|
||||
"Exporter les messages en ordre chronologique inversé (les plus récents en premier)",
|
||||
[nameof(FormatMarkdownLabel)] = "Formater le markdown",
|
||||
[nameof(FormatMarkdownTooltip)] =
|
||||
"Traiter le markdown, les mentions et autres tokens spéciaux",
|
||||
[nameof(DownloadAssetsLabel)] = "Télécharger les ressources",
|
||||
[nameof(DownloadAssetsTooltip)] =
|
||||
"Télécharger les ressources référencées par l'export (avatars, fichiers joints, images intégrées, etc.)",
|
||||
[nameof(ReuseAssetsLabel)] = "Réutiliser les ressources",
|
||||
[nameof(ReuseAssetsTooltip)] =
|
||||
"Réutiliser les ressources précédemment téléchargées pour éviter les requêtes redondantes",
|
||||
[nameof(AssetsDirPathLabel)] = "Chemin du dossier des ressources",
|
||||
[nameof(AssetsDirPathTooltip)] =
|
||||
"Télécharger les ressources dans ce dossier. Si non spécifié, le chemin sera dérivé du chemin de sortie.",
|
||||
[nameof(AdvancedOptionsTooltip)] = "Basculer les options avancées",
|
||||
[nameof(ExportButton)] = "EXPORTER",
|
||||
// Common buttons
|
||||
[nameof(CloseButton)] = "FERMER",
|
||||
[nameof(CancelButton)] = "ANNULER",
|
||||
// Dialog messages
|
||||
[nameof(UkraineSupportTitle)] = "Merci de soutenir l'Ukraine !",
|
||||
[nameof(UkraineSupportMessage)] = """
|
||||
Alors que la Russie mène une guerre génocidaire contre mon pays, je suis reconnaissant envers tous ceux qui continuent à soutenir l'Ukraine dans notre lutte pour la liberté.
|
||||
|
||||
Cliquez sur EN SAVOIR PLUS pour trouver des moyens d'aider.
|
||||
""",
|
||||
[nameof(LearnMoreButton)] = "EN SAVOIR PLUS",
|
||||
[nameof(UnstableBuildTitle)] = "Avertissement : version instable",
|
||||
[nameof(UnstableBuildMessage)] = """
|
||||
Vous utilisez une version de développement de {0}. Ces versions ne sont pas rigoureusement testées et peuvent contenir des bugs.
|
||||
|
||||
Les mises à jour automatiques sont désactivées pour les versions de développement.
|
||||
|
||||
Cliquez sur VOIR LES VERSIONS pour télécharger une version stable.
|
||||
""",
|
||||
[nameof(SeeReleasesButton)] = "VOIR LES VERSIONS",
|
||||
[nameof(UpdateDownloadingMessage)] = "Téléchargement de la mise à jour vers {0} v{1}...",
|
||||
[nameof(UpdateReadyMessage)] =
|
||||
"La mise à jour a été téléchargée et sera installée à la fermeture",
|
||||
[nameof(UpdateInstallNowButton)] = "INSTALLER MAINTENANT",
|
||||
[nameof(UpdateFailedMessage)] = "Échec de la mise à jour de l'application",
|
||||
[nameof(ErrorPullingGuildsTitle)] = "Erreur lors du chargement des serveurs",
|
||||
[nameof(ErrorPullingChannelsTitle)] = "Erreur lors du chargement des canaux",
|
||||
[nameof(ErrorExportingTitle)] = "Erreur lors de l'exportation des canaux",
|
||||
[nameof(SuccessfulExportMessage)] = "{0} canal(-aux) exporté(s) avec succès",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Localization;
|
||||
|
||||
public partial class LocalizationManager
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> GermanLocalization = new Dictionary<
|
||||
string,
|
||||
string
|
||||
>
|
||||
{
|
||||
// Dashboard
|
||||
[nameof(PullGuildsTooltip)] = "Verfügbare Server und Kanäle laden (Enter)",
|
||||
[nameof(SettingsTooltip)] = "Einstellungen",
|
||||
[nameof(LastMessageSentTooltip)] = "Letzte Nachricht gesendet:",
|
||||
[nameof(TokenWatermark)] = "Token",
|
||||
// Token instructions (personal account)
|
||||
[nameof(TokenPersonalHeader)] = "Token für Ihr persönliches Konto abrufen:",
|
||||
[nameof(TokenPersonalTosWarning)] =
|
||||
"* Das Automatisieren von Benutzerkonten verstößt technisch gegen die AGB — **auf eigene Gefahr**!",
|
||||
[nameof(TokenPersonalInstructions)] = """
|
||||
1. Öffnen Sie Discord in Ihrem Webbrowser und melden Sie sich an
|
||||
2. Öffnen Sie einen Server oder einen direkten Nachrichtenkanal
|
||||
3. Drücken Sie **Ctrl+Shift+I**, um die Entwicklertools anzuzeigen
|
||||
4. Navigieren Sie zum Reiter **Network**
|
||||
5. Drücken Sie **Ctrl+R** zum Neuladen
|
||||
6. Wechseln Sie zwischen Kanälen, um Netzwerkanfragen auszulösen
|
||||
7. Suchen Sie nach einer Anfrage, die mit **messages** beginnt
|
||||
8. Wählen Sie den Reiter **Headers** auf der rechten Seite
|
||||
9. Scrollen Sie nach unten zum Abschnitt **Request Headers**
|
||||
10. Kopieren Sie den Wert des Headers **authorization**
|
||||
""",
|
||||
// Token instructions (bot)
|
||||
[nameof(TokenBotHeader)] = "Token für Ihren Bot abrufen:",
|
||||
[nameof(TokenBotInstructions)] = """
|
||||
Der Token wird bei der Bot-Erstellung generiert. Falls er verloren gegangen ist, generieren Sie einen neuen:
|
||||
|
||||
1. Öffnen Sie Discord [Entwicklerportal](https://discord.com/developers/applications)
|
||||
2. Öffnen Sie die Einstellungen Ihrer Anwendung
|
||||
3. Navigieren Sie zum Abschnitt **Bot** auf der linken Seite
|
||||
4. Klicken Sie unter **Token** auf **Reset Token**
|
||||
5. Klicken Sie auf **Yes, do it!** und bestätigen Sie
|
||||
* Integrationen, die den alten Token verwenden, hören auf zu funktionieren, bis sie aktualisiert werden
|
||||
* Ihr Bot benötigt die aktivierte **Message Content Intent**, um Nachrichten zu lesen
|
||||
""",
|
||||
[nameof(TokenHelpText)] =
|
||||
"Bei Fragen oder Problemen lesen Sie die [Dokumentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
|
||||
// Settings
|
||||
[nameof(SettingsTitle)] = "Einstellungen",
|
||||
[nameof(ThemeLabel)] = "Design",
|
||||
[nameof(ThemeTooltip)] = "Bevorzugtes Oberflächendesign",
|
||||
[nameof(LanguageLabel)] = "Sprache",
|
||||
[nameof(LanguageTooltip)] = "Bevorzugte Sprache der Benutzeroberfläche",
|
||||
[nameof(AutoUpdateLabel)] = "Automatische Updates",
|
||||
[nameof(AutoUpdateTooltip)] = "Automatische Updates bei jedem Start durchführen",
|
||||
[nameof(PersistTokenLabel)] = "Token speichern",
|
||||
[nameof(PersistTokenTooltip)] = """
|
||||
Den zuletzt verwendeten Token in einer Datei speichern, damit er zwischen Sitzungen erhalten bleibt.
|
||||
**Warnung**: Der Token wird mit Verschlüsselung gespeichert, kann aber dennoch von einem Angreifer mit Zugriff auf Ihr System wiederhergestellt werden.
|
||||
""",
|
||||
[nameof(RateLimitPreferenceLabel)] = "Ratenlimit-Einstellung",
|
||||
[nameof(RateLimitPreferenceTooltip)] =
|
||||
"Ob empfohlene Ratenlimits eingehalten werden sollen. Wenn deaktiviert, werden nur harte Ratenlimits (d. h. 429-Antworten) eingehalten.",
|
||||
[nameof(ShowThreadsLabel)] = "Threads anzeigen",
|
||||
[nameof(ShowThreadsTooltip)] = "Welche Thread-Typen in der Kanalliste angezeigt werden",
|
||||
[nameof(LocaleLabel)] = "Gebietsschema",
|
||||
[nameof(LocaleTooltip)] = "Gebietsschema für die Formatierung von Daten und Zahlen",
|
||||
[nameof(NormalizeToUtcLabel)] = "Auf UTC normalisieren",
|
||||
[nameof(NormalizeToUtcTooltip)] = "Alle Zeitstempel auf UTC+0 normalisieren",
|
||||
[nameof(ParallelLimitLabel)] = "Paralleles Limit",
|
||||
[nameof(ParallelLimitTooltip)] = "Wie viele Kanäle gleichzeitig exportiert werden können",
|
||||
// Export Setup
|
||||
[nameof(ChannelsSelectedText)] = "Kanäle ausgewählt",
|
||||
[nameof(OutputPathLabel)] = "Ausgabepfad",
|
||||
[nameof(OutputPathTooltip)] = """
|
||||
Ausgabedatei- oder Verzeichnispfad.
|
||||
|
||||
Wenn ein Verzeichnis angegeben wird, werden Dateinamen automatisch basierend auf den Kanalnamen und Exportparametern generiert.
|
||||
|
||||
Verzeichnispfade müssen mit einem Schrägstrich enden, um Mehrdeutigkeiten zu vermeiden.
|
||||
|
||||
Verfügbare Vorlagen-Token:
|
||||
**%g** — Server-ID
|
||||
**%G** — Servername
|
||||
**%t** — Kategorie-ID
|
||||
**%T** — Kategoriename
|
||||
**%c** — Kanal-ID
|
||||
**%C** — Kanalname
|
||||
**%p** — Kanalposition
|
||||
**%P** — Kategorieposition
|
||||
**%a** — Datum ab
|
||||
**%b** — Datum bis
|
||||
**%d** — aktuelles Datum
|
||||
""",
|
||||
[nameof(FormatLabel)] = "Format",
|
||||
[nameof(FormatTooltip)] = "Exportformat",
|
||||
[nameof(AfterDateLabel)] = "Nach (Datum)",
|
||||
[nameof(AfterDateTooltip)] =
|
||||
"Nur Nachrichten einschließen, die nach diesem Datum gesendet wurden",
|
||||
[nameof(BeforeDateLabel)] = "Vor (Datum)",
|
||||
[nameof(BeforeDateTooltip)] =
|
||||
"Nur Nachrichten einschließen, die vor diesem Datum gesendet wurden",
|
||||
[nameof(AfterTimeLabel)] = "Nach (Uhrzeit)",
|
||||
[nameof(AfterTimeTooltip)] =
|
||||
"Nur Nachrichten einschließen, die nach dieser Uhrzeit gesendet wurden",
|
||||
[nameof(BeforeTimeLabel)] = "Vor (Uhrzeit)",
|
||||
[nameof(BeforeTimeTooltip)] =
|
||||
"Nur Nachrichten einschließen, die vor dieser Uhrzeit gesendet wurden",
|
||||
[nameof(PartitionLimitLabel)] = "Partitionslimit",
|
||||
[nameof(PartitionLimitTooltip)] =
|
||||
"Die Ausgabe in Partitionen aufteilen, jede begrenzt auf die angegebene Anzahl von Nachrichten (z. B. '100') oder Dateigröße (z. B. '10mb')",
|
||||
[nameof(MessageFilterLabel)] = "Nachrichtenfilter",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"Nur Nachrichten einschließen, die diesem Filter entsprechen (z. B. 'from:foo#1234' oder 'has:image'). Weitere Informationen finden Sie in der Dokumentation.",
|
||||
[nameof(ReverseMessageOrderLabel)] = "Nachrichtenreihenfolge umkehren",
|
||||
[nameof(ReverseMessageOrderTooltip)] =
|
||||
"Nachrichten in umgekehrter chronologischer Reihenfolge exportieren (neueste zuerst)",
|
||||
[nameof(FormatMarkdownLabel)] = "Markdown formatieren",
|
||||
[nameof(FormatMarkdownTooltip)] =
|
||||
"Markdown, Erwähnungen und andere spezielle Token verarbeiten",
|
||||
[nameof(DownloadAssetsLabel)] = "Assets herunterladen",
|
||||
[nameof(DownloadAssetsTooltip)] =
|
||||
"Vom Export referenzierte Assets herunterladen (Benutzeravatare, angehängte Dateien, eingebettete Bilder usw.)",
|
||||
[nameof(ReuseAssetsLabel)] = "Assets wiederverwenden",
|
||||
[nameof(ReuseAssetsTooltip)] =
|
||||
"Zuvor heruntergeladene Assets wiederverwenden, um redundante Anfragen zu vermeiden",
|
||||
[nameof(AssetsDirPathLabel)] = "Asset-Verzeichnispfad",
|
||||
[nameof(AssetsDirPathTooltip)] =
|
||||
"Assets in dieses Verzeichnis herunterladen. Wenn nicht angegeben, wird der Asset-Verzeichnispfad vom Ausgabepfad abgeleitet.",
|
||||
[nameof(AdvancedOptionsTooltip)] = "Erweiterte Optionen umschalten",
|
||||
[nameof(ExportButton)] = "EXPORTIEREN",
|
||||
// Common buttons
|
||||
[nameof(CloseButton)] = "SCHLIESSEN",
|
||||
[nameof(CancelButton)] = "ABBRECHEN",
|
||||
// Dialog messages
|
||||
[nameof(UkraineSupportTitle)] = "Danke für Ihre Unterstützung der Ukraine!",
|
||||
[nameof(UkraineSupportMessage)] = """
|
||||
Während Russland einen Vernichtungskrieg gegen mein Land führt, bin ich jedem dankbar, der weiterhin an der Seite der Ukraine in unserem Kampf für die Freiheit steht.
|
||||
|
||||
Klicken Sie auf MEHR ERFAHREN, um Möglichkeiten der Unterstützung zu finden.
|
||||
""",
|
||||
[nameof(LearnMoreButton)] = "MEHR ERFAHREN",
|
||||
[nameof(UnstableBuildTitle)] = "Warnung: Instabile Version",
|
||||
[nameof(UnstableBuildMessage)] = """
|
||||
Sie verwenden eine Entwicklungsversion von {0}. Diese Versionen wurden nicht gründlich getestet und können Fehler enthalten.
|
||||
|
||||
Automatische Updates sind für Entwicklungsversionen deaktiviert.
|
||||
|
||||
Klicken Sie auf RELEASES ANZEIGEN, wenn Sie stattdessen eine stabile Version herunterladen möchten.
|
||||
""",
|
||||
[nameof(SeeReleasesButton)] = "RELEASES ANZEIGEN",
|
||||
[nameof(UpdateDownloadingMessage)] = "Update auf {0} v{1} wird heruntergeladen...",
|
||||
[nameof(UpdateReadyMessage)] =
|
||||
"Update wurde heruntergeladen und wird beim Beenden installiert",
|
||||
[nameof(UpdateInstallNowButton)] = "JETZT INSTALLIEREN",
|
||||
[nameof(UpdateFailedMessage)] = "Anwendungsupdate konnte nicht durchgeführt werden",
|
||||
[nameof(ErrorPullingGuildsTitle)] = "Fehler beim Laden der Server",
|
||||
[nameof(ErrorPullingChannelsTitle)] = "Fehler beim Laden der Kanäle",
|
||||
[nameof(ErrorExportingTitle)] = "Fehler beim Exportieren der Kanäle",
|
||||
[nameof(SuccessfulExportMessage)] = "{0} Kanal/-äle erfolgreich exportiert",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Localization;
|
||||
|
||||
public partial class LocalizationManager
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> SpanishLocalization =
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
// Dashboard
|
||||
[nameof(PullGuildsTooltip)] = "Cargar servidores y canales disponibles (Enter)",
|
||||
[nameof(SettingsTooltip)] = "Ajustes",
|
||||
[nameof(LastMessageSentTooltip)] = "Último mensaje enviado:",
|
||||
[nameof(TokenWatermark)] = "Token",
|
||||
// Token instructions (personal account)
|
||||
[nameof(TokenPersonalHeader)] = "Cómo obtener el token para tu cuenta personal:",
|
||||
[nameof(TokenPersonalTosWarning)] =
|
||||
"* Automatizar cuentas de usuario técnicamente va en contra de los ToS — **bajo tu propio riesgo**!",
|
||||
[nameof(TokenPersonalInstructions)] = """
|
||||
1. Abre Discord en tu navegador web e inicia sesión
|
||||
2. Abre cualquier servidor o canal de mensaje directo
|
||||
3. Presiona **Ctrl+Shift+I** para mostrar las herramientas de desarrollo
|
||||
4. Navega a la pestaña **Network**
|
||||
5. Presiona **Ctrl+R** para recargar
|
||||
6. Cambia entre canales para activar solicitudes de red
|
||||
7. Busca una solicitud que comience con **messages**
|
||||
8. Selecciona la pestaña **Headers** a la derecha
|
||||
9. Desplázate hasta la sección **Request Headers**
|
||||
10. Copia el valor del encabezado **authorization**
|
||||
""",
|
||||
// Token instructions (bot)
|
||||
[nameof(TokenBotHeader)] = "Cómo obtener el token para tu bot:",
|
||||
[nameof(TokenBotInstructions)] = """
|
||||
El token se genera al crear el bot. Si lo perdiste, genera uno nuevo:
|
||||
|
||||
1. Abre Discord [portal de desarrolladores](https://discord.com/developers/applications)
|
||||
2. Abre la configuración de tu aplicación
|
||||
3. Navega a la sección **Bot** en el lado izquierdo
|
||||
4. En **Token**, haz clic en **Reset Token**
|
||||
5. Haz clic en **Yes, do it!** y autentica para confirmar
|
||||
* Las integraciones que usen el token anterior dejarán de funcionar hasta que se actualicen
|
||||
* Tu bot necesita tener habilitado **Message Content Intent** para leer mensajes
|
||||
""",
|
||||
[nameof(TokenHelpText)] =
|
||||
"Si tienes preguntas o problemas, consulta la [documentación](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
|
||||
// Settings
|
||||
[nameof(SettingsTitle)] = "Ajustes",
|
||||
[nameof(ThemeLabel)] = "Tema",
|
||||
[nameof(ThemeTooltip)] = "Tema de interfaz preferido",
|
||||
[nameof(LanguageLabel)] = "Idioma",
|
||||
[nameof(LanguageTooltip)] = "Idioma de interfaz preferido",
|
||||
[nameof(AutoUpdateLabel)] = "Actualización automática",
|
||||
[nameof(AutoUpdateTooltip)] = "Realizar actualizaciones automáticas en cada inicio",
|
||||
[nameof(PersistTokenLabel)] = "Guardar token",
|
||||
[nameof(PersistTokenTooltip)] = """
|
||||
Guardar el último token utilizado en un archivo para conservarlo entre sesiones.
|
||||
**Advertencia**: aunque el token se almacena con cifrado, aún puede ser recuperado por un atacante con acceso a tu sistema.
|
||||
""",
|
||||
[nameof(RateLimitPreferenceLabel)] = "Preferencia de límite de velocidad",
|
||||
[nameof(RateLimitPreferenceTooltip)] =
|
||||
"Si se deben respetar los límites de velocidad recomendados. Si está desactivado, solo se respetarán los límites estrictos (respuestas 429).",
|
||||
[nameof(ShowThreadsLabel)] = "Mostrar hilos",
|
||||
[nameof(ShowThreadsTooltip)] = "Qué tipos de hilos mostrar en la lista de canales",
|
||||
[nameof(LocaleLabel)] = "Configuración regional",
|
||||
[nameof(LocaleTooltip)] = "Configuración regional para el formato de fechas y números",
|
||||
[nameof(NormalizeToUtcLabel)] = "Normalizar a UTC",
|
||||
[nameof(NormalizeToUtcTooltip)] = "Normalizar todas las marcas de tiempo a UTC+0",
|
||||
[nameof(ParallelLimitLabel)] = "Límite paralelo",
|
||||
[nameof(ParallelLimitTooltip)] = "Cuántos canales pueden exportarse al mismo tiempo",
|
||||
// Export Setup
|
||||
[nameof(ChannelsSelectedText)] = "canales seleccionados",
|
||||
[nameof(OutputPathLabel)] = "Ruta de salida",
|
||||
[nameof(OutputPathTooltip)] = """
|
||||
Ruta del archivo o directorio de salida.
|
||||
|
||||
Si se especifica un directorio, los nombres de archivo se generarán automáticamente según los nombres de los canales y los parámetros de exportación.
|
||||
|
||||
Las rutas de directorio deben terminar con una barra diagonal para evitar ambigüedades.
|
||||
|
||||
Tokens de plantilla disponibles:
|
||||
**%g** — ID del servidor
|
||||
**%G** — nombre del servidor
|
||||
**%t** — ID de categoría
|
||||
**%T** — nombre de categoría
|
||||
**%c** — ID del canal
|
||||
**%C** — nombre del canal
|
||||
**%p** — posición del canal
|
||||
**%P** — posición de la categoría
|
||||
**%a** — fecha desde
|
||||
**%b** — fecha hasta
|
||||
**%d** — fecha actual
|
||||
""",
|
||||
[nameof(FormatLabel)] = "Formato",
|
||||
[nameof(FormatTooltip)] = "Formato de exportación",
|
||||
[nameof(AfterDateLabel)] = "Después (fecha)",
|
||||
[nameof(AfterDateTooltip)] = "Solo incluir mensajes enviados después de esta fecha",
|
||||
[nameof(BeforeDateLabel)] = "Antes (fecha)",
|
||||
[nameof(BeforeDateTooltip)] = "Solo incluir mensajes enviados antes de esta fecha",
|
||||
[nameof(AfterTimeLabel)] = "Después (hora)",
|
||||
[nameof(AfterTimeTooltip)] = "Solo incluir mensajes enviados después de esta hora",
|
||||
[nameof(BeforeTimeLabel)] = "Antes (hora)",
|
||||
[nameof(BeforeTimeTooltip)] = "Solo incluir mensajes enviados antes de esta hora",
|
||||
[nameof(PartitionLimitLabel)] = "Límite de partición",
|
||||
[nameof(PartitionLimitTooltip)] =
|
||||
"Dividir la salida en particiones, cada una limitada al número de mensajes especificado (p. ej. '100') o tamaño de archivo (p. ej. '10mb')",
|
||||
[nameof(MessageFilterLabel)] = "Filtro de mensajes",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"Solo incluir mensajes que satisfagan este filtro (p. ej. 'from:foo#1234' o 'has:image'). Consulte la documentación para más información.",
|
||||
[nameof(ReverseMessageOrderLabel)] = "Invertir orden de mensajes",
|
||||
[nameof(ReverseMessageOrderTooltip)] =
|
||||
"Exportar mensajes en orden cronológico inverso (los más recientes primero)",
|
||||
[nameof(FormatMarkdownLabel)] = "Formatear markdown",
|
||||
[nameof(FormatMarkdownTooltip)] =
|
||||
"Procesar markdown, menciones y otros tokens especiales",
|
||||
[nameof(DownloadAssetsLabel)] = "Descargar recursos",
|
||||
[nameof(DownloadAssetsTooltip)] =
|
||||
"Descargar los recursos referenciados por la exportación (avatares, archivos adjuntos, imágenes incrustadas, etc.)",
|
||||
[nameof(ReuseAssetsLabel)] = "Reutilizar recursos",
|
||||
[nameof(ReuseAssetsTooltip)] =
|
||||
"Reutilizar recursos previamente descargados para evitar solicitudes redundantes",
|
||||
[nameof(AssetsDirPathLabel)] = "Ruta del directorio de recursos",
|
||||
[nameof(AssetsDirPathTooltip)] =
|
||||
"Descargar recursos en este directorio. Si no se especifica, la ruta se derivará de la ruta de salida.",
|
||||
[nameof(AdvancedOptionsTooltip)] = "Alternar opciones avanzadas",
|
||||
[nameof(ExportButton)] = "EXPORTAR",
|
||||
// Common buttons
|
||||
[nameof(CloseButton)] = "CERRAR",
|
||||
[nameof(CancelButton)] = "CANCELAR",
|
||||
// Dialog messages
|
||||
[nameof(UkraineSupportTitle)] = "¡Gracias por apoyar a Ucrania!",
|
||||
[nameof(UkraineSupportMessage)] = """
|
||||
Mientras Rusia libra una guerra genocida contra mi país, estoy agradecido con todos los que continúan apoyando a Ucrania en nuestra lucha por la libertad.
|
||||
|
||||
Haga clic en MÁS INFORMACIÓN para encontrar formas de ayudar.
|
||||
""",
|
||||
[nameof(LearnMoreButton)] = "MÁS INFORMACIÓN",
|
||||
[nameof(UnstableBuildTitle)] = "Advertencia de versión inestable",
|
||||
[nameof(UnstableBuildMessage)] = """
|
||||
Está usando una versión de desarrollo de {0}. Estas versiones no han sido probadas exhaustivamente y pueden contener errores.
|
||||
|
||||
Las actualizaciones automáticas están desactivadas para las versiones de desarrollo.
|
||||
|
||||
Haga clic en VER VERSIONES si desea descargar una versión estable.
|
||||
""",
|
||||
[nameof(SeeReleasesButton)] = "VER VERSIONES",
|
||||
[nameof(UpdateDownloadingMessage)] = "Descargando actualización a {0} v{1}...",
|
||||
[nameof(UpdateReadyMessage)] =
|
||||
"La actualización se ha descargado y se instalará al salir",
|
||||
[nameof(UpdateInstallNowButton)] = "INSTALAR AHORA",
|
||||
[nameof(UpdateFailedMessage)] = "Error al realizar la actualización de la aplicación",
|
||||
[nameof(ErrorPullingGuildsTitle)] = "Error al cargar servidores",
|
||||
[nameof(ErrorPullingChannelsTitle)] = "Error al cargar canales",
|
||||
[nameof(ErrorExportingTitle)] = "Error al exportar canal(es)",
|
||||
[nameof(SuccessfulExportMessage)] = "{0} canal(es) exportado(s) con éxito",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Localization;
|
||||
|
||||
public partial class LocalizationManager
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> UkrainianLocalization =
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
// Dashboard
|
||||
[nameof(PullGuildsTooltip)] = "Завантажити доступні сервери та канали (Enter)",
|
||||
[nameof(SettingsTooltip)] = "Налаштування",
|
||||
[nameof(LastMessageSentTooltip)] = "Останнє повідомлення:",
|
||||
[nameof(TokenWatermark)] = "Токен",
|
||||
// Token instructions (personal account)
|
||||
[nameof(TokenPersonalHeader)] = "Як отримати токен для персонального акаунту:",
|
||||
[nameof(TokenPersonalTosWarning)] =
|
||||
"* Автоматизація облікових записів технічно порушує Умови обслуговування — **на власний ризик**!",
|
||||
[nameof(TokenPersonalInstructions)] = """
|
||||
1. Відкрийте Discord у вашому веб-браузері та увійдіть
|
||||
2. Відкрийте будь-який сервер або канал особистих повідомлень
|
||||
3. Натисніть **Ctrl+Shift+I**, щоб відкрити інструменти розробника
|
||||
4. Перейдіть на вкладку **Network**
|
||||
5. Натисніть **Ctrl+R** для перезавантаження
|
||||
6. Перемикайтеся між каналами, щоб викликати мережеві запити
|
||||
7. Знайдіть запит, що починається з **messages**
|
||||
8. Виберіть вкладку **Headers** праворуч
|
||||
9. Прокрутіть до розділу **Request Headers**
|
||||
10. Скопіюйте значення заголовка **authorization**
|
||||
""",
|
||||
// Token instructions (bot)
|
||||
[nameof(TokenBotHeader)] = "Як отримати токен для бота:",
|
||||
[nameof(TokenBotInstructions)] = """
|
||||
Токен генерується під час створення бота. Якщо ви його втратили, згенеруйте новий:
|
||||
|
||||
1. Відкрийте Discord [портал розробника](https://discord.com/developers/applications)
|
||||
2. Відкрийте налаштування вашого застосунку
|
||||
3. Перейдіть до розділу **Bot** ліворуч
|
||||
4. В розділі **Token** натисніть **Reset Token**
|
||||
5. Натисніть **Yes, do it!** та підтвердьте
|
||||
* Інтеграції, що використовують попередній токен, перестануть працювати
|
||||
* Ваш бот повинен мати включений **Message Content Intent** для читання повідомлень
|
||||
""",
|
||||
[nameof(TokenHelpText)] =
|
||||
"Якщо у вас є запитання або проблеми, зверніться до [документації](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
|
||||
// Settings
|
||||
[nameof(SettingsTitle)] = "Налаштування",
|
||||
[nameof(ThemeLabel)] = "Тема",
|
||||
[nameof(ThemeTooltip)] = "Бажана тема інтерфейсу",
|
||||
[nameof(LanguageLabel)] = "Мова",
|
||||
[nameof(LanguageTooltip)] = "Бажана мова інтерфейсу",
|
||||
[nameof(AutoUpdateLabel)] = "Авто-оновлення",
|
||||
[nameof(AutoUpdateTooltip)] = "Виконувати автоматичні оновлення при кожному запуску",
|
||||
[nameof(PersistTokenLabel)] = "Зберігати токен",
|
||||
[nameof(PersistTokenTooltip)] = """
|
||||
Зберігати останній використаний токен у файлі для збереження між сеансами.
|
||||
**Увага**: хоча токен зберігається із шифруванням, він може бути відновлений зловмисником, який має доступ до вашої системи.
|
||||
""",
|
||||
[nameof(RateLimitPreferenceLabel)] = "Ліміт запитів",
|
||||
[nameof(RateLimitPreferenceTooltip)] =
|
||||
"Чи дотримуватись рекомендованих лімітів запитів. Якщо вимкнено, будуть дотримуватись лише жорсткі ліміти (тобто відповіді 429).",
|
||||
[nameof(ShowThreadsLabel)] = "Показувати гілки",
|
||||
[nameof(ShowThreadsTooltip)] = "Які типи гілок показувати у списку каналів",
|
||||
[nameof(LocaleLabel)] = "Локаль",
|
||||
[nameof(LocaleTooltip)] = "Локаль для форматування дат та чисел",
|
||||
[nameof(NormalizeToUtcLabel)] = "Нормалізувати до UTC",
|
||||
[nameof(NormalizeToUtcTooltip)] = "Нормалізувати всі часові мітки до UTC+0",
|
||||
[nameof(ParallelLimitLabel)] = "Ліміт паралелізації",
|
||||
[nameof(ParallelLimitTooltip)] = "Скільки каналів може експортуватись одночасно",
|
||||
// Export Setup
|
||||
[nameof(ChannelsSelectedText)] = "каналів вибрано",
|
||||
[nameof(OutputPathLabel)] = "Шлях збереження",
|
||||
[nameof(OutputPathTooltip)] = """
|
||||
Шлях до файлу або директорії виводу.
|
||||
|
||||
Якщо вказано директорію, імена файлів генеруватимуться автоматично на основі назв каналів та параметрів експорту.
|
||||
|
||||
Шляхи до директорій повинні закінчуватись слешем для уникнення неоднозначності.
|
||||
|
||||
Доступні шаблонні токени:
|
||||
**%g** — ID сервера
|
||||
**%G** — назва сервера
|
||||
**%t** — ID категорії
|
||||
**%T** — назва категорії
|
||||
**%c** — ID каналу
|
||||
**%C** — назва каналу
|
||||
**%p** — позиція каналу
|
||||
**%P** — позиція категорії
|
||||
**%a** — дата після
|
||||
**%b** — дата до
|
||||
**%d** — поточна дата
|
||||
""",
|
||||
[nameof(FormatLabel)] = "Формат",
|
||||
[nameof(FormatTooltip)] = "Формат експорту",
|
||||
[nameof(AfterDateLabel)] = "Після (дата)",
|
||||
[nameof(AfterDateTooltip)] = "Включати лише повідомлення, надіслані після цієї дати",
|
||||
[nameof(BeforeDateLabel)] = "До (дата)",
|
||||
[nameof(BeforeDateTooltip)] = "Включати лише повідомлення, надіслані до цієї дати",
|
||||
[nameof(AfterTimeLabel)] = "Після (час)",
|
||||
[nameof(AfterTimeTooltip)] = "Включати лише повідомлення, надіслані після цього часу",
|
||||
[nameof(BeforeTimeLabel)] = "До (час)",
|
||||
[nameof(BeforeTimeTooltip)] = "Включати лише повідомлення, надіслані до цього часу",
|
||||
[nameof(PartitionLimitLabel)] = "Розділяти експорт",
|
||||
[nameof(PartitionLimitTooltip)] =
|
||||
"Розділити вивід на частини, кожна обмежена вказаною кількістю повідомлень (напр. '100') або розміром файлу (напр. '10mb')",
|
||||
[nameof(MessageFilterLabel)] = "Фільтр повідомлень",
|
||||
[nameof(MessageFilterTooltip)] =
|
||||
"Включати лише повідомлення, що відповідають цьому фільтру (напр. 'from:foo#1234' або 'has:image'). Дивіться документацію для більш детальної інформації.",
|
||||
[nameof(ReverseMessageOrderLabel)] = "Зворотній порядок повідомлень",
|
||||
[nameof(ReverseMessageOrderTooltip)] =
|
||||
"Експортувати повідомлення у зворотному хронологічному порядку (найновіші спочатку)",
|
||||
[nameof(FormatMarkdownLabel)] = "Форматувати markdown",
|
||||
[nameof(FormatMarkdownTooltip)] =
|
||||
"Обробляти markdown, згадки та інші спеціальні токени",
|
||||
[nameof(DownloadAssetsLabel)] = "Завантажувати ресурси",
|
||||
[nameof(DownloadAssetsTooltip)] =
|
||||
"Завантажувати ресурси, на які посилається експорт (аватари, вкладені файли, вбудовані зображення тощо)",
|
||||
[nameof(ReuseAssetsLabel)] = "Повторно використовувати ресурси",
|
||||
[nameof(ReuseAssetsTooltip)] =
|
||||
"Повторно використовувати раніше завантажені ресурси, щоб уникнути зайвих запитів",
|
||||
[nameof(AssetsDirPathLabel)] = "Шлях до директорії ресурсів",
|
||||
[nameof(AssetsDirPathTooltip)] =
|
||||
"Завантажувати ресурси до цієї директорії. Якщо не вказано, шлях до директорії ресурсів буде визначено з шляху збереження.",
|
||||
[nameof(AdvancedOptionsTooltip)] = "Перемкнути розширені параметри",
|
||||
[nameof(ExportButton)] = "ЕКСПОРТУВАТИ",
|
||||
// Common buttons
|
||||
[nameof(CloseButton)] = "ЗАКРИТИ",
|
||||
[nameof(CancelButton)] = "СКАСУВАТИ",
|
||||
// Dialog messages
|
||||
[nameof(UkraineSupportTitle)] = "Дякуємо за підтримку України!",
|
||||
[nameof(UkraineSupportMessage)] = """
|
||||
Поки Росія веде геноцидну війну проти моєї країни, я вдячний кожному, хто продовжує підтримувати Україну у нашій боротьбі за свободу.
|
||||
|
||||
Натисніть ДІЗНАТИСЬ БІЛЬШЕ, щоб знайти способи допомогти.
|
||||
""",
|
||||
[nameof(LearnMoreButton)] = "ДІЗНАТИСЬ БІЛЬШЕ",
|
||||
[nameof(UnstableBuildTitle)] = "Попередження про нестабільну збірку",
|
||||
[nameof(UnstableBuildMessage)] = """
|
||||
Ви використовуєте збірку розробки {0}. Ці збірки не пройшли ретельного тестування та можуть містити помилки.
|
||||
|
||||
Авто-оновлення вимкнено для збірок розробки.
|
||||
|
||||
Натисніть ПЕРЕГЛЯНУТИ РЕЛІЗИ, щоб завантажити стабільний реліз.
|
||||
""",
|
||||
[nameof(SeeReleasesButton)] = "ПЕРЕГЛЯНУТИ РЕЛІЗИ",
|
||||
[nameof(UpdateDownloadingMessage)] = "Завантаження оновлення {0} v{1}...",
|
||||
[nameof(UpdateReadyMessage)] = "Оновлення завантажено та буде встановлено після виходу",
|
||||
[nameof(UpdateInstallNowButton)] = "ВСТАНОВИТИ ЗАРАЗ",
|
||||
[nameof(UpdateFailedMessage)] = "Не вдалося виконати оновлення програми",
|
||||
[nameof(ErrorPullingGuildsTitle)] = "Помилка завантаження серверів",
|
||||
[nameof(ErrorPullingChannelsTitle)] = "Помилка завантаження каналів",
|
||||
[nameof(ErrorExportingTitle)] = "Помилка експорту каналу(-ів)",
|
||||
[nameof(SuccessfulExportMessage)] = "Успішно експортовано {0} канал(-ів)",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DiscordChatExporter.Gui.Services;
|
||||
using DiscordChatExporter.Gui.Utils;
|
||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Localization;
|
||||
|
||||
public partial class LocalizationManager : ObservableObject, IDisposable
|
||||
{
|
||||
private readonly DisposableCollector _eventRoot = new();
|
||||
|
||||
public LocalizationManager(SettingsService settingsService)
|
||||
{
|
||||
_eventRoot.Add(
|
||||
settingsService.WatchProperty(
|
||||
o => o.Language,
|
||||
() => Language = settingsService.Language,
|
||||
true
|
||||
)
|
||||
);
|
||||
|
||||
_eventRoot.Add(
|
||||
this.WatchProperty(
|
||||
o => o.Language,
|
||||
() =>
|
||||
{
|
||||
foreach (var propertyName in EnglishLocalization.Keys)
|
||||
OnPropertyChanged(propertyName);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
public partial Language Language { get; set; } = Language.System;
|
||||
|
||||
private string Get([CallerMemberName] string? key = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return string.Empty;
|
||||
|
||||
var localization = Language switch
|
||||
{
|
||||
Language.System =>
|
||||
CultureInfo.CurrentUICulture.ThreeLetterISOLanguageName.ToLowerInvariant() switch
|
||||
{
|
||||
"ukr" => UkrainianLocalization,
|
||||
"deu" => GermanLocalization,
|
||||
"fra" => FrenchLocalization,
|
||||
"spa" => SpanishLocalization,
|
||||
_ => EnglishLocalization,
|
||||
},
|
||||
Language.Ukrainian => UkrainianLocalization,
|
||||
Language.German => GermanLocalization,
|
||||
Language.French => FrenchLocalization,
|
||||
Language.Spanish => SpanishLocalization,
|
||||
_ => EnglishLocalization,
|
||||
};
|
||||
|
||||
if (
|
||||
localization.TryGetValue(key, out var value)
|
||||
// English is used as a fallback
|
||||
|| EnglishLocalization.TryGetValue(key, out value)
|
||||
)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return $"Missing localization for '{key}'";
|
||||
}
|
||||
|
||||
public void Dispose() => _eventRoot.Dispose();
|
||||
}
|
||||
|
||||
public partial class LocalizationManager
|
||||
{
|
||||
// ---- Dashboard ----
|
||||
|
||||
public string PullGuildsTooltip => Get();
|
||||
public string SettingsTooltip => Get();
|
||||
public string LastMessageSentTooltip => Get();
|
||||
public string TokenWatermark => Get();
|
||||
|
||||
// Token instructions (personal account)
|
||||
public string TokenPersonalHeader => Get();
|
||||
public string TokenPersonalTosWarning => Get();
|
||||
public string TokenPersonalInstructions => Get();
|
||||
|
||||
// Token instructions (bot)
|
||||
public string TokenBotHeader => Get();
|
||||
public string TokenBotInstructions => Get();
|
||||
public string TokenHelpText => Get();
|
||||
|
||||
// ---- Settings ----
|
||||
|
||||
public string SettingsTitle => Get();
|
||||
public string ThemeLabel => Get();
|
||||
public string ThemeTooltip => Get();
|
||||
public string LanguageLabel => Get();
|
||||
public string LanguageTooltip => Get();
|
||||
public string AutoUpdateLabel => Get();
|
||||
public string AutoUpdateTooltip => Get();
|
||||
public string PersistTokenLabel => Get();
|
||||
public string PersistTokenTooltip => Get();
|
||||
public string RateLimitPreferenceLabel => Get();
|
||||
public string RateLimitPreferenceTooltip => Get();
|
||||
public string ShowThreadsLabel => Get();
|
||||
public string ShowThreadsTooltip => Get();
|
||||
public string LocaleLabel => Get();
|
||||
public string LocaleTooltip => Get();
|
||||
public string NormalizeToUtcLabel => Get();
|
||||
public string NormalizeToUtcTooltip => Get();
|
||||
public string ParallelLimitLabel => Get();
|
||||
public string ParallelLimitTooltip => Get();
|
||||
|
||||
// ---- Export Setup ----
|
||||
|
||||
public string ChannelsSelectedText => Get();
|
||||
public string OutputPathLabel => Get();
|
||||
public string OutputPathTooltip => Get();
|
||||
public string FormatLabel => Get();
|
||||
public string FormatTooltip => Get();
|
||||
public string AfterDateLabel => Get();
|
||||
public string AfterDateTooltip => Get();
|
||||
public string BeforeDateLabel => Get();
|
||||
public string BeforeDateTooltip => Get();
|
||||
public string AfterTimeLabel => Get();
|
||||
public string AfterTimeTooltip => Get();
|
||||
public string BeforeTimeLabel => Get();
|
||||
public string BeforeTimeTooltip => Get();
|
||||
public string PartitionLimitLabel => Get();
|
||||
public string PartitionLimitTooltip => Get();
|
||||
public string MessageFilterLabel => Get();
|
||||
public string MessageFilterTooltip => Get();
|
||||
public string ReverseMessageOrderLabel => Get();
|
||||
public string ReverseMessageOrderTooltip => Get();
|
||||
public string FormatMarkdownLabel => Get();
|
||||
public string FormatMarkdownTooltip => Get();
|
||||
public string DownloadAssetsLabel => Get();
|
||||
public string DownloadAssetsTooltip => Get();
|
||||
public string ReuseAssetsLabel => Get();
|
||||
public string ReuseAssetsTooltip => Get();
|
||||
public string AssetsDirPathLabel => Get();
|
||||
public string AssetsDirPathTooltip => Get();
|
||||
public string AdvancedOptionsTooltip => Get();
|
||||
public string ExportButton => Get();
|
||||
|
||||
// ---- Common buttons ----
|
||||
|
||||
public string CloseButton => Get();
|
||||
public string CancelButton => Get();
|
||||
|
||||
// ---- Dialog messages ----
|
||||
|
||||
public string UkraineSupportTitle => Get();
|
||||
public string UkraineSupportMessage => Get();
|
||||
public string LearnMoreButton => Get();
|
||||
public string UnstableBuildTitle => Get();
|
||||
public string UnstableBuildMessage => Get();
|
||||
public string SeeReleasesButton => Get();
|
||||
public string UpdateDownloadingMessage => Get();
|
||||
public string UpdateReadyMessage => Get();
|
||||
public string UpdateInstallNowButton => Get();
|
||||
public string UpdateFailedMessage => Get();
|
||||
public string ErrorPullingGuildsTitle => Get();
|
||||
public string ErrorPullingChannelsTitle => Get();
|
||||
public string ErrorExportingTitle => Get();
|
||||
public string SuccessfulExportMessage => Get();
|
||||
}
|
||||
@@ -21,7 +21,7 @@ public static class Program
|
||||
|
||||
public static string ProjectReleasesUrl { get; } = $"{ProjectUrl}/releases";
|
||||
|
||||
public static string ProjectDocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
|
||||
public static string ProjectDocumentationUrl { get; } = ProjectUrl + "/tree/prime/.docs";
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp() =>
|
||||
AppBuilder.Configure<App>().UsePlatformDetect().LogToTrace();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Services;
|
||||
|
||||
public partial class SettingsService
|
||||
{
|
||||
private class TokenEncryptionConverter : JsonConverter<string?>
|
||||
{
|
||||
private const string Prefix = "enc:";
|
||||
|
||||
private static readonly Lazy<byte[]> Key = new(() =>
|
||||
Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(Environment.TryGetMachineId() ?? string.Empty),
|
||||
Encoding.UTF8.GetBytes(ThisAssembly.Project.EncryptionSalt),
|
||||
600_000,
|
||||
HashAlgorithmName.SHA256,
|
||||
16
|
||||
)
|
||||
);
|
||||
|
||||
public override string? Read(
|
||||
ref Utf8JsonReader reader,
|
||||
Type typeToConvert,
|
||||
JsonSerializerOptions options
|
||||
)
|
||||
{
|
||||
var value = reader.GetString();
|
||||
|
||||
// No prefix means the token is stored as plain text, which was the case for older
|
||||
// versions of the application. Load it as is and encrypt it on next save.
|
||||
if (
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
|| !value.StartsWith(Prefix, StringComparison.Ordinal)
|
||||
)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var encryptedData = Convert.FromHexString(value[Prefix.Length..]);
|
||||
var tokenData = new byte[encryptedData.AsSpan(28).Length];
|
||||
|
||||
// Layout: nonce (12 bytes) | tag (16 bytes) | cipher
|
||||
using var aes = new AesGcm(Key.Value, 16);
|
||||
aes.Decrypt(
|
||||
encryptedData.AsSpan(0, 12),
|
||||
encryptedData.AsSpan(28),
|
||||
encryptedData.AsSpan(12, 16),
|
||||
tokenData
|
||||
);
|
||||
|
||||
return Encoding.UTF8.GetString(tokenData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
when (ex
|
||||
is FormatException
|
||||
or CryptographicException
|
||||
or ArgumentException
|
||||
or IndexOutOfRangeException
|
||||
)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public override void Write(
|
||||
Utf8JsonWriter writer,
|
||||
string? value,
|
||||
JsonSerializerOptions options
|
||||
)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
return;
|
||||
}
|
||||
|
||||
var tokenData = Encoding.UTF8.GetBytes(value);
|
||||
var encryptedData = new byte[28 + tokenData.Length];
|
||||
|
||||
// Nonce
|
||||
RandomNumberGenerator.Fill(encryptedData.AsSpan(0, 12));
|
||||
|
||||
// Layout: nonce (12 bytes) | tag (16 bytes) | cipher
|
||||
using var aes = new AesGcm(Key.Value, 16);
|
||||
aes.Encrypt(
|
||||
encryptedData.AsSpan(0, 12),
|
||||
tokenData,
|
||||
encryptedData.AsSpan(28),
|
||||
encryptedData.AsSpan(12, 16)
|
||||
);
|
||||
|
||||
writer.WriteStringValue(Prefix + Convert.ToHexStringLower(encryptedData));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization;
|
||||
using Cogwheel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using DiscordChatExporter.Gui.Framework;
|
||||
using DiscordChatExporter.Gui.Localization;
|
||||
using DiscordChatExporter.Gui.Models;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Services;
|
||||
|
||||
[ObservableObject]
|
||||
public partial class SettingsService()
|
||||
: SettingsBase(
|
||||
Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
|
||||
SerializerContext.Default
|
||||
)
|
||||
: SettingsBase(StartOptions.Current.SettingsPath, SerializerContext.Default)
|
||||
{
|
||||
[ObservableProperty]
|
||||
public partial bool IsUkraineSupportMessageEnabled { get; set; } = true;
|
||||
@@ -23,6 +19,9 @@ public partial class SettingsService()
|
||||
[ObservableProperty]
|
||||
public partial ThemeVariant Theme { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial Language Language { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsAutoUpdateEnabled { get; set; } = true;
|
||||
|
||||
@@ -46,6 +45,7 @@ public partial class SettingsService()
|
||||
public partial int ParallelLimit { get; set; } = 1;
|
||||
|
||||
[ObservableProperty]
|
||||
[JsonConverter(typeof(TokenEncryptionConverter))]
|
||||
public partial string? LastToken { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -57,6 +57,9 @@ public partial class SettingsService()
|
||||
[ObservableProperty]
|
||||
public partial string? LastMessageFilterValue { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool LastIsReverseMessageOrder { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool LastShouldFormatMarkdown { get; set; } = true;
|
||||
|
||||
|
||||
@@ -9,24 +9,25 @@ namespace DiscordChatExporter.Gui.Services;
|
||||
|
||||
public class UpdateService(SettingsService settingsService) : IDisposable
|
||||
{
|
||||
private readonly IUpdateManager? _updateManager = OperatingSystem.IsWindows()
|
||||
? new UpdateManager(
|
||||
new GithubPackageResolver(
|
||||
"Tyrrrz",
|
||||
"DiscordChatExporter",
|
||||
// Examples:
|
||||
// DiscordChatExporter.win-arm64.zip
|
||||
// DiscordChatExporter.win-x64.zip
|
||||
// DiscordChatExporter.linux-x64.zip
|
||||
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
|
||||
),
|
||||
new ZipPackageExtractor()
|
||||
)
|
||||
: null;
|
||||
private readonly IUpdateManager? _updateManager =
|
||||
OperatingSystem.IsWindows() && StartOptions.Current.IsAutoUpdateAllowed
|
||||
? new UpdateManager(
|
||||
new GithubPackageResolver(
|
||||
"Tyrrrz",
|
||||
"DiscordChatExporter",
|
||||
// Examples:
|
||||
// DiscordChatExporter.win-arm64.zip
|
||||
// DiscordChatExporter.win-x64.zip
|
||||
// DiscordChatExporter.linux-x64.zip
|
||||
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
|
||||
),
|
||||
new ZipPackageExtractor()
|
||||
)
|
||||
: null;
|
||||
|
||||
private Version? _updateVersion;
|
||||
private bool _updatePrepared;
|
||||
private bool _updaterLaunched;
|
||||
private bool _isUpdatePrepared;
|
||||
private bool _isUpdaterLaunched;
|
||||
|
||||
public async ValueTask<Version?> CheckForUpdatesAsync()
|
||||
{
|
||||
@@ -51,7 +52,7 @@ public class UpdateService(SettingsService settingsService) : IDisposable
|
||||
try
|
||||
{
|
||||
await _updateManager.PrepareUpdateAsync(_updateVersion = version);
|
||||
_updatePrepared = true;
|
||||
_isUpdatePrepared = true;
|
||||
}
|
||||
catch (UpdaterAlreadyLaunchedException)
|
||||
{
|
||||
@@ -71,13 +72,13 @@ public class UpdateService(SettingsService settingsService) : IDisposable
|
||||
if (!settingsService.IsAutoUpdateEnabled)
|
||||
return;
|
||||
|
||||
if (_updateVersion is null || !_updatePrepared || _updaterLaunched)
|
||||
if (_updateVersion is null || !_isUpdatePrepared || _isUpdaterLaunched)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
_updateManager.LaunchUpdater(_updateVersion, needRestart);
|
||||
_updaterLaunched = true;
|
||||
_isUpdaterLaunched = true;
|
||||
}
|
||||
catch (UpdaterAlreadyLaunchedException)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace DiscordChatExporter.Gui;
|
||||
|
||||
public partial class StartOptions
|
||||
{
|
||||
public required string SettingsPath { get; init; }
|
||||
|
||||
public required bool IsAutoUpdateAllowed { get; init; }
|
||||
}
|
||||
|
||||
public partial class StartOptions
|
||||
{
|
||||
public static StartOptions Current { get; } =
|
||||
new()
|
||||
{
|
||||
SettingsPath =
|
||||
Environment.GetEnvironmentVariable("DISCORDCHATEXPORTER_SETTINGS_PATH") is { } path
|
||||
&& !string.IsNullOrWhiteSpace(path)
|
||||
? Path.EndsInDirectorySeparator(path) || Directory.Exists(path)
|
||||
? Path.Combine(path, "Settings.dat")
|
||||
: path
|
||||
: Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
|
||||
IsAutoUpdateAllowed = !(
|
||||
Environment.GetEnvironmentVariable("DISCORDCHATEXPORTER_ALLOW_AUTO_UPDATE")
|
||||
is { } env
|
||||
&& env.Equals("false", StringComparison.OrdinalIgnoreCase)
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Utils.Extensions;
|
||||
|
||||
internal static class EnvironmentExtensions
|
||||
{
|
||||
extension(Environment)
|
||||
{
|
||||
public static string? TryGetMachineId()
|
||||
{
|
||||
// Windows: stable GUID written during OS installation
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
using var regKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(
|
||||
@"SOFTWARE\Microsoft\Cryptography"
|
||||
);
|
||||
if (
|
||||
regKey?.GetValue("MachineGuid") is string guid
|
||||
&& !string.IsNullOrWhiteSpace(guid)
|
||||
)
|
||||
return guid;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unix: /etc/machine-id (set once by systemd at first boot)
|
||||
foreach (var path in new[] { "/etc/machine-id", "/var/lib/dbus/machine-id" })
|
||||
{
|
||||
try
|
||||
{
|
||||
var id = File.ReadAllText(path).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
return id;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// Last-resort fallback
|
||||
try
|
||||
{
|
||||
return Environment.MachineName;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Linq;
|
||||
using Markdig.Syntax.Inlines;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Utils.Extensions;
|
||||
|
||||
internal static class MarkdigExtensions
|
||||
{
|
||||
extension(Inline inline)
|
||||
{
|
||||
public string GetInnerText() =>
|
||||
inline switch
|
||||
{
|
||||
LiteralInline literal => literal.Content.ToString(),
|
||||
ContainerInline container => string.Concat(container.Select(c => c.GetInnerText())),
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ internal static class ProcessExtensions
|
||||
public static void StartShellExecute(string path)
|
||||
{
|
||||
using var process = new Process();
|
||||
process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true };
|
||||
process.StartInfo = new ProcessStartInfo(path) { UseShellExecute = true };
|
||||
|
||||
process.Start();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using DiscordChatExporter.Gui.Framework;
|
||||
using DiscordChatExporter.Gui.Localization;
|
||||
using DiscordChatExporter.Gui.Models;
|
||||
using DiscordChatExporter.Gui.Services;
|
||||
using DiscordChatExporter.Gui.Utils;
|
||||
@@ -38,13 +39,15 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
ViewModelManager viewModelManager,
|
||||
DialogManager dialogManager,
|
||||
SnackbarManager snackbarManager,
|
||||
SettingsService settingsService
|
||||
SettingsService settingsService,
|
||||
LocalizationManager localizationManager
|
||||
)
|
||||
{
|
||||
_viewModelManager = viewModelManager;
|
||||
_dialogManager = dialogManager;
|
||||
_snackbarManager = snackbarManager;
|
||||
_settingsService = settingsService;
|
||||
LocalizationManager = localizationManager;
|
||||
|
||||
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
|
||||
|
||||
@@ -70,6 +73,8 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
||||
public partial bool IsBusy { get; set; }
|
||||
|
||||
public LocalizationManager LocalizationManager { get; }
|
||||
|
||||
public ProgressContainer<Percentage> Progress { get; } = new();
|
||||
|
||||
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
|
||||
@@ -102,9 +107,6 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
private async Task ShowSettingsAsync() =>
|
||||
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowHelp() => Process.StartShellExecute(Program.ProjectDocumentationUrl);
|
||||
|
||||
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanPullGuilds))]
|
||||
@@ -141,7 +143,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
var dialog = _viewModelManager.CreateMessageBoxViewModel(
|
||||
"Error pulling servers",
|
||||
LocalizationManager.ErrorPullingGuildsTitle,
|
||||
ex.ToString()
|
||||
);
|
||||
|
||||
@@ -208,7 +210,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
var dialog = _viewModelManager.CreateMessageBoxViewModel(
|
||||
"Error pulling channels",
|
||||
LocalizationManager.ErrorPullingChannelsTitle,
|
||||
ex.ToString()
|
||||
);
|
||||
|
||||
@@ -273,6 +275,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
dialog.Before?.Pipe(Snowflake.FromDate),
|
||||
dialog.PartitionLimit,
|
||||
dialog.MessageFilter,
|
||||
dialog.IsReverseMessageOrder,
|
||||
dialog.ShouldFormatMarkdown,
|
||||
dialog.ShouldDownloadAssets,
|
||||
dialog.ShouldReuseAssets,
|
||||
@@ -303,14 +306,17 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
if (successfulExportCount > 0)
|
||||
{
|
||||
_snackbarManager.Notify(
|
||||
$"Successfully exported {successfulExportCount} channel(s)"
|
||||
string.Format(
|
||||
LocalizationManager.SuccessfulExportMessage,
|
||||
successfulExportCount
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var dialog = _viewModelManager.CreateMessageBoxViewModel(
|
||||
"Error exporting channel(s)",
|
||||
LocalizationManager.ErrorExportingTitle,
|
||||
ex.ToString()
|
||||
);
|
||||
|
||||
@@ -322,13 +328,6 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenDiscord() => Process.StartShellExecute("https://discord.com/app");
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenDiscordDeveloperPortal() =>
|
||||
Process.StartShellExecute("https://discord.com/developers/applications");
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
|
||||
@@ -12,15 +12,19 @@ using DiscordChatExporter.Core.Exporting.Filtering;
|
||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using DiscordChatExporter.Gui.Framework;
|
||||
using DiscordChatExporter.Gui.Localization;
|
||||
using DiscordChatExporter.Gui.Services;
|
||||
|
||||
namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||
|
||||
public partial class ExportSetupViewModel(
|
||||
DialogManager dialogManager,
|
||||
SettingsService settingsService
|
||||
SettingsService settingsService,
|
||||
LocalizationManager localizationManager
|
||||
) : DialogViewModelBase
|
||||
{
|
||||
public LocalizationManager LocalizationManager { get; } = localizationManager;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial Guild? Guild { get; set; }
|
||||
|
||||
@@ -58,6 +62,9 @@ public partial class ExportSetupViewModel(
|
||||
[NotifyPropertyChangedFor(nameof(MessageFilter))]
|
||||
public partial string? MessageFilterValue { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsReverseMessageOrder { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool ShouldFormatMarkdown { get; set; }
|
||||
|
||||
@@ -102,6 +109,7 @@ public partial class ExportSetupViewModel(
|
||||
SelectedFormat = settingsService.LastExportFormat;
|
||||
PartitionLimitValue = settingsService.LastPartitionLimitValue;
|
||||
MessageFilterValue = settingsService.LastMessageFilterValue;
|
||||
IsReverseMessageOrder = settingsService.LastIsReverseMessageOrder;
|
||||
ShouldFormatMarkdown = settingsService.LastShouldFormatMarkdown;
|
||||
ShouldDownloadAssets = settingsService.LastShouldDownloadAssets;
|
||||
ShouldReuseAssets = settingsService.LastShouldReuseAssets;
|
||||
@@ -116,7 +124,8 @@ public partial class ExportSetupViewModel(
|
||||
|| !string.IsNullOrWhiteSpace(MessageFilterValue)
|
||||
|| ShouldDownloadAssets
|
||||
|| ShouldReuseAssets
|
||||
|| !string.IsNullOrWhiteSpace(AssetsDirPath);
|
||||
|| !string.IsNullOrWhiteSpace(AssetsDirPath)
|
||||
|| IsReverseMessageOrder;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -180,6 +189,7 @@ public partial class ExportSetupViewModel(
|
||||
settingsService.LastExportFormat = SelectedFormat;
|
||||
settingsService.LastPartitionLimitValue = PartitionLimitValue;
|
||||
settingsService.LastMessageFilterValue = MessageFilterValue;
|
||||
settingsService.LastIsReverseMessageOrder = IsReverseMessageOrder;
|
||||
settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown;
|
||||
settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
|
||||
settingsService.LastShouldReuseAssets = ShouldReuseAssets;
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using DiscordChatExporter.Gui.Framework;
|
||||
using DiscordChatExporter.Gui.Localization;
|
||||
using DiscordChatExporter.Gui.Models;
|
||||
using DiscordChatExporter.Gui.Services;
|
||||
using DiscordChatExporter.Gui.Utils;
|
||||
@@ -16,13 +17,19 @@ public class SettingsViewModel : DialogViewModelBase
|
||||
|
||||
private readonly DisposableCollector _eventRoot = new();
|
||||
|
||||
public SettingsViewModel(SettingsService settingsService)
|
||||
public SettingsViewModel(
|
||||
SettingsService settingsService,
|
||||
LocalizationManager localizationManager
|
||||
)
|
||||
{
|
||||
_settingsService = settingsService;
|
||||
LocalizationManager = localizationManager;
|
||||
|
||||
_eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged));
|
||||
}
|
||||
|
||||
public LocalizationManager LocalizationManager { get; }
|
||||
|
||||
public IReadOnlyList<ThemeVariant> AvailableThemes { get; } = Enum.GetValues<ThemeVariant>();
|
||||
|
||||
public ThemeVariant Theme
|
||||
@@ -31,6 +38,17 @@ public class SettingsViewModel : DialogViewModelBase
|
||||
set => _settingsService.Theme = value;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Language> AvailableLanguages { get; } = Enum.GetValues<Language>();
|
||||
|
||||
public Language Language
|
||||
{
|
||||
get => _settingsService.Language;
|
||||
set => _settingsService.Language = value;
|
||||
}
|
||||
|
||||
public bool IsAutoUpdateAvailable { get; } =
|
||||
OperatingSystem.IsWindows() && StartOptions.Current.IsAutoUpdateAllowed;
|
||||
|
||||
public bool IsAutoUpdateEnabled
|
||||
{
|
||||
get => _settingsService.IsAutoUpdateEnabled;
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DiscordChatExporter.Gui.Framework;
|
||||
using DiscordChatExporter.Gui.Localization;
|
||||
using DiscordChatExporter.Gui.Services;
|
||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||
using DiscordChatExporter.Gui.ViewModels.Components;
|
||||
@@ -15,7 +16,8 @@ public partial class MainViewModel(
|
||||
DialogManager dialogManager,
|
||||
SnackbarManager snackbarManager,
|
||||
SettingsService settingsService,
|
||||
UpdateService updateService
|
||||
UpdateService updateService,
|
||||
LocalizationManager localizationManager
|
||||
) : ViewModelBase
|
||||
{
|
||||
public string Title { get; } = $"{Program.Name} v{Program.VersionString}";
|
||||
@@ -28,14 +30,10 @@ public partial class MainViewModel(
|
||||
return;
|
||||
|
||||
var dialog = viewModelManager.CreateMessageBoxViewModel(
|
||||
"Thank you for supporting Ukraine!",
|
||||
"""
|
||||
As Russia wages a genocidal war against my country, I'm grateful to everyone who continues to stand with Ukraine in our fight for freedom.
|
||||
|
||||
Click LEARN MORE to find ways that you can help.
|
||||
""",
|
||||
"LEARN MORE",
|
||||
"CLOSE"
|
||||
localizationManager.UkraineSupportTitle,
|
||||
localizationManager.UkraineSupportMessage,
|
||||
localizationManager.LearnMoreButton,
|
||||
localizationManager.CloseButton
|
||||
);
|
||||
|
||||
// Disable this message in the future
|
||||
@@ -56,16 +54,10 @@ public partial class MainViewModel(
|
||||
return;
|
||||
|
||||
var dialog = viewModelManager.CreateMessageBoxViewModel(
|
||||
"Unstable build warning",
|
||||
$"""
|
||||
You're using a development build of {Program.Name}. These builds are not thoroughly tested and may contain bugs.
|
||||
|
||||
Auto-updates are disabled for development builds.
|
||||
|
||||
Click SEE RELEASES if you want to download a stable release instead.
|
||||
""",
|
||||
"SEE RELEASES",
|
||||
"CLOSE"
|
||||
localizationManager.UnstableBuildTitle,
|
||||
string.Format(localizationManager.UnstableBuildMessage, Program.Name),
|
||||
localizationManager.SeeReleasesButton,
|
||||
localizationManager.CloseButton
|
||||
);
|
||||
|
||||
if (await dialogManager.ShowDialogAsync(dialog) == true)
|
||||
@@ -80,12 +72,18 @@ public partial class MainViewModel(
|
||||
if (updateVersion is null)
|
||||
return;
|
||||
|
||||
snackbarManager.Notify($"Downloading update to {Program.Name} v{updateVersion}...");
|
||||
snackbarManager.Notify(
|
||||
string.Format(
|
||||
localizationManager.UpdateDownloadingMessage,
|
||||
Program.Name,
|
||||
updateVersion
|
||||
)
|
||||
);
|
||||
await updateService.PrepareUpdateAsync(updateVersion);
|
||||
|
||||
snackbarManager.Notify(
|
||||
"Update has been downloaded and will be installed when you exit",
|
||||
"INSTALL NOW",
|
||||
localizationManager.UpdateReadyMessage,
|
||||
localizationManager.UpdateInstallNowButton,
|
||||
() =>
|
||||
{
|
||||
updateService.FinalizeUpdate(true);
|
||||
@@ -98,7 +96,7 @@ public partial class MainViewModel(
|
||||
catch
|
||||
{
|
||||
// Failure to update shouldn't crash the application
|
||||
snackbarManager.Notify("Failed to perform application update");
|
||||
snackbarManager.Notify(localizationManager.UpdateFailedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
|
||||
xmlns:components="clr-namespace:DiscordChatExporter.Gui.ViewModels.Components"
|
||||
xmlns:controls="clr-namespace:DiscordChatExporter.Gui.Views.Controls"
|
||||
xmlns:converters="clr-namespace:DiscordChatExporter.Gui.Converters"
|
||||
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:materialStyles="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
||||
@@ -27,7 +26,7 @@
|
||||
RevealPassword="{Binding $self.IsFocused}"
|
||||
Text="{Binding Token}"
|
||||
Theme="{DynamicResource SoloTextBox}"
|
||||
Watermark="Token">
|
||||
Watermark="{Binding LocalizationManager.TokenWatermark}">
|
||||
<TextBox.InnerLeftContent>
|
||||
<materialIcons:MaterialIcon
|
||||
Grid.Column="0"
|
||||
@@ -45,7 +44,7 @@
|
||||
Command="{Binding PullGuildsCommand}"
|
||||
IsDefault="True"
|
||||
Theme="{DynamicResource MaterialFlatButton}"
|
||||
ToolTip.Tip="Pull available servers and channels (Enter)">
|
||||
ToolTip.Tip="{Binding LocalizationManager.PullGuildsTooltip}">
|
||||
<materialIcons:MaterialIcon
|
||||
Width="24"
|
||||
Height="24"
|
||||
@@ -64,7 +63,7 @@
|
||||
Command="{Binding ShowSettingsCommand}"
|
||||
Foreground="{DynamicResource MaterialDarkForegroundBrush}"
|
||||
Theme="{DynamicResource MaterialFlatButton}"
|
||||
ToolTip.Tip="Settings">
|
||||
ToolTip.Tip="{Binding LocalizationManager.SettingsTooltip}">
|
||||
<materialIcons:MaterialIcon
|
||||
Width="24"
|
||||
Height="24"
|
||||
@@ -161,14 +160,15 @@
|
||||
<Grid
|
||||
Background="Transparent"
|
||||
Classes.category="{Binding Channel.IsCategory}"
|
||||
ColumnDefinitions="Auto,*,Auto">
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
DoubleTapped="ChannelGrid_OnDoubleTapped">
|
||||
<Grid.Styles>
|
||||
<Style Selector="Grid">
|
||||
<Style Selector="^:not(.category)">
|
||||
<Setter Property="ToolTip.Tip">
|
||||
<Template>
|
||||
<TextBlock>
|
||||
<Run Text="Last message sent:" />
|
||||
<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>
|
||||
@@ -219,172 +219,68 @@
|
||||
<!-- Placeholder / usage instructions -->
|
||||
<Panel IsVisible="{Binding !AvailableGuilds.Count}">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||
<TextBlock
|
||||
Margin="32,16"
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
LineHeight="23">
|
||||
<StackPanel Margin="32,16" Spacing="0">
|
||||
<!-- User token -->
|
||||
<InlineUIContainer>
|
||||
<materialIcons:MaterialIcon
|
||||
Width="18"
|
||||
Height="18"
|
||||
Margin="0,-2,0,0"
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Account" />
|
||||
</InlineUIContainer>
|
||||
<Run BaselineAlignment="Center" Text="" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="To get the token for your personal account:" />
|
||||
<LineBreak />
|
||||
<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>
|
||||
|
||||
<Run BaselineAlignment="Center" Text="* Automating user accounts is technically against TOS —" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="use at your own risk" /><Run Text="!" />
|
||||
<LineBreak />
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenPersonalTosWarning, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="1. Open Discord in your" />
|
||||
<controls:HyperLink Command="{Binding OpenDiscordCommand}" Text="web browser" />
|
||||
<Run BaselineAlignment="Center" Text="and login" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="2. Open any server or direct message channel" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="3. Press" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Ctrl+Shift+I" />
|
||||
<Run BaselineAlignment="Center" Text="to show developer tools" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="4. Navigate to the" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Network" />
|
||||
<Run BaselineAlignment="Center" Text="tab" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="5. Press" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Ctrl+R" />
|
||||
<Run BaselineAlignment="Center" Text="to reload" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="6. Switch between random channels to trigger network requests" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="7. Search for a request that starts with" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="messages" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="8. Select the" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Headers" />
|
||||
<Run BaselineAlignment="Center" Text="tab on the right" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="9. Scroll down to the" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Request Headers" />
|
||||
<Run BaselineAlignment="Center" Text="section" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="10. Copy the value of the" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="authorization" />
|
||||
<Run BaselineAlignment="Center" Text="header" />
|
||||
<LineBreak />
|
||||
<LineBreak />
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenPersonalInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<!-- Bot token -->
|
||||
<InlineUIContainer>
|
||||
<materialIcons:MaterialIcon
|
||||
Width="18"
|
||||
Height="18"
|
||||
Margin="0,-2,0,0"
|
||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||
Kind="Robot" />
|
||||
</InlineUIContainer>
|
||||
<Run BaselineAlignment="Center" Text="" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold"
|
||||
Text="To get the token for your bot:" />
|
||||
<LineBreak />
|
||||
<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>
|
||||
|
||||
<Run BaselineAlignment="Center" Text="The token is generated during bot creation. If you lost it, generate a new one:" />
|
||||
<LineBreak />
|
||||
<TextBlock
|
||||
FontSize="14"
|
||||
FontWeight="Light"
|
||||
Inlines="{Binding LocalizationManager.TokenBotInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
LineHeight="23"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="1. Open Discord" />
|
||||
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="developer portal" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="2. Open your application's settings" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="3. Navigate to the" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Bot" />
|
||||
<Run BaselineAlignment="Center" Text="section on the left" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="4. Under" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Token" />
|
||||
<Run BaselineAlignment="Center" Text="click" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Reset Token" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="5. Click" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Yes, do it!" />
|
||||
<Run BaselineAlignment="Center" Text="and authenticate to confirm" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="* Integrations using the previous token will stop working until updated" />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="* Your bot needs to have the" />
|
||||
<Run
|
||||
BaselineAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Message Content Intent" />
|
||||
<Run BaselineAlignment="Center" Text="enabled to read messages" />
|
||||
<LineBreak />
|
||||
<LineBreak />
|
||||
|
||||
<Run BaselineAlignment="Center" Text="If you have questions or issues, please refer to the" />
|
||||
<controls:HyperLink Command="{Binding ShowHelpCommand}" Text="documentation" />
|
||||
</TextBlock>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Gui.Framework;
|
||||
@@ -36,4 +37,12 @@ public partial class DashboardView : UserControl<DashboardViewModel>
|
||||
container.IsSelected = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ChannelGrid_OnDoubleTapped(object? sender, TappedEventArgs args)
|
||||
{
|
||||
if (DataContext.SelectedChannels.Count != 1)
|
||||
return;
|
||||
|
||||
DataContext.ExportCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,4 +17,4 @@
|
||||
</Style>
|
||||
</TextBlock.Styles>
|
||||
</TextBlock>
|
||||
</UserControl>
|
||||
</UserControl>
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Windows.Input;
|
||||
using System.Diagnostics;
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Views.Controls;
|
||||
|
||||
@@ -16,6 +18,12 @@ public partial class HyperLink : UserControl
|
||||
public static readonly StyledProperty<object?> CommandParameterProperty =
|
||||
Button.CommandParameterProperty.AddOwner<HyperLink>();
|
||||
|
||||
// If Url is set and Command is not set, clicking will open this URL in the default browser.
|
||||
public static readonly StyledProperty<string?> UrlProperty = AvaloniaProperty.Register<
|
||||
HyperLink,
|
||||
string?
|
||||
>(nameof(Url));
|
||||
|
||||
public HyperLink() => InitializeComponent();
|
||||
|
||||
public string? Text
|
||||
@@ -36,14 +44,22 @@ public partial class HyperLink : UserControl
|
||||
set => SetValue(CommandParameterProperty, value);
|
||||
}
|
||||
|
||||
public string? Url
|
||||
{
|
||||
get => GetValue(UrlProperty);
|
||||
set => SetValue(UrlProperty, value);
|
||||
}
|
||||
|
||||
private void TextBlock_OnPointerReleased(object? sender, PointerReleasedEventArgs args)
|
||||
{
|
||||
if (Command is null)
|
||||
return;
|
||||
|
||||
if (!Command.CanExecute(CommandParameter))
|
||||
return;
|
||||
|
||||
Command.Execute(CommandParameter);
|
||||
if (Command is not null)
|
||||
{
|
||||
if (Command.CanExecute(CommandParameter))
|
||||
Command.Execute(CommandParameter);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(Url))
|
||||
{
|
||||
Process.StartShellExecute(Url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
IsVisible="{Binding !IsSingleChannel}"
|
||||
TextTrimming="CharacterEllipsis">
|
||||
<Run Text="{Binding Channels.Count, FallbackValue=0, Mode=OneWay}" />
|
||||
<Run Text="channels selected" />
|
||||
<Run Text="{Binding LocalizationManager.ChannelsSelectedText}" />
|
||||
</TextBlock>
|
||||
|
||||
<!-- Category and channel name (for single channel) -->
|
||||
@@ -51,11 +51,7 @@
|
||||
IsVisible="{Binding IsSingleChannel}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Channels[0], Converter={x:Static converters:ChannelToHierarchicalNameStringConverter.Instance}}">
|
||||
<TextBlock IsVisible="{Binding !!Channels[0].Parent}">
|
||||
<Run Text="{Binding Channels[0].Parent.Name, Mode=OneWay}" />
|
||||
<Run Text="/" />
|
||||
</TextBlock>
|
||||
<Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
|
||||
<Run Text="{Binding Channels[0].Parent.Name, StringFormat='{}{0} / ', FallbackValue='', TargetNullValue='', Mode=OneWay}" /><Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
|
||||
@@ -69,64 +65,14 @@
|
||||
<!-- Output path -->
|
||||
<TextBox
|
||||
Margin="16,8"
|
||||
materialAssists:TextFieldAssist.Label="Output path"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.OutputPathLabel}"
|
||||
Text="{Binding OutputPath}"
|
||||
Theme="{DynamicResource FilledTextBox}">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock>
|
||||
<Run Text="Output file or directory path." />
|
||||
<LineBreak />
|
||||
<Run Text="If a directory is specified, file names will be generated automatically based on the channel names and export parameters." />
|
||||
<LineBreak />
|
||||
<Run Text="Directory paths must end with a slash to avoid ambiguity." />
|
||||
<LineBreak />
|
||||
<LineBreak />
|
||||
<Run Text="Available template tokens:" />
|
||||
<LineBreak />
|
||||
<Run Text=" " />
|
||||
<Run FontWeight="SemiBold" Text="%g" />
|
||||
<Run Text="— server ID" />
|
||||
<LineBreak />
|
||||
<Run Text=" " />
|
||||
<Run FontWeight="SemiBold" Text="%G" />
|
||||
<Run Text="— server name" />
|
||||
<LineBreak />
|
||||
<Run Text=" " />
|
||||
<Run FontWeight="SemiBold" Text="%t" />
|
||||
<Run Text="— category ID" />
|
||||
<LineBreak />
|
||||
<Run Text=" " />
|
||||
<Run FontWeight="SemiBold" Text="%T" />
|
||||
<Run Text="— category name" />
|
||||
<LineBreak />
|
||||
<Run Text=" " />
|
||||
<Run FontWeight="SemiBold" Text="%c" />
|
||||
<Run Text="— channel ID" />
|
||||
<LineBreak />
|
||||
<Run Text=" " />
|
||||
<Run FontWeight="SemiBold" Text="%C" />
|
||||
<Run Text="— channel name" />
|
||||
<LineBreak />
|
||||
<Run Text=" " />
|
||||
<Run FontWeight="SemiBold" Text="%p" />
|
||||
<Run Text="— channel position" />
|
||||
<LineBreak />
|
||||
<Run Text=" " />
|
||||
<Run FontWeight="SemiBold" Text="%P" />
|
||||
<Run Text="— category position" />
|
||||
<LineBreak />
|
||||
<Run Text=" " />
|
||||
<Run FontWeight="SemiBold" Text="%a" />
|
||||
<Run Text="— after date" />
|
||||
<LineBreak />
|
||||
<Run Text=" " />
|
||||
<Run FontWeight="SemiBold" Text="%b" />
|
||||
<Run Text="— before date" />
|
||||
<LineBreak />
|
||||
<Run Text=" " />
|
||||
<Run FontWeight="SemiBold" Text="%d" />
|
||||
<Run Text="— current date" />
|
||||
</TextBlock>
|
||||
<TextBlock
|
||||
MaxWidth="400"
|
||||
Inlines="{Binding LocalizationManager.OutputPathTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
TextWrapping="Wrap" />
|
||||
</ToolTip.Tip>
|
||||
<TextBox.InnerRightContent>
|
||||
<Button
|
||||
@@ -146,11 +92,11 @@
|
||||
<!-- Format -->
|
||||
<ComboBox
|
||||
Margin="16,8"
|
||||
materialAssists:ComboBoxAssist.Label="Format"
|
||||
materialAssists:ComboBoxAssist.Label="{Binding LocalizationManager.FormatLabel}"
|
||||
ItemsSource="{Binding AvailableFormats}"
|
||||
SelectedItem="{Binding SelectedFormat}"
|
||||
Theme="{DynamicResource MaterialFilledComboBox}"
|
||||
ToolTip.Tip="Export format">
|
||||
ToolTip.Tip="{Binding LocalizationManager.FormatTooltip}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" />
|
||||
@@ -166,9 +112,9 @@
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Margin="16,8,8,8"
|
||||
materialAssists:TextFieldAssist.Label="After (date)"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AfterDateLabel}"
|
||||
SelectedDate="{Binding AfterDate}"
|
||||
ToolTip.Tip="Only include messages sent after this date">
|
||||
ToolTip.Tip="{Binding LocalizationManager.AfterDateTooltip}">
|
||||
<DatePicker.Styles>
|
||||
<Style Selector="DatePicker">
|
||||
<Style Selector="^ /template/ TextBox#DisplayTextBox">
|
||||
@@ -181,9 +127,9 @@
|
||||
Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Margin="8,8,16,8"
|
||||
materialAssists:TextFieldAssist.Label="Before (date)"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.BeforeDateLabel}"
|
||||
SelectedDate="{Binding BeforeDate}"
|
||||
ToolTip.Tip="Only include messages sent before this date">
|
||||
ToolTip.Tip="{Binding LocalizationManager.BeforeDateTooltip}">
|
||||
<DatePicker.Styles>
|
||||
<Style Selector="DatePicker">
|
||||
<Style Selector="^ /template/ TextBox#DisplayTextBox">
|
||||
@@ -198,11 +144,11 @@
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Margin="16,8,8,8"
|
||||
materialAssists:TextFieldAssist.Label="After (time)"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AfterTimeLabel}"
|
||||
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
|
||||
IsEnabled="{Binding IsAfterDateSet}"
|
||||
SelectedTime="{Binding AfterTime}"
|
||||
ToolTip.Tip="Only include messages sent after this time">
|
||||
ToolTip.Tip="{Binding LocalizationManager.AfterTimeTooltip}">
|
||||
<TimePicker.Styles>
|
||||
<Style Selector="TimePicker">
|
||||
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
|
||||
@@ -215,11 +161,11 @@
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Margin="8,8,16,8"
|
||||
materialAssists:TextFieldAssist.Label="Before (time)"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.BeforeTimeLabel}"
|
||||
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
|
||||
IsEnabled="{Binding IsBeforeDateSet}"
|
||||
SelectedTime="{Binding BeforeTime}"
|
||||
ToolTip.Tip="Only include messages sent before this time">
|
||||
ToolTip.Tip="{Binding LocalizationManager.BeforeTimeTooltip}">
|
||||
<TimePicker.Styles>
|
||||
<Style Selector="TimePicker">
|
||||
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
|
||||
@@ -233,25 +179,34 @@
|
||||
<!-- Partitioning -->
|
||||
<TextBox
|
||||
Margin="16,8"
|
||||
materialAssists:TextFieldAssist.Label="Partition limit"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.PartitionLimitLabel}"
|
||||
Text="{Binding PartitionLimitValue}"
|
||||
Theme="{DynamicResource FilledTextBox}"
|
||||
ToolTip.Tip="Split the output into partitions, each limited to the specified number of messages (e.g. '100') or file size (e.g. '10mb')" />
|
||||
ToolTip.Tip="{Binding LocalizationManager.PartitionLimitTooltip}" />
|
||||
|
||||
<!-- Filtering -->
|
||||
<TextBox
|
||||
Margin="16,8"
|
||||
materialAssists:TextFieldAssist.Label="Message filter"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.MessageFilterLabel}"
|
||||
Text="{Binding MessageFilterValue}"
|
||||
Theme="{DynamicResource FilledTextBox}"
|
||||
ToolTip.Tip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image'). See the documentation for more info." />
|
||||
ToolTip.Tip="{Binding LocalizationManager.MessageFilterTooltip}" />
|
||||
|
||||
<!-- Reverse message order -->
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
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
|
||||
Margin="16,8"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="Process markdown, mentions, and other special tokens">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Format markdown" />
|
||||
ToolTip.Tip="{Binding LocalizationManager.FormatMarkdownTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.FormatMarkdownLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldFormatMarkdown}" />
|
||||
</DockPanel>
|
||||
|
||||
@@ -259,8 +214,8 @@
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="Download assets referenced by the export (user avatars, attached files, embedded images, etc.)">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Download assets" />
|
||||
ToolTip.Tip="{Binding LocalizationManager.DownloadAssetsTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.DownloadAssetsLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldDownloadAssets}" />
|
||||
</DockPanel>
|
||||
|
||||
@@ -269,19 +224,19 @@
|
||||
Margin="16,8"
|
||||
IsEnabled="{Binding ShouldDownloadAssets}"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="Reuse previously downloaded assets to avoid redundant requests">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Reuse assets" />
|
||||
ToolTip.Tip="{Binding LocalizationManager.ReuseAssetsTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ReuseAssetsLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldReuseAssets}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Assets path -->
|
||||
<TextBox
|
||||
Margin="16,8"
|
||||
materialAssists:TextFieldAssist.Label="Assets directory path"
|
||||
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AssetsDirPathLabel}"
|
||||
IsEnabled="{Binding ShouldDownloadAssets}"
|
||||
Text="{Binding AssetsDirPath}"
|
||||
Theme="{DynamicResource FilledTextBox}"
|
||||
ToolTip.Tip="Download assets to this directory. If not specified, the asset directory path will be derived from the output path.">
|
||||
ToolTip.Tip="{Binding LocalizationManager.AssetsDirPathTooltip}">
|
||||
<TextBox.InnerRightContent>
|
||||
<Button
|
||||
Margin="8,8,8,6"
|
||||
@@ -310,7 +265,7 @@
|
||||
Grid.Column="0"
|
||||
IsChecked="{Binding IsAdvancedSectionDisplayed}"
|
||||
Theme="{DynamicResource MaterialOutlineButton}"
|
||||
ToolTip.Tip="Toggle advanced options">
|
||||
ToolTip.Tip="{Binding LocalizationManager.AdvancedOptionsTooltip}">
|
||||
<Button.Styles>
|
||||
<Style Selector="ToggleButton">
|
||||
<Setter Property="Content" Value="MORE" />
|
||||
@@ -325,14 +280,14 @@
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
Command="{Binding ConfirmCommand}"
|
||||
Content="EXPORT"
|
||||
Content="{Binding LocalizationManager.ExportButton}"
|
||||
IsDefault="True"
|
||||
Theme="{DynamicResource MaterialOutlineButton}" />
|
||||
<Button
|
||||
Grid.Column="3"
|
||||
Margin="16,0,0,0"
|
||||
Command="{Binding CloseCommand}"
|
||||
Content="CANCEL"
|
||||
Content="{Binding LocalizationManager.CancelButton}"
|
||||
IsCancel="True"
|
||||
Theme="{DynamicResource MaterialOutlineButton}" />
|
||||
</Grid>
|
||||
|
||||
@@ -33,30 +33,40 @@
|
||||
|
||||
<UniformGrid
|
||||
Grid.Row="2"
|
||||
Margin="16"
|
||||
HorizontalAlignment="Right"
|
||||
Margin="8"
|
||||
Columns="{Binding ButtonsCount}">
|
||||
<!-- OK -->
|
||||
<Button
|
||||
Margin="8"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
Command="{Binding CloseCommand}"
|
||||
Content="{Binding DefaultButtonText}"
|
||||
IsDefault="True"
|
||||
IsVisible="{Binding IsDefaultButtonVisible}"
|
||||
Theme="{DynamicResource MaterialOutlineButton}">
|
||||
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="16,0,0,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Margin="8"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
Command="{Binding CloseCommand}"
|
||||
Content="{Binding CancelButtonText}"
|
||||
IsCancel="True"
|
||||
IsVisible="{Binding IsCancelButtonVisible}"
|
||||
Theme="{DynamicResource MaterialOutlineButton}" />
|
||||
Theme="{DynamicResource MaterialOutlineButton}"
|
||||
ToolTip.Tip="{Binding CancelButtonText}">
|
||||
<TextBlock
|
||||
Text="{Binding CancelButtonText}"
|
||||
TextAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</Button>
|
||||
</UniformGrid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -12,7 +12,7 @@
|
||||
Margin="16"
|
||||
FontSize="19"
|
||||
FontWeight="Light"
|
||||
Text="Settings" />
|
||||
Text="{Binding LocalizationManager.SettingsTitle}" />
|
||||
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
@@ -25,8 +25,8 @@
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="Preferred user interface theme">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Theme" />
|
||||
ToolTip.Tip="{Binding LocalizationManager.ThemeTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ThemeLabel}" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
@@ -34,23 +34,38 @@
|
||||
SelectedItem="{Binding Theme}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Language -->
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="{Binding LocalizationManager.LanguageTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.LanguageLabel}" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
ItemsSource="{Binding AvailableLanguages}"
|
||||
SelectedItem="{Binding Language}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Auto-updates -->
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
IsVisible="{OnPlatform False,
|
||||
Windows=True}"
|
||||
IsVisible="{Binding IsAutoUpdateAvailable}"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="Perform automatic updates on every launch">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Auto-update" />
|
||||
ToolTip.Tip="{Binding LocalizationManager.AutoUpdateTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.AutoUpdateLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsAutoUpdateEnabled}" />
|
||||
</DockPanel>
|
||||
|
||||
<!-- Persist token -->
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="Save the last used token to a file so that it can be persisted between sessions">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Persist token" />
|
||||
<DockPanel Margin="16,8" LastChildFill="False">
|
||||
<ToolTip.Tip>
|
||||
<TextBlock
|
||||
MaxWidth="400"
|
||||
Inlines="{Binding LocalizationManager.PersistTokenTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
|
||||
TextWrapping="Wrap" />
|
||||
</ToolTip.Tip>
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.PersistTokenLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
|
||||
</DockPanel>
|
||||
|
||||
@@ -58,8 +73,8 @@
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Rate limit preference" />
|
||||
ToolTip.Tip="{Binding LocalizationManager.RateLimitPreferenceTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.RateLimitPreferenceLabel}" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
@@ -77,8 +92,8 @@
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="Which types of threads to show in the channel list">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Show threads" />
|
||||
ToolTip.Tip="{Binding LocalizationManager.ShowThreadsTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ShowThreadsLabel}" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
@@ -90,8 +105,8 @@
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="Locale to use when formatting dates and numbers">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Locale" />
|
||||
ToolTip.Tip="{Binding LocalizationManager.LocaleTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.LocaleLabel}" />
|
||||
<ComboBox
|
||||
Width="150"
|
||||
DockPanel.Dock="Right"
|
||||
@@ -109,8 +124,8 @@
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="Normalize all timestamps to UTC+0">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Normalize to UTC" />
|
||||
ToolTip.Tip="{Binding LocalizationManager.NormalizeToUtcTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.NormalizeToUtcLabel}" />
|
||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsUtcNormalizationEnabled}" />
|
||||
</DockPanel>
|
||||
|
||||
@@ -118,8 +133,8 @@
|
||||
<DockPanel
|
||||
Margin="16,8"
|
||||
LastChildFill="False"
|
||||
ToolTip.Tip="How many channels can be exported at the same time">
|
||||
<TextBlock DockPanel.Dock="Left" Text="Parallel limit" />
|
||||
ToolTip.Tip="{Binding LocalizationManager.ParallelLimitTooltip}">
|
||||
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ParallelLimitLabel}" />
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||
<TextBlock Margin="10,0" Text="{Binding ParallelLimit}" />
|
||||
<Slider
|
||||
@@ -141,7 +156,7 @@
|
||||
Margin="16"
|
||||
HorizontalAlignment="Stretch"
|
||||
Command="{Binding CloseCommand}"
|
||||
Content="CLOSE"
|
||||
Content="{Binding LocalizationManager.CloseButton}"
|
||||
IsCancel="True"
|
||||
IsDefault="True"
|
||||
Theme="{DynamicResource MaterialOutlineButton}" />
|
||||
|
||||
@@ -8,6 +8,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{EA305DD5-1
|
||||
License.txt = License.txt
|
||||
Readme.md = Readme.md
|
||||
Directory.Build.props = Directory.Build.props
|
||||
Directory.Packages.props = Directory.Packages.props
|
||||
DiscordChatExporter.Cli.dockerfile = DiscordChatExporter.Cli.dockerfile
|
||||
global.json = global.json
|
||||
NuGet.config = NuGet.config
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# DiscordChatExporter
|
||||
|
||||
[](https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md)
|
||||
[](https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md)
|
||||
[](https://tyrrrz.me/ukraine)
|
||||
[](https://github.com/Tyrrrz/DiscordChatExporter/actions)
|
||||
[](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
|
||||
[](https://github.com/Tyrrrz/DiscordChatExporter/actions)
|
||||
[](https://codecov.io/gh/Tyrrrz/DiscordChatExporter)
|
||||
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||
[](https://github.com/Tyrrrz/DiscordChatExporter/releases)
|
||||
[](https://hub.docker.com/r/tyrrrz/discordchatexporter)
|
||||
@@ -27,7 +27,7 @@ It works with direct messages, group messages, and server channels, and supports
|
||||
|
||||
> 💬 If you want to chat, **join my [Discord server](https://discord.gg/2SUWKFnHSm)**.
|
||||
|
||||
## Terms of use<sup>[[?]](https://github.com/Tyrrrz/.github/blob/master/docs/why-so-political.md)</sup>
|
||||
## Terms of use<sup>[[?]](https://github.com/Tyrrrz/.github/blob/prime/docs/why-so-political.md)</sup>
|
||||
|
||||
By using this project or its source code, for any purpose and in any shape or form, you grant your **implicit agreement** to all the following statements:
|
||||
|
||||
@@ -43,23 +43,25 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
|
||||
- **Graphical user interface** (desktop app):
|
||||
- 🟢 **[Stable release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**: look for `DiscordChatExporter.*.zip`
|
||||
- 🟠 [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml): look for `DiscordChatExporter.*.zip`
|
||||
- 📦 [Scoop](https://scoop.sh/#/apps?q=DiscordChatExporter&p=1&id=c71b7367623c560a2dc746b9739b9568b79b59ae): `scoop install extras/discordchatexporter` (community-maintained)
|
||||
- 📦 [WinGet](https://winget.run/pkg/Tyrrrz/DiscordChatExporter.GUI): `winget install Tyrrrz.DiscordChatExporter.GUI` (community-maintained)
|
||||
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-gui): `yay -S discord-chat-exporter-gui` (community-maintained)
|
||||
- **Command-line interface** (terminal app):
|
||||
- 🟢 **[Stable release](https://github.com/Tyrrrz/DiscordChatExporter/releases/latest)**: look for `DiscordChatExporter.Cli.*.zip`
|
||||
- 🟠 [CI build](https://github.com/Tyrrrz/DiscordChatExporter/actions/workflows/main.yml): look for `DiscordChatExporter.Cli.*.zip`
|
||||
- 🐋 [Docker](https://hub.docker.com/r/tyrrrz/discordchatexporter): `docker pull tyrrrz/discordchatexporter`
|
||||
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `discord-chat-exporter-cli`
|
||||
- 📦 [Nix](https://search.nixos.org/packages?query=discordchatexporter-cli): `discordchatexporter-cli`
|
||||
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `yay -S discord-chat-exporter-cli` (community-maintained)
|
||||
- 📦 [Nix](https://search.nixos.org/packages?show=discordchatexporter-cli): `nix-shell -p discordchatexporter-cli` (community-maintained)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> To launch the GUI version of the app on MacOS, you need to first remove the downloaded file from quarantine.
|
||||
> To launch the GUI version of the app on MacOS, you may need to first remove the downloaded file from quarantine.
|
||||
> You can do that by running the following command in the terminal: `xattr -rd com.apple.quarantine DiscordChatExporter.app`.
|
||||
|
||||
> [!NOTE]
|
||||
> If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture.
|
||||
> Community-maintained packages are published independently from this repository and may not always be up to date with the latest release.
|
||||
|
||||
> [!NOTE]
|
||||
> AUR and Nix packages linked above are maintained by the community.
|
||||
> If you have any issues with them, please contact the corresponding maintainers.
|
||||
> If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture.
|
||||
|
||||
## Features
|
||||
|
||||
|
||||
Reference in New Issue
Block a user