mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-08-08 13:27:52 +02:00
Compare commits
67 Commits
2.45
...
b7f55ff633
| Author | SHA1 | Date | |
|---|---|---|---|
| b7f55ff633 | |||
| 5bbd3fa65e | |||
| c6871af438 | |||
| 2e29d03648 | |||
| 7257332914 | |||
| b1b0dc1625 | |||
| 10a5d1b2bf | |||
| 18086aa209 | |||
| dd7196b6a5 | |||
| 72f9e981de | |||
| 81e286751f | |||
| 225230a20f | |||
| dea412ff25 | |||
| 95953f8cf0 | |||
| 6191855e33 | |||
| 33a975b907 | |||
| 3d353bc1e6 | |||
| f555ab3f00 | |||
| a14a17db83 | |||
| 1f63d4fa26 | |||
| d3d078735a | |||
| 58dd40bf3e | |||
| 7c79e0639b | |||
| a74ba4da0c | |||
| 8bbb8fa5ee | |||
| a91337ccac | |||
| 303f5667bf | |||
| 934cb526e9 | |||
| c33b742dce | |||
| f0a74fbc6e | |||
| 768e124370 | |||
| 8dc7eb2937 | |||
| 8fff0f4445 | |||
| 0fc8124aa0 | |||
| 64ed6acf34 | |||
| e958600073 | |||
| 714d2e6806 | |||
| fbbac2afaa | |||
| 380dd6d511 | |||
| 607ec7442f | |||
| e6f675bcaa | |||
| 42ebb9928e | |||
| 7e64aa5a8e | |||
| a383722469 | |||
| 8f3fe7e501 | |||
| 27a434a663 | |||
| a966bee140 | |||
| fb17e86146 | |||
| 8fcf5e5377 | |||
| 845e2a52fe | |||
| 187befcf11 | |||
| 9e685a0375 | |||
| 0d98e1084d | |||
| f149fad406 | |||
| 5405e619a9 | |||
| adafaaa49d | |||
| d071459a5b | |||
| cc92b2cbe6 | |||
| 08718425f1 | |||
| 540ba34fb4 | |||
| 22134108a4 | |||
| 82499af25a | |||
| 4a74ff8a02 | |||
| 7dfcf5ac19 | |||
| 19c5fb40b2 | |||
| d4fa8f0954 | |||
| 0d9168e0d2 |
@@ -33,7 +33,7 @@ The HTML format replicates Discord's interface, making it the most user-friendly
|
|||||||
It's the best format for attachment preview and sharing.
|
It's the best format for attachment preview and sharing.
|
||||||
You can open `.html` files with a web browser, such as Google Chrome.
|
You can open `.html` files with a web browser, such as Google Chrome.
|
||||||
|
|
||||||
> **Warning**:
|
> [!WARNING]
|
||||||
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed.
|
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed.
|
||||||
> Export using the "Download assets" (`--media`) option to avoid this.
|
> Export using the "Download assets" (`--media`) option to avoid this.
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
|||||||
3. Type
|
3. Type
|
||||||
|
|
||||||
```js
|
```js
|
||||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
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());
|
||||||
```
|
```
|
||||||
|
|
||||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
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
|
1. Type
|
||||||
|
|
||||||
```js
|
```js
|
||||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
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());
|
||||||
```
|
```
|
||||||
|
|
||||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||||
@@ -306,7 +306,7 @@ If you don't have a bot token yet or if you've lost it, follow these steps to re
|
|||||||
> **Tip**:
|
> **Tip**:
|
||||||
> As the token is only shown once, make sure to store it in a safe place. If you lose the token, you will have to reset it again.
|
> As the token is only shown once, make sure to store it in a safe place. If you lose the token, you will have to reset it again.
|
||||||
|
|
||||||
> **Warning**:
|
> [!WARNING]
|
||||||
> Resetting the token will invalidate the old one. Any integrations relying on the old token will cease to function until they are updated.
|
> Resetting the token will invalidate the old one. Any integrations relying on the old token will cease to function until they are updated.
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -31,7 +31,7 @@ If you skip these steps, the "DiscordChatExporter is damaged and can’t be open
|
|||||||
|
|
||||||
Please refer to the on-screen instructions to get your token, then paste your token in the upper text box and hit ENTER or click the arrow (→).
|
Please refer to the on-screen instructions to get your token, then paste your token in the upper text box and hit ENTER or click the arrow (→).
|
||||||
|
|
||||||
> **Warning**:
|
> [!WARNING]
|
||||||
> **Never share your token!**
|
> **Never share your token!**
|
||||||
> A token gives full access to an account, treat it like a password.
|
> A token gives full access to an account, treat it like a password.
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install Docker Buildx
|
- name: Install Docker Buildx
|
||||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
run: >
|
run: >
|
||||||
@@ -36,8 +36,8 @@ jobs:
|
|||||||
--build-arg VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
--build-arg VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||||
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload image
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: DiscordChatExporter.Cli.Docker
|
name: DiscordChatExporter.Cli.Docker
|
||||||
path: DiscordChatExporter.Cli.Docker.tar
|
path: DiscordChatExporter.Cli.Docker.tar
|
||||||
@@ -55,10 +55,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install Docker Buildx
|
- name: Install Docker Buildx
|
||||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
run: >
|
run: >
|
||||||
|
|||||||
+15
-24
@@ -26,12 +26,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||||
with:
|
|
||||||
dotnet-version: 9.0.x
|
|
||||||
|
|
||||||
# Build the project separately to discern between build and format errors
|
# Build the project separately to discern between build and format errors
|
||||||
- name: Build
|
- name: Build
|
||||||
@@ -64,12 +62,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||||
with:
|
|
||||||
dotnet-version: 9.0.x
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
@@ -85,7 +81,7 @@ jobs:
|
|||||||
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2
|
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
@@ -121,12 +117,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||||
with:
|
|
||||||
dotnet-version: 9.0.x
|
|
||||||
|
|
||||||
- name: Publish app
|
- name: Publish app
|
||||||
run: >
|
run: >
|
||||||
@@ -139,8 +133,8 @@ jobs:
|
|||||||
--runtime ${{ matrix.rid }}
|
--runtime ${{ matrix.rid }}
|
||||||
--self-contained
|
--self-contained
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload app binaries
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||||
path: ${{ matrix.app }}/bin/publish/
|
path: ${{ matrix.app }}/bin/publish/
|
||||||
@@ -204,8 +198,8 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download app binaries
|
||||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||||
path: ${{ matrix.app }}/
|
path: ${{ matrix.app }}/
|
||||||
@@ -213,13 +207,10 @@ jobs:
|
|||||||
- name: Set permissions
|
- name: Set permissions
|
||||||
if: ${{ !startsWith(matrix.rid, 'win-') }}
|
if: ${{ !startsWith(matrix.rid, 'win-') }}
|
||||||
run: |
|
run: |
|
||||||
if [ -f ${{ matrix.app }}/${{ matrix.asset }} ]; then
|
[ -f ${{ matrix.app }}/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }} || true
|
||||||
chmod +x ${{ matrix.app }}/${{ matrix.asset }}
|
|
||||||
fi
|
# macOS bundle
|
||||||
|
[ -f ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} || true
|
||||||
if [ -f ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} ]; then
|
|
||||||
chmod +x ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }}
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create package
|
- name: Create package
|
||||||
# Change into the artifacts directory to avoid including the directory itself in the zip archive
|
# Change into the artifacts directory to avoid including the directory itself in the zip archive
|
||||||
|
|||||||
+1
-1
@@ -9,4 +9,4 @@ bin/
|
|||||||
obj/
|
obj/
|
||||||
|
|
||||||
# Test results
|
# Test results
|
||||||
TestResults/
|
TestResults/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Version>999.9.9-dev</Version>
|
<Version>999.9.9-dev</Version>
|
||||||
<Company>Tyrrrz</Company>
|
<Company>Tyrrrz</Company>
|
||||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
@@ -11,22 +10,24 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="1.3.0" />
|
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||||
<PackageReference Include="FluentAssertions" Version="8.2.0" />
|
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
||||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
|
<PackageReference Include="GitHubActionsTestLogger" Version="3.0.1" PrivateAssets="all" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.4" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.4" />
|
<PackageReference
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.4" />
|
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
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" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" PrivateAssets="all" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ In order to run these tests locally, you need to join the test server and config
|
|||||||
3. Add your token to user secrets: `dotnet user-secrets set DISCORD_TOKEN <token>`
|
3. Add your token to user secrets: `dotnet user-secrets set DISCORD_TOKEN <token>`
|
||||||
4. Run the tests: `dotnet test`
|
4. Run the tests: `dotnet test`
|
||||||
|
|
||||||
> **Note**:
|
> [!NOTE]
|
||||||
> If you want to add a new test case, please let me know and I will give you the required permissions on the server.
|
> If you want to add a new test case, please let me know and I will give you the required permissions on the server.
|
||||||
@@ -181,8 +181,14 @@ public class HtmlEmbedSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
|
// Check that the YouTube video thumbnail image exists with the correct video ID
|
||||||
iframeUrl.Should().StartWith("https://www.youtube.com/embed/qOWW4OlgbvE");
|
var youtubeThumbnailSrc = message
|
||||||
|
.QuerySelectorAll("img")
|
||||||
|
.Select(e => e.GetAttribute("src"))
|
||||||
|
.WhereNotNull()
|
||||||
|
.FirstOrDefault(s => s.Contains("qOWW4OlgbvE", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
youtubeThumbnailSrc.Should().NotBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -61,4 +61,17 @@ public class HtmlMentionSpecs
|
|||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Role mention: @Role 1");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,4 +75,21 @@ public class JsonMentionSpecs
|
|||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Role mention: @Role 1");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ namespace DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
|||||||
|
|
||||||
internal static class StringExtensions
|
internal static class StringExtensions
|
||||||
{
|
{
|
||||||
public static string ReplaceWhiteSpace(this string str, string replacement = " ")
|
extension(string str)
|
||||||
{
|
{
|
||||||
var buffer = new StringBuilder(str.Length);
|
public string ReplaceWhiteSpace(string replacement = " ")
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder(str.Length);
|
||||||
|
|
||||||
foreach (var ch in str)
|
foreach (var ch in str)
|
||||||
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
|
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
|
||||||
|
|
||||||
return buffer.ToString();
|
return buffer.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using PathEx = System.IO.Path;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||||
|
|
||||||
@@ -23,8 +22,8 @@ internal partial class TempDir
|
|||||||
{
|
{
|
||||||
public static TempDir Create()
|
public static TempDir Create()
|
||||||
{
|
{
|
||||||
var dirPath = PathEx.Combine(
|
var dirPath = System.IO.Path.Combine(
|
||||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||||
?? Directory.GetCurrentDirectory(),
|
?? Directory.GetCurrentDirectory(),
|
||||||
"Temp",
|
"Temp",
|
||||||
Guid.NewGuid().ToString()
|
Guid.NewGuid().ToString()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using PathEx = System.IO.Path;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||||
|
|
||||||
@@ -23,15 +22,15 @@ internal partial class TempFile
|
|||||||
{
|
{
|
||||||
public static TempFile Create()
|
public static TempFile Create()
|
||||||
{
|
{
|
||||||
var dirPath = PathEx.Combine(
|
var dirPath = System.IO.Path.Combine(
|
||||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||||
?? Directory.GetCurrentDirectory(),
|
?? Directory.GetCurrentDirectory(),
|
||||||
"Temp"
|
"Temp"
|
||||||
);
|
);
|
||||||
|
|
||||||
Directory.CreateDirectory(dirPath);
|
Directory.CreateDirectory(dirPath);
|
||||||
|
|
||||||
var filePath = PathEx.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
var filePath = System.IO.Path.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
||||||
|
|
||||||
return new TempFile(filePath);
|
return new TempFile(filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -- Build
|
# -- Build
|
||||||
# Specify the platform here so that we pull the SDK image matching the host platform,
|
# Specify the platform here so that we pull the SDK image matching the host platform,
|
||||||
# instead of the target platform specified during build by the `--platform` option.
|
# instead of the target platform specified during build by the `--platform` option.
|
||||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
|
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||||
|
|
||||||
# Expose the target architecture set by the `docker build --platform` option, so that
|
# Expose the target architecture set by the `docker build --platform` option, so that
|
||||||
# we can build the assembly for the correct platform.
|
# we can build the assembly for the correct platform.
|
||||||
@@ -30,7 +30,7 @@ RUN dotnet publish DiscordChatExporter.Cli \
|
|||||||
|
|
||||||
# -- Run
|
# -- Run
|
||||||
# Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
|
# Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
|
||||||
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:9.0-alpine AS run
|
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS run
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="DiscordChatExporter.Cli"
|
LABEL org.opencontainers.image.title="DiscordChatExporter.Cli"
|
||||||
LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
|
LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
@@ -34,9 +35,9 @@ public abstract class DiscordCommandBase : ICommand
|
|||||||
)]
|
)]
|
||||||
public bool ShouldRespectRateLimits { get; init; } = true;
|
public bool ShouldRespectRateLimits { get; init; } = true;
|
||||||
|
|
||||||
private DiscordClient? _discordClient;
|
[field: AllowNull, MaybeNull]
|
||||||
protected DiscordClient Discord =>
|
protected DiscordClient Discord =>
|
||||||
_discordClient ??= new DiscordClient(
|
field ??= new DiscordClient(
|
||||||
Token,
|
Token,
|
||||||
ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
|
ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -8,6 +9,7 @@ using CliFx.Attributes;
|
|||||||
using CliFx.Exceptions;
|
using CliFx.Exceptions;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Converters;
|
using DiscordChatExporter.Cli.Commands.Converters;
|
||||||
|
using DiscordChatExporter.Cli.Commands.Shared;
|
||||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
@@ -15,7 +17,6 @@ using DiscordChatExporter.Core.Exceptions;
|
|||||||
using DiscordChatExporter.Core.Exporting;
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
|
||||||
using Gress;
|
using Gress;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
@@ -23,8 +24,6 @@ namespace DiscordChatExporter.Cli.Commands.Base;
|
|||||||
|
|
||||||
public abstract class ExportCommandBase : DiscordCommandBase
|
public abstract class ExportCommandBase : DiscordCommandBase
|
||||||
{
|
{
|
||||||
private readonly string _outputPath = Directory.GetCurrentDirectory();
|
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"output",
|
"output",
|
||||||
'o',
|
'o',
|
||||||
@@ -35,11 +34,11 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public string OutputPath
|
public string OutputPath
|
||||||
{
|
{
|
||||||
get => _outputPath;
|
get;
|
||||||
// Handle ~/ in paths on Unix systems
|
// Handle ~/ in paths on Unix systems
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||||
init => _outputPath = Path.GetFullPath(value);
|
init => field = Path.GetFullPath(value);
|
||||||
}
|
} = Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
[CommandOption("format", 'f', Description = "Export format.")]
|
[CommandOption("format", 'f', Description = "Export format.")]
|
||||||
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
||||||
@@ -64,6 +63,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
||||||
|
|
||||||
|
[CommandOption(
|
||||||
|
"include-threads",
|
||||||
|
Description = "Which types of threads should be included.",
|
||||||
|
Converter = typeof(ThreadInclusionModeBindingConverter)
|
||||||
|
)]
|
||||||
|
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"filter",
|
"filter",
|
||||||
Description = "Only include messages that satisfy this filter. "
|
Description = "Only include messages that satisfy this filter. "
|
||||||
@@ -95,8 +101,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public bool ShouldReuseAssets { get; init; } = false;
|
public bool ShouldReuseAssets { get; init; } = false;
|
||||||
|
|
||||||
private readonly string? _assetsDirPath;
|
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"media-dir",
|
"media-dir",
|
||||||
Description = "Download assets to this directory. "
|
Description = "Download assets to this directory. "
|
||||||
@@ -104,10 +108,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public string? AssetsDirPath
|
public string? AssetsDirPath
|
||||||
{
|
{
|
||||||
get => _assetsDirPath;
|
get;
|
||||||
// Handle ~/ in paths on Unix systems
|
// Handle ~/ in paths on Unix systems
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||||
init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null;
|
init => field = value is not null ? Path.GetFullPath(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
||||||
@@ -136,11 +140,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
|
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
|
||||||
|
|
||||||
private ChannelExporter? _channelExporter;
|
[field: AllowNull, MaybeNull]
|
||||||
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord);
|
protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
|
||||||
|
|
||||||
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
|
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||||
{
|
{
|
||||||
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
// Asset reuse can only be enabled if the download assets option is set
|
// Asset reuse can only be enabled if the download assets option is set
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
|
||||||
if (ShouldReuseAssets && !ShouldDownloadAssets)
|
if (ShouldReuseAssets && !ShouldDownloadAssets)
|
||||||
@@ -154,13 +160,53 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
throw new CommandException("Option --media-dir cannot be used without --media.");
|
throw new CommandException("Option --media-dir cannot be used without --media.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var unwrappedChannels = new List<Channel>(channels);
|
||||||
|
|
||||||
|
// Unwrap threads
|
||||||
|
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
||||||
|
{
|
||||||
|
await console.Output.WriteLineAsync("Fetching threads...");
|
||||||
|
|
||||||
|
var fetchedThreadsCount = 0;
|
||||||
|
await console
|
||||||
|
.CreateStatusTicker()
|
||||||
|
.StartAsync(
|
||||||
|
"...",
|
||||||
|
async ctx =>
|
||||||
|
{
|
||||||
|
await foreach (
|
||||||
|
var thread in Discord.GetChannelThreadsAsync(
|
||||||
|
channels,
|
||||||
|
ThreadInclusionMode == ThreadInclusionMode.All,
|
||||||
|
Before,
|
||||||
|
After,
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
unwrappedChannels.Add(thread);
|
||||||
|
|
||||||
|
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
|
||||||
|
|
||||||
|
fetchedThreadsCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove forums, as they cannot be exported directly and their constituent threads
|
||||||
|
// have already been fetched.
|
||||||
|
unwrappedChannels.RemoveAll(channel => channel.Kind == ChannelKind.GuildForum);
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
||||||
|
}
|
||||||
|
|
||||||
// Make sure the user does not try to export multiple channels into one file.
|
// Make sure the user does not try to export multiple channels into one file.
|
||||||
// Output path must either be a directory or contain template tokens for this to work.
|
// Output path must either be a directory or contain template tokens for this to work.
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
||||||
var isValidOutputPath =
|
var isValidOutputPath =
|
||||||
// Anything is valid when exporting a single channel
|
// Anything is valid when exporting a single channel
|
||||||
channels.Count <= 1
|
unwrappedChannels.Count <= 1
|
||||||
// When using template tokens, assume the user knows what they're doing
|
// When using template tokens, assume the user knows what they're doing
|
||||||
|| OutputPath.Contains('%')
|
|| OutputPath.Contains('%')
|
||||||
// Otherwise, require an existing directory or an unambiguous directory path
|
// Otherwise, require an existing directory or an unambiguous directory path
|
||||||
@@ -177,11 +223,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
|
||||||
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
|
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
|
||||||
var warningsByChannel = new ConcurrentDictionary<Channel, string>();
|
var warningsByChannel = new ConcurrentDictionary<Channel, string>();
|
||||||
|
|
||||||
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
|
await console.Output.WriteLineAsync($"Exporting {unwrappedChannels.Count} channel(s)...");
|
||||||
await console
|
await console
|
||||||
.CreateProgressTicker()
|
.CreateProgressTicker()
|
||||||
.HideCompleted(
|
.HideCompleted(
|
||||||
@@ -193,7 +238,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
.StartAsync(async ctx =>
|
.StartAsync(async ctx =>
|
||||||
{
|
{
|
||||||
await Parallel.ForEachAsync(
|
await Parallel.ForEachAsync(
|
||||||
channels,
|
unwrappedChannels,
|
||||||
new ParallelOptions
|
new ParallelOptions
|
||||||
{
|
{
|
||||||
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
|
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
|
||||||
@@ -253,7 +298,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
{
|
{
|
||||||
await console.Output.WriteLineAsync(
|
await console.Output.WriteLineAsync(
|
||||||
$"Successfully exported {channels.Count - errorsByChannel.Count} channel(s)."
|
$"Successfully exported {unwrappedChannels.Count - errorsByChannel.Count} channel(s)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,48 +346,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
|
|
||||||
// Fail the command only if ALL channels failed to export.
|
// Fail the command only if ALL channels failed to export.
|
||||||
// If only some channels failed to export, it's okay.
|
// If only some channels failed to export, it's okay.
|
||||||
if (errorsByChannel.Count >= channels.Count)
|
if (errorsByChannel.Count >= unwrappedChannels.Count)
|
||||||
throw new CommandException("Export failed.");
|
throw new CommandException("Export failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Snowflake> channelIds)
|
|
||||||
{
|
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
|
||||||
|
|
||||||
await console.Output.WriteLineAsync("Resolving channel(s)...");
|
|
||||||
|
|
||||||
var channels = new List<Channel>();
|
|
||||||
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
|
||||||
|
|
||||||
foreach (var channelId in channelIds)
|
|
||||||
{
|
|
||||||
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
|
|
||||||
|
|
||||||
// Unwrap categories
|
|
||||||
if (channel.IsCategory)
|
|
||||||
{
|
|
||||||
var guildChannels =
|
|
||||||
channelsByGuild.GetValueOrDefault(channel.GuildId)
|
|
||||||
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
|
||||||
|
|
||||||
foreach (var guildChannel in guildChannels)
|
|
||||||
{
|
|
||||||
if (guildChannel.Parent?.Id == channel.Id)
|
|
||||||
channels.Add(guildChannel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the guild channels to avoid redundant work
|
|
||||||
channelsByGuild[channel.GuildId] = guildChannels;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
channels.Add(channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ExportAsync(console, channels);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
// Support Ukraine callout
|
// Support Ukraine callout
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ using System.Threading.Tasks;
|
|||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Cli.Commands.Converters;
|
|
||||||
using DiscordChatExporter.Cli.Commands.Shared;
|
|
||||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Discord.Dump;
|
using DiscordChatExporter.Core.Discord.Dump;
|
||||||
@@ -27,13 +25,6 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||||
public bool IncludeVoiceChannels { get; init; } = true;
|
public bool IncludeVoiceChannels { get; init; } = true;
|
||||||
|
|
||||||
[CommandOption(
|
|
||||||
"include-threads",
|
|
||||||
Description = "Which types of threads should be included.",
|
|
||||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
|
||||||
)]
|
|
||||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"data-package",
|
"data-package",
|
||||||
Description = "Path to the personal data package (ZIP file) requested from Discord. "
|
Description = "Path to the personal data package (ZIP file) requested from Discord. "
|
||||||
@@ -90,46 +81,6 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
);
|
);
|
||||||
|
|
||||||
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
||||||
|
|
||||||
// Threads
|
|
||||||
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
|
||||||
{
|
|
||||||
await console.Output.WriteLineAsync(
|
|
||||||
$"Fetching threads for server '{guild.Name}'..."
|
|
||||||
);
|
|
||||||
|
|
||||||
var fetchedThreadsCount = 0;
|
|
||||||
await console
|
|
||||||
.CreateStatusTicker()
|
|
||||||
.StartAsync(
|
|
||||||
"...",
|
|
||||||
async ctx =>
|
|
||||||
{
|
|
||||||
await foreach (
|
|
||||||
var thread in Discord.GetGuildThreadsAsync(
|
|
||||||
guild.Id,
|
|
||||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
|
||||||
Before,
|
|
||||||
After,
|
|
||||||
cancellationToken
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
channels.Add(thread);
|
|
||||||
|
|
||||||
ctx.Status(
|
|
||||||
Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'.")
|
|
||||||
);
|
|
||||||
|
|
||||||
fetchedThreadsCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await console.Output.WriteLineAsync(
|
|
||||||
$"Fetched {fetchedThreadsCount} thread(s)."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pull from the data package
|
// Pull from the data package
|
||||||
@@ -199,10 +150,6 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
channels.RemoveAll(c => c.IsGuild);
|
channels.RemoveAll(c => c.IsGuild);
|
||||||
if (!IncludeVoiceChannels)
|
if (!IncludeVoiceChannels)
|
||||||
channels.RemoveAll(c => c.IsVoice);
|
channels.RemoveAll(c => c.IsVoice);
|
||||||
if (ThreadInclusionMode == ThreadInclusionMode.None)
|
|
||||||
channels.RemoveAll(c => c.IsThread);
|
|
||||||
if (ThreadInclusionMode != ThreadInclusionMode.All)
|
|
||||||
channels.RemoveAll(c => c is { IsThread: true, IsArchived: true });
|
|
||||||
|
|
||||||
await ExportAsync(console, channels);
|
await ExportAsync(console, channels);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using CliFx.Attributes;
|
|||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands;
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
|
|
||||||
@@ -22,6 +24,40 @@ public class ExportChannelsCommand : ExportCommandBase
|
|||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
await base.ExecuteAsync(console);
|
await base.ExecuteAsync(console);
|
||||||
await ExportAsync(console, ChannelIds);
|
|
||||||
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync("Resolving channel(s)...");
|
||||||
|
|
||||||
|
var channels = new List<Channel>();
|
||||||
|
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
||||||
|
|
||||||
|
foreach (var channelId in ChannelIds)
|
||||||
|
{
|
||||||
|
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
|
||||||
|
|
||||||
|
// Unwrap categories
|
||||||
|
if (channel.IsCategory)
|
||||||
|
{
|
||||||
|
var guildChannels =
|
||||||
|
channelsByGuild.GetValueOrDefault(channel.GuildId)
|
||||||
|
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
||||||
|
|
||||||
|
foreach (var guildChannel in guildChannels)
|
||||||
|
{
|
||||||
|
if (guildChannel.Parent?.Id == channel.Id)
|
||||||
|
channels.Add(guildChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the guild channels to avoid redundant work
|
||||||
|
channelsByGuild[channel.GuildId] = guildChannels;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
channels.Add(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExportAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ using System.Threading.Tasks;
|
|||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Cli.Commands.Converters;
|
|
||||||
using DiscordChatExporter.Cli.Commands.Shared;
|
|
||||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
@@ -21,13 +19,6 @@ public class ExportGuildCommand : ExportCommandBase
|
|||||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||||
public bool IncludeVoiceChannels { get; init; } = true;
|
public bool IncludeVoiceChannels { get; init; } = true;
|
||||||
|
|
||||||
[CommandOption(
|
|
||||||
"include-threads",
|
|
||||||
Description = "Which types of threads should be included.",
|
|
||||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
|
||||||
)]
|
|
||||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
await base.ExecuteAsync(console);
|
await base.ExecuteAsync(console);
|
||||||
@@ -35,7 +26,6 @@ public class ExportGuildCommand : ExportCommandBase
|
|||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
var channels = new List<Channel>();
|
var channels = new List<Channel>();
|
||||||
|
|
||||||
// Regular channels
|
|
||||||
await console.Output.WriteLineAsync("Fetching channels...");
|
await console.Output.WriteLineAsync("Fetching channels...");
|
||||||
|
|
||||||
var fetchedChannelsCount = 0;
|
var fetchedChannelsCount = 0;
|
||||||
@@ -66,40 +56,6 @@ public class ExportGuildCommand : ExportCommandBase
|
|||||||
|
|
||||||
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
||||||
|
|
||||||
// Threads
|
|
||||||
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
|
||||||
{
|
|
||||||
await console.Output.WriteLineAsync("Fetching threads...");
|
|
||||||
|
|
||||||
var fetchedThreadsCount = 0;
|
|
||||||
await console
|
|
||||||
.CreateStatusTicker()
|
|
||||||
.StartAsync(
|
|
||||||
"...",
|
|
||||||
async ctx =>
|
|
||||||
{
|
|
||||||
await foreach (
|
|
||||||
var thread in Discord.GetGuildThreadsAsync(
|
|
||||||
GuildId,
|
|
||||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
|
||||||
Before,
|
|
||||||
After,
|
|
||||||
cancellationToken
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
channels.Add(thread);
|
|
||||||
|
|
||||||
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
|
|
||||||
|
|
||||||
fetchedThreadsCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
|
||||||
}
|
|
||||||
|
|
||||||
await ExportAsync(console, channels);
|
await ExportAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||||
@@ -7,16 +6,21 @@
|
|||||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
|
||||||
|
<EnableAotAnalyzer>false</EnableAotAnalyzer>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliFx" Version="2.3.5" />
|
<PackageReference Include="CliFx" Version="2.3.6" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" Version="2.1.1" />
|
||||||
<PackageReference Include="Spectre.Console" Version="0.50.0" />
|
<PackageReference Include="Spectre.Console" Version="0.54.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
|
|||||||
@@ -7,32 +7,35 @@ namespace DiscordChatExporter.Cli.Utils.Extensions;
|
|||||||
|
|
||||||
internal static class ConsoleExtensions
|
internal static class ConsoleExtensions
|
||||||
{
|
{
|
||||||
public static IAnsiConsole CreateAnsiConsole(this IConsole console) =>
|
extension(IConsole console)
|
||||||
AnsiConsole.Create(
|
{
|
||||||
new AnsiConsoleSettings
|
public IAnsiConsole CreateAnsiConsole() =>
|
||||||
{
|
AnsiConsole.Create(
|
||||||
Ansi = AnsiSupport.Detect,
|
new AnsiConsoleSettings
|
||||||
ColorSystem = ColorSystemSupport.Detect,
|
{
|
||||||
Out = new AnsiConsoleOutput(console.Output),
|
Ansi = AnsiSupport.Detect,
|
||||||
}
|
ColorSystem = ColorSystemSupport.Detect,
|
||||||
);
|
Out = new AnsiConsoleOutput(console.Output),
|
||||||
|
}
|
||||||
public static Status CreateStatusTicker(this IConsole console) =>
|
|
||||||
console.CreateAnsiConsole().Status().AutoRefresh(true);
|
|
||||||
|
|
||||||
public static Progress CreateProgressTicker(this IConsole console) =>
|
|
||||||
console
|
|
||||||
.CreateAnsiConsole()
|
|
||||||
.Progress()
|
|
||||||
.AutoClear(false)
|
|
||||||
.AutoRefresh(true)
|
|
||||||
.HideCompleted(false)
|
|
||||||
.Columns(
|
|
||||||
new TaskDescriptionColumn { Alignment = Justify.Left },
|
|
||||||
new ProgressBarColumn(),
|
|
||||||
new PercentageColumn()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public Status CreateStatusTicker() =>
|
||||||
|
console.CreateAnsiConsole().Status().AutoRefresh(true);
|
||||||
|
|
||||||
|
public Progress CreateProgressTicker() =>
|
||||||
|
console
|
||||||
|
.CreateAnsiConsole()
|
||||||
|
.Progress()
|
||||||
|
.AutoClear(false)
|
||||||
|
.AutoRefresh(true)
|
||||||
|
.HideCompleted(false)
|
||||||
|
.Columns(
|
||||||
|
new TaskDescriptionColumn { Alignment = Justify.Left },
|
||||||
|
new ProgressBarColumn(),
|
||||||
|
new PercentageColumn()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static async ValueTask StartTaskAsync(
|
public static async ValueTask StartTaskAsync(
|
||||||
this ProgressContext context,
|
this ProgressContext context,
|
||||||
string description,
|
string description,
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ public partial record Channel(
|
|||||||
|
|
||||||
public bool IsThread { get; } =
|
public bool IsThread { get; } =
|
||||||
Kind
|
Kind
|
||||||
is ChannelKind.GuildNewsThread
|
is ChannelKind.GuildNewsThread
|
||||||
or ChannelKind.GuildPublicThread
|
or ChannelKind.GuildPublicThread
|
||||||
or ChannelKind.GuildPrivateThread;
|
or ChannelKind.GuildPrivateThread;
|
||||||
|
|
||||||
public bool IsEmpty { get; } = LastMessageId is null;
|
public bool IsEmpty { get; } = LastMessageId is null;
|
||||||
|
|
||||||
@@ -69,7 +69,8 @@ public partial record Channel
|
|||||||
var guildId =
|
var guildId =
|
||||||
json.GetPropertyOrNull("guild_id")
|
json.GetPropertyOrNull("guild_id")
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(Snowflake.Parse) ?? Guild.DirectMessages.Id;
|
?.Pipe(Snowflake.Parse)
|
||||||
|
?? Guild.DirectMessages.Id;
|
||||||
|
|
||||||
var name =
|
var name =
|
||||||
// Guild channel
|
// Guild channel
|
||||||
@@ -78,6 +79,7 @@ public partial record Channel
|
|||||||
?? json.GetPropertyOrNull("recipients")
|
?? json.GetPropertyOrNull("recipients")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(User.Parse)
|
?.Select(User.Parse)
|
||||||
|
.OrderBy(u => u.Id)
|
||||||
.Select(u => u.DisplayName)
|
.Select(u => u.DisplayName)
|
||||||
.Pipe(s => string.Join(", ", s))
|
.Pipe(s => string.Join(", ", s))
|
||||||
// Fallback
|
// Fallback
|
||||||
@@ -95,7 +97,8 @@ public partial record Channel
|
|||||||
var isArchived =
|
var isArchived =
|
||||||
json.GetPropertyOrNull("thread_metadata")
|
json.GetPropertyOrNull("thread_metadata")
|
||||||
?.GetPropertyOrNull("archived")
|
?.GetPropertyOrNull("archived")
|
||||||
?.GetBooleanOrNull() ?? false;
|
?.GetBooleanOrNull()
|
||||||
|
?? false;
|
||||||
|
|
||||||
var lastMessageId = json.GetPropertyOrNull("last_message_id")
|
var lastMessageId = json.GetPropertyOrNull("last_message_id")
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ public partial record Embed
|
|||||||
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
||||||
|
|
||||||
var kind =
|
var kind =
|
||||||
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>()
|
json.GetPropertyOrNull("type")
|
||||||
|
?.GetStringOrNull()
|
||||||
|
?.Pipe(s =>
|
||||||
|
Enum.TryParse<EmbedKind>(s, true, out var result) ? result : (EmbedKind?)null
|
||||||
|
)
|
||||||
?? EmbedKind.Rich;
|
?? EmbedKind.Rich;
|
||||||
|
|
||||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||||
@@ -63,7 +67,8 @@ public partial record Embed
|
|||||||
json.GetPropertyOrNull("fields")
|
json.GetPropertyOrNull("fields")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(EmbedField.Parse)
|
?.Select(EmbedField.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||||
|
|
||||||
@@ -78,7 +83,8 @@ public partial record Embed
|
|||||||
json.GetPropertyOrNull("image")
|
json.GetPropertyOrNull("image")
|
||||||
?.Pipe(EmbedImage.Parse)
|
?.Pipe(EmbedImage.Parse)
|
||||||
.ToSingletonEnumerable()
|
.ToSingletonEnumerable()
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
||||||
{
|
{
|
||||||
public string Url { get; } = $"https://www.youtube.com/embed/{VideoId}";
|
public string Url { get; } = $"https://www.youtube.com/watch?v={VideoId}";
|
||||||
|
|
||||||
|
// Using hqdefault.jpg which is guaranteed to exist for all YouTube videos
|
||||||
|
public string ThumbnailUrl { get; } = $"https://i.ytimg.com/vi/{VideoId}/hqdefault.jpg";
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record YouTubeVideoEmbedProjection
|
public partial record YouTubeVideoEmbedProjection
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ public partial record Guild
|
|||||||
var iconUrl =
|
var iconUrl =
|
||||||
json.GetPropertyOrNull("icon")
|
json.GetPropertyOrNull("icon")
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? ImageCdn.GetFallbackUserAvatarUrl();
|
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h))
|
||||||
|
?? ImageCdn.GetFallbackUserAvatarUrl();
|
||||||
|
|
||||||
return new Guild(id, name, iconUrl);
|
return new Guild(id, name, iconUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ public partial record Member
|
|||||||
?.EnumerateArray()
|
?.EnumerateArray()
|
||||||
.Select(j => j.GetNonWhiteSpaceString())
|
.Select(j => j.GetNonWhiteSpaceString())
|
||||||
.Select(Snowflake.Parse)
|
.Select(Snowflake.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var avatarUrl = guildId is not null
|
var avatarUrl = guildId is not null
|
||||||
? json.GetPropertyOrNull("avatar")
|
? json.GetPropertyOrNull("avatar")
|
||||||
|
|||||||
@@ -143,7 +143,8 @@ public partial record Message
|
|||||||
json.GetPropertyOrNull("attachments")
|
json.GetPropertyOrNull("attachments")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(Attachment.Parse)
|
?.Select(Attachment.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var embeds = NormalizeEmbeds(
|
var embeds = NormalizeEmbeds(
|
||||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||||
@@ -154,13 +155,15 @@ public partial record Message
|
|||||||
json.GetPropertyOrNull("sticker_items")
|
json.GetPropertyOrNull("sticker_items")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(Sticker.Parse)
|
?.Select(Sticker.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var reactions =
|
var reactions =
|
||||||
json.GetPropertyOrNull("reactions")
|
json.GetPropertyOrNull("reactions")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(Reaction.Parse)
|
?.Select(Reaction.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var mentionedUsers =
|
var mentionedUsers =
|
||||||
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
||||||
|
|||||||
@@ -305,7 +305,117 @@ public class DiscordClient(
|
|||||||
if (guildId == Guild.DirectMessages.Id)
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
var channels = (await GetGuildChannelsAsync(guildId, cancellationToken))
|
var channels = await GetGuildChannelsAsync(guildId, cancellationToken);
|
||||||
|
|
||||||
|
foreach (
|
||||||
|
var channel in await GetChannelThreadsAsync(
|
||||||
|
channels,
|
||||||
|
includeArchived,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
yield return channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Role> GetGuildRolesAsync(
|
||||||
|
Snowflake guildId,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken);
|
||||||
|
foreach (var roleJson in response.EnumerateArray())
|
||||||
|
yield return Role.Parse(roleJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Member?> TryGetGuildMemberAsync(
|
||||||
|
Snowflake guildId,
|
||||||
|
Snowflake memberId,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var response = await TryGetJsonResponseAsync(
|
||||||
|
$"guilds/{guildId}/members/{memberId}",
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
return response?.Pipe(j => Member.Parse(j, guildId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Invite?> TryGetInviteAsync(
|
||||||
|
string code,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var response = await TryGetJsonResponseAsync($"invites/{code}", cancellationToken);
|
||||||
|
return response?.Pipe(Invite.Parse);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Channel> GetChannelAsync(
|
||||||
|
Snowflake channelId,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
|
||||||
|
|
||||||
|
var parentId = response
|
||||||
|
.GetPropertyOrNull("parent_id")
|
||||||
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
|
?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
|
// It's possible for the parent channel to be inaccessible, despite the
|
||||||
|
// child channel being accessible.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
||||||
|
var parent = parentId is not null
|
||||||
|
? await TryGetChannelAsync(parentId.Value, cancellationToken)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Channel.Parse(response, 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)
|
||||||
|
{
|
||||||
|
// 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(
|
||||||
|
IReadOnlyList<Channel> channels,
|
||||||
|
bool includeArchived = false,
|
||||||
|
Snowflake? before = null,
|
||||||
|
Snowflake? after = null,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var filteredChannels = channels
|
||||||
// Categories cannot have threads
|
// Categories cannot have threads
|
||||||
.Where(c => !c.IsCategory)
|
.Where(c => !c.IsCategory)
|
||||||
// Voice channels cannot have threads
|
// Voice channels cannot have threads
|
||||||
@@ -322,7 +432,7 @@ public class DiscordClient(
|
|||||||
// User accounts can only fetch threads using the search endpoint
|
// User accounts can only fetch threads using the search endpoint
|
||||||
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
||||||
{
|
{
|
||||||
foreach (var channel in channels)
|
foreach (var channel in filteredChannels)
|
||||||
{
|
{
|
||||||
// Either include both active and archived threads, or only active threads
|
// Either include both active and archived threads, or only active threads
|
||||||
foreach (
|
foreach (
|
||||||
@@ -378,9 +488,14 @@ public class DiscordClient(
|
|||||||
// Bot accounts can only fetch threads using the threads endpoint
|
// Bot accounts can only fetch threads using the threads endpoint
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
var guilds = new HashSet<Snowflake>();
|
||||||
|
foreach (var channel in filteredChannels)
|
||||||
|
guilds.Add(channel.GuildId);
|
||||||
|
|
||||||
// Active threads
|
// Active threads
|
||||||
|
foreach (var guildId in guilds)
|
||||||
{
|
{
|
||||||
var parentsById = channels.ToDictionary(c => c.Id);
|
var parentsById = filteredChannels.ToDictionary(c => c.Id);
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync(
|
var response = await GetJsonResponseAsync(
|
||||||
$"guilds/{guildId}/threads/active",
|
$"guilds/{guildId}/threads/active",
|
||||||
@@ -395,14 +510,15 @@ public class DiscordClient(
|
|||||||
?.Pipe(Snowflake.Parse)
|
?.Pipe(Snowflake.Parse)
|
||||||
.Pipe(parentsById.GetValueOrDefault);
|
.Pipe(parentsById.GetValueOrDefault);
|
||||||
|
|
||||||
yield return Channel.Parse(threadJson, parent);
|
if (filteredChannels.Contains(parent))
|
||||||
|
yield return Channel.Parse(threadJson, parent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archived threads
|
// Archived threads
|
||||||
if (includeArchived)
|
if (includeArchived)
|
||||||
{
|
{
|
||||||
foreach (var channel in channels)
|
foreach (var channel in filteredChannels)
|
||||||
{
|
{
|
||||||
foreach (var archiveType in new[] { "public", "private" })
|
foreach (var archiveType in new[] { "public", "private" })
|
||||||
{
|
{
|
||||||
@@ -448,73 +564,6 @@ public class DiscordClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<Role> GetGuildRolesAsync(
|
|
||||||
Snowflake guildId,
|
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (guildId == Guild.DirectMessages.Id)
|
|
||||||
yield break;
|
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken);
|
|
||||||
foreach (var roleJson in response.EnumerateArray())
|
|
||||||
yield return Role.Parse(roleJson);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<Member?> TryGetGuildMemberAsync(
|
|
||||||
Snowflake guildId,
|
|
||||||
Snowflake memberId,
|
|
||||||
CancellationToken cancellationToken = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (guildId == Guild.DirectMessages.Id)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var response = await TryGetJsonResponseAsync(
|
|
||||||
$"guilds/{guildId}/members/{memberId}",
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
return response?.Pipe(j => Member.Parse(j, guildId));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<Invite?> TryGetInviteAsync(
|
|
||||||
string code,
|
|
||||||
CancellationToken cancellationToken = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var response = await TryGetJsonResponseAsync($"invites/{code}", cancellationToken);
|
|
||||||
return response?.Pipe(Invite.Parse);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask<Channel> GetChannelAsync(
|
|
||||||
Snowflake channelId,
|
|
||||||
CancellationToken cancellationToken = default
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
|
|
||||||
|
|
||||||
var parentId = response
|
|
||||||
.GetPropertyOrNull("parent_id")
|
|
||||||
?.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)
|
|
||||||
{
|
|
||||||
return Channel.Parse(response);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||||
Snowflake channelId,
|
Snowflake channelId,
|
||||||
Snowflake? before = null,
|
Snowflake? before = null,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -40,17 +41,19 @@ public partial class DataDump
|
|||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
using var archive = ZipFile.OpenRead(zipFilePath);
|
await using var archive = await ZipFile.OpenReadAsync(zipFilePath, cancellationToken);
|
||||||
|
|
||||||
var entry = archive.GetEntry("messages/index.json");
|
// Use case-insensitive search to accommodate for different data dump versions
|
||||||
if (entry is null)
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1459
|
||||||
{
|
var entry =
|
||||||
throw new InvalidOperationException(
|
archive.Entries.FirstOrDefault(e =>
|
||||||
|
e.FullName.Equals("messages/index.json", StringComparison.OrdinalIgnoreCase)
|
||||||
|
)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
"Failed to locate the channel index inside the data package."
|
"Failed to locate the channel index inside the data package."
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
await using var stream = entry.Open();
|
await using var stream = await entry.OpenAsync(cancellationToken);
|
||||||
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
|
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
|
||||||
|
|
||||||
return Parse(document.RootElement);
|
return Parse(document.RootElement);
|
||||||
|
|||||||
@@ -13,24 +13,26 @@ public enum RateLimitPreference
|
|||||||
|
|
||||||
public static class RateLimitPreferenceExtensions
|
public static class RateLimitPreferenceExtensions
|
||||||
{
|
{
|
||||||
internal static bool IsRespectedFor(
|
extension(RateLimitPreference rateLimitPreference)
|
||||||
this RateLimitPreference rateLimitPreference,
|
{
|
||||||
TokenKind tokenKind
|
internal bool IsRespectedFor(TokenKind tokenKind) =>
|
||||||
) =>
|
tokenKind switch
|
||||||
tokenKind switch
|
{
|
||||||
{
|
TokenKind.User => (rateLimitPreference & RateLimitPreference.RespectForUserTokens)
|
||||||
TokenKind.User => (rateLimitPreference & RateLimitPreference.RespectForUserTokens) != 0,
|
!= 0,
|
||||||
TokenKind.Bot => (rateLimitPreference & RateLimitPreference.RespectForBotTokens) != 0,
|
TokenKind.Bot => (rateLimitPreference & RateLimitPreference.RespectForBotTokens)
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(tokenKind)),
|
!= 0,
|
||||||
};
|
_ => throw new ArgumentOutOfRangeException(nameof(tokenKind)),
|
||||||
|
};
|
||||||
|
|
||||||
public static string GetDisplayName(this RateLimitPreference rateLimitPreference) =>
|
public string GetDisplayName() =>
|
||||||
rateLimitPreference switch
|
rateLimitPreference switch
|
||||||
{
|
{
|
||||||
RateLimitPreference.IgnoreAll => "Always ignore",
|
RateLimitPreference.IgnoreAll => "Always ignore",
|
||||||
RateLimitPreference.RespectForUserTokens => "Respect for user tokens",
|
RateLimitPreference.RespectForUserTokens => "Respect for user tokens",
|
||||||
RateLimitPreference.RespectForBotTokens => "Respect for bot tokens",
|
RateLimitPreference.RespectForBotTokens => "Respect for bot tokens",
|
||||||
RateLimitPreference.RespectAll => "Always respect",
|
RateLimitPreference.RespectAll => "Always respect",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(rateLimitPreference)),
|
_ => throw new ArgumentOutOfRangeException(nameof(rateLimitPreference)),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AsyncKeyedLock" Version="7.1.6" />
|
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
<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="Gress" Version="2.1.1" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="Polly" Version="8.5.2" />
|
<PackageReference Include="Polly" Version="8.6.5" />
|
||||||
<PackageReference Include="RazorBlade" Version="0.9.0" />
|
<PackageReference Include="RazorBlade" Version="0.11.0" />
|
||||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
<PackageReference Include="Superpower" Version="3.1.0" />
|
||||||
<PackageReference Include="WebMarkupMin.Core" Version="2.17.0" />
|
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||||
<PackageReference Include="YoutubeExplode" Version="6.5.4" />
|
<PackageReference Include="YoutubeExplode" Version="6.5.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ internal partial class ExportAssetDownloader
|
|||||||
|
|
||||||
return SHA256
|
return SHA256
|
||||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||||
.ToHex()
|
.Pipe(Convert.ToHexStringLower)
|
||||||
// 5 chars ought to be enough for anybody
|
// 5 chars ought to be enough for anybody
|
||||||
.Truncate(5);
|
.Truncate(5);
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ internal partial class ExportAssetDownloader
|
|||||||
fileExtension = "";
|
fileExtension = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return PathEx.EscapeFileName(
|
return Path.EscapeFileName(
|
||||||
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
|
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace DiscordChatExporter.Core.Exporting;
|
|||||||
internal class ExportContext(DiscordClient discord, ExportRequest request)
|
internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||||
{
|
{
|
||||||
private readonly Dictionary<Snowflake, Member?> _membersById = new();
|
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 Dictionary<Snowflake, Role> _rolesById = new();
|
||||||
|
|
||||||
private readonly ExportAssetDownloader _assetDownloader = 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
|
// Because members cannot be pulled in bulk, we need to populate them on demand
|
||||||
private async ValueTask PopulateMemberAsync(
|
private async ValueTask PopulateMemberAsync(
|
||||||
Snowflake id,
|
Snowflake id,
|
||||||
@@ -99,7 +114,8 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
|||||||
?.RoleIds.Select(TryGetRole)
|
?.RoleIds.Select(TryGetRole)
|
||||||
.WhereNotNull()
|
.WhereNotNull()
|
||||||
.OrderByDescending(r => r.Position)
|
.OrderByDescending(r => r.Position)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
public Color? TryGetUserColor(Snowflake id) =>
|
public Color? TryGetUserColor(Snowflake id) =>
|
||||||
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
|
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
|
||||||
|
|||||||
@@ -13,25 +13,28 @@ public enum ExportFormat
|
|||||||
|
|
||||||
public static class ExportFormatExtensions
|
public static class ExportFormatExtensions
|
||||||
{
|
{
|
||||||
public static string GetFileExtension(this ExportFormat format) =>
|
extension(ExportFormat format)
|
||||||
format switch
|
{
|
||||||
{
|
public string GetFileExtension() =>
|
||||||
ExportFormat.PlainText => "txt",
|
format switch
|
||||||
ExportFormat.HtmlDark => "html",
|
{
|
||||||
ExportFormat.HtmlLight => "html",
|
ExportFormat.PlainText => "txt",
|
||||||
ExportFormat.Csv => "csv",
|
ExportFormat.HtmlDark => "html",
|
||||||
ExportFormat.Json => "json",
|
ExportFormat.HtmlLight => "html",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
ExportFormat.Csv => "csv",
|
||||||
};
|
ExportFormat.Json => "json",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||||
|
};
|
||||||
|
|
||||||
public static string GetDisplayName(this ExportFormat format) =>
|
public string GetDisplayName() =>
|
||||||
format switch
|
format switch
|
||||||
{
|
{
|
||||||
ExportFormat.PlainText => "TXT",
|
ExportFormat.PlainText => "TXT",
|
||||||
ExportFormat.HtmlDark => "HTML (Dark)",
|
ExportFormat.HtmlDark => "HTML (Dark)",
|
||||||
ExportFormat.HtmlLight => "HTML (Light)",
|
ExportFormat.HtmlLight => "HTML (Light)",
|
||||||
ExportFormat.Csv => "CSV",
|
ExportFormat.Csv => "CSV",
|
||||||
ExportFormat.Json => "JSON",
|
ExportFormat.Json => "JSON",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ using DiscordChatExporter.Core.Discord;
|
|||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
using DiscordChatExporter.Core.Utils;
|
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting;
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
@@ -145,7 +144,7 @@ public partial class ExportRequest
|
|||||||
// File extension
|
// File extension
|
||||||
buffer.Append('.').Append(format.GetFileExtension());
|
buffer.Append('.').Append(format.GetFileExtension());
|
||||||
|
|
||||||
return PathEx.EscapeFileName(buffer.ToString());
|
return Path.EscapeFileName(buffer.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatPath(
|
private static string FormatPath(
|
||||||
@@ -159,7 +158,7 @@ public partial class ExportRequest
|
|||||||
path,
|
path,
|
||||||
"%.",
|
"%.",
|
||||||
m =>
|
m =>
|
||||||
PathEx.EscapeFileName(
|
Path.EscapeFileName(
|
||||||
m.Value switch
|
m.Value switch
|
||||||
{
|
{
|
||||||
"%g" => guild.Id.ToString(),
|
"%g" => guild.Id.ToString(),
|
||||||
@@ -179,7 +178,8 @@ public partial class ExportRequest
|
|||||||
?? "",
|
?? "",
|
||||||
"%b" => before
|
"%b" => before
|
||||||
?.ToDate()
|
?.ToDate()
|
||||||
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "",
|
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||||
|
?? "",
|
||||||
"%d" => DateTimeOffset.Now.ToString(
|
"%d" => DateTimeOffset.Now.ToString(
|
||||||
"yyyy-MM-dd",
|
"yyyy-MM-dd",
|
||||||
CultureInfo.InvariantCulture
|
CultureInfo.InvariantCulture
|
||||||
|
|||||||
@@ -270,6 +270,12 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
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 channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
||||||
var symbol = channel?.IsVoice == true ? "🔊" : "#";
|
var symbol = channel?.IsVoice == true ? "🔊" : "#";
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|||||||
@@ -8,14 +8,20 @@ internal static class HtmlMessageExtensions
|
|||||||
{
|
{
|
||||||
// Message content is hidden if it's a link to an embedded media
|
// Message content is hidden if it's a link to an embedded media
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
|
||||||
public static bool IsContentHidden(this Message message)
|
extension(Message message)
|
||||||
{
|
{
|
||||||
if (message.Embeds.Count != 1)
|
public bool IsContentHidden()
|
||||||
return false;
|
{
|
||||||
|
if (message.Embeds.Count != 1)
|
||||||
|
return false;
|
||||||
|
|
||||||
var embed = message.Embeds[0];
|
var embed = message.Embeds[0];
|
||||||
|
|
||||||
return string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase)
|
return string.Equals(
|
||||||
&& embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
message.Content.Trim(),
|
||||||
|
embed.Url,
|
||||||
|
StringComparison.OrdinalIgnoreCase
|
||||||
|
) && embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("iconCanonicalUrl", embedAuthor.IconUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
@@ -160,6 +162,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("canonicalUrl", embedImage.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteNumber("width", embedImage.Width);
|
_writer.WriteNumber("width", embedImage.Width);
|
||||||
@@ -185,6 +189,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("canonicalUrl", embedVideo.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteNumber("width", embedVideo.Width);
|
_writer.WriteNumber("width", embedVideo.Width);
|
||||||
@@ -212,6 +218,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("iconCanonicalUrl", embedFooter.IconUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
|||||||
@@ -388,7 +388,7 @@
|
|||||||
<div class="chatlog__embed-author-container">
|
<div class="chatlog__embed-author-container">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||||
@@ -424,9 +424,11 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* Video player *@
|
@* Video thumbnail *@
|
||||||
<div class="chatlog__embed-youtube-container">
|
<div class="chatlog__embed-youtube-container">
|
||||||
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
|
<a href="@youTubeVideoEmbed.Url">
|
||||||
|
<img class="chatlog__embed-youtube-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)" alt="YouTube video thumbnail" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@(embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)">
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -441,9 +443,12 @@
|
|||||||
embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ??
|
embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ??
|
||||||
embed.Url;
|
embed.Url;
|
||||||
|
|
||||||
|
var embedImageCanonicalUrl =
|
||||||
|
embed.Image?.Url ?? embed.Thumbnail?.Url ?? embed.Url;
|
||||||
|
|
||||||
<div class="chatlog__embed">
|
<div class="chatlog__embed">
|
||||||
<a href="@await ResolveAssetUrlAsync(embedImageUrl)">
|
<a href="@await ResolveAssetUrlAsync(embedImageUrl)">
|
||||||
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy">
|
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy" data-canonical-url="@embedImageCanonicalUrl">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -457,8 +462,10 @@
|
|||||||
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
||||||
embed.Url;
|
embed.Url;
|
||||||
|
|
||||||
|
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
|
||||||
|
|
||||||
<div class="chatlog__embed">
|
<div class="chatlog__embed">
|
||||||
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls>
|
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls data-canonical-url="@embedVideoCanonicalUrl">
|
||||||
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
|
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
@@ -470,8 +477,10 @@
|
|||||||
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
||||||
embed.Url;
|
embed.Url;
|
||||||
|
|
||||||
|
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
|
||||||
|
|
||||||
<div class="chatlog__embed">
|
<div class="chatlog__embed">
|
||||||
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()">
|
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()" data-canonical-url="@embedVideoCanonicalUrl">
|
||||||
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv">
|
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv">
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,7 +508,7 @@
|
|||||||
<div class="chatlog__embed-author-container">
|
<div class="chatlog__embed-author-container">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||||
@@ -574,7 +583,7 @@
|
|||||||
{
|
{
|
||||||
<div class="chatlog__embed-thumbnail-container">
|
<div class="chatlog__embed-thumbnail-container">
|
||||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
||||||
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
|
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy" data-canonical-url="@embed.Thumbnail.Url">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -590,7 +599,7 @@
|
|||||||
{
|
{
|
||||||
<div class="chatlog__embed-image-container">
|
<div class="chatlog__embed-image-container">
|
||||||
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
|
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
|
||||||
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
|
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy" data-canonical-url="@image.Url">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -605,7 +614,7 @@
|
|||||||
@* Footer icon *@
|
@* Footer icon *@
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
|
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy" data-canonical-url="@embed.Footer.IconUrl">
|
||||||
}
|
}
|
||||||
|
|
||||||
<span class="chatlog__embed-footer-text">
|
<span class="chatlog__embed-footer-text">
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
|
|||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
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 channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|
||||||
|
|||||||
@@ -7,37 +7,40 @@ namespace DiscordChatExporter.Core.Exporting;
|
|||||||
|
|
||||||
internal static class PlainTextMessageExtensions
|
internal static class PlainTextMessageExtensions
|
||||||
{
|
{
|
||||||
public static string GetFallbackContent(this Message message) =>
|
extension(Message message)
|
||||||
message.Kind switch
|
{
|
||||||
{
|
public string GetFallbackContent() =>
|
||||||
MessageKind.RecipientAdd => message.MentionedUsers.Any()
|
message.Kind switch
|
||||||
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
{
|
||||||
: "Added a recipient.",
|
MessageKind.RecipientAdd => message.MentionedUsers.Any()
|
||||||
|
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
||||||
|
: "Added a recipient.",
|
||||||
|
|
||||||
MessageKind.RecipientRemove => message.MentionedUsers.Any()
|
MessageKind.RecipientRemove => message.MentionedUsers.Any()
|
||||||
? message.Author.Id == message.MentionedUsers.First().Id
|
? message.Author.Id == message.MentionedUsers.First().Id
|
||||||
? "Left the group."
|
? "Left the group."
|
||||||
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
|
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
|
||||||
: "Removed a recipient.",
|
: "Removed a recipient.",
|
||||||
|
|
||||||
MessageKind.Call =>
|
MessageKind.Call =>
|
||||||
$"Started a call that lasted {
|
$"Started a call that lasted {
|
||||||
message
|
message
|
||||||
.CallEndedTimestamp?
|
.CallEndedTimestamp?
|
||||||
.Pipe(t => t - message.Timestamp)
|
.Pipe(t => t - message.Timestamp)
|
||||||
.Pipe(t => t.TotalMinutes)
|
.Pipe(t => t.TotalMinutes)
|
||||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||||
} minutes.",
|
} minutes.",
|
||||||
|
|
||||||
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
|
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
|
||||||
? $"Changed the channel name: {message.Content}"
|
? $"Changed the channel name: {message.Content}"
|
||||||
: "Changed the channel name.",
|
: "Changed the channel name.",
|
||||||
|
|
||||||
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
||||||
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
||||||
MessageKind.ThreadCreated => "Started a thread.",
|
MessageKind.ThreadCreated => "Started a thread.",
|
||||||
MessageKind.GuildMemberJoin => "Joined the server.",
|
MessageKind.GuildMemberJoin => "Joined the server.",
|
||||||
|
|
||||||
_ => message.Content,
|
_ => message.Content,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -711,9 +711,11 @@
|
|||||||
margin-top: 0.6rem;
|
margin-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-youtube {
|
.chatlog__embed-youtube-thumbnail {
|
||||||
border: 0;
|
max-width: 400px;
|
||||||
|
max-height: 225px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__sticker {
|
.chatlog__sticker {
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class AsyncCollectionExtensions
|
public static class AsyncCollectionExtensions
|
||||||
{
|
{
|
||||||
private static async ValueTask<IReadOnlyList<T>> CollectAsync<T>(
|
extension<T>(IAsyncEnumerable<T> asyncEnumerable)
|
||||||
this IAsyncEnumerable<T> asyncEnumerable
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var list = new List<T>();
|
private async ValueTask<IReadOnlyList<T>> CollectAsync()
|
||||||
|
{
|
||||||
|
var list = new List<T>();
|
||||||
|
|
||||||
await foreach (var i in asyncEnumerable)
|
await foreach (var i in asyncEnumerable)
|
||||||
list.Add(i);
|
list.Add(i);
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter() =>
|
||||||
|
asyncEnumerable.CollectAsync().GetAwaiter();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
|
|
||||||
this IAsyncEnumerable<T> asyncEnumerable
|
|
||||||
) => asyncEnumerable.CollectAsync().GetAwaiter();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
|
||||||
|
|
||||||
public static class BinaryExtensions
|
|
||||||
{
|
|
||||||
public static string ToHex(this byte[] data, bool isUpperCase = true)
|
|
||||||
{
|
|
||||||
var buffer = new StringBuilder(2 * data.Length);
|
|
||||||
|
|
||||||
foreach (var b in data)
|
|
||||||
{
|
|
||||||
buffer.Append(b.ToString(isUpperCase ? "X2" : "x2", CultureInfo.InvariantCulture));
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,25 +4,34 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class CollectionExtensions
|
public static class CollectionExtensions
|
||||||
{
|
{
|
||||||
public static IEnumerable<T> ToSingletonEnumerable<T>(this T obj)
|
extension<T>(T obj)
|
||||||
{
|
{
|
||||||
yield return obj;
|
public IEnumerable<T> ToSingletonEnumerable()
|
||||||
|
{
|
||||||
|
yield return obj;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IEnumerable<(T value, int index)> WithIndex<T>(this IEnumerable<T> source)
|
extension<T>(IEnumerable<T> source)
|
||||||
{
|
{
|
||||||
var i = 0;
|
public IEnumerable<(T value, int index)> WithIndex()
|
||||||
foreach (var o in source)
|
{
|
||||||
yield return (o, i++);
|
var i = 0;
|
||||||
|
foreach (var o in source)
|
||||||
|
yield return (o, i++);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source)
|
extension<T>(IEnumerable<T?> source)
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
foreach (var o in source)
|
public IEnumerable<T> WhereNotNull()
|
||||||
{
|
{
|
||||||
if (o is not null)
|
foreach (var o in source)
|
||||||
yield return o;
|
{
|
||||||
|
if (o is not null)
|
||||||
|
yield return o;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class ColorExtensions
|
public static class ColorExtensions
|
||||||
{
|
{
|
||||||
public static Color WithAlpha(this Color color, int alpha) => Color.FromArgb(alpha, color);
|
extension(Color color)
|
||||||
|
{
|
||||||
|
public Color WithAlpha(int alpha) => Color.FromArgb(alpha, color);
|
||||||
|
|
||||||
public static Color ResetAlpha(this Color color) => color.WithAlpha(255);
|
public Color ResetAlpha() => color.WithAlpha(255);
|
||||||
|
|
||||||
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
|
public int ToRgb() => color.ToArgb() & 0xffffff;
|
||||||
|
|
||||||
public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
public string ToHex() => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,27 +5,30 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class ExceptionExtensions
|
public static class ExceptionExtensions
|
||||||
{
|
{
|
||||||
private static void PopulateChildren(this Exception exception, ICollection<Exception> children)
|
extension(Exception exception)
|
||||||
{
|
{
|
||||||
if (exception is AggregateException aggregateException)
|
private void PopulateChildren(ICollection<Exception> children)
|
||||||
{
|
{
|
||||||
foreach (var innerException in aggregateException.InnerExceptions)
|
if (exception is AggregateException aggregateException)
|
||||||
{
|
{
|
||||||
children.Add(innerException);
|
foreach (var innerException in aggregateException.InnerExceptions)
|
||||||
PopulateChildren(innerException, children);
|
{
|
||||||
|
children.Add(innerException);
|
||||||
|
PopulateChildren(innerException, children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (exception.InnerException is not null)
|
||||||
|
{
|
||||||
|
children.Add(exception.InnerException);
|
||||||
|
PopulateChildren(exception.InnerException, children);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (exception.InnerException is not null)
|
|
||||||
|
public IReadOnlyList<Exception> GetSelfAndChildren()
|
||||||
{
|
{
|
||||||
children.Add(exception.InnerException);
|
var children = new List<Exception> { exception };
|
||||||
PopulateChildren(exception.InnerException, children);
|
PopulateChildren(exception, children);
|
||||||
|
return children;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IReadOnlyList<Exception> GetSelfAndChildren(this Exception exception)
|
|
||||||
{
|
|
||||||
var children = new List<Exception> { exception };
|
|
||||||
PopulateChildren(exception, children);
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class GenericExtensions
|
public static class GenericExtensions
|
||||||
{
|
{
|
||||||
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) =>
|
extension<TIn>(TIn input)
|
||||||
transform(input);
|
{
|
||||||
|
public TOut Pipe<TOut>(Func<TIn, TOut> transform) => transform(input);
|
||||||
|
}
|
||||||
|
|
||||||
public static T? NullIf<T>(this T value, Func<T, bool> predicate)
|
extension<T>(T value)
|
||||||
where T : struct => !predicate(value) ? value : null;
|
where T : struct
|
||||||
|
{
|
||||||
|
public T? NullIf(Func<T, bool> predicate) => !predicate(value) ? value : null;
|
||||||
|
|
||||||
public static T? NullIfDefault<T>(this T value)
|
public T? NullIfDefault() =>
|
||||||
where T : struct => value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
|
value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class HttpExtensions
|
public static class HttpExtensions
|
||||||
{
|
{
|
||||||
public static string? TryGetValue(this HttpHeaders headers, string name) =>
|
extension(HttpHeaders headers)
|
||||||
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
|
{
|
||||||
|
public string? TryGetValue(string name) =>
|
||||||
|
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
|
public static class PathExtensions
|
||||||
|
{
|
||||||
|
// This is a union of invalid characters from Windows (NTFS/FAT32), Linux (ext4/XFS), and macOS (HFS+/APFS).
|
||||||
|
// We use this instead of Path.GetInvalidFileNameChars() because that only returns OS-specific characters,
|
||||||
|
// not filesystem-specific characters. It's possible to use, for example, an NTFS drive on Linux,
|
||||||
|
// which would make some additional characters invalid that are otherwise valid on Linux.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1452
|
||||||
|
private static readonly char[] InvalidFileNameChars =
|
||||||
|
[
|
||||||
|
'\0', // Null character - invalid on all filesystems
|
||||||
|
'/', // Path separator on Unix and Windows
|
||||||
|
'\\', // Path separator on Windows
|
||||||
|
':', // Reserved on Windows (drive letters, NTFS streams)
|
||||||
|
'*', // Wildcard on Windows
|
||||||
|
'?', // Wildcard on Windows
|
||||||
|
'"', // Reserved on Windows
|
||||||
|
'<', // Redirection on Windows
|
||||||
|
'>', // Redirection on Windows
|
||||||
|
'|', // Pipe on Windows
|
||||||
|
];
|
||||||
|
|
||||||
|
extension(Path)
|
||||||
|
{
|
||||||
|
public static string EscapeFileName(string path)
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder(path.Length);
|
||||||
|
|
||||||
|
foreach (var c in path)
|
||||||
|
buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_');
|
||||||
|
|
||||||
|
// File names cannot end with a dot on Windows
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
while (buffer.Length > 0 && buffer[^1] == '.')
|
||||||
|
buffer.Remove(buffer.Length - 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,34 @@
|
|||||||
using System;
|
using System.Text;
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
public static class StringExtensions
|
public static class StringExtensions
|
||||||
{
|
{
|
||||||
public static string? NullIfWhiteSpace(this string str) =>
|
extension(string str)
|
||||||
!string.IsNullOrWhiteSpace(str) ? str : null;
|
|
||||||
|
|
||||||
public static string Truncate(this string str, int charCount) =>
|
|
||||||
str.Length > charCount ? str[..charCount] : str;
|
|
||||||
|
|
||||||
public static string ToSpaceSeparatedWords(this string str)
|
|
||||||
{
|
{
|
||||||
var builder = new StringBuilder(str.Length * 2);
|
public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(str) ? str : null;
|
||||||
|
|
||||||
foreach (var c in str)
|
public string Truncate(int charCount) => str.Length > charCount ? str[..charCount] : str;
|
||||||
|
|
||||||
|
public string ToSpaceSeparatedWords()
|
||||||
{
|
{
|
||||||
if (char.IsUpper(c) && builder.Length > 0)
|
var builder = new StringBuilder(str.Length * 2);
|
||||||
builder.Append(' ');
|
|
||||||
|
|
||||||
builder.Append(c);
|
foreach (var c in str)
|
||||||
|
{
|
||||||
|
if (char.IsUpper(c) && builder.Length > 0)
|
||||||
|
builder.Append(' ');
|
||||||
|
|
||||||
|
builder.Append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true)
|
extension(StringBuilder builder)
|
||||||
where T : struct, Enum => Enum.TryParse<T>(str, ignoreCase, out var result) ? result : null;
|
{
|
||||||
|
public StringBuilder AppendIfNotEmpty(char value) =>
|
||||||
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
|
builder.Length > 0 ? builder.Append(value) : builder;
|
||||||
builder.Length > 0 ? builder.Append(value) : builder;
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,21 +7,21 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class SuperpowerExtensions
|
public static class SuperpowerExtensions
|
||||||
{
|
{
|
||||||
public static TextParser<string> Text(this TextParser<char[]> parser) =>
|
extension<T>(TextParser<T> parser)
|
||||||
parser.Select(chars => new string(chars));
|
{
|
||||||
|
public TextParser<T> Token() =>
|
||||||
|
parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany());
|
||||||
|
|
||||||
public static TextParser<T> Token<T>(this TextParser<T> parser) =>
|
// Only used for debugging while writing Superpower parsers.
|
||||||
parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany());
|
// From https://twitter.com/nblumhardt/status/1389349059786264578
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
// Only used for debugging while writing Superpower parsers.
|
public TextParser<T> Log(string description) =>
|
||||||
// From https://twitter.com/nblumhardt/status/1389349059786264578
|
i =>
|
||||||
[ExcludeFromCodeCoverage]
|
{
|
||||||
public static TextParser<T> Log<T>(this TextParser<T> parser, string description) =>
|
Console.WriteLine($"Trying {description} ->");
|
||||||
i =>
|
var r = parser(i);
|
||||||
{
|
Console.WriteLine($"Result was {r}");
|
||||||
Console.WriteLine($"Trying {description} ->");
|
return r;
|
||||||
var r = parser(i);
|
};
|
||||||
Console.WriteLine($"Result was {r}");
|
}
|
||||||
return r;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class TimeSpanExtensions
|
public static class TimeSpanExtensions
|
||||||
{
|
{
|
||||||
public static TimeSpan Clamp(this TimeSpan value, TimeSpan min, TimeSpan max)
|
extension(TimeSpan value)
|
||||||
{
|
{
|
||||||
if (value < min)
|
public TimeSpan Clamp(TimeSpan min, TimeSpan max)
|
||||||
return min;
|
{
|
||||||
|
if (value < min)
|
||||||
|
return min;
|
||||||
|
|
||||||
if (value > max)
|
if (value > max)
|
||||||
return max;
|
return max;
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Utils;
|
|
||||||
|
|
||||||
public static class PathEx
|
|
||||||
{
|
|
||||||
private static readonly HashSet<char> InvalidFileNameChars =
|
|
||||||
[
|
|
||||||
.. Path.GetInvalidFileNameChars(),
|
|
||||||
];
|
|
||||||
|
|
||||||
public static string EscapeFileName(string path)
|
|
||||||
{
|
|
||||||
var buffer = new StringBuilder(path.Length);
|
|
||||||
|
|
||||||
foreach (var c in path)
|
|
||||||
buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_');
|
|
||||||
|
|
||||||
// File names cannot end with a dot on Windows
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
while (buffer.Length > 0 && buffer[^1] == '.')
|
|
||||||
buffer.Remove(buffer.Length - 1, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Utils;
|
namespace DiscordChatExporter.Core.Utils;
|
||||||
@@ -10,6 +11,19 @@ public static class Url
|
|||||||
var buffer = new StringBuilder();
|
var buffer = new StringBuilder();
|
||||||
var position = 0;
|
var position = 0;
|
||||||
|
|
||||||
|
// For absolute paths, prepend file:// protocol for proper browser handling
|
||||||
|
if (Path.IsPathFullyQualified(filePath))
|
||||||
|
{
|
||||||
|
buffer.Append("file://");
|
||||||
|
|
||||||
|
// On Windows, we need to add an extra slash before the drive letter
|
||||||
|
// e.g., file:///C:/path instead of file://C:/path
|
||||||
|
if (!filePath.StartsWith('/') && !filePath.StartsWith('\\'))
|
||||||
|
{
|
||||||
|
buffer.Append('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
if (position >= filePath.Length)
|
if (position >= filePath.Length)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
xmlns:materialControls="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
xmlns:materialControls="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
||||||
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||||
xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
|
xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
|
||||||
|
Name="DiscordChatExporter"
|
||||||
ActualThemeVariantChanged="Application_OnActualThemeVariantChanged">
|
ActualThemeVariantChanged="Application_OnActualThemeVariantChanged">
|
||||||
<Application.DataTemplates>
|
<Application.DataTemplates>
|
||||||
<framework:ViewManager />
|
<framework:ViewManager />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Avalonia.Markup.Xaml;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
|
using DiscordChatExporter.Gui.Localization;
|
||||||
using DiscordChatExporter.Gui.Services;
|
using DiscordChatExporter.Gui.Services;
|
||||||
using DiscordChatExporter.Gui.Utils;
|
using DiscordChatExporter.Gui.Utils;
|
||||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||||
@@ -39,6 +40,9 @@ public class App : Application, IDisposable
|
|||||||
services.AddSingleton<SettingsService>();
|
services.AddSingleton<SettingsService>();
|
||||||
services.AddSingleton<UpdateService>();
|
services.AddSingleton<UpdateService>();
|
||||||
|
|
||||||
|
// Localization
|
||||||
|
services.AddSingleton<LocalizationManager>();
|
||||||
|
|
||||||
// View models
|
// View models
|
||||||
services.AddTransient<MainViewModel>();
|
services.AddTransient<MainViewModel>();
|
||||||
services.AddTransient<DashboardViewModel>();
|
services.AddTransient<DashboardViewModel>();
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Controls.Documents;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
void RenderParagraph(ParagraphBlock 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RenderListBlock(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 } p)
|
||||||
|
foreach (var markdownInline in p.Inline)
|
||||||
|
ProcessInline(inlines, markdownInline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var block in Markdown.Parse(text, MarkdownPipeline))
|
||||||
|
{
|
||||||
|
switch (block)
|
||||||
|
{
|
||||||
|
case ParagraphBlock { Inline: not null } paragraph:
|
||||||
|
RenderParagraph(paragraph);
|
||||||
|
break;
|
||||||
|
case ListBlock list:
|
||||||
|
RenderListBlock(list);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inlines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(
|
||||||
|
object? value,
|
||||||
|
Type targetType,
|
||||||
|
object? parameter,
|
||||||
|
CultureInfo culture
|
||||||
|
) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<AssemblyName>DiscordChatExporter</AssemblyName>
|
<AssemblyName>DiscordChatExporter</AssemblyName>
|
||||||
@@ -9,24 +8,39 @@
|
|||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublishMacOSBundle>false</PublishMacOSBundle>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
|
||||||
|
<EnableAotAnalyzer>false</EnableAotAnalyzer>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" />
|
<AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.3.0" />
|
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.5.0" />
|
||||||
<PackageReference Include="Avalonia" Version="11.3.0" />
|
<PackageReference Include="Avalonia" Version="11.3.11" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.11" />
|
||||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.0" Condition="'$(Configuration)' == 'Debug'" />
|
<PackageReference
|
||||||
|
Include="Avalonia.Diagnostics"
|
||||||
|
Version="11.3.11"
|
||||||
|
Condition="'$(Configuration)' == 'Debug'"
|
||||||
|
/>
|
||||||
<PackageReference Include="Cogwheel" Version="2.1.0" />
|
<PackageReference Include="Cogwheel" Version="2.1.0" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||||
<PackageReference Include="DialogHost.Avalonia" Version="0.9.2" />
|
<PackageReference Include="DialogHost.Avalonia" Version="0.10.4" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" Version="2.1.1" />
|
||||||
|
<PackageReference Include="Markdig" Version="1.0.0" />
|
||||||
<PackageReference Include="Material.Avalonia" Version="3.9.2" />
|
<PackageReference Include="Material.Avalonia" Version="3.9.2" />
|
||||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.2.0" />
|
<PackageReference Include="Material.Icons.Avalonia" Version="2.2.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||||
<PackageReference Include="Onova" Version="2.6.13" />
|
<PackageReference Include="Onova" Version="2.6.13" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -35,7 +49,9 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<Target Name="PublishMacOSBundle" AfterTargets="Publish" Condition="$(PublishMacOSBundle)">
|
<Target Name="PublishMacOSBundle" AfterTargets="Publish" Condition="$(PublishMacOSBundle)">
|
||||||
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Publish-MacOSBundle.ps1 -PublishDirPath $(PublishDir) -IconsFilePath $(ProjectDir)/../favicon.icns -FullVersion $(Version) -ShortVersion $(AssemblyVersion)" LogStandardErrorAsError="true" />
|
<Exec
|
||||||
|
Command="pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Publish-MacOSBundle.ps1 -PublishDirPath $(PublishDir) -IconsFilePath $(ProjectDir)/../favicon.icns -FullVersion $(Version) -ShortVersion $(AssemblyVersion)"
|
||||||
|
LogStandardErrorAsError="true"
|
||||||
|
/>
|
||||||
</Target>
|
</Target>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ 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;
|
return dialog.DialogResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +64,7 @@ public class DialogManager : IDisposable
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return file?.Path.LocalPath;
|
return file?.TryGetLocalPath() ?? file?.Path.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> PromptDirectoryPathAsync(string defaultDirPath = "")
|
public async Task<string?> PromptDirectoryPathAsync(string defaultDirPath = "")
|
||||||
@@ -69,19 +73,21 @@ public class DialogManager : IDisposable
|
|||||||
Application.Current?.ApplicationLifetime?.TryGetTopLevel()
|
Application.Current?.ApplicationLifetime?.TryGetTopLevel()
|
||||||
?? throw new ApplicationException("Could not find the top-level visual element.");
|
?? throw new ApplicationException("Could not find the top-level visual element.");
|
||||||
|
|
||||||
var startLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(
|
var result = await topLevel.StorageProvider.OpenFolderPickerAsync(
|
||||||
defaultDirPath
|
|
||||||
);
|
|
||||||
|
|
||||||
var folderPickResult = await topLevel.StorageProvider.OpenFolderPickerAsync(
|
|
||||||
new FolderPickerOpenOptions
|
new FolderPickerOpenOptions
|
||||||
{
|
{
|
||||||
AllowMultiple = false,
|
AllowMultiple = false,
|
||||||
SuggestedStartLocation = startLocation,
|
SuggestedStartLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(
|
||||||
|
defaultDirPath
|
||||||
|
),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return folderPickResult.FirstOrDefault()?.Path.LocalPath;
|
var directory = result.FirstOrDefault();
|
||||||
|
if (directory is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return directory.TryGetLocalPath() ?? directory.Path.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => _dialogLock.Dispose();
|
public void Dispose() => _dialogLock.Dispose();
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace DiscordChatExporter.Gui.Localization;
|
||||||
|
|
||||||
|
public enum Language
|
||||||
|
{
|
||||||
|
System,
|
||||||
|
English,
|
||||||
|
Ukrainian,
|
||||||
|
German,
|
||||||
|
French,
|
||||||
|
Spanish,
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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(TokenBotIntro)] =
|
||||||
|
"The token is generated during bot creation. If you lost it, generate a new one:",
|
||||||
|
[nameof(TokenBotStep1)] = "1. Open Discord",
|
||||||
|
[nameof(TokenBotInstructions)] = """
|
||||||
|
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(TokenDeveloperPortalLinkText)] = "developer portal",
|
||||||
|
[nameof(TokenDocumentationLinkText)] = "documentation",
|
||||||
|
[nameof(TokenHelpText)] = "If you have questions or issues, please refer to the",
|
||||||
|
// 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",
|
||||||
|
[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(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(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(ErrorPullingServersTitle)] = "Error pulling servers",
|
||||||
|
[nameof(ErrorPullingChannelsTitle)] = "Error pulling channels",
|
||||||
|
[nameof(ErrorExportingTitle)] = "Error exporting channel(s)",
|
||||||
|
[nameof(SuccessfulExportMessage)] = "Successfully exported {0} channel(s)",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
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(TokenBotIntro)] =
|
||||||
|
"Le token est généré lors de la création du bot. Si vous l'avez perdu, générez-en un nouveau :",
|
||||||
|
[nameof(TokenBotStep1)] = "1. Ouvrez Discord",
|
||||||
|
[nameof(TokenBotInstructions)] = """
|
||||||
|
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(TokenDeveloperPortalLinkText)] = "portail développeur",
|
||||||
|
[nameof(TokenDocumentationLinkText)] = "documentation",
|
||||||
|
[nameof(TokenHelpText)] = "Pour les questions ou problèmes, veuillez consulter la",
|
||||||
|
// 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",
|
||||||
|
[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(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(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(ErrorPullingServersTitle)] = "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,138 @@
|
|||||||
|
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(TokenBotIntro)] =
|
||||||
|
"Der Token wird bei der Bot-Erstellung generiert. Falls er verloren gegangen ist, generieren Sie einen neuen:",
|
||||||
|
[nameof(TokenBotStep1)] = "1. Öffnen Sie Discord",
|
||||||
|
[nameof(TokenBotInstructions)] = """
|
||||||
|
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(TokenDeveloperPortalLinkText)] = "Entwicklerportal",
|
||||||
|
[nameof(TokenDocumentationLinkText)] = "Dokumentation",
|
||||||
|
[nameof(TokenHelpText)] = "Bei Fragen oder Problemen lesen Sie die",
|
||||||
|
// 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",
|
||||||
|
[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(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(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(ErrorPullingServersTitle)] = "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,132 @@
|
|||||||
|
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(TokenBotIntro)] =
|
||||||
|
"El token se genera al crear el bot. Si lo perdiste, genera uno nuevo:",
|
||||||
|
[nameof(TokenBotStep1)] = "1. Abre Discord",
|
||||||
|
[nameof(TokenBotInstructions)] = """
|
||||||
|
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(TokenDeveloperPortalLinkText)] = "portal de desarrolladores",
|
||||||
|
[nameof(TokenDocumentationLinkText)] = "documentación",
|
||||||
|
[nameof(TokenHelpText)] = "Si tienes preguntas o problemas, consulta la",
|
||||||
|
// 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",
|
||||||
|
[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(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(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(ErrorPullingServersTitle)] = "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,131 @@
|
|||||||
|
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(TokenBotIntro)] =
|
||||||
|
"Токен генерується під час створення бота. Якщо ви його втратили, згенеруйте новий:",
|
||||||
|
[nameof(TokenBotStep1)] = "1. Відкрийте Discord",
|
||||||
|
[nameof(TokenBotInstructions)] = """
|
||||||
|
2. Відкрийте налаштування вашого застосунку
|
||||||
|
3. Перейдіть до розділу **Bot** ліворуч
|
||||||
|
4. В розділі **Token** натисніть **Reset Token**
|
||||||
|
5. Натисніть **Yes, do it!** та підтвердьте
|
||||||
|
* Інтеграції, що використовують попередній токен, перестануть працювати
|
||||||
|
* Ваш бот повинен мати включений **Message Content Intent** для читання повідомлень
|
||||||
|
""",
|
||||||
|
[nameof(TokenDeveloperPortalLinkText)] = "портал розробника",
|
||||||
|
[nameof(TokenDocumentationLinkText)] = "документацію",
|
||||||
|
[nameof(TokenHelpText)] = "Якщо у вас є запитання або проблеми, зверніться до",
|
||||||
|
// 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(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(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(ErrorPullingServersTitle)] = "Помилка завантаження серверів",
|
||||||
|
[nameof(ErrorPullingChannelsTitle)] = "Помилка завантаження каналів",
|
||||||
|
[nameof(ErrorExportingTitle)] = "Помилка експорту каналу(-ів)",
|
||||||
|
[nameof(SuccessfulExportMessage)] = "Успішно експортовано {0} канал(-ів)",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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 TokenBotIntro => Get();
|
||||||
|
public string TokenBotStep1 => Get();
|
||||||
|
public string TokenBotInstructions => Get();
|
||||||
|
public string TokenDeveloperPortalLinkText => Get();
|
||||||
|
public string TokenDocumentationLinkText => 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 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 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 ErrorPullingServersTitle => Get();
|
||||||
|
public string ErrorPullingChannelsTitle => Get();
|
||||||
|
public string ErrorExportingTitle => Get();
|
||||||
|
public string SuccessfulExportMessage => Get();
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Exporting;
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
|
using DiscordChatExporter.Gui.Localization;
|
||||||
using DiscordChatExporter.Gui.Models;
|
using DiscordChatExporter.Gui.Models;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.Services;
|
namespace DiscordChatExporter.Gui.Services;
|
||||||
@@ -23,6 +24,9 @@ public partial class SettingsService()
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial ThemeVariant Theme { get; set; }
|
public partial ThemeVariant Theme { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial Language Language { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial bool IsAutoUpdateEnabled { get; set; } = true;
|
public partial bool IsAutoUpdateEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
|||||||
@@ -6,28 +6,31 @@ namespace DiscordChatExporter.Gui.Utils.Extensions;
|
|||||||
|
|
||||||
internal static class AvaloniaExtensions
|
internal static class AvaloniaExtensions
|
||||||
{
|
{
|
||||||
public static Window? TryGetMainWindow(this IApplicationLifetime lifetime) =>
|
extension(IApplicationLifetime lifetime)
|
||||||
lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime
|
|
||||||
? desktopLifetime.MainWindow
|
|
||||||
: null;
|
|
||||||
|
|
||||||
public static TopLevel? TryGetTopLevel(this IApplicationLifetime lifetime) =>
|
|
||||||
lifetime.TryGetMainWindow()
|
|
||||||
?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel;
|
|
||||||
|
|
||||||
public static bool TryShutdown(this IApplicationLifetime lifetime, int exitCode = 0)
|
|
||||||
{
|
{
|
||||||
if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)
|
public Window? TryGetMainWindow() =>
|
||||||
{
|
lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime
|
||||||
return desktopLifetime.TryShutdown(exitCode);
|
? desktopLifetime.MainWindow
|
||||||
}
|
: null;
|
||||||
|
|
||||||
if (lifetime is IControlledApplicationLifetime controlledLifetime)
|
public TopLevel? TryGetTopLevel() =>
|
||||||
{
|
lifetime.TryGetMainWindow()
|
||||||
controlledLifetime.Shutdown(exitCode);
|
?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
public bool TryShutdown(int exitCode = 0)
|
||||||
|
{
|
||||||
|
if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)
|
||||||
|
{
|
||||||
|
return desktopLifetime.TryShutdown(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lifetime is IControlledApplicationLifetime controlledLifetime)
|
||||||
|
{
|
||||||
|
controlledLifetime.Shutdown(exitCode);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,26 @@ namespace DiscordChatExporter.Gui.Utils.Extensions;
|
|||||||
|
|
||||||
internal static class DisposableExtensions
|
internal static class DisposableExtensions
|
||||||
{
|
{
|
||||||
public static void DisposeAll(this IEnumerable<IDisposable> disposables)
|
extension(IEnumerable<IDisposable> disposables)
|
||||||
{
|
{
|
||||||
var exceptions = default(List<Exception>);
|
public void DisposeAll()
|
||||||
|
|
||||||
foreach (var disposable in disposables)
|
|
||||||
{
|
{
|
||||||
try
|
var exceptions = default(List<Exception>);
|
||||||
{
|
|
||||||
disposable.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
(exceptions ??= []).Add(ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exceptions?.Any() == true)
|
foreach (var disposable in disposables)
|
||||||
throw new AggregateException(exceptions);
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
(exceptions ??= []).Add(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exceptions?.Any() == true)
|
||||||
|
throw new AggregateException(exceptions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,50 +7,47 @@ namespace DiscordChatExporter.Gui.Utils.Extensions;
|
|||||||
|
|
||||||
internal static class NotifyPropertyChangedExtensions
|
internal static class NotifyPropertyChangedExtensions
|
||||||
{
|
{
|
||||||
public static IDisposable WatchProperty<TOwner, TProperty>(
|
extension<TOwner>(TOwner owner)
|
||||||
this TOwner owner,
|
|
||||||
Expression<Func<TOwner, TProperty>> propertyExpression,
|
|
||||||
Action callback,
|
|
||||||
bool watchInitialValue = false
|
|
||||||
)
|
|
||||||
where TOwner : INotifyPropertyChanged
|
where TOwner : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
var memberExpression = propertyExpression.Body as MemberExpression;
|
public IDisposable WatchProperty<TProperty>(
|
||||||
if (memberExpression?.Member is not PropertyInfo property)
|
Expression<Func<TOwner, TProperty>> propertyExpression,
|
||||||
throw new ArgumentException("Provided expression must reference a property.");
|
Action callback,
|
||||||
|
bool watchInitialValue = false
|
||||||
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
|
)
|
||||||
{
|
{
|
||||||
if (
|
var memberExpression = propertyExpression.Body as MemberExpression;
|
||||||
string.IsNullOrWhiteSpace(args.PropertyName)
|
if (memberExpression?.Member is not PropertyInfo property)
|
||||||
|| string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal)
|
throw new ArgumentException("Provided expression must reference a property.");
|
||||||
)
|
|
||||||
|
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
|
||||||
{
|
{
|
||||||
callback();
|
if (
|
||||||
|
string.IsNullOrWhiteSpace(args.PropertyName)
|
||||||
|
|| string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
callback();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
owner.PropertyChanged += OnPropertyChanged;
|
||||||
|
|
||||||
|
if (watchInitialValue)
|
||||||
|
callback();
|
||||||
|
|
||||||
|
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
owner.PropertyChanged += OnPropertyChanged;
|
public IDisposable WatchAllProperties(Action callback, bool watchInitialValues = false)
|
||||||
|
{
|
||||||
|
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback();
|
||||||
|
owner.PropertyChanged += OnPropertyChanged;
|
||||||
|
|
||||||
if (watchInitialValue)
|
if (watchInitialValues)
|
||||||
callback();
|
callback();
|
||||||
|
|
||||||
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
|
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IDisposable WatchAllProperties<TOwner>(
|
|
||||||
this TOwner owner,
|
|
||||||
Action callback,
|
|
||||||
bool watchInitialValues = false
|
|
||||||
)
|
|
||||||
where TOwner : INotifyPropertyChanged
|
|
||||||
{
|
|
||||||
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback();
|
|
||||||
owner.PropertyChanged += OnPropertyChanged;
|
|
||||||
|
|
||||||
if (watchInitialValues)
|
|
||||||
callback();
|
|
||||||
|
|
||||||
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Utils.Extensions;
|
||||||
|
|
||||||
|
internal static class ProcessExtensions
|
||||||
|
{
|
||||||
|
extension(Process)
|
||||||
|
{
|
||||||
|
public static void StartShellExecute(string path)
|
||||||
|
{
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true };
|
||||||
|
|
||||||
|
process.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.Utils;
|
|
||||||
|
|
||||||
internal static class ProcessEx
|
|
||||||
{
|
|
||||||
public static void StartShellExecute(string path)
|
|
||||||
{
|
|
||||||
using var process = new Process();
|
|
||||||
process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true };
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -12,6 +13,7 @@ using DiscordChatExporter.Core.Exceptions;
|
|||||||
using DiscordChatExporter.Core.Exporting;
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
|
using DiscordChatExporter.Gui.Localization;
|
||||||
using DiscordChatExporter.Gui.Models;
|
using DiscordChatExporter.Gui.Models;
|
||||||
using DiscordChatExporter.Gui.Services;
|
using DiscordChatExporter.Gui.Services;
|
||||||
using DiscordChatExporter.Gui.Utils;
|
using DiscordChatExporter.Gui.Utils;
|
||||||
@@ -37,13 +39,15 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
ViewModelManager viewModelManager,
|
ViewModelManager viewModelManager,
|
||||||
DialogManager dialogManager,
|
DialogManager dialogManager,
|
||||||
SnackbarManager snackbarManager,
|
SnackbarManager snackbarManager,
|
||||||
SettingsService settingsService
|
SettingsService settingsService,
|
||||||
|
LocalizationManager localizationManager
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
_viewModelManager = viewModelManager;
|
_viewModelManager = viewModelManager;
|
||||||
_dialogManager = dialogManager;
|
_dialogManager = dialogManager;
|
||||||
_snackbarManager = snackbarManager;
|
_snackbarManager = snackbarManager;
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
|
LocalizationManager = localizationManager;
|
||||||
|
|
||||||
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
|
_progressMuxer = Progress.CreateMuxer().WithAutoReset();
|
||||||
|
|
||||||
@@ -69,6 +73,8 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
||||||
public partial bool IsBusy { get; set; }
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
|
public LocalizationManager LocalizationManager { get; }
|
||||||
|
|
||||||
public ProgressContainer<Percentage> Progress { get; } = new();
|
public ProgressContainer<Percentage> Progress { get; } = new();
|
||||||
|
|
||||||
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
|
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
|
||||||
@@ -102,7 +108,7 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
|
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ShowHelp() => ProcessEx.StartShellExecute(Program.ProjectDocumentationUrl);
|
private void ShowHelp() => Process.StartShellExecute(Program.ProjectDocumentationUrl);
|
||||||
|
|
||||||
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
|
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
|
||||||
|
|
||||||
@@ -140,7 +146,7 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var dialog = _viewModelManager.CreateMessageBoxViewModel(
|
var dialog = _viewModelManager.CreateMessageBoxViewModel(
|
||||||
"Error pulling guilds",
|
LocalizationManager.ErrorPullingServersTitle,
|
||||||
ex.ToString()
|
ex.ToString()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -207,7 +213,7 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var dialog = _viewModelManager.CreateMessageBoxViewModel(
|
var dialog = _viewModelManager.CreateMessageBoxViewModel(
|
||||||
"Error pulling channels",
|
LocalizationManager.ErrorPullingChannelsTitle,
|
||||||
ex.ToString()
|
ex.ToString()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -302,14 +308,17 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
if (successfulExportCount > 0)
|
if (successfulExportCount > 0)
|
||||||
{
|
{
|
||||||
_snackbarManager.Notify(
|
_snackbarManager.Notify(
|
||||||
$"Successfully exported {successfulExportCount} channel(s)"
|
string.Format(
|
||||||
|
LocalizationManager.SuccessfulExportMessage,
|
||||||
|
successfulExportCount
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var dialog = _viewModelManager.CreateMessageBoxViewModel(
|
var dialog = _viewModelManager.CreateMessageBoxViewModel(
|
||||||
"Error exporting channel(s)",
|
LocalizationManager.ErrorExportingTitle,
|
||||||
ex.ToString()
|
ex.ToString()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -322,11 +331,11 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenDiscord() => ProcessEx.StartShellExecute("https://discord.com/app");
|
private void OpenDiscord() => Process.StartShellExecute("https://discord.com/app");
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenDiscordDeveloperPortal() =>
|
private void OpenDiscordDeveloperPortal() =>
|
||||||
ProcessEx.StartShellExecute("https://discord.com/developers/applications");
|
Process.StartShellExecute("https://discord.com/developers/applications");
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,15 +12,19 @@ using DiscordChatExporter.Core.Exporting.Filtering;
|
|||||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
|
using DiscordChatExporter.Gui.Localization;
|
||||||
using DiscordChatExporter.Gui.Services;
|
using DiscordChatExporter.Gui.Services;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
|
namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
|
||||||
|
|
||||||
public partial class ExportSetupViewModel(
|
public partial class ExportSetupViewModel(
|
||||||
DialogManager dialogManager,
|
DialogManager dialogManager,
|
||||||
SettingsService settingsService
|
SettingsService settingsService,
|
||||||
|
LocalizationManager localizationManager
|
||||||
) : DialogViewModelBase
|
) : DialogViewModelBase
|
||||||
{
|
{
|
||||||
|
public LocalizationManager LocalizationManager { get; } = localizationManager;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial Guild? Guild { get; set; }
|
public partial Guild? Guild { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
|
using DiscordChatExporter.Gui.Localization;
|
||||||
using DiscordChatExporter.Gui.Models;
|
using DiscordChatExporter.Gui.Models;
|
||||||
using DiscordChatExporter.Gui.Services;
|
using DiscordChatExporter.Gui.Services;
|
||||||
using DiscordChatExporter.Gui.Utils;
|
using DiscordChatExporter.Gui.Utils;
|
||||||
@@ -16,13 +17,19 @@ public class SettingsViewModel : DialogViewModelBase
|
|||||||
|
|
||||||
private readonly DisposableCollector _eventRoot = new();
|
private readonly DisposableCollector _eventRoot = new();
|
||||||
|
|
||||||
public SettingsViewModel(SettingsService settingsService)
|
public SettingsViewModel(
|
||||||
|
SettingsService settingsService,
|
||||||
|
LocalizationManager localizationManager
|
||||||
|
)
|
||||||
{
|
{
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
|
LocalizationManager = localizationManager;
|
||||||
|
|
||||||
_eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged));
|
_eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LocalizationManager LocalizationManager { get; }
|
||||||
|
|
||||||
public IReadOnlyList<ThemeVariant> AvailableThemes { get; } = Enum.GetValues<ThemeVariant>();
|
public IReadOnlyList<ThemeVariant> AvailableThemes { get; } = Enum.GetValues<ThemeVariant>();
|
||||||
|
|
||||||
public ThemeVariant Theme
|
public ThemeVariant Theme
|
||||||
@@ -31,6 +38,14 @@ public class SettingsViewModel : DialogViewModelBase
|
|||||||
set => _settingsService.Theme = value;
|
set => _settingsService.Theme = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<Language> AvailableLanguages { get; } = Enum.GetValues<Language>();
|
||||||
|
|
||||||
|
public Language Language
|
||||||
|
{
|
||||||
|
get => _settingsService.Language;
|
||||||
|
set => _settingsService.Language = value;
|
||||||
|
}
|
||||||
|
|
||||||
public bool IsAutoUpdateEnabled
|
public bool IsAutoUpdateEnabled
|
||||||
{
|
{
|
||||||
get => _settingsService.IsAutoUpdateEnabled;
|
get => _settingsService.IsAutoUpdateEnabled;
|
||||||
@@ -63,40 +78,40 @@ public class SettingsViewModel : DialogViewModelBase
|
|||||||
|
|
||||||
// These items have to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
|
// These items have to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
|
||||||
public IReadOnlyList<string> AvailableLocales { get; } =
|
public IReadOnlyList<string> AvailableLocales { get; } =
|
||||||
[
|
[
|
||||||
// Current locale (maps to null downstream)
|
// Current locale (maps to null downstream)
|
||||||
"",
|
"",
|
||||||
// Locales supported by the Discord app
|
// Locales supported by the Discord app
|
||||||
"da-DK",
|
"da-DK",
|
||||||
"de-DE",
|
"de-DE",
|
||||||
"en-GB",
|
"en-GB",
|
||||||
"en-US",
|
"en-US",
|
||||||
"es-ES",
|
"es-ES",
|
||||||
"fr-FR",
|
"fr-FR",
|
||||||
"hr-HR",
|
"hr-HR",
|
||||||
"it-IT",
|
"it-IT",
|
||||||
"lt-LT",
|
"lt-LT",
|
||||||
"hu-HU",
|
"hu-HU",
|
||||||
"nl-NL",
|
"nl-NL",
|
||||||
"no-NO",
|
"no-NO",
|
||||||
"pl-PL",
|
"pl-PL",
|
||||||
"pt-BR",
|
"pt-BR",
|
||||||
"ro-RO",
|
"ro-RO",
|
||||||
"fi-FI",
|
"fi-FI",
|
||||||
"sv-SE",
|
"sv-SE",
|
||||||
"vi-VN",
|
"vi-VN",
|
||||||
"tr-TR",
|
"tr-TR",
|
||||||
"cs-CZ",
|
"cs-CZ",
|
||||||
"el-GR",
|
"el-GR",
|
||||||
"bg-BG",
|
"bg-BG",
|
||||||
"ru-RU",
|
"ru-RU",
|
||||||
"uk-UA",
|
"uk-UA",
|
||||||
"th-TH",
|
"th-TH",
|
||||||
"zh-CN",
|
"zh-CN",
|
||||||
"ja-JP",
|
"ja-JP",
|
||||||
"zh-TW",
|
"zh-TW",
|
||||||
"ko-KR",
|
"ko-KR",
|
||||||
];
|
];
|
||||||
|
|
||||||
// This has to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
|
// This has to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
|
||||||
public string Locale
|
public string Locale
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ using System.Threading.Tasks;
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
|
using DiscordChatExporter.Gui.Localization;
|
||||||
using DiscordChatExporter.Gui.Services;
|
using DiscordChatExporter.Gui.Services;
|
||||||
using DiscordChatExporter.Gui.Utils;
|
|
||||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||||
using DiscordChatExporter.Gui.ViewModels.Components;
|
using DiscordChatExporter.Gui.ViewModels.Components;
|
||||||
|
|
||||||
@@ -16,7 +16,8 @@ public partial class MainViewModel(
|
|||||||
DialogManager dialogManager,
|
DialogManager dialogManager,
|
||||||
SnackbarManager snackbarManager,
|
SnackbarManager snackbarManager,
|
||||||
SettingsService settingsService,
|
SettingsService settingsService,
|
||||||
UpdateService updateService
|
UpdateService updateService,
|
||||||
|
LocalizationManager localizationManager
|
||||||
) : ViewModelBase
|
) : ViewModelBase
|
||||||
{
|
{
|
||||||
public string Title { get; } = $"{Program.Name} v{Program.VersionString}";
|
public string Title { get; } = $"{Program.Name} v{Program.VersionString}";
|
||||||
@@ -29,14 +30,10 @@ public partial class MainViewModel(
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var dialog = viewModelManager.CreateMessageBoxViewModel(
|
var dialog = viewModelManager.CreateMessageBoxViewModel(
|
||||||
"Thank you for supporting Ukraine!",
|
localizationManager.UkraineSupportTitle,
|
||||||
"""
|
localizationManager.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.
|
localizationManager.LearnMoreButton,
|
||||||
|
localizationManager.CloseButton
|
||||||
Click LEARN MORE to find ways that you can help.
|
|
||||||
""",
|
|
||||||
"LEARN MORE",
|
|
||||||
"CLOSE"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Disable this message in the future
|
// Disable this message in the future
|
||||||
@@ -44,7 +41,7 @@ public partial class MainViewModel(
|
|||||||
settingsService.Save();
|
settingsService.Save();
|
||||||
|
|
||||||
if (await dialogManager.ShowDialogAsync(dialog) == true)
|
if (await dialogManager.ShowDialogAsync(dialog) == true)
|
||||||
ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter");
|
Process.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ShowDevelopmentBuildMessageAsync()
|
private async Task ShowDevelopmentBuildMessageAsync()
|
||||||
@@ -57,20 +54,14 @@ public partial class MainViewModel(
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var dialog = viewModelManager.CreateMessageBoxViewModel(
|
var dialog = viewModelManager.CreateMessageBoxViewModel(
|
||||||
"Unstable build warning",
|
localizationManager.UnstableBuildTitle,
|
||||||
$"""
|
string.Format(localizationManager.UnstableBuildMessage, Program.Name),
|
||||||
You're using a development build of {Program.Name}. These builds are not thoroughly tested and may contain bugs.
|
localizationManager.SeeReleasesButton,
|
||||||
|
localizationManager.CloseButton
|
||||||
Auto-updates are disabled for development builds.
|
|
||||||
|
|
||||||
Click SEE RELEASES if you want to download a stable release instead.
|
|
||||||
""",
|
|
||||||
"SEE RELEASES",
|
|
||||||
"CLOSE"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (await dialogManager.ShowDialogAsync(dialog) == true)
|
if (await dialogManager.ShowDialogAsync(dialog) == true)
|
||||||
ProcessEx.StartShellExecute(Program.ProjectReleasesUrl);
|
Process.StartShellExecute(Program.ProjectReleasesUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CheckForUpdatesAsync()
|
private async Task CheckForUpdatesAsync()
|
||||||
@@ -81,12 +72,18 @@ public partial class MainViewModel(
|
|||||||
if (updateVersion is null)
|
if (updateVersion is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
snackbarManager.Notify($"Downloading update to {Program.Name} v{updateVersion}...");
|
snackbarManager.Notify(
|
||||||
|
string.Format(
|
||||||
|
localizationManager.UpdateDownloadingMessage,
|
||||||
|
Program.Name,
|
||||||
|
updateVersion
|
||||||
|
)
|
||||||
|
);
|
||||||
await updateService.PrepareUpdateAsync(updateVersion);
|
await updateService.PrepareUpdateAsync(updateVersion);
|
||||||
|
|
||||||
snackbarManager.Notify(
|
snackbarManager.Notify(
|
||||||
"Update has been downloaded and will be installed when you exit",
|
localizationManager.UpdateReadyMessage,
|
||||||
"INSTALL NOW",
|
localizationManager.UpdateInstallNowButton,
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
updateService.FinalizeUpdate(true);
|
updateService.FinalizeUpdate(true);
|
||||||
@@ -99,7 +96,7 @@ public partial class MainViewModel(
|
|||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Failure to update shouldn't crash the application
|
// Failure to update shouldn't crash the application
|
||||||
snackbarManager.Notify("Failed to perform application update");
|
snackbarManager.Notify(localizationManager.UpdateFailedMessage);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
RevealPassword="{Binding $self.IsFocused}"
|
RevealPassword="{Binding $self.IsFocused}"
|
||||||
Text="{Binding Token}"
|
Text="{Binding Token}"
|
||||||
Theme="{DynamicResource SoloTextBox}"
|
Theme="{DynamicResource SoloTextBox}"
|
||||||
Watermark="Token">
|
Watermark="{Binding LocalizationManager.TokenWatermark}">
|
||||||
<TextBox.InnerLeftContent>
|
<TextBox.InnerLeftContent>
|
||||||
<materialIcons:MaterialIcon
|
<materialIcons:MaterialIcon
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
Command="{Binding PullGuildsCommand}"
|
Command="{Binding PullGuildsCommand}"
|
||||||
IsDefault="True"
|
IsDefault="True"
|
||||||
Theme="{DynamicResource MaterialFlatButton}"
|
Theme="{DynamicResource MaterialFlatButton}"
|
||||||
ToolTip.Tip="Pull available servers and channels (Enter)">
|
ToolTip.Tip="{Binding LocalizationManager.PullGuildsTooltip}">
|
||||||
<materialIcons:MaterialIcon
|
<materialIcons:MaterialIcon
|
||||||
Width="24"
|
Width="24"
|
||||||
Height="24"
|
Height="24"
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
Command="{Binding ShowSettingsCommand}"
|
Command="{Binding ShowSettingsCommand}"
|
||||||
Foreground="{DynamicResource MaterialDarkForegroundBrush}"
|
Foreground="{DynamicResource MaterialDarkForegroundBrush}"
|
||||||
Theme="{DynamicResource MaterialFlatButton}"
|
Theme="{DynamicResource MaterialFlatButton}"
|
||||||
ToolTip.Tip="Settings">
|
ToolTip.Tip="{Binding LocalizationManager.SettingsTooltip}">
|
||||||
<materialIcons:MaterialIcon
|
<materialIcons:MaterialIcon
|
||||||
Width="24"
|
Width="24"
|
||||||
Height="24"
|
Height="24"
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
<Setter Property="ToolTip.Tip">
|
<Setter Property="ToolTip.Tip">
|
||||||
<Template>
|
<Template>
|
||||||
<TextBlock>
|
<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}" />
|
<Run FontWeight="SemiBold" Text="{Binding Channel.LastMessageId, Converter={x:Static converters:SnowflakeToTimestampStringConverter.Instance}, TargetNullValue=never, Mode=OneWay}" />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
</Template>
|
</Template>
|
||||||
@@ -219,172 +219,68 @@
|
|||||||
<!-- Placeholder / usage instructions -->
|
<!-- Placeholder / usage instructions -->
|
||||||
<Panel IsVisible="{Binding !AvailableGuilds.Count}">
|
<Panel IsVisible="{Binding !AvailableGuilds.Count}">
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
|
||||||
<TextBlock
|
<StackPanel Margin="32,16" Spacing="0">
|
||||||
Margin="32,16"
|
<StackPanel.Styles>
|
||||||
FontSize="14"
|
<Style Selector="TextBlock">
|
||||||
FontWeight="Light"
|
<Setter Property="FontSize" Value="14" />
|
||||||
LineHeight="23">
|
<Setter Property="FontWeight" Value="Light" />
|
||||||
|
<Setter Property="LineHeight" Value="23" />
|
||||||
|
<Setter Property="TextWrapping" Value="Wrap" />
|
||||||
|
</Style>
|
||||||
|
</StackPanel.Styles>
|
||||||
|
|
||||||
<!-- User token -->
|
<!-- User token -->
|
||||||
<InlineUIContainer>
|
<TextBlock>
|
||||||
<materialIcons:MaterialIcon
|
<InlineUIContainer>
|
||||||
Width="18"
|
<materialIcons:MaterialIcon
|
||||||
Height="18"
|
Width="18"
|
||||||
Margin="0,-2,0,0"
|
Height="18"
|
||||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
Margin="0,-2,0,0"
|
||||||
Kind="Account" />
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
</InlineUIContainer>
|
Kind="Account" />
|
||||||
<Run BaselineAlignment="Center" Text="" />
|
</InlineUIContainer>
|
||||||
<Run
|
<Run Text=" " />
|
||||||
BaselineAlignment="Center"
|
<Run
|
||||||
FontSize="16"
|
FontSize="16"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Text="To get the token for your personal account:" />
|
Text="{Binding LocalizationManager.TokenPersonalHeader}" />
|
||||||
<LineBreak />
|
</TextBlock>
|
||||||
|
|
||||||
<Run BaselineAlignment="Center" Text="* Automating user accounts is technically against TOS —" />
|
<TextBlock Inlines="{Binding LocalizationManager.TokenPersonalTosWarning, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" />
|
||||||
<Run
|
|
||||||
BaselineAlignment="Center"
|
|
||||||
FontWeight="SemiBold"
|
|
||||||
Text="use at your own risk" /><Run Text="!" />
|
|
||||||
<LineBreak />
|
|
||||||
|
|
||||||
<Run BaselineAlignment="Center" Text="1. Open Discord in your" />
|
<TextBlock Inlines="{Binding LocalizationManager.TokenPersonalInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" />
|
||||||
<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 />
|
|
||||||
|
|
||||||
<!-- Bot token -->
|
<!-- Bot token -->
|
||||||
<InlineUIContainer>
|
<TextBlock Margin="0,12,0,0">
|
||||||
<materialIcons:MaterialIcon
|
<InlineUIContainer>
|
||||||
Width="18"
|
<materialIcons:MaterialIcon
|
||||||
Height="18"
|
Width="18"
|
||||||
Margin="0,-2,0,0"
|
Height="18"
|
||||||
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
Margin="0,-2,0,0"
|
||||||
Kind="Robot" />
|
Foreground="{DynamicResource PrimaryHueMidBrush}"
|
||||||
</InlineUIContainer>
|
Kind="Robot" />
|
||||||
<Run BaselineAlignment="Center" Text="" />
|
</InlineUIContainer>
|
||||||
<Run
|
<Run Text=" " />
|
||||||
BaselineAlignment="Center"
|
<Run
|
||||||
FontSize="16"
|
FontSize="16"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Text="To get the token for your bot:" />
|
Text="{Binding LocalizationManager.TokenBotHeader}" />
|
||||||
<LineBreak />
|
</TextBlock>
|
||||||
|
|
||||||
<Run BaselineAlignment="Center" Text="The token is generated during bot creation. If you lost it, generate a new one:" />
|
<TextBlock Text="{Binding LocalizationManager.TokenBotIntro}" />
|
||||||
<LineBreak />
|
|
||||||
|
|
||||||
<Run BaselineAlignment="Center" Text="1. Open Discord" />
|
<TextBlock>
|
||||||
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="developer portal" />
|
<Run Text="{Binding LocalizationManager.TokenBotStep1}" />
|
||||||
<LineBreak />
|
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="{Binding LocalizationManager.TokenDeveloperPortalLinkText}" />
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
<Run BaselineAlignment="Center" Text="2. Open your application's settings" />
|
<TextBlock Inlines="{Binding LocalizationManager.TokenBotInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" />
|
||||||
<LineBreak />
|
|
||||||
|
|
||||||
<Run BaselineAlignment="Center" Text="3. Navigate to the" />
|
<TextBlock Margin="0,12,0,0">
|
||||||
<Run
|
<Run Text="{Binding LocalizationManager.TokenHelpText}" />
|
||||||
BaselineAlignment="Center"
|
<controls:HyperLink Command="{Binding ShowHelpCommand}" Text="{Binding LocalizationManager.TokenDocumentationLinkText}" />
|
||||||
FontWeight="SemiBold"
|
</TextBlock>
|
||||||
Text="Bot" />
|
</StackPanel>
|
||||||
<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>
|
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
IsVisible="{Binding !IsSingleChannel}"
|
IsVisible="{Binding !IsSingleChannel}"
|
||||||
TextTrimming="CharacterEllipsis">
|
TextTrimming="CharacterEllipsis">
|
||||||
<Run Text="{Binding Channels.Count, FallbackValue=0, Mode=OneWay}" />
|
<Run Text="{Binding Channels.Count, FallbackValue=0, Mode=OneWay}" />
|
||||||
<Run Text="channels selected" />
|
<Run Text="{Binding LocalizationManager.ChannelsSelectedText}" />
|
||||||
</TextBlock>
|
</TextBlock>
|
||||||
|
|
||||||
<!-- Category and channel name (for single channel) -->
|
<!-- Category and channel name (for single channel) -->
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
<!-- Output path -->
|
<!-- Output path -->
|
||||||
<TextBox
|
<TextBox
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
materialAssists:TextFieldAssist.Label="Output path"
|
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.OutputPathLabel}"
|
||||||
Text="{Binding OutputPath}"
|
Text="{Binding OutputPath}"
|
||||||
Theme="{DynamicResource FilledTextBox}">
|
Theme="{DynamicResource FilledTextBox}">
|
||||||
<ToolTip.Tip>
|
<ToolTip.Tip>
|
||||||
@@ -146,11 +146,11 @@
|
|||||||
<!-- Format -->
|
<!-- Format -->
|
||||||
<ComboBox
|
<ComboBox
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
materialAssists:ComboBoxAssist.Label="Format"
|
materialAssists:ComboBoxAssist.Label="{Binding LocalizationManager.FormatLabel}"
|
||||||
ItemsSource="{Binding AvailableFormats}"
|
ItemsSource="{Binding AvailableFormats}"
|
||||||
SelectedItem="{Binding SelectedFormat}"
|
SelectedItem="{Binding SelectedFormat}"
|
||||||
Theme="{DynamicResource MaterialFilledComboBox}"
|
Theme="{DynamicResource MaterialFilledComboBox}"
|
||||||
ToolTip.Tip="Export format">
|
ToolTip.Tip="{Binding LocalizationManager.FormatTooltip}">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
<TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" />
|
<TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" />
|
||||||
@@ -166,9 +166,9 @@
|
|||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="16,8,8,8"
|
Margin="16,8,8,8"
|
||||||
materialAssists:TextFieldAssist.Label="After (date)"
|
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AfterDateLabel}"
|
||||||
SelectedDate="{Binding AfterDate}"
|
SelectedDate="{Binding AfterDate}"
|
||||||
ToolTip.Tip="Only include messages sent after this date">
|
ToolTip.Tip="{Binding LocalizationManager.AfterDateTooltip}">
|
||||||
<DatePicker.Styles>
|
<DatePicker.Styles>
|
||||||
<Style Selector="DatePicker">
|
<Style Selector="DatePicker">
|
||||||
<Style Selector="^ /template/ TextBox#DisplayTextBox">
|
<Style Selector="^ /template/ TextBox#DisplayTextBox">
|
||||||
@@ -181,9 +181,9 @@
|
|||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="8,8,16,8"
|
Margin="8,8,16,8"
|
||||||
materialAssists:TextFieldAssist.Label="Before (date)"
|
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.BeforeDateLabel}"
|
||||||
SelectedDate="{Binding BeforeDate}"
|
SelectedDate="{Binding BeforeDate}"
|
||||||
ToolTip.Tip="Only include messages sent before this date">
|
ToolTip.Tip="{Binding LocalizationManager.BeforeDateTooltip}">
|
||||||
<DatePicker.Styles>
|
<DatePicker.Styles>
|
||||||
<Style Selector="DatePicker">
|
<Style Selector="DatePicker">
|
||||||
<Style Selector="^ /template/ TextBox#DisplayTextBox">
|
<Style Selector="^ /template/ TextBox#DisplayTextBox">
|
||||||
@@ -198,11 +198,11 @@
|
|||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Margin="16,8,8,8"
|
Margin="16,8,8,8"
|
||||||
materialAssists:TextFieldAssist.Label="After (time)"
|
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AfterTimeLabel}"
|
||||||
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
|
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
|
||||||
IsEnabled="{Binding IsAfterDateSet}"
|
IsEnabled="{Binding IsAfterDateSet}"
|
||||||
SelectedTime="{Binding AfterTime}"
|
SelectedTime="{Binding AfterTime}"
|
||||||
ToolTip.Tip="Only include messages sent after this time">
|
ToolTip.Tip="{Binding LocalizationManager.AfterTimeTooltip}">
|
||||||
<TimePicker.Styles>
|
<TimePicker.Styles>
|
||||||
<Style Selector="TimePicker">
|
<Style Selector="TimePicker">
|
||||||
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
|
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
|
||||||
@@ -215,11 +215,11 @@
|
|||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="8,8,16,8"
|
Margin="8,8,16,8"
|
||||||
materialAssists:TextFieldAssist.Label="Before (time)"
|
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.BeforeTimeLabel}"
|
||||||
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
|
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
|
||||||
IsEnabled="{Binding IsBeforeDateSet}"
|
IsEnabled="{Binding IsBeforeDateSet}"
|
||||||
SelectedTime="{Binding BeforeTime}"
|
SelectedTime="{Binding BeforeTime}"
|
||||||
ToolTip.Tip="Only include messages sent before this time">
|
ToolTip.Tip="{Binding LocalizationManager.BeforeTimeTooltip}">
|
||||||
<TimePicker.Styles>
|
<TimePicker.Styles>
|
||||||
<Style Selector="TimePicker">
|
<Style Selector="TimePicker">
|
||||||
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
|
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
|
||||||
@@ -233,25 +233,25 @@
|
|||||||
<!-- Partitioning -->
|
<!-- Partitioning -->
|
||||||
<TextBox
|
<TextBox
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
materialAssists:TextFieldAssist.Label="Partition limit"
|
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.PartitionLimitLabel}"
|
||||||
Text="{Binding PartitionLimitValue}"
|
Text="{Binding PartitionLimitValue}"
|
||||||
Theme="{DynamicResource FilledTextBox}"
|
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 -->
|
<!-- Filtering -->
|
||||||
<TextBox
|
<TextBox
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
materialAssists:TextFieldAssist.Label="Message filter"
|
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.MessageFilterLabel}"
|
||||||
Text="{Binding MessageFilterValue}"
|
Text="{Binding MessageFilterValue}"
|
||||||
Theme="{DynamicResource FilledTextBox}"
|
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}" />
|
||||||
|
|
||||||
<!-- Markdown formatting -->
|
<!-- Markdown formatting -->
|
||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
LastChildFill="False"
|
LastChildFill="False"
|
||||||
ToolTip.Tip="Process markdown, mentions, and other special tokens">
|
ToolTip.Tip="{Binding LocalizationManager.FormatMarkdownTooltip}">
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Format markdown" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.FormatMarkdownLabel}" />
|
||||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldFormatMarkdown}" />
|
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldFormatMarkdown}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
@@ -259,8 +259,8 @@
|
|||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
LastChildFill="False"
|
LastChildFill="False"
|
||||||
ToolTip.Tip="Download assets referenced by the export (user avatars, attached files, embedded images, etc.)">
|
ToolTip.Tip="{Binding LocalizationManager.DownloadAssetsTooltip}">
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Download assets" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.DownloadAssetsLabel}" />
|
||||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldDownloadAssets}" />
|
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldDownloadAssets}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
@@ -269,19 +269,19 @@
|
|||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
IsEnabled="{Binding ShouldDownloadAssets}"
|
IsEnabled="{Binding ShouldDownloadAssets}"
|
||||||
LastChildFill="False"
|
LastChildFill="False"
|
||||||
ToolTip.Tip="Reuse previously downloaded assets to avoid redundant requests">
|
ToolTip.Tip="{Binding LocalizationManager.ReuseAssetsTooltip}">
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Reuse assets" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ReuseAssetsLabel}" />
|
||||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldReuseAssets}" />
|
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldReuseAssets}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Assets path -->
|
<!-- Assets path -->
|
||||||
<TextBox
|
<TextBox
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
materialAssists:TextFieldAssist.Label="Assets directory path"
|
materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AssetsDirPathLabel}"
|
||||||
IsEnabled="{Binding ShouldDownloadAssets}"
|
IsEnabled="{Binding ShouldDownloadAssets}"
|
||||||
Text="{Binding AssetsDirPath}"
|
Text="{Binding AssetsDirPath}"
|
||||||
Theme="{DynamicResource FilledTextBox}"
|
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>
|
<TextBox.InnerRightContent>
|
||||||
<Button
|
<Button
|
||||||
Margin="8,8,8,6"
|
Margin="8,8,8,6"
|
||||||
@@ -310,7 +310,7 @@
|
|||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
IsChecked="{Binding IsAdvancedSectionDisplayed}"
|
IsChecked="{Binding IsAdvancedSectionDisplayed}"
|
||||||
Theme="{DynamicResource MaterialOutlineButton}"
|
Theme="{DynamicResource MaterialOutlineButton}"
|
||||||
ToolTip.Tip="Toggle advanced options">
|
ToolTip.Tip="{Binding LocalizationManager.AdvancedOptionsTooltip}">
|
||||||
<Button.Styles>
|
<Button.Styles>
|
||||||
<Style Selector="ToggleButton">
|
<Style Selector="ToggleButton">
|
||||||
<Setter Property="Content" Value="MORE" />
|
<Setter Property="Content" Value="MORE" />
|
||||||
@@ -325,14 +325,14 @@
|
|||||||
<Button
|
<Button
|
||||||
Grid.Column="2"
|
Grid.Column="2"
|
||||||
Command="{Binding ConfirmCommand}"
|
Command="{Binding ConfirmCommand}"
|
||||||
Content="EXPORT"
|
Content="{Binding LocalizationManager.ExportButton}"
|
||||||
IsDefault="True"
|
IsDefault="True"
|
||||||
Theme="{DynamicResource MaterialOutlineButton}" />
|
Theme="{DynamicResource MaterialOutlineButton}" />
|
||||||
<Button
|
<Button
|
||||||
Grid.Column="3"
|
Grid.Column="3"
|
||||||
Margin="16,0,0,0"
|
Margin="16,0,0,0"
|
||||||
Command="{Binding CloseCommand}"
|
Command="{Binding CloseCommand}"
|
||||||
Content="CANCEL"
|
Content="{Binding LocalizationManager.CancelButton}"
|
||||||
IsCancel="True"
|
IsCancel="True"
|
||||||
Theme="{DynamicResource MaterialOutlineButton}" />
|
Theme="{DynamicResource MaterialOutlineButton}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
Margin="16"
|
Margin="16"
|
||||||
FontSize="19"
|
FontSize="19"
|
||||||
FontWeight="Light"
|
FontWeight="Light"
|
||||||
Text="Settings" />
|
Text="{Binding LocalizationManager.SettingsTitle}" />
|
||||||
|
|
||||||
<Border
|
<Border
|
||||||
Grid.Row="1"
|
Grid.Row="1"
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
LastChildFill="False"
|
LastChildFill="False"
|
||||||
ToolTip.Tip="Preferred user interface theme">
|
ToolTip.Tip="{Binding LocalizationManager.ThemeTooltip}">
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Theme" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ThemeLabel}" />
|
||||||
<ComboBox
|
<ComboBox
|
||||||
Width="150"
|
Width="150"
|
||||||
DockPanel.Dock="Right"
|
DockPanel.Dock="Right"
|
||||||
@@ -34,14 +34,27 @@
|
|||||||
SelectedItem="{Binding Theme}" />
|
SelectedItem="{Binding Theme}" />
|
||||||
</DockPanel>
|
</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 -->
|
<!-- Auto-updates -->
|
||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
IsVisible="{OnPlatform False,
|
IsVisible="{OnPlatform False,
|
||||||
Windows=True}"
|
Windows=True}"
|
||||||
LastChildFill="False"
|
LastChildFill="False"
|
||||||
ToolTip.Tip="Perform automatic updates on every launch">
|
ToolTip.Tip="{Binding LocalizationManager.AutoUpdateTooltip}">
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Auto-update" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.AutoUpdateLabel}" />
|
||||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsAutoUpdateEnabled}" />
|
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsAutoUpdateEnabled}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
@@ -49,8 +62,8 @@
|
|||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
LastChildFill="False"
|
LastChildFill="False"
|
||||||
ToolTip.Tip="Save the last used token to a file so that it can be persisted between sessions">
|
ToolTip.Tip="{Binding LocalizationManager.PersistTokenTooltip}">
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Persist token" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.PersistTokenLabel}" />
|
||||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
|
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
@@ -58,8 +71,8 @@
|
|||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
LastChildFill="False"
|
LastChildFill="False"
|
||||||
ToolTip.Tip="Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.">
|
ToolTip.Tip="{Binding LocalizationManager.RateLimitPreferenceTooltip}">
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Rate limit preference" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.RateLimitPreferenceLabel}" />
|
||||||
<ComboBox
|
<ComboBox
|
||||||
Width="150"
|
Width="150"
|
||||||
DockPanel.Dock="Right"
|
DockPanel.Dock="Right"
|
||||||
@@ -77,8 +90,8 @@
|
|||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
LastChildFill="False"
|
LastChildFill="False"
|
||||||
ToolTip.Tip="Which types of threads to show in the channel list">
|
ToolTip.Tip="{Binding LocalizationManager.ShowThreadsTooltip}">
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Show threads" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ShowThreadsLabel}" />
|
||||||
<ComboBox
|
<ComboBox
|
||||||
Width="150"
|
Width="150"
|
||||||
DockPanel.Dock="Right"
|
DockPanel.Dock="Right"
|
||||||
@@ -90,8 +103,8 @@
|
|||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
LastChildFill="False"
|
LastChildFill="False"
|
||||||
ToolTip.Tip="Locale to use when formatting dates and numbers">
|
ToolTip.Tip="{Binding LocalizationManager.LocaleTooltip}">
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Locale" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.LocaleLabel}" />
|
||||||
<ComboBox
|
<ComboBox
|
||||||
Width="150"
|
Width="150"
|
||||||
DockPanel.Dock="Right"
|
DockPanel.Dock="Right"
|
||||||
@@ -109,8 +122,8 @@
|
|||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
LastChildFill="False"
|
LastChildFill="False"
|
||||||
ToolTip.Tip="Normalize all timestamps to UTC+0">
|
ToolTip.Tip="{Binding LocalizationManager.NormalizeToUtcTooltip}">
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Normalize to UTC" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.NormalizeToUtcLabel}" />
|
||||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsUtcNormalizationEnabled}" />
|
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsUtcNormalizationEnabled}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
@@ -118,8 +131,8 @@
|
|||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
LastChildFill="False"
|
LastChildFill="False"
|
||||||
ToolTip.Tip="How many channels can be exported at the same time">
|
ToolTip.Tip="{Binding LocalizationManager.ParallelLimitTooltip}">
|
||||||
<TextBlock DockPanel.Dock="Left" Text="Parallel limit" />
|
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ParallelLimitLabel}" />
|
||||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||||
<TextBlock Margin="10,0" Text="{Binding ParallelLimit}" />
|
<TextBlock Margin="10,0" Text="{Binding ParallelLimit}" />
|
||||||
<Slider
|
<Slider
|
||||||
@@ -141,7 +154,7 @@
|
|||||||
Margin="16"
|
Margin="16"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
Command="{Binding CloseCommand}"
|
Command="{Binding CloseCommand}"
|
||||||
Content="CLOSE"
|
Content="{Binding LocalizationManager.CloseButton}"
|
||||||
IsCancel="True"
|
IsCancel="True"
|
||||||
IsDefault="True"
|
IsDefault="True"
|
||||||
Theme="{DynamicResource MaterialOutlineButton}" />
|
Theme="{DynamicResource MaterialOutlineButton}" />
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
|||||||
# Visual Studio Version 16
|
# Visual Studio Version 16
|
||||||
VisualStudioVersion = 16.0.28729.10
|
VisualStudioVersion = 16.0.28729.10
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
License.txt = License.txt
|
License.txt = License.txt
|
||||||
Readme.md = Readme.md
|
Readme.md = Readme.md
|
||||||
Directory.Build.props = Directory.Build.props
|
Directory.Build.props = Directory.Build.props
|
||||||
DiscordChatExporter.Cli.dockerfile = DiscordChatExporter.Cli.dockerfile
|
DiscordChatExporter.Cli.dockerfile = DiscordChatExporter.Cli.dockerfile
|
||||||
|
global.json = global.json
|
||||||
|
NuGet.config = NuGet.config
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2017-2025 Oleksii Holub
|
Copyright (c) 2017-2026 Oleksii Holub
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -50,10 +50,14 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
|
|||||||
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `discord-chat-exporter-cli`
|
- 📦 [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`
|
- 📦 [Nix](https://search.nixos.org/packages?query=discordchatexporter-cli): `discordchatexporter-cli`
|
||||||
|
|
||||||
> **Note**:
|
> [!IMPORTANT]
|
||||||
|
> To launch the GUI version of the app on MacOS, you 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.
|
> 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.
|
||||||
|
|
||||||
> **Note**:
|
> [!NOTE]
|
||||||
> AUR and Nix packages linked above are maintained by the community.
|
> 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 have any issues with them, please contact the corresponding maintainers.
|
||||||
|
|
||||||
@@ -74,4 +78,4 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
|
|||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [**Chat Analytics**](https://github.com/mlomb/chat-analytics) — solution for analyzing chat patterns of Discord users, using exports produced by **DiscordChatExporter**.
|
- [**Chat Analytics**](https://github.com/mlomb/chat-analytics) — solution for analyzing chat patterns of Discord users, using exports produced by **DiscordChatExporter**.
|
||||||
- [**DiscordChatExporter-frontend**](https://github.com/slatinsky/DiscordChatExporter-frontend) — convenient viewer for exports produced by **DiscordChatExporter**.
|
- [**DiscordChatExporter-frontend**](https://github.com/slatinsky/DiscordChatExporter-frontend) — convenient viewer for exports produced by **DiscordChatExporter**.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.100",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user