mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-08 23:24:39 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc92b2cbe6 | |||
| 08718425f1 | |||
| 540ba34fb4 | |||
| 22134108a4 | |||
| 82499af25a | |||
| 4a74ff8a02 | |||
| 7dfcf5ac19 | |||
| 19c5fb40b2 | |||
| d4fa8f0954 | |||
| 0d9168e0d2 | |||
| aa8ace8389 | |||
| 97cb8e2347 | |||
| 92ee97a5da | |||
| 612ae2e894 | |||
| 1506afc4a2 | |||
| b56c7db3ec | |||
| 59d803d9f1 | |||
| 30ba273fb1 | |||
| f9fa35b6ad | |||
| 7d2ebb04de | |||
| 1c9f4275b2 | |||
| 1fadc0755b | |||
| 7ddd55951c | |||
| 6fb197cf0b | |||
| 7add81a472 | |||
| cf7580014c | |||
| 606c082759 | |||
| 5bbb706b3c | |||
| deca3fc1bf | |||
| fca6729ef0 |
+53
-8
@@ -1,10 +1,11 @@
|
|||||||
# Obtaining Token and Channel IDs
|
# Obtaining Token and Channel IDs
|
||||||
|
|
||||||
> **Warning**:
|
> [!WARNING]
|
||||||
> **Do not share your token!**
|
> **Do not share your token!** A token gives full access to an account.
|
||||||
> A token gives full access to an account. To reset a user token, change your account password. To reset a bot token, click on [Regenerate](#how-to-get-a-bot-token) in the bot settings.
|
> To reset a user token, change your account password.
|
||||||
|
> To reset a bot token, click on [Reset Token](#how-to-export-with-a-bot-token) in the bot settings.
|
||||||
|
|
||||||
## How to get a User Token
|
## How to get a user token
|
||||||
|
|
||||||
**Caution:** [Automating user accounts violates Discord's terms of service](https://support.discord.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-) and may result in account termination. Use at your own risk.
|
**Caution:** [Automating user accounts violates Discord's terms of service](https://support.discord.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-) and may result in account termination. Use at your own risk.
|
||||||
|
|
||||||
@@ -254,17 +255,61 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
|||||||
4. Press <kbd>Esc</kbd>. The settings page will close.
|
4. Press <kbd>Esc</kbd>. The settings page will close.
|
||||||
5. To find your user token, continue [here](#in-chrome).
|
5. To find your user token, continue [here](#in-chrome).
|
||||||
|
|
||||||
## How to get a Bot Token
|
## How to export with a bot token
|
||||||
|
|
||||||
|
### Step 1 - Create an application
|
||||||
|
|
||||||
|
You can create a new application or use an existing one. If you want to create a new one:
|
||||||
|
|
||||||
|
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||||
|
2. Click on **New Application** in the top right corner
|
||||||
|
3. Enter a name for your application and click **Create**
|
||||||
|
|
||||||
|
### Step 2 - Invite the bot to your server
|
||||||
|
|
||||||
|
The bot needs to be invited to the server you'd like to export from.
|
||||||
|
|
||||||
|
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||||
|
2. Navigate to **General Information** on the left
|
||||||
|
3. Copy the **Application ID**
|
||||||
|
4. Open the following URL in your browser, replacing `YOUR_APP_ID` with the copied Client ID:
|
||||||
|
|
||||||
|
<!-- Permission code 66560 corresponds to "View Channels" and "Read Message History" permissions.
|
||||||
|
User can uncheck these when adding the bot to their server. -->
|
||||||
|
|
||||||
|
```
|
||||||
|
https://discord.com/oauth2/authorize?scope=bot&permissions=66560&client_id=YOUR_APP_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3 - Ensure message content intent is enabled
|
||||||
|
|
||||||
|
If this option is not enabled, the exported files will be empty.
|
||||||
|
|
||||||
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||||
2. Open your Application's settings
|
2. Open your Application's settings
|
||||||
3. Navigate to the **Bot** section on the left
|
3. Navigate to the **Bot** section on the left
|
||||||
4. Under **Token** click **Copy**
|
4. Scroll down to the **Privileged Gateway Intents** section
|
||||||
|
5. Enable **Message Content Intent** by toggling the switch
|
||||||
|
|
||||||
|
<img width="500" align="right" src="https://i.imgur.com/PPm2KKn.png" />
|
||||||
|
|
||||||
|
### Step 4 - Copy the bot token
|
||||||
|
|
||||||
|
If you don't have a bot token yet or if you've lost it, follow these steps to reset it:
|
||||||
|
|
||||||
|
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||||
|
2. Open your Application's settings
|
||||||
|
3. Navigate to the **Bot** section on the left
|
||||||
|
4. Under **Token** click **Reset Token**
|
||||||
|
5. Click **Yes, do it!** and authenticate to confirm
|
||||||
|
|
||||||
|
> **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.
|
||||||
|
|
||||||
> **Warning**:
|
> **Warning**:
|
||||||
> Your bot needs to have **Message Content Intent** enabled for it to be able to read messages!
|
> Resetting the token will invalidate the old one. Any integrations relying on the old token will cease to function until they are updated.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Follow the [instructions here](Token-and-IDs.md).
|
|||||||
|
|
||||||
### Will I get banned if I use this?
|
### Will I get banned if I use this?
|
||||||
|
|
||||||
Automating user accounts is technically against [TOS](https://discord.com/terms), use at your discretion. [Bot accounts](https://discord.com/developers/docs/topics/oauth2#bots) don't have this restriction.
|
Automating user accounts is technically against [TOS](https://discord.com/terms), use at your discretion. [Bot accounts](https://discord.com/developers/docs/topics/oauth2#bot-users) don't have this restriction.
|
||||||
|
|
||||||
### Will the messages disappear from the exported file if I delete a message, delete my account or block a person?
|
### Will the messages disappear from the exported file if I delete a message, delete my account or block a person?
|
||||||
|
|
||||||
@@ -146,6 +146,12 @@ Red Hat: `cert-sync --user /etc/pki/tls/certs/ca-bundle.crt`
|
|||||||
|
|
||||||
If it still doesn't work, try mozroots: `mozroots --import --ask-remove`
|
If it still doesn't work, try mozroots: `mozroots --import --ask-remove`
|
||||||
|
|
||||||
|
## macOS-specific
|
||||||
|
|
||||||
|
### DiscordChatExporter is damaged and can’t be opened. You should move it to the Trash.
|
||||||
|
|
||||||
|
Check the [Using the GUI page](Using-the-GUI.md#step-1) for instructions on how to run the app.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> ❓ If you still have unanswered questions, feel free to [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new).
|
> ❓ If you still have unanswered questions, feel free to [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new).
|
||||||
|
|||||||
+16
-1
@@ -10,7 +10,22 @@
|
|||||||
|
|
||||||
### Step 1
|
### Step 1
|
||||||
|
|
||||||
After extracting the `.zip`, run `DiscordChatExporter.exe` (Windows), or `DiscordChatExporter` (Mac OS and Linux).
|
After extracting the `.zip`, run `DiscordChatExporter.exe` **(Windows)**, or `DiscordChatExporter` **(Linux)**.
|
||||||
|
|
||||||
|
If you're using **macOS**, you'll need to manually grant permission for the app to run.
|
||||||
|
If you skip these steps, the "DiscordChatExporter is damaged and can’t be opened" error will be shown.
|
||||||
|
|
||||||
|
1. Open Terminal.app. You can search for it in Spotlight (press <kbd>⌘</kbd> + <kbd>Space</kbd> and type "Terminal").
|
||||||
|
2. Paste the following into the terminal window:
|
||||||
|
```bash
|
||||||
|
xattr -rd com.apple.quarantine
|
||||||
|
```
|
||||||
|
3. Hit <kbd>Space</kbd> once to add a space after the command
|
||||||
|
4. Drag and drop DiscordChatExporter.app into the terminal window
|
||||||
|
5. Press <kbd>Return</kbd> to run the command
|
||||||
|
6. Open DiscordChatExporter.app normally
|
||||||
|
|
||||||
|
> Apple requires apps to be notarized and signed in order to run on macOS without warnings, which in turn requires an Apple Developer membership ($99/year). This open-source project is distributed for free and without commercial intent.
|
||||||
|
|
||||||
### Step 2
|
### Step 2
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: DiscordChatExporter.Cli.Docker
|
name: DiscordChatExporter.Cli.Docker
|
||||||
path: DiscordChatExporter.Cli.Docker.tar
|
path: DiscordChatExporter.Cli.Docker.tar
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: 9.0.x
|
dotnet-version: 9.0.x
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: 9.0.x
|
dotnet-version: 9.0.x
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ jobs:
|
|||||||
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0
|
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ jobs:
|
|||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
|
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: 9.0.x
|
dotnet-version: 9.0.x
|
||||||
|
|
||||||
@@ -133,13 +133,14 @@ jobs:
|
|||||||
dotnet publish ${{ matrix.app }}
|
dotnet publish ${{ matrix.app }}
|
||||||
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||||
-p:CSharpier_Bypass=true
|
-p:CSharpier_Bypass=true
|
||||||
|
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
|
||||||
--output ${{ matrix.app }}/bin/publish/
|
--output ${{ matrix.app }}/bin/publish/
|
||||||
--configuration Release
|
--configuration Release
|
||||||
--runtime ${{ matrix.rid }}
|
--runtime ${{ matrix.rid }}
|
||||||
--self-contained
|
--self-contained
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||||
path: ${{ matrix.app }}/bin/publish/
|
path: ${{ matrix.app }}/bin/publish/
|
||||||
@@ -204,14 +205,21 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download artifacts
|
||||||
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
|
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||||
path: ${{ matrix.app }}/
|
path: ${{ matrix.app }}/
|
||||||
|
|
||||||
- name: Set permissions
|
- name: Set permissions
|
||||||
if: ${{ !startsWith(matrix.rid, 'win-') }}
|
if: ${{ !startsWith(matrix.rid, 'win-') }}
|
||||||
run: chmod +x ${{ matrix.app }}/${{ matrix.asset }}
|
run: |
|
||||||
|
if [ -f ${{ matrix.app }}/${{ matrix.asset }} ]; then
|
||||||
|
chmod +x ${{ matrix.app }}/${{ matrix.asset }}
|
||||||
|
fi
|
||||||
|
|
||||||
|
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,32 +1,30 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
<UserSecretsId>d1fe5ae2-2a19-404d-a36e-81ba9eada1c1</UserSecretsId>
|
<UserSecretsId>d1fe5ae2-2a19-404d-a36e-81ba9eada1c1</UserSecretsId>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="1.2.0" />
|
<PackageReference Include="AngleSharp" Version="1.3.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.0.2" PrivateAssets="all" />
|
||||||
<PackageReference Include="FluentAssertions" Version="8.1.1" />
|
<PackageReference Include="FluentAssertions" Version="8.3.0" />
|
||||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
|
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.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.2" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.5" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.2" />
|
<PackageReference
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.2" />
|
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
Version="9.0.5"
|
||||||
|
/>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.5" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||||
<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.0" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
|
|||||||
@@ -146,4 +146,31 @@ public class DateRangeSpecs
|
|||||||
.WhenTypeIs<DateTimeOffset>()
|
.WhenTypeIs<DateTimeOffset>()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_filter_the_export_to_not_include_any_messages()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var before = new DateTimeOffset(2020, 08, 01, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
using var file = TempFile.Create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
Token = Secrets.DiscordToken,
|
||||||
|
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = file.Path,
|
||||||
|
Before = Snowflake.FromDate(before),
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||||
|
.GetProperty("messages")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
timestamps.Should().BeEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -27,8 +28,19 @@ public abstract class DiscordCommandBase : ICommand
|
|||||||
)]
|
)]
|
||||||
public bool IsBotToken { get; init; } = false;
|
public bool IsBotToken { get; init; } = false;
|
||||||
|
|
||||||
private DiscordClient? _discordClient;
|
[CommandOption(
|
||||||
protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token);
|
"respect-rate-limits",
|
||||||
|
Description = "Whether to respect advisory rate limits. "
|
||||||
|
+ "If disabled, only hard rate limits (i.e. 429 responses) will be respected."
|
||||||
|
)]
|
||||||
|
public bool ShouldRespectRateLimits { get; init; } = true;
|
||||||
|
|
||||||
|
[field: AllowNull, MaybeNull]
|
||||||
|
protected DiscordClient Discord =>
|
||||||
|
field ??= new DiscordClient(
|
||||||
|
Token,
|
||||||
|
ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
|
||||||
|
);
|
||||||
|
|
||||||
public virtual ValueTask ExecuteAsync(IConsole console)
|
public virtual ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,10 +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>();
|
||||||
|
|
||||||
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(
|
||||||
@@ -192,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),
|
||||||
@@ -236,6 +282,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
catch (ChannelEmptyException ex)
|
||||||
|
{
|
||||||
|
warningsByChannel[channel] = ex.Message;
|
||||||
|
}
|
||||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||||
{
|
{
|
||||||
errorsByChannel[channel] = ex.Message;
|
errorsByChannel[channel] = ex.Message;
|
||||||
@@ -248,10 +298,32 @@ 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)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Print warnings
|
||||||
|
if (warningsByChannel.Any())
|
||||||
|
{
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.Yellow))
|
||||||
|
{
|
||||||
|
await console.Error.WriteLineAsync(
|
||||||
|
"Warnings reported for the following channel(s):"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (channel, message) in warningsByChannel)
|
||||||
|
{
|
||||||
|
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.Yellow))
|
||||||
|
await console.Error.WriteLineAsync(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await console.Error.WriteLineAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// Print errors
|
// Print errors
|
||||||
if (errorsByChannel.Any())
|
if (errorsByChannel.Any())
|
||||||
{
|
{
|
||||||
@@ -259,16 +331,14 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
|
|
||||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||||
{
|
{
|
||||||
await console.Error.WriteLineAsync(
|
await console.Error.WriteLineAsync("Failed to export the following channel(s):");
|
||||||
$"Failed to export {errorsByChannel.Count} the following channel(s):"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var (channel, error) in errorsByChannel)
|
foreach (var (channel, message) in errorsByChannel)
|
||||||
{
|
{
|
||||||
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
|
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
|
||||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||||
await console.Error.WriteLineAsync(error);
|
await console.Error.WriteLineAsync(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
await console.Error.WriteLineAsync();
|
await console.Error.WriteLineAsync();
|
||||||
@@ -276,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,17 @@ public class GuideCommand : ICommand
|
|||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
console.Output.WriteLine("To get the token for your bot:");
|
console.Output.WriteLine("To get the token for your bot:");
|
||||||
|
|
||||||
|
console.Output.WriteLine(
|
||||||
|
" The token is generated during bot creation. If you lost it, generate a new one:"
|
||||||
|
);
|
||||||
console.Output.WriteLine(" 1. Go to Discord developer portal");
|
console.Output.WriteLine(" 1. Go to Discord developer portal");
|
||||||
console.Output.WriteLine(" 2. Open your application's settings");
|
console.Output.WriteLine(" 2. Open your application's settings");
|
||||||
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
||||||
console.Output.WriteLine(" 4. Under Token click Copy");
|
console.Output.WriteLine(" 4. Under Token click Reset Token");
|
||||||
|
console.Output.WriteLine(" 5. Click Yes, do it! and authenticate to confirm");
|
||||||
|
console.Output.WriteLine(
|
||||||
|
" * Integrations using the previous token will stop working until updated"
|
||||||
|
);
|
||||||
console.Output.WriteLine(
|
console.Output.WriteLine(
|
||||||
" * Your bot needs to have the Message Content Intent enabled to read messages"
|
" * Your bot needs to have the Message Content Intent enabled to read messages"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
<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>
|
||||||
<PublishTrimmed>true</PublishTrimmed>
|
<PublishTrimmed>true</PublishTrimmed>
|
||||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||||
</PropertyGroup>
|
</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.0.2" 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.49.1" />
|
<PackageReference Include="Spectre.Console" Version="0.50.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
|
|||||||
@@ -78,6 +78,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
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ using JsonExtensions.Reading;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord;
|
namespace DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
public class DiscordClient(string token)
|
public class DiscordClient(
|
||||||
|
string token,
|
||||||
|
RateLimitPreference rateLimitPreference = RateLimitPreference.RespectAll
|
||||||
|
)
|
||||||
{
|
{
|
||||||
private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute);
|
private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute);
|
||||||
private TokenKind? _resolvedTokenKind;
|
private TokenKind? _resolvedTokenKind;
|
||||||
@@ -47,33 +50,41 @@ public class DiscordClient(string token)
|
|||||||
innerCancellationToken
|
innerCancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
// If this was the last request available before hitting the rate limit,
|
// Discord has advisory rate limits (communicated via response headers), but they are typically
|
||||||
// wait out the reset time so that future requests can succeed.
|
// way stricter than the actual rate limits enforced by the server.
|
||||||
// This may add an unnecessary delay in case the user doesn't intend to
|
// The user may choose to ignore the advisory rate limits and only retry on hard rate limits,
|
||||||
// make any more requests, but implementing a smarter solution would
|
// if they want to prioritize speed over compliance (and safety of their account/bot).
|
||||||
// require properly keeping track of Discord's global/per-route/per-resource
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1021
|
||||||
// rate limits and that's just way too much effort.
|
if (rateLimitPreference.IsRespectedFor(tokenKind))
|
||||||
// https://discord.com/developers/docs/topics/rate-limits
|
|
||||||
var remainingRequestCount = response
|
|
||||||
.Headers.TryGetValue("X-RateLimit-Remaining")
|
|
||||||
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
|
|
||||||
|
|
||||||
var resetAfterDelay = response
|
|
||||||
.Headers.TryGetValue("X-RateLimit-Reset-After")
|
|
||||||
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
|
|
||||||
.Pipe(TimeSpan.FromSeconds);
|
|
||||||
|
|
||||||
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
|
|
||||||
{
|
{
|
||||||
var delay =
|
var remainingRequestCount = response
|
||||||
// Adding a small buffer to the reset time reduces the chance of getting
|
.Headers.TryGetValue("X-RateLimit-Remaining")
|
||||||
// rate limited again, because it allows for more requests to be released.
|
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
|
||||||
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
|
|
||||||
// Sometimes Discord returns an absurdly high value for the reset time, which
|
|
||||||
// is not actually enforced by the server. So we cap it at a reasonable value.
|
|
||||||
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
|
|
||||||
|
|
||||||
await Task.Delay(delay, innerCancellationToken);
|
var resetAfterDelay = response
|
||||||
|
.Headers.TryGetValue("X-RateLimit-Reset-After")
|
||||||
|
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
|
||||||
|
.Pipe(TimeSpan.FromSeconds);
|
||||||
|
|
||||||
|
// If this was the last request available before hitting the rate limit,
|
||||||
|
// wait out the reset time so that future requests can succeed.
|
||||||
|
// This may add an unnecessary delay in case the user doesn't intend to
|
||||||
|
// make any more requests, but implementing a smarter solution would
|
||||||
|
// require properly keeping track of Discord's global/per-route/per-resource
|
||||||
|
// rate limits and that's just way too much effort.
|
||||||
|
// https://discord.com/developers/docs/topics/rate-limits
|
||||||
|
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
|
||||||
|
{
|
||||||
|
var delay =
|
||||||
|
// Adding a small buffer to the reset time reduces the chance of getting
|
||||||
|
// rate limited again, because it allows for more requests to be released.
|
||||||
|
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
|
||||||
|
// Sometimes Discord returns an absurdly high value for the reset time, which
|
||||||
|
// is not actually enforced by the server. So we cap it at a reasonable value.
|
||||||
|
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
|
||||||
|
|
||||||
|
await Task.Delay(delay, innerCancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -148,14 +159,14 @@ public class DiscordClient(string token)
|
|||||||
|
|
||||||
_ => throw new DiscordChatExporterException(
|
_ => throw new DiscordChatExporterException(
|
||||||
$"""
|
$"""
|
||||||
Request to '{url}' failed: {response
|
Request to '{url}' failed: {response
|
||||||
.StatusCode.ToString()
|
.StatusCode.ToString()
|
||||||
.ToSpaceSeparatedWords()
|
.ToSpaceSeparatedWords()
|
||||||
.ToLowerInvariant()}.
|
.ToLowerInvariant()}.
|
||||||
Response content: {await response.Content.ReadAsStringAsync(
|
Response content: {await response.Content.ReadAsStringAsync(
|
||||||
cancellationToken
|
cancellationToken
|
||||||
)}
|
)}
|
||||||
""",
|
""",
|
||||||
true
|
true
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -294,184 +305,19 @@ public class DiscordClient(string token)
|
|||||||
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);
|
||||||
// Categories cannot have threads
|
|
||||||
.Where(c => !c.IsCategory)
|
|
||||||
// Voice channels cannot have threads
|
|
||||||
.Where(c => !c.IsVoice)
|
|
||||||
// Empty channels cannot have threads
|
|
||||||
.Where(c => !c.IsEmpty)
|
|
||||||
// If the 'before' boundary is specified, skip channels that don't have messages
|
|
||||||
// for that range, because thread-start event should always be accompanied by a message.
|
|
||||||
// Note that we don't perform a similar check for the 'after' boundary, because
|
|
||||||
// threads may have messages in range, even if the parent channel doesn't.
|
|
||||||
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
// User accounts can only fetch threads using the search endpoint
|
foreach (
|
||||||
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
var channel in await GetChannelThreadsAsync(
|
||||||
|
channels,
|
||||||
|
includeArchived,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
)
|
||||||
{
|
{
|
||||||
// Active threads
|
yield return channel;
|
||||||
foreach (var channel in channels)
|
|
||||||
{
|
|
||||||
var currentOffset = 0;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var url = new UrlBuilder()
|
|
||||||
.SetPath($"channels/{channel.Id}/threads/search")
|
|
||||||
.SetQueryParameter("sort_by", "last_message_time")
|
|
||||||
.SetQueryParameter("sort_order", "desc")
|
|
||||||
.SetQueryParameter("archived", "false")
|
|
||||||
.SetQueryParameter("offset", currentOffset.ToString())
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
// Can be null on channels that the user cannot access or channels without threads
|
|
||||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
|
||||||
if (response is null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
var breakOuter = false;
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var thread = Channel.Parse(threadJson, channel);
|
|
||||||
|
|
||||||
// If the 'after' boundary is specified, we can break early,
|
|
||||||
// because threads are sorted by last message time.
|
|
||||||
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
|
||||||
{
|
|
||||||
breakOuter = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
yield return thread;
|
|
||||||
currentOffset++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (breakOuter)
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Archived threads
|
|
||||||
if (includeArchived)
|
|
||||||
{
|
|
||||||
foreach (var channel in channels)
|
|
||||||
{
|
|
||||||
var currentOffset = 0;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var url = new UrlBuilder()
|
|
||||||
.SetPath($"channels/{channel.Id}/threads/search")
|
|
||||||
.SetQueryParameter("sort_by", "last_message_time")
|
|
||||||
.SetQueryParameter("sort_order", "desc")
|
|
||||||
.SetQueryParameter("archived", "true")
|
|
||||||
.SetQueryParameter("offset", currentOffset.ToString())
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
// Can be null on channels that the user cannot access or channels without threads
|
|
||||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
|
||||||
if (response is null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
var breakOuter = false;
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var thread = Channel.Parse(threadJson, channel);
|
|
||||||
|
|
||||||
// If the 'after' boundary is specified, we can break early,
|
|
||||||
// because threads are sorted by last message time.
|
|
||||||
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
|
||||||
{
|
|
||||||
breakOuter = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
yield return thread;
|
|
||||||
currentOffset++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (breakOuter)
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Bot accounts can only fetch threads using the threads endpoint
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Active threads
|
|
||||||
{
|
|
||||||
var parentsById = channels.ToDictionary(c => c.Id);
|
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync(
|
|
||||||
$"guilds/{guildId}/threads/active",
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
|
||||||
{
|
|
||||||
var parent = threadJson
|
|
||||||
.GetPropertyOrNull("parent_id")
|
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
|
||||||
?.Pipe(Snowflake.Parse)
|
|
||||||
.Pipe(parentsById.GetValueOrDefault);
|
|
||||||
|
|
||||||
yield return Channel.Parse(threadJson, parent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Archived threads
|
|
||||||
if (includeArchived)
|
|
||||||
{
|
|
||||||
foreach (var channel in channels)
|
|
||||||
{
|
|
||||||
// Public archived threads
|
|
||||||
{
|
|
||||||
// Can be null on certain channels
|
|
||||||
var response = await TryGetJsonResponseAsync(
|
|
||||||
$"channels/{channel.Id}/threads/archived/public",
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
|
||||||
)
|
|
||||||
yield return Channel.Parse(threadJson, channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private archived threads
|
|
||||||
{
|
|
||||||
// Can be null on certain channels
|
|
||||||
var response = await TryGetJsonResponseAsync(
|
|
||||||
$"channels/{channel.Id}/threads/archived/private",
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
|
||||||
)
|
|
||||||
yield return Channel.Parse(threadJson, channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +388,163 @@ public class DiscordClient(string token)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
.Where(c => !c.IsCategory)
|
||||||
|
// Voice channels cannot have threads
|
||||||
|
.Where(c => !c.IsVoice)
|
||||||
|
// Empty channels cannot have threads
|
||||||
|
.Where(c => !c.IsEmpty)
|
||||||
|
// If the 'before' boundary is specified, skip channels that don't have messages
|
||||||
|
// for that range, because thread-start event should always be accompanied by a message.
|
||||||
|
// Note that we don't perform a similar check for the 'after' boundary, because
|
||||||
|
// threads may have messages in range, even if the parent channel doesn't.
|
||||||
|
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// User accounts can only fetch threads using the search endpoint
|
||||||
|
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
||||||
|
{
|
||||||
|
foreach (var channel in filteredChannels)
|
||||||
|
{
|
||||||
|
// Either include both active and archived threads, or only active threads
|
||||||
|
foreach (
|
||||||
|
var isArchived in includeArchived ? new[] { false, true } : new[] { false }
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Offset is just the index of the last thread in the previous batch
|
||||||
|
var currentOffset = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var url = new UrlBuilder()
|
||||||
|
.SetPath($"channels/{channel.Id}/threads/search")
|
||||||
|
.SetQueryParameter("sort_by", "last_message_time")
|
||||||
|
.SetQueryParameter("sort_order", "desc")
|
||||||
|
.SetQueryParameter("archived", isArchived.ToString().ToLowerInvariant())
|
||||||
|
.SetQueryParameter("offset", currentOffset.ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Can be null on channels that the user cannot access or channels without threads
|
||||||
|
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||||
|
if (response is null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var breakOuter = false;
|
||||||
|
|
||||||
|
foreach (
|
||||||
|
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var thread = Channel.Parse(threadJson, channel);
|
||||||
|
|
||||||
|
// If the 'after' boundary is specified, we can break early,
|
||||||
|
// because threads are sorted by last message timestamp.
|
||||||
|
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
||||||
|
{
|
||||||
|
breakOuter = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return thread;
|
||||||
|
currentOffset++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breakOuter)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bot accounts can only fetch threads using the threads endpoint
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var guilds = new HashSet<Snowflake>();
|
||||||
|
foreach (var channel in filteredChannels)
|
||||||
|
guilds.Add(channel.GuildId);
|
||||||
|
|
||||||
|
// Active threads
|
||||||
|
foreach (var guildId in guilds)
|
||||||
|
{
|
||||||
|
var parentsById = filteredChannels.ToDictionary(c => c.Id);
|
||||||
|
|
||||||
|
var response = await GetJsonResponseAsync(
|
||||||
|
$"guilds/{guildId}/threads/active",
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
||||||
|
{
|
||||||
|
var parent = threadJson
|
||||||
|
.GetPropertyOrNull("parent_id")
|
||||||
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
|
?.Pipe(Snowflake.Parse)
|
||||||
|
.Pipe(parentsById.GetValueOrDefault);
|
||||||
|
|
||||||
|
if (filteredChannels.Contains(parent))
|
||||||
|
yield return Channel.Parse(threadJson, parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archived threads
|
||||||
|
if (includeArchived)
|
||||||
|
{
|
||||||
|
foreach (var channel in filteredChannels)
|
||||||
|
{
|
||||||
|
foreach (var archiveType in new[] { "public", "private" })
|
||||||
|
{
|
||||||
|
// This endpoint parameter expects an ISO8601 timestamp, not a snowflake
|
||||||
|
var currentBefore = before
|
||||||
|
?.ToDate()
|
||||||
|
.ToString("O", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
// Threads are sorted by archive timestamp, not by last message timestamp
|
||||||
|
var url = new UrlBuilder()
|
||||||
|
.SetPath($"channels/{channel.Id}/threads/archived/{archiveType}")
|
||||||
|
.SetQueryParameter("before", currentBefore)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Can be null on certain channels
|
||||||
|
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||||
|
if (response is null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
foreach (
|
||||||
|
var threadJson in response
|
||||||
|
.Value.GetProperty("threads")
|
||||||
|
.EnumerateArray()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var thread = Channel.Parse(threadJson, channel);
|
||||||
|
yield return thread;
|
||||||
|
|
||||||
|
currentBefore = threadJson
|
||||||
|
.GetProperty("thread_metadata")
|
||||||
|
.GetProperty("archive_timestamp")
|
||||||
|
.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||||
Snowflake channelId,
|
Snowflake channelId,
|
||||||
Snowflake? before = null,
|
Snowflake? before = null,
|
||||||
@@ -688,9 +691,7 @@ public class DiscordClient(string token)
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each batch can contain up to 100 users.
|
if (count <= 0)
|
||||||
// If we got fewer, then it's definitely the last batch.
|
|
||||||
if (count < 100)
|
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum RateLimitPreference
|
||||||
|
{
|
||||||
|
IgnoreAll = 0,
|
||||||
|
RespectForUserTokens = 0b1,
|
||||||
|
RespectForBotTokens = 0b10,
|
||||||
|
RespectAll = RespectForUserTokens | RespectForBotTokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class RateLimitPreferenceExtensions
|
||||||
|
{
|
||||||
|
internal static bool IsRespectedFor(
|
||||||
|
this RateLimitPreference rateLimitPreference,
|
||||||
|
TokenKind tokenKind
|
||||||
|
) =>
|
||||||
|
tokenKind switch
|
||||||
|
{
|
||||||
|
TokenKind.User => (rateLimitPreference & RateLimitPreference.RespectForUserTokens) != 0,
|
||||||
|
TokenKind.Bot => (rateLimitPreference & RateLimitPreference.RespectForBotTokens) != 0,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(tokenKind)),
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string GetDisplayName(this RateLimitPreference rateLimitPreference) =>
|
||||||
|
rateLimitPreference switch
|
||||||
|
{
|
||||||
|
RateLimitPreference.IgnoreAll => "Always ignore",
|
||||||
|
RateLimitPreference.RespectForUserTokens => "Respect for user tokens",
|
||||||
|
RateLimitPreference.RespectForBotTokens => "Respect for bot tokens",
|
||||||
|
RateLimitPreference.RespectAll => "Always respect",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(rateLimitPreference)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AsyncKeyedLock" Version="7.1.4" />
|
<PackageReference Include="AsyncKeyedLock" Version="7.1.6" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" Version="1.0.2" 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.5.2" />
|
||||||
<PackageReference Include="RazorBlade" Version="0.8.0" />
|
<PackageReference Include="RazorBlade" Version="0.9.0" />
|
||||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
<PackageReference Include="Superpower" Version="3.0.0" />
|
||||||
<PackageReference Include="WebMarkupMin.Core" Version="2.17.0" />
|
<PackageReference Include="WebMarkupMin.Core" Version="2.19.0" />
|
||||||
<PackageReference Include="YoutubeExplode" Version="6.5.3" />
|
<PackageReference Include="YoutubeExplode" Version="6.5.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Exceptions;
|
||||||
|
|
||||||
|
public class ChannelEmptyException(string message) : DiscordChatExporterException(message);
|
||||||
@@ -27,45 +27,43 @@ public class ChannelExporter(DiscordClient discord)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the channel is empty
|
|
||||||
if (request.Channel.IsEmpty)
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
$"Channel '{request.Channel.Name}' "
|
|
||||||
+ $"of guild '{request.Guild.Name}' "
|
|
||||||
+ $"does not contain any messages."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the 'after' boundary is valid
|
|
||||||
if (request.After is not null && !request.Channel.MayHaveMessagesAfter(request.After.Value))
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
$"Channel '{request.Channel.Name}' "
|
|
||||||
+ $"of guild '{request.Guild.Name}' "
|
|
||||||
+ $"does not contain any messages within the specified period."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the 'before' boundary is valid
|
|
||||||
if (
|
|
||||||
request.Before is not null
|
|
||||||
&& !request.Channel.MayHaveMessagesBefore(request.Before.Value)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
$"Channel '{request.Channel.Name}' "
|
|
||||||
+ $"of guild '{request.Guild.Name}' "
|
|
||||||
+ $"does not contain any messages within the specified period."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build context
|
// Build context
|
||||||
var context = new ExportContext(discord, request);
|
var context = new ExportContext(discord, request);
|
||||||
await context.PopulateChannelsAndRolesAsync(cancellationToken);
|
await context.PopulateChannelsAndRolesAsync(cancellationToken);
|
||||||
|
|
||||||
// Export messages
|
// Initialize the exporter before further checks to ensure the file is created even if
|
||||||
|
// an exception is thrown after this point.
|
||||||
await using var messageExporter = new MessageExporter(context);
|
await using var messageExporter = new MessageExporter(context);
|
||||||
|
|
||||||
|
// Check if the channel is empty
|
||||||
|
if (request.Channel.IsEmpty)
|
||||||
|
{
|
||||||
|
throw new ChannelEmptyException(
|
||||||
|
$"Channel '{request.Channel.Name}' "
|
||||||
|
+ $"of guild '{request.Guild.Name}' "
|
||||||
|
+ $"does not contain any messages; an empty file will be created."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the 'before' and 'after' boundaries are valid
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
request.Before is not null
|
||||||
|
&& !request.Channel.MayHaveMessagesBefore(request.Before.Value)
|
||||||
|
)
|
||||||
|
|| (
|
||||||
|
request.After is not null
|
||||||
|
&& !request.Channel.MayHaveMessagesAfter(request.After.Value)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
throw new ChannelEmptyException(
|
||||||
|
$"Channel '{request.Channel.Name}' "
|
||||||
|
+ $"of guild '{request.Guild.Name}' "
|
||||||
|
+ $"does not contain any messages within the specified period; an empty file will be created."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await foreach (
|
await foreach (
|
||||||
var message in discord.GetMessagesAsync(
|
var message in discord.GetMessagesAsync(
|
||||||
request.Channel.Id,
|
request.Channel.Id,
|
||||||
@@ -98,15 +96,5 @@ public class ChannelExporter(DiscordClient discord)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throw if no messages were exported
|
|
||||||
if (messageExporter.MessagesExported <= 0)
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
|
|
||||||
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
|
|
||||||
+ $"does not contain any matching messages within the specified period."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,24 +13,7 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
|||||||
|
|
||||||
public long MessagesExported { get; private set; }
|
public long MessagesExported { get; private set; }
|
||||||
|
|
||||||
private async ValueTask ResetWriterAsync(CancellationToken cancellationToken = default)
|
private async ValueTask<MessageWriter> InitializeWriterAsync(
|
||||||
{
|
|
||||||
if (_writer is not null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _writer.WritePostambleAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
// Writer must be disposed, even if it fails to write the postamble
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await _writer.DisposeAsync();
|
|
||||||
_writer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask<MessageWriter> GetWriterAsync(
|
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -43,7 +26,7 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await ResetWriterAsync(cancellationToken);
|
await UninitializeWriterAsync(cancellationToken);
|
||||||
_partitionIndex++;
|
_partitionIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,17 +43,41 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
|||||||
return _writer = writer;
|
return _writer = writer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask UninitializeWriterAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_writer is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _writer.WritePostambleAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
// Writer must be disposed, even if it fails to write the postamble
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _writer.DisposeAsync();
|
||||||
|
_writer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask ExportMessageAsync(
|
public async ValueTask ExportMessageAsync(
|
||||||
Message message,
|
Message message,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var writer = await GetWriterAsync(cancellationToken);
|
var writer = await InitializeWriterAsync(cancellationToken);
|
||||||
await writer.WriteMessageAsync(message, cancellationToken);
|
await writer.WriteMessageAsync(message, cancellationToken);
|
||||||
MessagesExported++;
|
MessagesExported++;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() => await ResetWriterAsync();
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
// If not messages were written, force the creation of an empty file
|
||||||
|
if (MessagesExported <= 0)
|
||||||
|
_ = await InitializeWriterAsync();
|
||||||
|
|
||||||
|
await UninitializeWriterAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal partial class MessageExporter
|
internal partial class MessageExporter
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Frozen;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ namespace DiscordChatExporter.Core.Utils;
|
|||||||
|
|
||||||
public static class PathEx
|
public static class PathEx
|
||||||
{
|
{
|
||||||
private static readonly HashSet<char> InvalidFileNameChars =
|
private static readonly FrozenSet<char> InvalidFileNameChars =
|
||||||
[
|
[
|
||||||
.. Path.GetInvalidFileNameChars(),
|
.. Path.GetInvalidFileNameChars(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Utils;
|
namespace DiscordChatExporter.Core.Utils;
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ public class UrlBuilder
|
|||||||
if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value))
|
if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value))
|
||||||
return this;
|
return this;
|
||||||
|
|
||||||
var keyEncoded = WebUtility.UrlEncode(key);
|
var keyEncoded = Uri.EscapeDataString(key);
|
||||||
var valueEncoded = WebUtility.UrlEncode(value);
|
var valueEncoded = value?.Pipe(Uri.EscapeDataString);
|
||||||
_queryParameters[keyEncoded] = valueEncoded;
|
_queryParameters[keyEncoded] = valueEncoded;
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
@@ -39,9 +39,11 @@ public class UrlBuilder
|
|||||||
buffer.Append(_path);
|
buffer.Append(_path);
|
||||||
|
|
||||||
if (_queryParameters.Any())
|
if (_queryParameters.Any())
|
||||||
|
{
|
||||||
buffer
|
buffer
|
||||||
.Append('?')
|
.Append('?')
|
||||||
.AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
.AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||||
|
}
|
||||||
|
|
||||||
return buffer.ToString();
|
return buffer.ToString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Converters;
|
||||||
|
|
||||||
|
public class RateLimitPreferenceToStringConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static RateLimitPreferenceToStringConverter Instance { get; } = new();
|
||||||
|
|
||||||
|
public object? Convert(
|
||||||
|
object? value,
|
||||||
|
Type targetType,
|
||||||
|
object? parameter,
|
||||||
|
CultureInfo culture
|
||||||
|
) =>
|
||||||
|
value is RateLimitPreference rateLimitPreference
|
||||||
|
? rateLimitPreference.GetDisplayName()
|
||||||
|
: default;
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -8,30 +7,36 @@
|
|||||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</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.3.0" />
|
||||||
<PackageReference Include="Avalonia" Version="11.2.5" />
|
<PackageReference Include="Avalonia" Version="11.3.0" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
|
||||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.5" Condition="'$(Configuration)' == 'Debug'" />
|
<PackageReference
|
||||||
|
Include="Avalonia.Diagnostics"
|
||||||
|
Version="11.3.0"
|
||||||
|
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.0.2" 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.9.2" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" Version="2.1.1" />
|
||||||
<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.2" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.5" />
|
||||||
<PackageReference Include="Onova" Version="2.6.12" />
|
<PackageReference Include="Onova" Version="2.6.13" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<Target Name="PublishMacOSBundle" AfterTargets="Publish" Condition="$(PublishMacOSBundle)">
|
||||||
</Project>
|
<Exec
|
||||||
|
Command="pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Publish-MacOSBundle.ps1 -PublishDirPath $(PublishDir) -IconsFilePath $(ProjectDir)/../favicon.icns -FullVersion $(Version) -ShortVersion $(AssemblyVersion)"
|
||||||
|
LogStandardErrorAsError="true"
|
||||||
|
/>
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public abstract partial class DialogViewModelBase<T> : ViewModelBase
|
|||||||
);
|
);
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private T? _dialogResult;
|
public partial T? DialogResult { get; set; }
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
protected void Close(T dialogResult)
|
protected void Close(T dialogResult)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$PublishDirPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$IconsFilePath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$FullVersion,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$ShortVersion
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Setup paths
|
||||||
|
$tempDirPath = Join-Path $PublishDirPath "../publish-macos-app-temp"
|
||||||
|
$bundleName = "DiscordChatExporter.app"
|
||||||
|
$bundleDirPath = Join-Path $tempDirPath $bundleName
|
||||||
|
$contentsDirPath = Join-Path $bundleDirPath "Contents"
|
||||||
|
$macosDirPath = Join-Path $contentsDirPath "MacOS"
|
||||||
|
$resourcesDirPath = Join-Path $contentsDirPath "Resources"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Initialize the bundle's directory structure
|
||||||
|
New-Item -Path $bundleDirPath -ItemType Directory -Force
|
||||||
|
New-Item -Path $contentsDirPath -ItemType Directory -Force
|
||||||
|
New-Item -Path $macosDirPath -ItemType Directory -Force
|
||||||
|
New-Item -Path $resourcesDirPath -ItemType Directory -Force
|
||||||
|
|
||||||
|
# Copy icons into the .app's Resources folder
|
||||||
|
Copy-Item -Path $IconsFilePath -Destination (Join-Path $resourcesDirPath "AppIcon.icns") -Force
|
||||||
|
|
||||||
|
# Generate the Info.plist metadata file with the app information
|
||||||
|
$plistContent = @"
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>DiscordChatExporter</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>DiscordChatExporter</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>DiscordChatExporter</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>© Oleksii Holub</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>me.Tyrrrz.DiscordChatExporter</string>
|
||||||
|
<key>CFBundleSpokenName</key>
|
||||||
|
<string>Discord Chat Exporter</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>CFBundleIconName</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$FullVersion</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$ShortVersion</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true />
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
"@
|
||||||
|
|
||||||
|
Set-Content -Path (Join-Path $contentsDirPath "Info.plist") -Value $plistContent
|
||||||
|
|
||||||
|
# Delete the previous bundle if it exists
|
||||||
|
if (Test-Path (Join-Path $PublishDirPath $bundleName)) {
|
||||||
|
Remove-Item -Path (Join-Path $PublishDirPath $bundleName) -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Move all files from the publish directory into the MacOS directory
|
||||||
|
Get-ChildItem -Path $PublishDirPath | ForEach-Object {
|
||||||
|
Move-Item -Path $_.FullName -Destination $macosDirPath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Move the final bundle into the publish directory for upload
|
||||||
|
Move-Item -Path $bundleDirPath -Destination $PublishDirPath -Force
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# Clean up the temporary directory
|
||||||
|
Remove-Item -Path $tempDirPath -Recurse -Force
|
||||||
|
}
|
||||||
@@ -3,132 +3,71 @@ using System.IO;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Cogwheel;
|
using Cogwheel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Exporting;
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
using DiscordChatExporter.Gui.Models;
|
using DiscordChatExporter.Gui.Models;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.Services;
|
namespace DiscordChatExporter.Gui.Services;
|
||||||
|
|
||||||
// Can't use [ObservableProperty] here because System.Text.Json's source generator doesn't see
|
[ObservableObject]
|
||||||
// the generated properties.
|
|
||||||
[INotifyPropertyChanged]
|
|
||||||
public partial class SettingsService()
|
public partial class SettingsService()
|
||||||
: SettingsBase(
|
: SettingsBase(
|
||||||
Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
|
Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
|
||||||
SerializerContext.Default
|
SerializerContext.Default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private bool _isUkraineSupportMessageEnabled = true;
|
[ObservableProperty]
|
||||||
public bool IsUkraineSupportMessageEnabled
|
public partial bool IsUkraineSupportMessageEnabled { get; set; } = true;
|
||||||
{
|
|
||||||
get => _isUkraineSupportMessageEnabled;
|
|
||||||
set => SetProperty(ref _isUkraineSupportMessageEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ThemeVariant _theme;
|
[ObservableProperty]
|
||||||
public ThemeVariant Theme
|
public partial ThemeVariant Theme { get; set; }
|
||||||
{
|
|
||||||
get => _theme;
|
|
||||||
set => SetProperty(ref _theme, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _isAutoUpdateEnabled = true;
|
[ObservableProperty]
|
||||||
public bool IsAutoUpdateEnabled
|
public partial bool IsAutoUpdateEnabled { get; set; } = true;
|
||||||
{
|
|
||||||
get => _isAutoUpdateEnabled;
|
|
||||||
set => SetProperty(ref _isAutoUpdateEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _isTokenPersisted = true;
|
[ObservableProperty]
|
||||||
public bool IsTokenPersisted
|
public partial bool IsTokenPersisted { get; set; } = true;
|
||||||
{
|
|
||||||
get => _isTokenPersisted;
|
|
||||||
set => SetProperty(ref _isTokenPersisted, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ThreadInclusionMode _threadInclusionMode;
|
[ObservableProperty]
|
||||||
public ThreadInclusionMode ThreadInclusionMode
|
public partial RateLimitPreference RateLimitPreference { get; set; } =
|
||||||
{
|
RateLimitPreference.RespectAll;
|
||||||
get => _threadInclusionMode;
|
|
||||||
set => SetProperty(ref _threadInclusionMode, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _locale;
|
[ObservableProperty]
|
||||||
public string? Locale
|
public partial ThreadInclusionMode ThreadInclusionMode { get; set; }
|
||||||
{
|
|
||||||
get => _locale;
|
|
||||||
set => SetProperty(ref _locale, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _isUtcNormalizationEnabled;
|
[ObservableProperty]
|
||||||
public bool IsUtcNormalizationEnabled
|
public partial string? Locale { get; set; }
|
||||||
{
|
|
||||||
get => _isUtcNormalizationEnabled;
|
|
||||||
set => SetProperty(ref _isUtcNormalizationEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int _parallelLimit = 1;
|
[ObservableProperty]
|
||||||
public int ParallelLimit
|
public partial bool IsUtcNormalizationEnabled { get; set; }
|
||||||
{
|
|
||||||
get => _parallelLimit;
|
|
||||||
set => SetProperty(ref _parallelLimit, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _lastToken;
|
[ObservableProperty]
|
||||||
public string? LastToken
|
public partial int ParallelLimit { get; set; } = 1;
|
||||||
{
|
|
||||||
get => _lastToken;
|
|
||||||
set => SetProperty(ref _lastToken, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ExportFormat _lastExportFormat = ExportFormat.HtmlDark;
|
[ObservableProperty]
|
||||||
public ExportFormat LastExportFormat
|
public partial string? LastToken { get; set; }
|
||||||
{
|
|
||||||
get => _lastExportFormat;
|
|
||||||
set => SetProperty(ref _lastExportFormat, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _lastPartitionLimitValue;
|
[ObservableProperty]
|
||||||
public string? LastPartitionLimitValue
|
public partial ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
{
|
|
||||||
get => _lastPartitionLimitValue;
|
|
||||||
set => SetProperty(ref _lastPartitionLimitValue, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _lastMessageFilterValue;
|
[ObservableProperty]
|
||||||
public string? LastMessageFilterValue
|
public partial string? LastPartitionLimitValue { get; set; }
|
||||||
{
|
|
||||||
get => _lastMessageFilterValue;
|
|
||||||
set => SetProperty(ref _lastMessageFilterValue, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _lastShouldFormatMarkdown = true;
|
[ObservableProperty]
|
||||||
public bool LastShouldFormatMarkdown
|
public partial string? LastMessageFilterValue { get; set; }
|
||||||
{
|
|
||||||
get => _lastShouldFormatMarkdown;
|
|
||||||
set => SetProperty(ref _lastShouldFormatMarkdown, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _lastShouldDownloadAssets;
|
[ObservableProperty]
|
||||||
public bool LastShouldDownloadAssets
|
public partial bool LastShouldFormatMarkdown { get; set; } = true;
|
||||||
{
|
|
||||||
get => _lastShouldDownloadAssets;
|
|
||||||
set => SetProperty(ref _lastShouldDownloadAssets, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _lastShouldReuseAssets;
|
[ObservableProperty]
|
||||||
public bool LastShouldReuseAssets
|
public partial bool LastShouldDownloadAssets { get; set; }
|
||||||
{
|
|
||||||
get => _lastShouldReuseAssets;
|
|
||||||
set => SetProperty(ref _lastShouldReuseAssets, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _lastAssetsDirPath;
|
[ObservableProperty]
|
||||||
public string? LastAssetsDirPath
|
public partial bool LastShouldReuseAssets { get; set; }
|
||||||
{
|
|
||||||
get => _lastAssetsDirPath;
|
[ObservableProperty]
|
||||||
set => SetProperty(ref _lastAssetsDirPath, value);
|
public partial string? LastAssetsDirPath { get; set; }
|
||||||
}
|
|
||||||
|
|
||||||
public override void Save()
|
public override void Save()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,28 +33,6 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
|
|
||||||
private DiscordClient? _discord;
|
private DiscordClient? _discord;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsProgressIndeterminate))]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
|
||||||
private bool _isBusy;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
|
|
||||||
private string? _token;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private IReadOnlyList<Guild>? _availableGuilds;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
|
||||||
private Guild? _selectedGuild;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private IReadOnlyList<ChannelConnection>? _availableChannels;
|
|
||||||
|
|
||||||
public DashboardViewModel(
|
public DashboardViewModel(
|
||||||
ViewModelManager viewModelManager,
|
ViewModelManager viewModelManager,
|
||||||
DialogManager dialogManager,
|
DialogManager dialogManager,
|
||||||
@@ -84,10 +62,32 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsProgressIndeterminate))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
||||||
|
public partial bool IsBusy { get; set; }
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
|
||||||
|
public partial string? Token { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial IReadOnlyList<Guild>? AvailableGuilds { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
||||||
|
public partial Guild? SelectedGuild { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial IReadOnlyList<ChannelConnection>? AvailableChannels { get; set; }
|
||||||
|
|
||||||
public ObservableCollection<ChannelConnection> SelectedChannels { get; } = [];
|
public ObservableCollection<ChannelConnection> SelectedChannels { get; } = [];
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -123,7 +123,7 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
AvailableChannels = null;
|
AvailableChannels = null;
|
||||||
SelectedChannels.Clear();
|
SelectedChannels.Clear();
|
||||||
|
|
||||||
_discord = new DiscordClient(token);
|
_discord = new DiscordClient(token, _settingsService.RateLimitPreference);
|
||||||
_settingsService.LastToken = token;
|
_settingsService.LastToken = token;
|
||||||
|
|
||||||
var guilds = await _discord.GetUserGuildsAsync();
|
var guilds = await _discord.GetUserGuildsAsync();
|
||||||
@@ -283,6 +283,10 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
|
|
||||||
Interlocked.Increment(ref successfulExportCount);
|
Interlocked.Increment(ref successfulExportCount);
|
||||||
}
|
}
|
||||||
|
catch (ChannelEmptyException ex)
|
||||||
|
{
|
||||||
|
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
|
||||||
|
}
|
||||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||||
{
|
{
|
||||||
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
|
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
|
||||||
|
|||||||
@@ -22,56 +22,56 @@ public partial class ExportSetupViewModel(
|
|||||||
) : DialogViewModelBase
|
) : DialogViewModelBase
|
||||||
{
|
{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private Guild? _guild;
|
public partial Guild? Guild { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsSingleChannel))]
|
[NotifyPropertyChangedFor(nameof(IsSingleChannel))]
|
||||||
private IReadOnlyList<Channel>? _channels;
|
public partial IReadOnlyList<Channel>? Channels { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? _outputPath;
|
public partial string? OutputPath { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ExportFormat _selectedFormat;
|
public partial ExportFormat SelectedFormat { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsAfterDateSet))]
|
[NotifyPropertyChangedFor(nameof(IsAfterDateSet))]
|
||||||
[NotifyPropertyChangedFor(nameof(After))]
|
[NotifyPropertyChangedFor(nameof(After))]
|
||||||
private DateTimeOffset? _afterDate;
|
public partial DateTimeOffset? AfterDate { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private TimeSpan? _afterTime;
|
public partial TimeSpan? AfterTime { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsBeforeDateSet))]
|
[NotifyPropertyChangedFor(nameof(IsBeforeDateSet))]
|
||||||
[NotifyPropertyChangedFor(nameof(Before))]
|
[NotifyPropertyChangedFor(nameof(Before))]
|
||||||
private DateTimeOffset? _beforeDate;
|
public partial DateTimeOffset? BeforeDate { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private TimeSpan? _beforeTime;
|
public partial TimeSpan? BeforeTime { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(PartitionLimit))]
|
[NotifyPropertyChangedFor(nameof(PartitionLimit))]
|
||||||
private string? _partitionLimitValue;
|
public partial string? PartitionLimitValue { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(MessageFilter))]
|
[NotifyPropertyChangedFor(nameof(MessageFilter))]
|
||||||
private string? _messageFilterValue;
|
public partial string? MessageFilterValue { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _shouldFormatMarkdown;
|
public partial bool ShouldFormatMarkdown { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _shouldDownloadAssets;
|
public partial bool ShouldDownloadAssets { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _shouldReuseAssets;
|
public partial bool ShouldReuseAssets { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? _assetsDirPath;
|
public partial string? AssetsDirPath { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isAdvancedSectionDisplayed;
|
public partial bool IsAdvancedSectionDisplayed { get; set; }
|
||||||
|
|
||||||
public bool IsSingleChannel => Channels?.Count == 1;
|
public bool IsSingleChannel => Channels?.Count == 1;
|
||||||
|
|
||||||
|
|||||||
@@ -6,20 +6,20 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
|
|||||||
public partial class MessageBoxViewModel : DialogViewModelBase
|
public partial class MessageBoxViewModel : DialogViewModelBase
|
||||||
{
|
{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? _title = "Title";
|
public partial string? Title { get; set; } = "Title";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? _message = "Message";
|
public partial string? Message { get; set; } = "Message";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsDefaultButtonVisible))]
|
[NotifyPropertyChangedFor(nameof(IsDefaultButtonVisible))]
|
||||||
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
|
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
|
||||||
private string? _defaultButtonText = "OK";
|
public partial string? DefaultButtonText { get; set; } = "OK";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsCancelButtonVisible))]
|
[NotifyPropertyChangedFor(nameof(IsCancelButtonVisible))]
|
||||||
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
|
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
|
||||||
private string? _cancelButtonText = "Cancel";
|
public partial string? CancelButtonText { get; set; } = "Cancel";
|
||||||
|
|
||||||
public bool IsDefaultButtonVisible => !string.IsNullOrWhiteSpace(DefaultButtonText);
|
public bool IsDefaultButtonVisible => !string.IsNullOrWhiteSpace(DefaultButtonText);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
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.Models;
|
using DiscordChatExporter.Gui.Models;
|
||||||
@@ -42,6 +43,15 @@ public class SettingsViewModel : DialogViewModelBase
|
|||||||
set => _settingsService.IsTokenPersisted = value;
|
set => _settingsService.IsTokenPersisted = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<RateLimitPreference> AvailableRateLimitPreferences { get; } =
|
||||||
|
Enum.GetValues<RateLimitPreference>();
|
||||||
|
|
||||||
|
public RateLimitPreference RateLimitPreference
|
||||||
|
{
|
||||||
|
get => _settingsService.RateLimitPreference;
|
||||||
|
set => _settingsService.RateLimitPreference = value;
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyList<ThreadInclusionMode> AvailableThreadInclusionModes { get; } =
|
public IReadOnlyList<ThreadInclusionMode> AvailableThreadInclusionModes { get; } =
|
||||||
Enum.GetValues<ThreadInclusionMode>();
|
Enum.GetValues<ThreadInclusionMode>();
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ public partial class MainViewModel(
|
|||||||
$"""
|
$"""
|
||||||
You're using a development build of {Program.Name}. These builds are not thoroughly tested and may contain bugs.
|
You're using a development build of {Program.Name}. These builds are not thoroughly tested and may contain bugs.
|
||||||
|
|
||||||
Auto-updates are disabled for development builds. If you want to switch to a stable release, please download it manually.
|
Auto-updates are disabled for development builds.
|
||||||
|
|
||||||
|
Click SEE RELEASES if you want to download a stable release instead.
|
||||||
""",
|
""",
|
||||||
"SEE RELEASES",
|
"SEE RELEASES",
|
||||||
"CLOSE"
|
"CLOSE"
|
||||||
|
|||||||
@@ -332,6 +332,9 @@
|
|||||||
Text="To get the token for your bot:" />
|
Text="To get the token for your bot:" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
|
|
||||||
|
<Run BaselineAlignment="Center" Text="The token is generated during bot creation. If you lost it, generate a new one:" />
|
||||||
|
<LineBreak />
|
||||||
|
|
||||||
<Run BaselineAlignment="Center" Text="1. Open Discord" />
|
<Run BaselineAlignment="Center" Text="1. Open Discord" />
|
||||||
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="developer portal" />
|
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="developer portal" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
@@ -356,7 +359,18 @@
|
|||||||
<Run
|
<Run
|
||||||
BaselineAlignment="Center"
|
BaselineAlignment="Center"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Text="Copy" />
|
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 />
|
<LineBreak />
|
||||||
|
|
||||||
<Run BaselineAlignment="Center" Text="* Your bot needs to have the" />
|
<Run BaselineAlignment="Center" Text="* Your bot needs to have the" />
|
||||||
|
|||||||
@@ -54,6 +54,25 @@
|
|||||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
|
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Rate limit preference -->
|
||||||
|
<DockPanel
|
||||||
|
Margin="16,8"
|
||||||
|
LastChildFill="False"
|
||||||
|
ToolTip.Tip="Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Rate limit preference" />
|
||||||
|
<ComboBox
|
||||||
|
Width="150"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
ItemsSource="{Binding AvailableRateLimitPreferences}"
|
||||||
|
SelectedItem="{Binding RateLimitPreference}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding Converter={x:Static converters:RateLimitPreferenceToStringConverter.Instance}}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Thread inclusion mode -->
|
<!-- Thread inclusion mode -->
|
||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
|
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Width="625"
|
Width="625"
|
||||||
Height="625"
|
Height="665"
|
||||||
MinWidth="600"
|
MinWidth="600"
|
||||||
MinHeight="400"
|
MinHeight="400"
|
||||||
x:DataType="viewModels:MainViewModel"
|
x:DataType="viewModels:MainViewModel"
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
x:Name="DialogHost"
|
x:Name="DialogHost"
|
||||||
CloseOnClickAway="False"
|
CloseOnClickAway="False"
|
||||||
Loaded="DialogHost_OnLoaded">
|
Loaded="DialogHost_OnLoaded">
|
||||||
<materialStyles:SnackbarHost HostName="Root">
|
<materialStyles:SnackbarHost HostName="Root" SnackbarMaxCounts="3">
|
||||||
<ContentControl Content="{Binding Dashboard}" />
|
<ContentControl Content="{Binding Dashboard}" />
|
||||||
</materialStyles:SnackbarHost>
|
</materialStyles:SnackbarHost>
|
||||||
</dialogHostAvalonia:DialogHost>
|
</dialogHostAvalonia:DialogHost>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ 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
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ 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`
|
||||||
|
|
||||||
|
> **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**:
|
> **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.
|
||||||
|
|
||||||
@@ -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**.
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user