Compare commits

..

21 Commits

Author SHA1 Message Date
Tyrrrz aa8ace8389 Streamline dev build dialog 2025-05-13 19:28:40 +03:00
Tyrrrz 97cb8e2347 Allow snackbar to show multiple elements 2025-05-13 19:26:35 +03:00
Tyrrrz 92ee97a5da Clean up 2025-05-12 23:22:54 +03:00
Oleksii Holub 612ae2e894 Add a setting to control whether to respect advisory rate limits (#1342) 2025-05-12 19:52:47 +03:00
Tyrrrz 1506afc4a2 $ErrorActionPreference 2025-05-11 01:33:43 +03:00
Oleksii Holub b56c7db3ec Create MacOS app bundle via a project target (#1376) 2025-05-11 01:29:55 +03:00
Yudi 59d803d9f1 Bundle the app for macOS (#1375) 2025-05-10 03:03:55 +03:00
Yudi 30ba273fb1 Update bot token docs (#1371)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
2025-05-04 19:49:42 +03:00
Tyrrrz f9fa35b6ad Downgrade CSharpier 2025-05-03 23:40:59 +03:00
dependabot[bot] 7d2ebb04de Bump the nuget group across 1 directory with 11 updates (#1374)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 23:40:26 +03:00
dependabot[bot] 1c9f4275b2 Bump the actions group with 2 updates (#1372)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 23:27:34 +03:00
Tyrrrz 1fadc0755b Refactor after last changes 2025-04-21 00:45:26 +03:00
masmc05 7ddd55951c Don't ignore next pages of archived threads 2025-04-20 00:25:35 +03:00
Tyrrrz 6fb197cf0b Refactor 2025-04-02 00:34:52 +03:00
Leonardo Mosquera 7add81a472 Don't consider it an error if there is nothing to export (#1349) 2025-04-02 00:14:35 +03:00
dependabot[bot] cf7580014c Bump the nuget group across 1 directory with 12 updates (#1361)
Bumps the nuget group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [FluentAssertions](https://github.com/fluentassertions/fluentassertions) | `8.1.1` | `8.2.0` |
| [Microsoft.Extensions.Configuration](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Microsoft.Extensions.Configuration](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Microsoft.Extensions.Configuration.EnvironmentVariables](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Microsoft.Extensions.Configuration](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Microsoft.Extensions.Configuration.UserSecrets](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [RazorBlade](https://github.com/ltrzesniewski/RazorBlade) | `0.8.0` | `0.9.0` |
| [AngleSharp](https://github.com/AngleSharp/AngleSharp) | `1.2.0` | `1.2.0` |
| [YoutubeExplode](https://github.com/Tyrrrz/YoutubeExplode) | `6.5.3` | `6.5.4` |
| [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Avalonia.Desktop](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Avalonia.Diagnostics](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Microsoft.Extensions.DependencyInjection](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Onova](https://github.com/Tyrrrz/Onova) | `2.6.12` | `2.6.13` |



Updates `FluentAssertions` from 8.1.1 to 8.2.0
- [Release notes](https://github.com/fluentassertions/fluentassertions/releases)
- [Changelog](https://github.com/fluentassertions/fluentassertions/blob/main/AcceptApiChanges.ps1)
- [Commits](https://github.com/fluentassertions/fluentassertions/compare/8.1.1...8.2.0)

Updates `Microsoft.Extensions.Configuration` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Microsoft.Extensions.Configuration` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Microsoft.Extensions.Configuration.EnvironmentVariables` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Microsoft.Extensions.Configuration` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Microsoft.Extensions.Configuration.UserSecrets` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `RazorBlade` from 0.8.0 to 0.9.0
- [Release notes](https://github.com/ltrzesniewski/RazorBlade/releases)
- [Commits](https://github.com/ltrzesniewski/RazorBlade/compare/v0.8.0...v0.9.0)

Updates `AngleSharp` from 1.2.0 to 1.2.0
- [Release notes](https://github.com/AngleSharp/AngleSharp/releases)
- [Changelog](https://github.com/AngleSharp/AngleSharp/blob/devel/CHANGELOG.md)
- [Commits](https://github.com/AngleSharp/AngleSharp/compare/1.2.0...1.2.0)

Updates `YoutubeExplode` from 6.5.3 to 6.5.4
- [Release notes](https://github.com/Tyrrrz/YoutubeExplode/releases)
- [Commits](https://github.com/Tyrrrz/YoutubeExplode/compare/6.5.3...6.5.4)

Updates `Avalonia` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Avalonia` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Avalonia.Desktop` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Avalonia` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Avalonia.Diagnostics` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Microsoft.Extensions.DependencyInjection` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Onova` from 2.6.12 to 2.6.13
- [Release notes](https://github.com/Tyrrrz/Onova/releases)
- [Commits](https://github.com/Tyrrrz/Onova/compare/2.6.12...2.6.13)

---
updated-dependencies:
- dependency-name: FluentAssertions
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration.EnvironmentVariables
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration.UserSecrets
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: RazorBlade
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: AngleSharp
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: YoutubeExplode
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia.Desktop
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia.Diagnostics
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.DependencyInjection
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Onova
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 18:58:39 +03:00
dependabot[bot] 606c082759 Bump the actions group with 3 updates (#1360)
Bumps the actions group with 3 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact), [actions/setup-dotnet](https://github.com/actions/setup-dotnet) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `actions/upload-artifact` from 4.6.1 to 4.6.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1...ea165f8d65b6e75b540449e92b4886f43607fa02)

Updates `actions/setup-dotnet` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/3951f0dfe7a07e2313ec93c75700083e2005cbab...67a3573c9a986a3f9c594539f4ab511d57bb3ce9)

Updates `actions/download-artifact` from 4.1.9 to 4.2.1
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/cc203385981b70ca67e1cc392babf9cc229d5806...95815c38cf2ff2164869cbab79da8d1f422bc89e)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-dotnet
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 18:35:39 +03:00
Tyrrrz 5bbb706b3c Fix nullref warning 2025-03-18 20:08:18 +02:00
Tyrrrz deca3fc1bf Use partial properties 2025-03-18 20:06:44 +02:00
Tyrrrz fca6729ef0 Use Uri.EscapeDataString in a few others places 2025-03-18 19:35:24 +02:00
Lucas LaBuff 6f877cf543 Revert to Uri.EscapeDataString when encoding asset URIs (#1355) 2025-03-18 19:10:21 +02:00
33 changed files with 631 additions and 373 deletions
+53 -8
View File
@@ -1,10 +1,11 @@
# Obtaining Token and Channel IDs
> **Warning**:
> **Do not share your token!**
> 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.
> [!WARNING]
> **Do not share your token!** A token gives full access to an account.
> 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.
@@ -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.
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)
2. Open your Application's settings
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**:
> 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.
![https://discord.com/developers/applications/](https://i.imgur.com/BdrrxlY.png)
![https://discord.com/developers/applications/](https://i.imgur.com/soiB8Qc.png)
---
+7 -1
View File
@@ -24,7 +24,7 @@ Follow the [instructions here](Token-and-IDs.md).
### 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?
@@ -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`
## macOS-specific
### DiscordChatExporter is damaged and cant 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).
+16 -1
View File
@@ -10,7 +10,22 @@
### 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 cant be opened" error will be shown.
1. Open Terminal.app. You can search for it in Spotlight (press <kbd>⌘</kbd> + <kbd>Space</kbd> and type "Terminal").
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
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
- name: Upload artifacts
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: DiscordChatExporter.Cli.Docker
path: DiscordChatExporter.Cli.Docker.tar
+15 -7
View File
@@ -29,7 +29,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install .NET
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 9.0.x
@@ -67,7 +67,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install .NET
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 9.0.x
@@ -85,7 +85,7 @@ jobs:
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- name: Upload coverage
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0
uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -124,7 +124,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install .NET
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: 9.0.x
@@ -133,13 +133,14 @@ jobs:
dotnet publish ${{ matrix.app }}
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
-p:CSharpier_Bypass=true
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
--output ${{ matrix.app }}/bin/publish/
--configuration Release
--runtime ${{ matrix.rid }}
--self-contained
- name: Upload artifacts
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/bin/publish/
@@ -204,14 +205,21 @@ jobs:
steps:
- name: Download artifacts
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/
- name: Set permissions
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
# Change into the artifacts directory to avoid including the directory itself in the zip archive
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
@@ -11,15 +11,15 @@
</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="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="8.1.1" />
<PackageReference Include="FluentAssertions" Version="8.2.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" PrivateAssets="all" />
@@ -146,4 +146,31 @@ public class DateRangeSpecs
.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();
}
}
@@ -27,8 +27,19 @@ public abstract class DiscordCommandBase : ICommand
)]
public bool IsBotToken { get; init; } = false;
[CommandOption(
"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;
private DiscordClient? _discordClient;
protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token);
protected DiscordClient Discord =>
_discordClient ??= new DiscordClient(
Token,
ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
);
public virtual ValueTask ExecuteAsync(IConsole console)
{
@@ -179,6 +179,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
// Export
var cancellationToken = console.RegisterCancellationHandler();
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
var warningsByChannel = new ConcurrentDictionary<Channel, string>();
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
await console
@@ -236,6 +237,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
}
);
}
catch (ChannelEmptyException ex)
{
warningsByChannel[channel] = ex.Message;
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{
errorsByChannel[channel] = ex.Message;
@@ -252,6 +257,28 @@ public abstract class ExportCommandBase : DiscordCommandBase
);
}
// 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
if (errorsByChannel.Any())
{
@@ -259,16 +286,14 @@ public abstract class ExportCommandBase : DiscordCommandBase
using (console.WithForegroundColor(ConsoleColor.Red))
{
await console.Error.WriteLineAsync(
$"Failed to export {errorsByChannel.Count} the following channel(s):"
);
await console.Error.WriteLineAsync("Failed to export the following channel(s):");
}
foreach (var (channel, error) in errorsByChannel)
foreach (var (channel, message) in errorsByChannel)
{
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
using (console.WithForegroundColor(ConsoleColor.Red))
await console.Error.WriteLineAsync(error);
await console.Error.WriteLineAsync(message);
}
await console.Error.WriteLineAsync();
@@ -34,10 +34,17 @@ public class GuideCommand : ICommand
using (console.WithForegroundColor(ConsoleColor.White))
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(" 2. Open your application's settings");
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(
" * Your bot needs to have the Message Content Intent enabled to read messages"
);
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -12,7 +12,7 @@
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
<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>
+85 -114
View File
@@ -18,7 +18,10 @@ using JsonExtensions.Reading;
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 TokenKind? _resolvedTokenKind;
@@ -47,33 +50,41 @@ public class DiscordClient(string token)
innerCancellationToken
);
// 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
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)
// Discord has advisory rate limits (communicated via response headers), but they are typically
// way stricter than the actual rate limits enforced by the server.
// The user may choose to ignore the advisory rate limits and only retry on hard rate limits,
// if they want to prioritize speed over compliance (and safety of their account/bot).
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1021
if (rateLimitPreference.IsRespectedFor(tokenKind))
{
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));
var remainingRequestCount = response
.Headers.TryGetValue("X-RateLimit-Remaining")
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
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;
@@ -148,14 +159,14 @@ public class DiscordClient(string token)
_ => throw new DiscordChatExporterException(
$"""
Request to '{url}' failed: {response
.StatusCode.ToString()
.ToSpaceSeparatedWords()
.ToLowerInvariant()}.
Response content: {await response.Content.ReadAsStringAsync(
cancellationToken
)}
""",
Request to '{url}' failed: {response
.StatusCode.ToString()
.ToSpaceSeparatedWords()
.ToLowerInvariant()}.
Response content: {await response.Content.ReadAsStringAsync(
cancellationToken
)}
""",
true
),
};
@@ -311,58 +322,14 @@ public class DiscordClient(string token)
// User accounts can only fetch threads using the search endpoint
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
{
// Active threads
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)
// 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)
{
@@ -370,7 +337,7 @@ public class DiscordClient(string token)
.SetPath($"channels/{channel.Id}/threads/search")
.SetQueryParameter("sort_by", "last_message_time")
.SetQueryParameter("sort_order", "desc")
.SetQueryParameter("archived", "true")
.SetQueryParameter("archived", isArchived.ToString().ToLowerInvariant())
.SetQueryParameter("offset", currentOffset.ToString())
.Build();
@@ -388,7 +355,7 @@ public class DiscordClient(string token)
var thread = Channel.Parse(threadJson, channel);
// If the 'after' boundary is specified, we can break early,
// because threads are sorted by last message time.
// because threads are sorted by last message timestamp.
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
{
breakOuter = true;
@@ -437,38 +404,44 @@ public class DiscordClient(string token)
{
foreach (var channel in channels)
{
// Public archived threads
foreach (var archiveType in new[] { "public", "private" })
{
// Can be null on certain channels
var response = await TryGetJsonResponseAsync(
$"channels/{channel.Id}/threads/archived/public",
cancellationToken
);
// This endpoint parameter expects an ISO8601 timestamp, not a snowflake
var currentBefore = before
?.ToDate()
.ToString("O", CultureInfo.InvariantCulture);
if (response is null)
continue;
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();
foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
yield return Channel.Parse(threadJson, channel);
}
// Can be null on certain channels
var response = await TryGetJsonResponseAsync(url, cancellationToken);
if (response is null)
break;
// Private archived threads
{
// Can be null on certain channels
var response = await TryGetJsonResponseAsync(
$"channels/{channel.Id}/threads/archived/private",
cancellationToken
);
foreach (
var threadJson in response
.Value.GetProperty("threads")
.EnumerateArray()
)
{
var thread = Channel.Parse(threadJson, channel);
yield return thread;
if (response is null)
continue;
currentBefore = threadJson
.GetProperty("thread_metadata")
.GetProperty("archive_timestamp")
.GetString();
}
foreach (
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
)
yield return Channel.Parse(threadJson, channel);
if (!response.Value.GetProperty("has_more").GetBoolean())
break;
}
}
}
}
@@ -688,9 +661,7 @@ public class DiscordClient(string token)
count++;
}
// Each batch can contain up to 100 users.
// If we got fewer, then it's definitely the last batch.
if (count < 100)
if (count <= 0)
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,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<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="Gress" Version="2.1.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" />
<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="WebMarkupMin.Core" Version="2.17.0" />
<PackageReference Include="YoutubeExplode" Version="6.5.3" />
<PackageReference Include="YoutubeExplode" Version="6.5.4" />
</ItemGroup>
</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
var context = new ExportContext(discord, request);
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);
// 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 (
var message in discord.GetMessagesAsync(
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; }
private async ValueTask ResetWriterAsync(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;
}
}
}
private async ValueTask<MessageWriter> GetWriterAsync(
private async ValueTask<MessageWriter> InitializeWriterAsync(
CancellationToken cancellationToken = default
)
{
@@ -43,7 +26,7 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
)
)
{
await ResetWriterAsync(cancellationToken);
await UninitializeWriterAsync(cancellationToken);
_partitionIndex++;
}
@@ -60,17 +43,41 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
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(
Message message,
CancellationToken cancellationToken = default
)
{
var writer = await GetWriterAsync(cancellationToken);
var writer = await InitializeWriterAsync(cancellationToken);
await writer.WriteMessageAsync(message, cancellationToken);
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
+3 -3
View File
@@ -1,4 +1,4 @@
using System.Net;
using System;
using System.Text;
namespace DiscordChatExporter.Core.Utils;
@@ -18,12 +18,12 @@ public static class Url
var separatorIndex = filePath.IndexOfAny([':', '/', '\\'], position);
if (separatorIndex < 0)
{
buffer.Append(WebUtility.UrlEncode(filePath[position..]));
buffer.Append(Uri.EscapeDataString(filePath[position..]));
break;
}
// Append the segment
buffer.Append(WebUtility.UrlEncode(filePath[position..separatorIndex]));
buffer.Append(Uri.EscapeDataString(filePath[position..separatorIndex]));
// Append the separator
buffer.Append(
+5 -3
View File
@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Utils;
@@ -25,8 +25,8 @@ public class UrlBuilder
if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value))
return this;
var keyEncoded = WebUtility.UrlEncode(key);
var valueEncoded = WebUtility.UrlEncode(value);
var keyEncoded = Uri.EscapeDataString(key);
var valueEncoded = value?.Pipe(Uri.EscapeDataString);
_queryParameters[keyEncoded] = valueEncoded;
return this;
@@ -39,9 +39,11 @@ public class UrlBuilder
buffer.Append(_path);
if (_queryParameters.Any())
{
buffer
.Append('?')
.AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
}
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,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
@@ -15,9 +15,9 @@
<ItemGroup>
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.3.0" />
<PackageReference Include="Avalonia" Version="11.2.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.5" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Avalonia" Version="11.3.0" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.0" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Cogwheel" Version="2.1.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
@@ -26,12 +26,16 @@
<PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="Material.Avalonia" Version="3.9.2" />
<PackageReference Include="Material.Icons.Avalonia" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
<PackageReference Include="Onova" Version="2.6.12" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" />
<PackageReference Include="Onova" Version="2.6.13" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
</ItemGroup>
<Target Name="PublishMacOSBundle" AfterTargets="Publish" Condition="$(PublishMacOSBundle)">
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Publish-MacOSBundle.ps1 -PublishDirPath $(PublishDir) -IconsFilePath $(ProjectDir)/../favicon.icns -FullVersion $(Version) -ShortVersion $(AssemblyVersion)" LogStandardErrorAsError="true" />
</Target>
</Project>
@@ -11,7 +11,7 @@ public abstract partial class DialogViewModelBase<T> : ViewModelBase
);
[ObservableProperty]
private T? _dialogResult;
public partial T? DialogResult { get; set; }
[RelayCommand]
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 Cogwheel;
using CommunityToolkit.Mvvm.ComponentModel;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Models;
namespace DiscordChatExporter.Gui.Services;
// Can't use [ObservableProperty] here because System.Text.Json's source generator doesn't see
// the generated properties.
[INotifyPropertyChanged]
[ObservableObject]
public partial class SettingsService()
: SettingsBase(
Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
SerializerContext.Default
)
{
private bool _isUkraineSupportMessageEnabled = true;
public bool IsUkraineSupportMessageEnabled
{
get => _isUkraineSupportMessageEnabled;
set => SetProperty(ref _isUkraineSupportMessageEnabled, value);
}
[ObservableProperty]
public partial bool IsUkraineSupportMessageEnabled { get; set; } = true;
private ThemeVariant _theme;
public ThemeVariant Theme
{
get => _theme;
set => SetProperty(ref _theme, value);
}
[ObservableProperty]
public partial ThemeVariant Theme { get; set; }
private bool _isAutoUpdateEnabled = true;
public bool IsAutoUpdateEnabled
{
get => _isAutoUpdateEnabled;
set => SetProperty(ref _isAutoUpdateEnabled, value);
}
[ObservableProperty]
public partial bool IsAutoUpdateEnabled { get; set; } = true;
private bool _isTokenPersisted = true;
public bool IsTokenPersisted
{
get => _isTokenPersisted;
set => SetProperty(ref _isTokenPersisted, value);
}
[ObservableProperty]
public partial bool IsTokenPersisted { get; set; } = true;
private ThreadInclusionMode _threadInclusionMode;
public ThreadInclusionMode ThreadInclusionMode
{
get => _threadInclusionMode;
set => SetProperty(ref _threadInclusionMode, value);
}
[ObservableProperty]
public partial RateLimitPreference RateLimitPreference { get; set; } =
RateLimitPreference.RespectAll;
private string? _locale;
public string? Locale
{
get => _locale;
set => SetProperty(ref _locale, value);
}
[ObservableProperty]
public partial ThreadInclusionMode ThreadInclusionMode { get; set; }
private bool _isUtcNormalizationEnabled;
public bool IsUtcNormalizationEnabled
{
get => _isUtcNormalizationEnabled;
set => SetProperty(ref _isUtcNormalizationEnabled, value);
}
[ObservableProperty]
public partial string? Locale { get; set; }
private int _parallelLimit = 1;
public int ParallelLimit
{
get => _parallelLimit;
set => SetProperty(ref _parallelLimit, value);
}
[ObservableProperty]
public partial bool IsUtcNormalizationEnabled { get; set; }
private string? _lastToken;
public string? LastToken
{
get => _lastToken;
set => SetProperty(ref _lastToken, value);
}
[ObservableProperty]
public partial int ParallelLimit { get; set; } = 1;
private ExportFormat _lastExportFormat = ExportFormat.HtmlDark;
public ExportFormat LastExportFormat
{
get => _lastExportFormat;
set => SetProperty(ref _lastExportFormat, value);
}
[ObservableProperty]
public partial string? LastToken { get; set; }
private string? _lastPartitionLimitValue;
public string? LastPartitionLimitValue
{
get => _lastPartitionLimitValue;
set => SetProperty(ref _lastPartitionLimitValue, value);
}
[ObservableProperty]
public partial ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
private string? _lastMessageFilterValue;
public string? LastMessageFilterValue
{
get => _lastMessageFilterValue;
set => SetProperty(ref _lastMessageFilterValue, value);
}
[ObservableProperty]
public partial string? LastPartitionLimitValue { get; set; }
private bool _lastShouldFormatMarkdown = true;
public bool LastShouldFormatMarkdown
{
get => _lastShouldFormatMarkdown;
set => SetProperty(ref _lastShouldFormatMarkdown, value);
}
[ObservableProperty]
public partial string? LastMessageFilterValue { get; set; }
private bool _lastShouldDownloadAssets;
public bool LastShouldDownloadAssets
{
get => _lastShouldDownloadAssets;
set => SetProperty(ref _lastShouldDownloadAssets, value);
}
[ObservableProperty]
public partial bool LastShouldFormatMarkdown { get; set; } = true;
private bool _lastShouldReuseAssets;
public bool LastShouldReuseAssets
{
get => _lastShouldReuseAssets;
set => SetProperty(ref _lastShouldReuseAssets, value);
}
[ObservableProperty]
public partial bool LastShouldDownloadAssets { get; set; }
private string? _lastAssetsDirPath;
public string? LastAssetsDirPath
{
get => _lastAssetsDirPath;
set => SetProperty(ref _lastAssetsDirPath, value);
}
[ObservableProperty]
public partial bool LastShouldReuseAssets { get; set; }
[ObservableProperty]
public partial string? LastAssetsDirPath { get; set; }
public override void Save()
{
@@ -33,28 +33,6 @@ public partial class DashboardViewModel : ViewModelBase
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(
ViewModelManager viewModelManager,
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 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; } = [];
[RelayCommand]
@@ -123,7 +123,7 @@ public partial class DashboardViewModel : ViewModelBase
AvailableChannels = null;
SelectedChannels.Clear();
_discord = new DiscordClient(token);
_discord = new DiscordClient(token, _settingsService.RateLimitPreference);
_settingsService.LastToken = token;
var guilds = await _discord.GetUserGuildsAsync();
@@ -283,6 +283,10 @@ public partial class DashboardViewModel : ViewModelBase
Interlocked.Increment(ref successfulExportCount);
}
catch (ChannelEmptyException ex)
{
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
}
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
{
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
@@ -22,56 +22,56 @@ public partial class ExportSetupViewModel(
) : DialogViewModelBase
{
[ObservableProperty]
private Guild? _guild;
public partial Guild? Guild { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsSingleChannel))]
private IReadOnlyList<Channel>? _channels;
public partial IReadOnlyList<Channel>? Channels { get; set; }
[ObservableProperty]
private string? _outputPath;
public partial string? OutputPath { get; set; }
[ObservableProperty]
private ExportFormat _selectedFormat;
public partial ExportFormat SelectedFormat { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAfterDateSet))]
[NotifyPropertyChangedFor(nameof(After))]
private DateTimeOffset? _afterDate;
public partial DateTimeOffset? AfterDate { get; set; }
[ObservableProperty]
private TimeSpan? _afterTime;
public partial TimeSpan? AfterTime { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsBeforeDateSet))]
[NotifyPropertyChangedFor(nameof(Before))]
private DateTimeOffset? _beforeDate;
public partial DateTimeOffset? BeforeDate { get; set; }
[ObservableProperty]
private TimeSpan? _beforeTime;
public partial TimeSpan? BeforeTime { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PartitionLimit))]
private string? _partitionLimitValue;
public partial string? PartitionLimitValue { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(MessageFilter))]
private string? _messageFilterValue;
public partial string? MessageFilterValue { get; set; }
[ObservableProperty]
private bool _shouldFormatMarkdown;
public partial bool ShouldFormatMarkdown { get; set; }
[ObservableProperty]
private bool _shouldDownloadAssets;
public partial bool ShouldDownloadAssets { get; set; }
[ObservableProperty]
private bool _shouldReuseAssets;
public partial bool ShouldReuseAssets { get; set; }
[ObservableProperty]
private string? _assetsDirPath;
public partial string? AssetsDirPath { get; set; }
[ObservableProperty]
private bool _isAdvancedSectionDisplayed;
public partial bool IsAdvancedSectionDisplayed { get; set; }
public bool IsSingleChannel => Channels?.Count == 1;
@@ -6,20 +6,20 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
public partial class MessageBoxViewModel : DialogViewModelBase
{
[ObservableProperty]
private string? _title = "Title";
public partial string? Title { get; set; } = "Title";
[ObservableProperty]
private string? _message = "Message";
public partial string? Message { get; set; } = "Message";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDefaultButtonVisible))]
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
private string? _defaultButtonText = "OK";
public partial string? DefaultButtonText { get; set; } = "OK";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsCancelButtonVisible))]
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
private string? _cancelButtonText = "Cancel";
public partial string? CancelButtonText { get; set; } = "Cancel";
public bool IsDefaultButtonVisible => !string.IsNullOrWhiteSpace(DefaultButtonText);
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils.Extensions;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Models;
@@ -42,6 +43,15 @@ public class SettingsViewModel : DialogViewModelBase
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; } =
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.
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",
"CLOSE"
@@ -332,6 +332,9 @@
Text="To get the token for your bot:" />
<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" />
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="developer portal" />
<LineBreak />
@@ -356,7 +359,18 @@
<Run
BaselineAlignment="Center"
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 />
<Run BaselineAlignment="Center" Text="* Your bot needs to have the" />
@@ -54,6 +54,25 @@
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
</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 -->
<DockPanel
Margin="16,8"
+2 -2
View File
@@ -7,7 +7,7 @@
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
Title="{Binding Title}"
Width="625"
Height="625"
Height="665"
MinWidth="600"
MinHeight="400"
x:DataType="viewModels:MainViewModel"
@@ -18,7 +18,7 @@
x:Name="DialogHost"
CloseOnClickAway="False"
Loaded="DialogHost_OnLoaded">
<materialStyles:SnackbarHost HostName="Root">
<materialStyles:SnackbarHost HostName="Root" SnackbarMaxCounts="3">
<ContentControl Content="{Binding Dashboard}" />
</materialStyles:SnackbarHost>
</dialogHostAvalonia:DialogHost>
BIN
View File
Binary file not shown.