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 # 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.
![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? ### 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 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). > ❓ 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 ### 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 ### Step 2
+1 -1
View File
@@ -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
+15 -7
View File
@@ -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@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2
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,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
@@ -11,15 +11,15 @@
</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="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="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.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.2" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.2" /> <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.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.0.2" PrivateAssets="all" />
@@ -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();
}
} }
@@ -27,8 +27,19 @@ public abstract class DiscordCommandBase : ICommand
)] )]
public bool IsBotToken { get; init; } = false; 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; 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) public virtual ValueTask ExecuteAsync(IConsole console)
{ {
@@ -179,6 +179,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
// Export // Export
var cancellationToken = console.RegisterCancellationHandler(); 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 {channels.Count} channel(s)...");
await console await console
@@ -236,6 +237,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;
@@ -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 // Print errors
if (errorsByChannel.Any()) if (errorsByChannel.Any())
{ {
@@ -259,16 +286,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();
@@ -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,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
@@ -12,7 +12,7 @@
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="0.30.6" 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>
+85 -114
View File
@@ -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
), ),
}; };
@@ -311,58 +322,14 @@ public class DiscordClient(string token)
// User accounts can only fetch threads using the search endpoint // User accounts can only fetch threads using the search endpoint
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User) if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
{ {
// Active threads
foreach (var channel in channels) foreach (var channel in channels)
{ {
var currentOffset = 0; // Either include both active and archived threads, or only active threads
while (true) foreach (
{ var isArchived in includeArchived ? new[] { false, true } : new[] { false }
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)
{ {
// Offset is just the index of the last thread in the previous batch
var currentOffset = 0; var currentOffset = 0;
while (true) while (true)
{ {
@@ -370,7 +337,7 @@ public class DiscordClient(string token)
.SetPath($"channels/{channel.Id}/threads/search") .SetPath($"channels/{channel.Id}/threads/search")
.SetQueryParameter("sort_by", "last_message_time") .SetQueryParameter("sort_by", "last_message_time")
.SetQueryParameter("sort_order", "desc") .SetQueryParameter("sort_order", "desc")
.SetQueryParameter("archived", "true") .SetQueryParameter("archived", isArchived.ToString().ToLowerInvariant())
.SetQueryParameter("offset", currentOffset.ToString()) .SetQueryParameter("offset", currentOffset.ToString())
.Build(); .Build();
@@ -388,7 +355,7 @@ public class DiscordClient(string token)
var thread = Channel.Parse(threadJson, channel); var thread = Channel.Parse(threadJson, channel);
// If the 'after' boundary is specified, we can break early, // 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)) if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
{ {
breakOuter = true; breakOuter = true;
@@ -437,38 +404,44 @@ public class DiscordClient(string token)
{ {
foreach (var channel in channels) foreach (var channel in channels)
{ {
// Public archived threads foreach (var archiveType in new[] { "public", "private" })
{ {
// Can be null on certain channels // This endpoint parameter expects an ISO8601 timestamp, not a snowflake
var response = await TryGetJsonResponseAsync( var currentBefore = before
$"channels/{channel.Id}/threads/archived/public", ?.ToDate()
cancellationToken .ToString("O", CultureInfo.InvariantCulture);
);
if (response is null) while (true)
continue; {
// 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 ( // Can be null on certain channels
var threadJson in response.Value.GetProperty("threads").EnumerateArray() var response = await TryGetJsonResponseAsync(url, cancellationToken);
) if (response is null)
yield return Channel.Parse(threadJson, channel); break;
}
// Private archived threads foreach (
{ var threadJson in response
// Can be null on certain channels .Value.GetProperty("threads")
var response = await TryGetJsonResponseAsync( .EnumerateArray()
$"channels/{channel.Id}/threads/archived/private", )
cancellationToken {
); var thread = Channel.Parse(threadJson, channel);
yield return thread;
if (response is null) currentBefore = threadJson
continue; .GetProperty("thread_metadata")
.GetProperty("archive_timestamp")
.GetString();
}
foreach ( if (!response.Value.GetProperty("has_more").GetBoolean())
var threadJson in response.Value.GetProperty("threads").EnumerateArray() break;
) }
yield return Channel.Parse(threadJson, channel);
} }
} }
} }
@@ -688,9 +661,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,15 @@
<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="0.30.6" 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.17.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
+3 -3
View File
@@ -1,4 +1,4 @@
using System.Net; using System;
using System.Text; using System.Text;
namespace DiscordChatExporter.Core.Utils; namespace DiscordChatExporter.Core.Utils;
@@ -18,12 +18,12 @@ public static class Url
var separatorIndex = filePath.IndexOfAny([':', '/', '\\'], position); var separatorIndex = filePath.IndexOfAny([':', '/', '\\'], position);
if (separatorIndex < 0) if (separatorIndex < 0)
{ {
buffer.Append(WebUtility.UrlEncode(filePath[position..])); buffer.Append(Uri.EscapeDataString(filePath[position..]));
break; break;
} }
// Append the segment // Append the segment
buffer.Append(WebUtility.UrlEncode(filePath[position..separatorIndex])); buffer.Append(Uri.EscapeDataString(filePath[position..separatorIndex]));
// Append the separator // Append the separator
buffer.Append( buffer.Append(
+5 -3
View File
@@ -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,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
@@ -15,9 +15,9 @@
<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="0.30.6" PrivateAssets="all" />
@@ -26,12 +26,16 @@
<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.4" />
<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)">
<Exec Command="pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Publish-MacOSBundle.ps1 -PublishDirPath $(PublishDir) -IconsFilePath $(ProjectDir)/../favicon.icns -FullVersion $(Version) -ShortVersion $(AssemblyVersion)" LogStandardErrorAsError="true" />
</Target>
</Project> </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"
+2 -2
View File
@@ -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>
BIN
View File
Binary file not shown.