mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 72f9e981de | |||
| 81e286751f | |||
| 225230a20f | |||
| dea412ff25 | |||
| 95953f8cf0 | |||
| 6191855e33 | |||
| 33a975b907 | |||
| 3d353bc1e6 | |||
| f555ab3f00 | |||
| a14a17db83 | |||
| 1f63d4fa26 | |||
| d3d078735a | |||
| 58dd40bf3e | |||
| 7c79e0639b | |||
| a74ba4da0c | |||
| 8bbb8fa5ee | |||
| a91337ccac | |||
| 303f5667bf | |||
| 934cb526e9 | |||
| c33b742dce | |||
| f0a74fbc6e | |||
| 768e124370 | |||
| 8dc7eb2937 | |||
| 8fff0f4445 | |||
| 0fc8124aa0 | |||
| 64ed6acf34 | |||
| e958600073 | |||
| 714d2e6806 | |||
| fbbac2afaa | |||
| 380dd6d511 | |||
| 607ec7442f | |||
| e6f675bcaa | |||
| 42ebb9928e | |||
| 7e64aa5a8e | |||
| a383722469 | |||
| 8f3fe7e501 | |||
| 27a434a663 | |||
| a966bee140 | |||
| fb17e86146 | |||
| 8fcf5e5377 | |||
| 845e2a52fe | |||
| 187befcf11 | |||
| 9e685a0375 | |||
| 0d98e1084d | |||
| f149fad406 | |||
| 5405e619a9 | |||
| adafaaa49d | |||
| d071459a5b | |||
| cc92b2cbe6 | |||
| 08718425f1 | |||
| 540ba34fb4 | |||
| 22134108a4 | |||
| 82499af25a | |||
| 4a74ff8a02 | |||
| 7dfcf5ac19 | |||
| 19c5fb40b2 | |||
| d4fa8f0954 | |||
| 0d9168e0d2 | |||
| aa8ace8389 | |||
| 97cb8e2347 | |||
| 92ee97a5da | |||
| 612ae2e894 | |||
| 1506afc4a2 | |||
| b56c7db3ec | |||
| 59d803d9f1 | |||
| 30ba273fb1 | |||
| f9fa35b6ad | |||
| 7d2ebb04de | |||
| 1c9f4275b2 | |||
| 1fadc0755b | |||
| 7ddd55951c | |||
| 6fb197cf0b | |||
| 7add81a472 | |||
| cf7580014c | |||
| 606c082759 | |||
| 5bbb706b3c | |||
| deca3fc1bf | |||
| fca6729ef0 | |||
| 6f877cf543 |
@@ -33,7 +33,7 @@ The HTML format replicates Discord's interface, making it the most user-friendly
|
||||
It's the best format for attachment preview and sharing.
|
||||
You can open `.html` files with a web browser, such as Google Chrome.
|
||||
|
||||
> **Warning**:
|
||||
> [!WARNING]
|
||||
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed.
|
||||
> Export using the "Download assets" (`--media`) option to avoid this.
|
||||
|
||||
|
||||
+56
-11
@@ -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.
|
||||
|
||||
@@ -26,7 +27,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
3. Type
|
||||
|
||||
```js
|
||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
||||
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.$8&&x.exports.LP&&x.exports.gK){m=x;break}}}]);m&&console.log("Token:",m.exports.LP());
|
||||
```
|
||||
|
||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||
@@ -119,7 +120,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
||||
1. Type
|
||||
|
||||
```js
|
||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
||||
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.$8&&x.exports.LP&&x.exports.gK){m=x;break}}}]);m&&console.log("Token:",m.exports.LP());
|
||||
```
|
||||
|
||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||
@@ -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
|
||||
|
||||
> **Warning**:
|
||||
> Your bot needs to have **Message Content Intent** enabled for it to be able to read messages!
|
||||
<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]
|
||||
> Resetting the token will invalidate the old one. Any integrations relying on the old token will cease to function until they are updated.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Follow the [instructions here](Token-and-IDs.md).
|
||||
|
||||
### Will I get banned if I use this?
|
||||
|
||||
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 can’t be opened. You should move it to the Trash.
|
||||
|
||||
Check the [Using the GUI page](Using-the-GUI.md#step-1) for instructions on how to run the app.
|
||||
|
||||
---
|
||||
|
||||
> ❓ If you still have unanswered questions, feel free to [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new).
|
||||
|
||||
+17
-2
@@ -10,13 +10,28 @@
|
||||
|
||||
### Step 1
|
||||
|
||||
After extracting the `.zip`, run `DiscordChatExporter.exe` (Windows), or `DiscordChatExporter` (Mac OS and Linux).
|
||||
After extracting the `.zip`, run `DiscordChatExporter.exe` **(Windows)**, or `DiscordChatExporter` **(Linux)**.
|
||||
|
||||
If you're using **macOS**, you'll need to manually grant permission for the app to run.
|
||||
If you skip these steps, the "DiscordChatExporter is damaged and can’t be opened" error will be shown.
|
||||
|
||||
1. Open Terminal.app. You can search for it in Spotlight (press <kbd>⌘</kbd> + <kbd>Space</kbd> and type "Terminal").
|
||||
2. Paste the following into the terminal window:
|
||||
```bash
|
||||
xattr -rd com.apple.quarantine
|
||||
```
|
||||
3. Hit <kbd>Space</kbd> once to add a space after the command
|
||||
4. Drag and drop DiscordChatExporter.app into the terminal window
|
||||
5. Press <kbd>Return</kbd> to run the command
|
||||
6. Open DiscordChatExporter.app normally
|
||||
|
||||
> Apple requires apps to be notarized and signed in order to run on macOS without warnings, which in turn requires an Apple Developer membership ($99/year). This open-source project is distributed for free and without commercial intent.
|
||||
|
||||
### Step 2
|
||||
|
||||
Please refer to the on-screen instructions to get your token, then paste your token in the upper text box and hit ENTER or click the arrow (→).
|
||||
|
||||
> **Warning**:
|
||||
> [!WARNING]
|
||||
> **Never share your token!**
|
||||
> A token gives full access to an account, treat it like a password.
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build image
|
||||
run: >
|
||||
@@ -36,8 +36,8 @@ jobs:
|
||||
--build-arg VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||
- name: Upload image
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: DiscordChatExporter.Cli.Docker
|
||||
path: DiscordChatExporter.Cli.Docker.tar
|
||||
@@ -55,10 +55,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Login to DockerHub
|
||||
run: >
|
||||
|
||||
+17
-18
@@ -26,12 +26,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
# Build the project separately to discern between build and format errors
|
||||
- name: Build
|
||||
@@ -64,12 +62,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -85,7 +81,7 @@ jobs:
|
||||
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -121,25 +117,24 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install .NET
|
||||
uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||
|
||||
- name: Publish app
|
||||
run: >
|
||||
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
|
||||
- name: Upload app binaries
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||
path: ${{ matrix.app }}/bin/publish/
|
||||
@@ -203,15 +198,19 @@ jobs:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
|
||||
- name: Download app binaries
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.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: |
|
||||
[ -f ${{ matrix.app }}/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }} || true
|
||||
|
||||
# macOS bundle
|
||||
[ -f ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} || true
|
||||
|
||||
- name: Create package
|
||||
# Change into the artifacts directory to avoid including the directory itself in the zip archive
|
||||
|
||||
+1
-1
@@ -9,4 +9,4 @@ bin/
|
||||
obj/
|
||||
|
||||
# Test results
|
||||
TestResults/
|
||||
TestResults/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>999.9.9-dev</Version>
|
||||
<Company>Tyrrrz</Company>
|
||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
@@ -11,22 +10,24 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.2.0" />
|
||||
<PackageReference Include="AngleSharp" Version="1.4.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="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="3.0.1" PrivateAssets="all" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="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.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
|
||||
<PackageReference
|
||||
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
||||
Version="10.0.2"
|
||||
/>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" PrivateAssets="all" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -8,5 +8,5 @@ In order to run these tests locally, you need to join the test server and config
|
||||
3. Add your token to user secrets: `dotnet user-secrets set DISCORD_TOKEN <token>`
|
||||
4. Run the tests: `dotnet test`
|
||||
|
||||
> **Note**:
|
||||
> [!NOTE]
|
||||
> If you want to add a new test case, please let me know and I will give you the required permissions on the server.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,8 +181,14 @@ public class HtmlEmbedSpecs
|
||||
);
|
||||
|
||||
// Assert
|
||||
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
|
||||
iframeUrl.Should().StartWith("https://www.youtube.com/embed/qOWW4OlgbvE");
|
||||
// Check that the YouTube video thumbnail image exists with the correct video ID
|
||||
var youtubeThumbnailSrc = message
|
||||
.QuerySelectorAll("img")
|
||||
.Select(e => e.GetAttribute("src"))
|
||||
.WhereNotNull()
|
||||
.FirstOrDefault(s => s.Contains("qOWW4OlgbvE", StringComparison.Ordinal));
|
||||
|
||||
youtubeThumbnailSrc.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -4,13 +4,16 @@ namespace DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||
|
||||
internal static class StringExtensions
|
||||
{
|
||||
public static string ReplaceWhiteSpace(this string str, string replacement = " ")
|
||||
extension(string str)
|
||||
{
|
||||
var buffer = new StringBuilder(str.Length);
|
||||
public string ReplaceWhiteSpace(string replacement = " ")
|
||||
{
|
||||
var buffer = new StringBuilder(str.Length);
|
||||
|
||||
foreach (var ch in str)
|
||||
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
|
||||
foreach (var ch in str)
|
||||
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
|
||||
|
||||
return buffer.ToString();
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using PathEx = System.IO.Path;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||
|
||||
@@ -23,8 +22,8 @@ internal partial class TempDir
|
||||
{
|
||||
public static TempDir Create()
|
||||
{
|
||||
var dirPath = PathEx.Combine(
|
||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
var dirPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
?? Directory.GetCurrentDirectory(),
|
||||
"Temp",
|
||||
Guid.NewGuid().ToString()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using PathEx = System.IO.Path;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||
|
||||
@@ -23,15 +22,15 @@ internal partial class TempFile
|
||||
{
|
||||
public static TempFile Create()
|
||||
{
|
||||
var dirPath = PathEx.Combine(
|
||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
var dirPath = System.IO.Path.Combine(
|
||||
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||
?? Directory.GetCurrentDirectory(),
|
||||
"Temp"
|
||||
);
|
||||
|
||||
Directory.CreateDirectory(dirPath);
|
||||
|
||||
var filePath = PathEx.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
||||
var filePath = System.IO.Path.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
||||
|
||||
return new TempFile(filePath);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -- Build
|
||||
# Specify the platform here so that we pull the SDK image matching the host platform,
|
||||
# instead of the target platform specified during build by the `--platform` option.
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||
|
||||
# Expose the target architecture set by the `docker build --platform` option, so that
|
||||
# we can build the assembly for the correct platform.
|
||||
@@ -30,7 +30,7 @@ RUN dotnet publish DiscordChatExporter.Cli \
|
||||
|
||||
# -- Run
|
||||
# Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
|
||||
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:9.0-alpine AS run
|
||||
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS run
|
||||
|
||||
LABEL org.opencontainers.image.title="DiscordChatExporter.Cli"
|
||||
LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading.Tasks;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
@@ -27,8 +28,19 @@ public abstract class DiscordCommandBase : ICommand
|
||||
)]
|
||||
public bool IsBotToken { get; init; } = false;
|
||||
|
||||
private DiscordClient? _discordClient;
|
||||
protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token);
|
||||
[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;
|
||||
|
||||
[field: AllowNull, MaybeNull]
|
||||
protected DiscordClient Discord =>
|
||||
field ??= new DiscordClient(
|
||||
Token,
|
||||
ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
|
||||
);
|
||||
|
||||
public virtual ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@@ -8,6 +9,7 @@ using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
@@ -15,7 +17,6 @@ using DiscordChatExporter.Core.Exceptions;
|
||||
using DiscordChatExporter.Core.Exporting;
|
||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
using Gress;
|
||||
using Spectre.Console;
|
||||
|
||||
@@ -23,8 +24,6 @@ namespace DiscordChatExporter.Cli.Commands.Base;
|
||||
|
||||
public abstract class ExportCommandBase : DiscordCommandBase
|
||||
{
|
||||
private readonly string _outputPath = Directory.GetCurrentDirectory();
|
||||
|
||||
[CommandOption(
|
||||
"output",
|
||||
'o',
|
||||
@@ -35,11 +34,11 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public string OutputPath
|
||||
{
|
||||
get => _outputPath;
|
||||
get;
|
||||
// Handle ~/ in paths on Unix systems
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||
init => _outputPath = Path.GetFullPath(value);
|
||||
}
|
||||
init => field = Path.GetFullPath(value);
|
||||
} = Directory.GetCurrentDirectory();
|
||||
|
||||
[CommandOption("format", 'f', Description = "Export format.")]
|
||||
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
||||
@@ -64,6 +63,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
||||
|
||||
[CommandOption(
|
||||
"include-threads",
|
||||
Description = "Which types of threads should be included.",
|
||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
||||
)]
|
||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
||||
|
||||
[CommandOption(
|
||||
"filter",
|
||||
Description = "Only include messages that satisfy this filter. "
|
||||
@@ -95,8 +101,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public bool ShouldReuseAssets { get; init; } = false;
|
||||
|
||||
private readonly string? _assetsDirPath;
|
||||
|
||||
[CommandOption(
|
||||
"media-dir",
|
||||
Description = "Download assets to this directory. "
|
||||
@@ -104,10 +108,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public string? AssetsDirPath
|
||||
{
|
||||
get => _assetsDirPath;
|
||||
get;
|
||||
// Handle ~/ in paths on Unix systems
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||
init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null;
|
||||
init => field = value is not null ? Path.GetFullPath(value) : null;
|
||||
}
|
||||
|
||||
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
||||
@@ -136,11 +140,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
)]
|
||||
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
|
||||
|
||||
private ChannelExporter? _channelExporter;
|
||||
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord);
|
||||
[field: AllowNull, MaybeNull]
|
||||
protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
|
||||
|
||||
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||
{
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
// Asset reuse can only be enabled if the download assets option is set
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
|
||||
if (ShouldReuseAssets && !ShouldDownloadAssets)
|
||||
@@ -154,13 +160,53 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
throw new CommandException("Option --media-dir cannot be used without --media.");
|
||||
}
|
||||
|
||||
var unwrappedChannels = new List<Channel>(channels);
|
||||
|
||||
// Unwrap threads
|
||||
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
||||
{
|
||||
await console.Output.WriteLineAsync("Fetching threads...");
|
||||
|
||||
var fetchedThreadsCount = 0;
|
||||
await console
|
||||
.CreateStatusTicker()
|
||||
.StartAsync(
|
||||
"...",
|
||||
async ctx =>
|
||||
{
|
||||
await foreach (
|
||||
var thread in Discord.GetChannelThreadsAsync(
|
||||
channels,
|
||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
||||
Before,
|
||||
After,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
unwrappedChannels.Add(thread);
|
||||
|
||||
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
|
||||
|
||||
fetchedThreadsCount++;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Remove forums, as they cannot be exported directly and their constituent threads
|
||||
// have already been fetched.
|
||||
unwrappedChannels.RemoveAll(channel => channel.Kind == ChannelKind.GuildForum);
|
||||
|
||||
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
||||
}
|
||||
|
||||
// Make sure the user does not try to export multiple channels into one file.
|
||||
// Output path must either be a directory or contain template tokens for this to work.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
||||
var isValidOutputPath =
|
||||
// Anything is valid when exporting a single channel
|
||||
channels.Count <= 1
|
||||
unwrappedChannels.Count <= 1
|
||||
// When using template tokens, assume the user knows what they're doing
|
||||
|| OutputPath.Contains('%')
|
||||
// Otherwise, require an existing directory or an unambiguous directory path
|
||||
@@ -177,10 +223,10 @@ 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.Output.WriteLineAsync($"Exporting {unwrappedChannels.Count} channel(s)...");
|
||||
await console
|
||||
.CreateProgressTicker()
|
||||
.HideCompleted(
|
||||
@@ -192,7 +238,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
.StartAsync(async ctx =>
|
||||
{
|
||||
await Parallel.ForEachAsync(
|
||||
channels,
|
||||
unwrappedChannels,
|
||||
new ParallelOptions
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
|
||||
@@ -236,6 +282,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
}
|
||||
);
|
||||
}
|
||||
catch (ChannelEmptyException ex)
|
||||
{
|
||||
warningsByChannel[channel] = ex.Message;
|
||||
}
|
||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||
{
|
||||
errorsByChannel[channel] = ex.Message;
|
||||
@@ -248,10 +298,32 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
using (console.WithForegroundColor(ConsoleColor.White))
|
||||
{
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Successfully exported {channels.Count - errorsByChannel.Count} channel(s)."
|
||||
$"Successfully exported {unwrappedChannels.Count - errorsByChannel.Count} channel(s)."
|
||||
);
|
||||
}
|
||||
|
||||
// Print warnings
|
||||
if (warningsByChannel.Any())
|
||||
{
|
||||
await console.Output.WriteLineAsync();
|
||||
|
||||
using (console.WithForegroundColor(ConsoleColor.Yellow))
|
||||
{
|
||||
await console.Error.WriteLineAsync(
|
||||
"Warnings reported for the following channel(s):"
|
||||
);
|
||||
}
|
||||
|
||||
foreach (var (channel, message) in warningsByChannel)
|
||||
{
|
||||
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
|
||||
using (console.WithForegroundColor(ConsoleColor.Yellow))
|
||||
await console.Error.WriteLineAsync(message);
|
||||
}
|
||||
|
||||
await console.Error.WriteLineAsync();
|
||||
}
|
||||
|
||||
// Print errors
|
||||
if (errorsByChannel.Any())
|
||||
{
|
||||
@@ -259,16 +331,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();
|
||||
@@ -276,48 +346,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
||||
|
||||
// Fail the command only if ALL channels failed to export.
|
||||
// If only some channels failed to export, it's okay.
|
||||
if (errorsByChannel.Count >= channels.Count)
|
||||
if (errorsByChannel.Count >= unwrappedChannels.Count)
|
||||
throw new CommandException("Export failed.");
|
||||
}
|
||||
|
||||
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Snowflake> channelIds)
|
||||
{
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
await console.Output.WriteLineAsync("Resolving channel(s)...");
|
||||
|
||||
var channels = new List<Channel>();
|
||||
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
||||
|
||||
foreach (var channelId in channelIds)
|
||||
{
|
||||
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
|
||||
|
||||
// Unwrap categories
|
||||
if (channel.IsCategory)
|
||||
{
|
||||
var guildChannels =
|
||||
channelsByGuild.GetValueOrDefault(channel.GuildId)
|
||||
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
||||
|
||||
foreach (var guildChannel in guildChannels)
|
||||
{
|
||||
if (guildChannel.Parent?.Id == channel.Id)
|
||||
channels.Add(guildChannel);
|
||||
}
|
||||
|
||||
// Cache the guild channels to avoid redundant work
|
||||
channelsByGuild[channel.GuildId] = guildChannels;
|
||||
}
|
||||
else
|
||||
{
|
||||
channels.Add(channel);
|
||||
}
|
||||
}
|
||||
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
// Support Ukraine callout
|
||||
|
||||
@@ -5,8 +5,6 @@ using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Discord.Dump;
|
||||
@@ -27,13 +25,6 @@ public class ExportAllCommand : ExportCommandBase
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
public bool IncludeVoiceChannels { get; init; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"include-threads",
|
||||
Description = "Which types of threads should be included.",
|
||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
||||
)]
|
||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
||||
|
||||
[CommandOption(
|
||||
"data-package",
|
||||
Description = "Path to the personal data package (ZIP file) requested from Discord. "
|
||||
@@ -90,46 +81,6 @@ public class ExportAllCommand : ExportCommandBase
|
||||
);
|
||||
|
||||
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
||||
|
||||
// Threads
|
||||
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
||||
{
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Fetching threads for server '{guild.Name}'..."
|
||||
);
|
||||
|
||||
var fetchedThreadsCount = 0;
|
||||
await console
|
||||
.CreateStatusTicker()
|
||||
.StartAsync(
|
||||
"...",
|
||||
async ctx =>
|
||||
{
|
||||
await foreach (
|
||||
var thread in Discord.GetGuildThreadsAsync(
|
||||
guild.Id,
|
||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
||||
Before,
|
||||
After,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
channels.Add(thread);
|
||||
|
||||
ctx.Status(
|
||||
Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'.")
|
||||
);
|
||||
|
||||
fetchedThreadsCount++;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await console.Output.WriteLineAsync(
|
||||
$"Fetched {fetchedThreadsCount} thread(s)."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pull from the data package
|
||||
@@ -199,10 +150,6 @@ public class ExportAllCommand : ExportCommandBase
|
||||
channels.RemoveAll(c => c.IsGuild);
|
||||
if (!IncludeVoiceChannels)
|
||||
channels.RemoveAll(c => c.IsVoice);
|
||||
if (ThreadInclusionMode == ThreadInclusionMode.None)
|
||||
channels.RemoveAll(c => c.IsThread);
|
||||
if (ThreadInclusionMode != ThreadInclusionMode.All)
|
||||
channels.RemoveAll(c => c is { IsThread: true, IsArchived: true });
|
||||
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Cli.Commands;
|
||||
|
||||
@@ -22,6 +24,40 @@ public class ExportChannelsCommand : ExportCommandBase
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
await base.ExecuteAsync(console);
|
||||
await ExportAsync(console, ChannelIds);
|
||||
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
|
||||
await console.Output.WriteLineAsync("Resolving channel(s)...");
|
||||
|
||||
var channels = new List<Channel>();
|
||||
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
||||
|
||||
foreach (var channelId in ChannelIds)
|
||||
{
|
||||
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
|
||||
|
||||
// Unwrap categories
|
||||
if (channel.IsCategory)
|
||||
{
|
||||
var guildChannels =
|
||||
channelsByGuild.GetValueOrDefault(channel.GuildId)
|
||||
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
||||
|
||||
foreach (var guildChannel in guildChannels)
|
||||
{
|
||||
if (guildChannel.Parent?.Id == channel.Id)
|
||||
channels.Add(guildChannel);
|
||||
}
|
||||
|
||||
// Cache the guild channels to avoid redundant work
|
||||
channelsByGuild[channel.GuildId] = guildChannels;
|
||||
}
|
||||
else
|
||||
{
|
||||
channels.Add(channel);
|
||||
}
|
||||
}
|
||||
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ using System.Threading.Tasks;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using DiscordChatExporter.Cli.Commands.Base;
|
||||
using DiscordChatExporter.Cli.Commands.Converters;
|
||||
using DiscordChatExporter.Cli.Commands.Shared;
|
||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
@@ -21,13 +19,6 @@ public class ExportGuildCommand : ExportCommandBase
|
||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||
public bool IncludeVoiceChannels { get; init; } = true;
|
||||
|
||||
[CommandOption(
|
||||
"include-threads",
|
||||
Description = "Which types of threads should be included.",
|
||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
||||
)]
|
||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
||||
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
await base.ExecuteAsync(console);
|
||||
@@ -35,7 +26,6 @@ public class ExportGuildCommand : ExportCommandBase
|
||||
var cancellationToken = console.RegisterCancellationHandler();
|
||||
var channels = new List<Channel>();
|
||||
|
||||
// Regular channels
|
||||
await console.Output.WriteLineAsync("Fetching channels...");
|
||||
|
||||
var fetchedChannelsCount = 0;
|
||||
@@ -66,40 +56,6 @@ public class ExportGuildCommand : ExportCommandBase
|
||||
|
||||
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
||||
|
||||
// Threads
|
||||
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
||||
{
|
||||
await console.Output.WriteLineAsync("Fetching threads...");
|
||||
|
||||
var fetchedThreadsCount = 0;
|
||||
await console
|
||||
.CreateStatusTicker()
|
||||
.StartAsync(
|
||||
"...",
|
||||
async ctx =>
|
||||
{
|
||||
await foreach (
|
||||
var thread in Discord.GetGuildThreadsAsync(
|
||||
GuildId,
|
||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
||||
Before,
|
||||
After,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
channels.Add(thread);
|
||||
|
||||
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
|
||||
|
||||
fetchedThreadsCount++;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
||||
}
|
||||
|
||||
await ExportAsync(console, channels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||
@@ -7,16 +6,21 @@
|
||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
|
||||
<PropertyGroup>
|
||||
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
|
||||
<EnableAotAnalyzer>false</EnableAotAnalyzer>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliFx" Version="2.3.5" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
||||
<PackageReference Include="CliFx" Version="2.3.6" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
||||
<PackageReference Include="Spectre.Console" Version="0.54.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -7,32 +7,35 @@ namespace DiscordChatExporter.Cli.Utils.Extensions;
|
||||
|
||||
internal static class ConsoleExtensions
|
||||
{
|
||||
public static IAnsiConsole CreateAnsiConsole(this IConsole console) =>
|
||||
AnsiConsole.Create(
|
||||
new AnsiConsoleSettings
|
||||
{
|
||||
Ansi = AnsiSupport.Detect,
|
||||
ColorSystem = ColorSystemSupport.Detect,
|
||||
Out = new AnsiConsoleOutput(console.Output),
|
||||
}
|
||||
);
|
||||
|
||||
public static Status CreateStatusTicker(this IConsole console) =>
|
||||
console.CreateAnsiConsole().Status().AutoRefresh(true);
|
||||
|
||||
public static Progress CreateProgressTicker(this IConsole console) =>
|
||||
console
|
||||
.CreateAnsiConsole()
|
||||
.Progress()
|
||||
.AutoClear(false)
|
||||
.AutoRefresh(true)
|
||||
.HideCompleted(false)
|
||||
.Columns(
|
||||
new TaskDescriptionColumn { Alignment = Justify.Left },
|
||||
new ProgressBarColumn(),
|
||||
new PercentageColumn()
|
||||
extension(IConsole console)
|
||||
{
|
||||
public IAnsiConsole CreateAnsiConsole() =>
|
||||
AnsiConsole.Create(
|
||||
new AnsiConsoleSettings
|
||||
{
|
||||
Ansi = AnsiSupport.Detect,
|
||||
ColorSystem = ColorSystemSupport.Detect,
|
||||
Out = new AnsiConsoleOutput(console.Output),
|
||||
}
|
||||
);
|
||||
|
||||
public Status CreateStatusTicker() =>
|
||||
console.CreateAnsiConsole().Status().AutoRefresh(true);
|
||||
|
||||
public Progress CreateProgressTicker() =>
|
||||
console
|
||||
.CreateAnsiConsole()
|
||||
.Progress()
|
||||
.AutoClear(false)
|
||||
.AutoRefresh(true)
|
||||
.HideCompleted(false)
|
||||
.Columns(
|
||||
new TaskDescriptionColumn { Alignment = Justify.Left },
|
||||
new ProgressBarColumn(),
|
||||
new PercentageColumn()
|
||||
);
|
||||
}
|
||||
|
||||
public static async ValueTask StartTaskAsync(
|
||||
this ProgressContext context,
|
||||
string description,
|
||||
|
||||
@@ -33,9 +33,9 @@ public partial record Channel(
|
||||
|
||||
public bool IsThread { get; } =
|
||||
Kind
|
||||
is ChannelKind.GuildNewsThread
|
||||
or ChannelKind.GuildPublicThread
|
||||
or ChannelKind.GuildPrivateThread;
|
||||
is ChannelKind.GuildNewsThread
|
||||
or ChannelKind.GuildPublicThread
|
||||
or ChannelKind.GuildPrivateThread;
|
||||
|
||||
public bool IsEmpty { get; } = LastMessageId is null;
|
||||
|
||||
@@ -69,7 +69,8 @@ public partial record Channel
|
||||
var guildId =
|
||||
json.GetPropertyOrNull("guild_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse) ?? Guild.DirectMessages.Id;
|
||||
?.Pipe(Snowflake.Parse)
|
||||
?? Guild.DirectMessages.Id;
|
||||
|
||||
var name =
|
||||
// Guild channel
|
||||
@@ -78,6 +79,7 @@ public partial record Channel
|
||||
?? json.GetPropertyOrNull("recipients")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(User.Parse)
|
||||
.OrderBy(u => u.Id)
|
||||
.Select(u => u.DisplayName)
|
||||
.Pipe(s => string.Join(", ", s))
|
||||
// Fallback
|
||||
@@ -95,7 +97,8 @@ public partial record Channel
|
||||
var isArchived =
|
||||
json.GetPropertyOrNull("thread_metadata")
|
||||
?.GetPropertyOrNull("archived")
|
||||
?.GetBooleanOrNull() ?? false;
|
||||
?.GetBooleanOrNull()
|
||||
?? false;
|
||||
|
||||
var lastMessageId = json.GetPropertyOrNull("last_message_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
|
||||
@@ -45,7 +45,11 @@ public partial record Embed
|
||||
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
||||
|
||||
var kind =
|
||||
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>()
|
||||
json.GetPropertyOrNull("type")
|
||||
?.GetStringOrNull()
|
||||
?.Pipe(s =>
|
||||
Enum.TryParse<EmbedKind>(s, true, out var result) ? result : (EmbedKind?)null
|
||||
)
|
||||
?? EmbedKind.Rich;
|
||||
|
||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||
@@ -63,7 +67,8 @@ public partial record Embed
|
||||
json.GetPropertyOrNull("fields")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(EmbedField.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||
|
||||
@@ -78,7 +83,8 @@ public partial record Embed
|
||||
json.GetPropertyOrNull("image")
|
||||
?.Pipe(EmbedImage.Parse)
|
||||
.ToSingletonEnumerable()
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
||||
{
|
||||
public string Url { get; } = $"https://www.youtube.com/embed/{VideoId}";
|
||||
public string Url { get; } = $"https://www.youtube.com/watch?v={VideoId}";
|
||||
|
||||
// Using hqdefault.jpg which is guaranteed to exist for all YouTube videos
|
||||
public string ThumbnailUrl { get; } = $"https://i.ytimg.com/vi/{VideoId}/hqdefault.jpg";
|
||||
}
|
||||
|
||||
public partial record YouTubeVideoEmbedProjection
|
||||
|
||||
@@ -25,7 +25,8 @@ public partial record Guild
|
||||
var iconUrl =
|
||||
json.GetPropertyOrNull("icon")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? ImageCdn.GetFallbackUserAvatarUrl();
|
||||
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h))
|
||||
?? ImageCdn.GetFallbackUserAvatarUrl();
|
||||
|
||||
return new Guild(id, name, iconUrl);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ public partial record Member
|
||||
?.EnumerateArray()
|
||||
.Select(j => j.GetNonWhiteSpaceString())
|
||||
.Select(Snowflake.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var avatarUrl = guildId is not null
|
||||
? json.GetPropertyOrNull("avatar")
|
||||
|
||||
@@ -143,7 +143,8 @@ public partial record Message
|
||||
json.GetPropertyOrNull("attachments")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Attachment.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var embeds = NormalizeEmbeds(
|
||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||
@@ -154,13 +155,15 @@ public partial record Message
|
||||
json.GetPropertyOrNull("sticker_items")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Sticker.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var reactions =
|
||||
json.GetPropertyOrNull("reactions")
|
||||
?.EnumerateArrayOrNull()
|
||||
?.Select(Reaction.Parse)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
var mentionedUsers =
|
||||
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
||||
|
||||
@@ -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
|
||||
),
|
||||
};
|
||||
@@ -294,184 +305,19 @@ public class DiscordClient(string token)
|
||||
if (guildId == Guild.DirectMessages.Id)
|
||||
yield break;
|
||||
|
||||
var channels = (await GetGuildChannelsAsync(guildId, cancellationToken))
|
||||
// Categories cannot have threads
|
||||
.Where(c => !c.IsCategory)
|
||||
// Voice channels cannot have threads
|
||||
.Where(c => !c.IsVoice)
|
||||
// Empty channels cannot have threads
|
||||
.Where(c => !c.IsEmpty)
|
||||
// If the 'before' boundary is specified, skip channels that don't have messages
|
||||
// for that range, because thread-start event should always be accompanied by a message.
|
||||
// Note that we don't perform a similar check for the 'after' boundary, because
|
||||
// threads may have messages in range, even if the parent channel doesn't.
|
||||
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
||||
.ToArray();
|
||||
var channels = await GetGuildChannelsAsync(guildId, cancellationToken);
|
||||
|
||||
// User accounts can only fetch threads using the search endpoint
|
||||
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
||||
foreach (
|
||||
var channel in await GetChannelThreadsAsync(
|
||||
channels,
|
||||
includeArchived,
|
||||
before,
|
||||
after,
|
||||
cancellationToken
|
||||
)
|
||||
)
|
||||
{
|
||||
// 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)
|
||||
{
|
||||
var currentOffset = 0;
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channel.Id}/threads/search")
|
||||
.SetQueryParameter("sort_by", "last_message_time")
|
||||
.SetQueryParameter("sort_order", "desc")
|
||||
.SetQueryParameter("archived", "true")
|
||||
.SetQueryParameter("offset", currentOffset.ToString())
|
||||
.Build();
|
||||
|
||||
// Can be null on channels that the user cannot access or channels without threads
|
||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||
if (response is null)
|
||||
break;
|
||||
|
||||
var breakOuter = false;
|
||||
|
||||
foreach (
|
||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
||||
)
|
||||
{
|
||||
var thread = Channel.Parse(threadJson, channel);
|
||||
|
||||
// If the 'after' boundary is specified, we can break early,
|
||||
// because threads are sorted by last message time.
|
||||
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
||||
{
|
||||
breakOuter = true;
|
||||
break;
|
||||
}
|
||||
|
||||
yield return thread;
|
||||
currentOffset++;
|
||||
}
|
||||
|
||||
if (breakOuter)
|
||||
break;
|
||||
|
||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bot accounts can only fetch threads using the threads endpoint
|
||||
else
|
||||
{
|
||||
// Active threads
|
||||
{
|
||||
var parentsById = channels.ToDictionary(c => c.Id);
|
||||
|
||||
var response = await GetJsonResponseAsync(
|
||||
$"guilds/{guildId}/threads/active",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
||||
{
|
||||
var parent = threadJson
|
||||
.GetPropertyOrNull("parent_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse)
|
||||
.Pipe(parentsById.GetValueOrDefault);
|
||||
|
||||
yield return Channel.Parse(threadJson, parent);
|
||||
}
|
||||
}
|
||||
|
||||
// Archived threads
|
||||
if (includeArchived)
|
||||
{
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
// Public archived threads
|
||||
{
|
||||
// Can be null on certain channels
|
||||
var response = await TryGetJsonResponseAsync(
|
||||
$"channels/{channel.Id}/threads/archived/public",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (response is null)
|
||||
continue;
|
||||
|
||||
foreach (
|
||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
||||
)
|
||||
yield return Channel.Parse(threadJson, channel);
|
||||
}
|
||||
|
||||
// Private archived threads
|
||||
{
|
||||
// Can be null on certain channels
|
||||
var response = await TryGetJsonResponseAsync(
|
||||
$"channels/{channel.Id}/threads/archived/private",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (response is null)
|
||||
continue;
|
||||
|
||||
foreach (
|
||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
||||
)
|
||||
yield return Channel.Parse(threadJson, channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
yield return channel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,6 +388,163 @@ public class DiscordClient(string token)
|
||||
}
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<Channel> GetChannelThreadsAsync(
|
||||
IReadOnlyList<Channel> channels,
|
||||
bool includeArchived = false,
|
||||
Snowflake? before = null,
|
||||
Snowflake? after = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var filteredChannels = channels
|
||||
// Categories cannot have threads
|
||||
.Where(c => !c.IsCategory)
|
||||
// Voice channels cannot have threads
|
||||
.Where(c => !c.IsVoice)
|
||||
// Empty channels cannot have threads
|
||||
.Where(c => !c.IsEmpty)
|
||||
// If the 'before' boundary is specified, skip channels that don't have messages
|
||||
// for that range, because thread-start event should always be accompanied by a message.
|
||||
// Note that we don't perform a similar check for the 'after' boundary, because
|
||||
// threads may have messages in range, even if the parent channel doesn't.
|
||||
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
||||
.ToArray();
|
||||
|
||||
// User accounts can only fetch threads using the search endpoint
|
||||
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
||||
{
|
||||
foreach (var channel in filteredChannels)
|
||||
{
|
||||
// Either include both active and archived threads, or only active threads
|
||||
foreach (
|
||||
var isArchived in includeArchived ? new[] { false, true } : new[] { false }
|
||||
)
|
||||
{
|
||||
// Offset is just the index of the last thread in the previous batch
|
||||
var currentOffset = 0;
|
||||
while (true)
|
||||
{
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channel.Id}/threads/search")
|
||||
.SetQueryParameter("sort_by", "last_message_time")
|
||||
.SetQueryParameter("sort_order", "desc")
|
||||
.SetQueryParameter("archived", isArchived.ToString().ToLowerInvariant())
|
||||
.SetQueryParameter("offset", currentOffset.ToString())
|
||||
.Build();
|
||||
|
||||
// Can be null on channels that the user cannot access or channels without threads
|
||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||
if (response is null)
|
||||
break;
|
||||
|
||||
var breakOuter = false;
|
||||
|
||||
foreach (
|
||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
||||
)
|
||||
{
|
||||
var thread = Channel.Parse(threadJson, channel);
|
||||
|
||||
// If the 'after' boundary is specified, we can break early,
|
||||
// because threads are sorted by last message timestamp.
|
||||
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
||||
{
|
||||
breakOuter = true;
|
||||
break;
|
||||
}
|
||||
|
||||
yield return thread;
|
||||
currentOffset++;
|
||||
}
|
||||
|
||||
if (breakOuter)
|
||||
break;
|
||||
|
||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bot accounts can only fetch threads using the threads endpoint
|
||||
else
|
||||
{
|
||||
var guilds = new HashSet<Snowflake>();
|
||||
foreach (var channel in filteredChannels)
|
||||
guilds.Add(channel.GuildId);
|
||||
|
||||
// Active threads
|
||||
foreach (var guildId in guilds)
|
||||
{
|
||||
var parentsById = filteredChannels.ToDictionary(c => c.Id);
|
||||
|
||||
var response = await GetJsonResponseAsync(
|
||||
$"guilds/{guildId}/threads/active",
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
||||
{
|
||||
var parent = threadJson
|
||||
.GetPropertyOrNull("parent_id")
|
||||
?.GetNonWhiteSpaceStringOrNull()
|
||||
?.Pipe(Snowflake.Parse)
|
||||
.Pipe(parentsById.GetValueOrDefault);
|
||||
|
||||
if (filteredChannels.Contains(parent))
|
||||
yield return Channel.Parse(threadJson, parent);
|
||||
}
|
||||
}
|
||||
|
||||
// Archived threads
|
||||
if (includeArchived)
|
||||
{
|
||||
foreach (var channel in filteredChannels)
|
||||
{
|
||||
foreach (var archiveType in new[] { "public", "private" })
|
||||
{
|
||||
// This endpoint parameter expects an ISO8601 timestamp, not a snowflake
|
||||
var currentBefore = before
|
||||
?.ToDate()
|
||||
.ToString("O", CultureInfo.InvariantCulture);
|
||||
|
||||
while (true)
|
||||
{
|
||||
// Threads are sorted by archive timestamp, not by last message timestamp
|
||||
var url = new UrlBuilder()
|
||||
.SetPath($"channels/{channel.Id}/threads/archived/{archiveType}")
|
||||
.SetQueryParameter("before", currentBefore)
|
||||
.Build();
|
||||
|
||||
// Can be null on certain channels
|
||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||
if (response is null)
|
||||
break;
|
||||
|
||||
foreach (
|
||||
var threadJson in response
|
||||
.Value.GetProperty("threads")
|
||||
.EnumerateArray()
|
||||
)
|
||||
{
|
||||
var thread = Channel.Parse(threadJson, channel);
|
||||
yield return thread;
|
||||
|
||||
currentBefore = threadJson
|
||||
.GetProperty("thread_metadata")
|
||||
.GetProperty("archive_timestamp")
|
||||
.GetString();
|
||||
}
|
||||
|
||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||
Snowflake channelId,
|
||||
Snowflake? before = null,
|
||||
@@ -688,9 +691,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -40,17 +41,19 @@ public partial class DataDump
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(zipFilePath);
|
||||
await using var archive = await ZipFile.OpenReadAsync(zipFilePath, cancellationToken);
|
||||
|
||||
var entry = archive.GetEntry("messages/index.json");
|
||||
if (entry is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
// Use case-insensitive search to accommodate for different data dump versions
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1459
|
||||
var entry =
|
||||
archive.Entries.FirstOrDefault(e =>
|
||||
e.FullName.Equals("messages/index.json", StringComparison.OrdinalIgnoreCase)
|
||||
)
|
||||
?? throw new InvalidOperationException(
|
||||
"Failed to locate the channel index inside the data package."
|
||||
);
|
||||
}
|
||||
|
||||
await using var stream = entry.Open();
|
||||
await using var stream = await entry.OpenAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
|
||||
|
||||
return Parse(document.RootElement);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System;
|
||||
|
||||
namespace DiscordChatExporter.Core.Discord;
|
||||
|
||||
[Flags]
|
||||
public enum RateLimitPreference
|
||||
{
|
||||
IgnoreAll = 0,
|
||||
RespectForUserTokens = 0b1,
|
||||
RespectForBotTokens = 0b10,
|
||||
RespectAll = RespectForUserTokens | RespectForBotTokens,
|
||||
}
|
||||
|
||||
public static class RateLimitPreferenceExtensions
|
||||
{
|
||||
extension(RateLimitPreference rateLimitPreference)
|
||||
{
|
||||
internal bool IsRespectedFor(TokenKind tokenKind) =>
|
||||
tokenKind switch
|
||||
{
|
||||
TokenKind.User => (rateLimitPreference & RateLimitPreference.RespectForUserTokens)
|
||||
!= 0,
|
||||
TokenKind.Bot => (rateLimitPreference & RateLimitPreference.RespectForBotTokens)
|
||||
!= 0,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(tokenKind)),
|
||||
};
|
||||
|
||||
public string GetDisplayName() =>
|
||||
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,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncKeyedLock" Version="7.1.4" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageReference Include="AsyncKeyedLock" Version="8.0.1" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||
<PackageReference Include="Polly" Version="8.5.2" />
|
||||
<PackageReference Include="RazorBlade" Version="0.8.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="Polly" Version="8.6.5" />
|
||||
<PackageReference Include="RazorBlade" Version="0.11.0" />
|
||||
<PackageReference Include="Superpower" Version="3.1.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||
<PackageReference Include="YoutubeExplode" Version="6.5.6" />
|
||||
</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."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ internal partial class ExportAssetDownloader
|
||||
|
||||
return SHA256
|
||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||
.ToHex()
|
||||
.Pipe(Convert.ToHexStringLower)
|
||||
// 5 chars ought to be enough for anybody
|
||||
.Truncate(5);
|
||||
}
|
||||
@@ -103,7 +103,7 @@ internal partial class ExportAssetDownloader
|
||||
fileExtension = "";
|
||||
}
|
||||
|
||||
return PathEx.EscapeFileName(
|
||||
return Path.EscapeFileName(
|
||||
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,7 +99,8 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||
?.RoleIds.Select(TryGetRole)
|
||||
.WhereNotNull()
|
||||
.OrderByDescending(r => r.Position)
|
||||
.ToArray() ?? [];
|
||||
.ToArray()
|
||||
?? [];
|
||||
|
||||
public Color? TryGetUserColor(Snowflake id) =>
|
||||
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
|
||||
|
||||
@@ -13,25 +13,28 @@ public enum ExportFormat
|
||||
|
||||
public static class ExportFormatExtensions
|
||||
{
|
||||
public static string GetFileExtension(this ExportFormat format) =>
|
||||
format switch
|
||||
{
|
||||
ExportFormat.PlainText => "txt",
|
||||
ExportFormat.HtmlDark => "html",
|
||||
ExportFormat.HtmlLight => "html",
|
||||
ExportFormat.Csv => "csv",
|
||||
ExportFormat.Json => "json",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
extension(ExportFormat format)
|
||||
{
|
||||
public string GetFileExtension() =>
|
||||
format switch
|
||||
{
|
||||
ExportFormat.PlainText => "txt",
|
||||
ExportFormat.HtmlDark => "html",
|
||||
ExportFormat.HtmlLight => "html",
|
||||
ExportFormat.Csv => "csv",
|
||||
ExportFormat.Json => "json",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
|
||||
public static string GetDisplayName(this ExportFormat format) =>
|
||||
format switch
|
||||
{
|
||||
ExportFormat.PlainText => "TXT",
|
||||
ExportFormat.HtmlDark => "HTML (Dark)",
|
||||
ExportFormat.HtmlLight => "HTML (Light)",
|
||||
ExportFormat.Csv => "CSV",
|
||||
ExportFormat.Json => "JSON",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
public string GetDisplayName() =>
|
||||
format switch
|
||||
{
|
||||
ExportFormat.PlainText => "TXT",
|
||||
ExportFormat.HtmlDark => "HTML (Dark)",
|
||||
ExportFormat.HtmlLight => "HTML (Light)",
|
||||
ExportFormat.Csv => "CSV",
|
||||
ExportFormat.Json => "JSON",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ using DiscordChatExporter.Core.Discord;
|
||||
using DiscordChatExporter.Core.Discord.Data;
|
||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||
using DiscordChatExporter.Core.Utils;
|
||||
using DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
namespace DiscordChatExporter.Core.Exporting;
|
||||
@@ -145,7 +144,7 @@ public partial class ExportRequest
|
||||
// File extension
|
||||
buffer.Append('.').Append(format.GetFileExtension());
|
||||
|
||||
return PathEx.EscapeFileName(buffer.ToString());
|
||||
return Path.EscapeFileName(buffer.ToString());
|
||||
}
|
||||
|
||||
private static string FormatPath(
|
||||
@@ -159,7 +158,7 @@ public partial class ExportRequest
|
||||
path,
|
||||
"%.",
|
||||
m =>
|
||||
PathEx.EscapeFileName(
|
||||
Path.EscapeFileName(
|
||||
m.Value switch
|
||||
{
|
||||
"%g" => guild.Id.ToString(),
|
||||
@@ -179,7 +178,8 @@ public partial class ExportRequest
|
||||
?? "",
|
||||
"%b" => before
|
||||
?.ToDate()
|
||||
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "",
|
||||
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
?? "",
|
||||
"%d" => DateTimeOffset.Now.ToString(
|
||||
"yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture
|
||||
|
||||
@@ -8,14 +8,20 @@ internal static class HtmlMessageExtensions
|
||||
{
|
||||
// Message content is hidden if it's a link to an embedded media
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
|
||||
public static bool IsContentHidden(this Message message)
|
||||
extension(Message message)
|
||||
{
|
||||
if (message.Embeds.Count != 1)
|
||||
return false;
|
||||
public bool IsContentHidden()
|
||||
{
|
||||
if (message.Embeds.Count != 1)
|
||||
return false;
|
||||
|
||||
var embed = message.Embeds[0];
|
||||
var embed = message.Embeds[0];
|
||||
|
||||
return string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase)
|
||||
&& embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
||||
return string.Equals(
|
||||
message.Content.Trim(),
|
||||
embed.Url,
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
) && embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteString("iconCanonicalUrl", embedAuthor.IconUrl);
|
||||
}
|
||||
|
||||
_writer.WriteEndObject();
|
||||
@@ -160,6 +162,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteString("canonicalUrl", embedImage.Url);
|
||||
}
|
||||
|
||||
_writer.WriteNumber("width", embedImage.Width);
|
||||
@@ -185,6 +189,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteString("canonicalUrl", embedVideo.Url);
|
||||
}
|
||||
|
||||
_writer.WriteNumber("width", embedVideo.Width);
|
||||
@@ -212,6 +218,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||
cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
_writer.WriteString("iconCanonicalUrl", embedFooter.IconUrl);
|
||||
}
|
||||
|
||||
_writer.WriteEndObject();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -388,7 +388,7 @@
|
||||
<div class="chatlog__embed-author-container">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
@@ -424,9 +424,11 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Video player *@
|
||||
@* Video thumbnail *@
|
||||
<div class="chatlog__embed-youtube-container">
|
||||
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
|
||||
<a href="@youTubeVideoEmbed.Url">
|
||||
<img class="chatlog__embed-youtube-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)" alt="YouTube video thumbnail" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@(embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)">
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -441,9 +443,12 @@
|
||||
embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ??
|
||||
embed.Url;
|
||||
|
||||
var embedImageCanonicalUrl =
|
||||
embed.Image?.Url ?? embed.Thumbnail?.Url ?? embed.Url;
|
||||
|
||||
<div class="chatlog__embed">
|
||||
<a href="@await ResolveAssetUrlAsync(embedImageUrl)">
|
||||
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy">
|
||||
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy" data-canonical-url="@embedImageCanonicalUrl">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@@ -457,8 +462,10 @@
|
||||
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
||||
embed.Url;
|
||||
|
||||
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
|
||||
|
||||
<div class="chatlog__embed">
|
||||
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls>
|
||||
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls data-canonical-url="@embedVideoCanonicalUrl">
|
||||
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
|
||||
</video>
|
||||
</div>
|
||||
@@ -470,8 +477,10 @@
|
||||
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
||||
embed.Url;
|
||||
|
||||
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
|
||||
|
||||
<div class="chatlog__embed">
|
||||
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()">
|
||||
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()" data-canonical-url="@embedVideoCanonicalUrl">
|
||||
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv">
|
||||
</video>
|
||||
</div>
|
||||
@@ -499,7 +508,7 @@
|
||||
<div class="chatlog__embed-author-container">
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||
@@ -574,7 +583,7 @@
|
||||
{
|
||||
<div class="chatlog__embed-thumbnail-container">
|
||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
||||
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
|
||||
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy" data-canonical-url="@embed.Thumbnail.Url">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@@ -590,7 +599,7 @@
|
||||
{
|
||||
<div class="chatlog__embed-image-container">
|
||||
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
|
||||
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
|
||||
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy" data-canonical-url="@image.Url">
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@@ -605,7 +614,7 @@
|
||||
@* Footer icon *@
|
||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||
{
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
|
||||
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy" data-canonical-url="@embed.Footer.IconUrl">
|
||||
}
|
||||
|
||||
<span class="chatlog__embed-footer-text">
|
||||
|
||||
@@ -7,37 +7,40 @@ namespace DiscordChatExporter.Core.Exporting;
|
||||
|
||||
internal static class PlainTextMessageExtensions
|
||||
{
|
||||
public static string GetFallbackContent(this Message message) =>
|
||||
message.Kind switch
|
||||
{
|
||||
MessageKind.RecipientAdd => message.MentionedUsers.Any()
|
||||
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
||||
: "Added a recipient.",
|
||||
extension(Message message)
|
||||
{
|
||||
public string GetFallbackContent() =>
|
||||
message.Kind switch
|
||||
{
|
||||
MessageKind.RecipientAdd => message.MentionedUsers.Any()
|
||||
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
||||
: "Added a recipient.",
|
||||
|
||||
MessageKind.RecipientRemove => message.MentionedUsers.Any()
|
||||
? message.Author.Id == message.MentionedUsers.First().Id
|
||||
? "Left the group."
|
||||
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
|
||||
: "Removed a recipient.",
|
||||
MessageKind.RecipientRemove => message.MentionedUsers.Any()
|
||||
? message.Author.Id == message.MentionedUsers.First().Id
|
||||
? "Left the group."
|
||||
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
|
||||
: "Removed a recipient.",
|
||||
|
||||
MessageKind.Call =>
|
||||
$"Started a call that lasted {
|
||||
message
|
||||
.CallEndedTimestamp?
|
||||
.Pipe(t => t - message.Timestamp)
|
||||
.Pipe(t => t.TotalMinutes)
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||
} minutes.",
|
||||
MessageKind.Call =>
|
||||
$"Started a call that lasted {
|
||||
message
|
||||
.CallEndedTimestamp?
|
||||
.Pipe(t => t - message.Timestamp)
|
||||
.Pipe(t => t.TotalMinutes)
|
||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||
} minutes.",
|
||||
|
||||
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
|
||||
? $"Changed the channel name: {message.Content}"
|
||||
: "Changed the channel name.",
|
||||
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
|
||||
? $"Changed the channel name: {message.Content}"
|
||||
: "Changed the channel name.",
|
||||
|
||||
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
||||
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
||||
MessageKind.ThreadCreated => "Started a thread.",
|
||||
MessageKind.GuildMemberJoin => "Joined the server.",
|
||||
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
||||
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
||||
MessageKind.ThreadCreated => "Started a thread.",
|
||||
MessageKind.GuildMemberJoin => "Joined the server.",
|
||||
|
||||
_ => message.Content,
|
||||
};
|
||||
_ => message.Content,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -711,9 +711,11 @@
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.chatlog__embed-youtube {
|
||||
border: 0;
|
||||
.chatlog__embed-youtube-thumbnail {
|
||||
max-width: 400px;
|
||||
max-height: 225px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chatlog__sticker {
|
||||
|
||||
@@ -6,19 +6,19 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class AsyncCollectionExtensions
|
||||
{
|
||||
private static async ValueTask<IReadOnlyList<T>> CollectAsync<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable
|
||||
)
|
||||
extension<T>(IAsyncEnumerable<T> asyncEnumerable)
|
||||
{
|
||||
var list = new List<T>();
|
||||
private async ValueTask<IReadOnlyList<T>> CollectAsync()
|
||||
{
|
||||
var list = new List<T>();
|
||||
|
||||
await foreach (var i in asyncEnumerable)
|
||||
list.Add(i);
|
||||
await foreach (var i in asyncEnumerable)
|
||||
list.Add(i);
|
||||
|
||||
return list;
|
||||
return list;
|
||||
}
|
||||
|
||||
public ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter() =>
|
||||
asyncEnumerable.CollectAsync().GetAwaiter();
|
||||
}
|
||||
|
||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
|
||||
this IAsyncEnumerable<T> asyncEnumerable
|
||||
) => asyncEnumerable.CollectAsync().GetAwaiter();
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class BinaryExtensions
|
||||
{
|
||||
public static string ToHex(this byte[] data, bool isUpperCase = true)
|
||||
{
|
||||
var buffer = new StringBuilder(2 * data.Length);
|
||||
|
||||
foreach (var b in data)
|
||||
{
|
||||
buffer.Append(b.ToString(isUpperCase ? "X2" : "x2", CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
@@ -4,25 +4,34 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class CollectionExtensions
|
||||
{
|
||||
public static IEnumerable<T> ToSingletonEnumerable<T>(this T obj)
|
||||
extension<T>(T obj)
|
||||
{
|
||||
yield return obj;
|
||||
public IEnumerable<T> ToSingletonEnumerable()
|
||||
{
|
||||
yield return obj;
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<(T value, int index)> WithIndex<T>(this IEnumerable<T> source)
|
||||
extension<T>(IEnumerable<T> source)
|
||||
{
|
||||
var i = 0;
|
||||
foreach (var o in source)
|
||||
yield return (o, i++);
|
||||
public IEnumerable<(T value, int index)> WithIndex()
|
||||
{
|
||||
var i = 0;
|
||||
foreach (var o in source)
|
||||
yield return (o, i++);
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source)
|
||||
extension<T>(IEnumerable<T?> source)
|
||||
where T : class
|
||||
{
|
||||
foreach (var o in source)
|
||||
public IEnumerable<T> WhereNotNull()
|
||||
{
|
||||
if (o is not null)
|
||||
yield return o;
|
||||
foreach (var o in source)
|
||||
{
|
||||
if (o is not null)
|
||||
yield return o;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,14 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class ColorExtensions
|
||||
{
|
||||
public static Color WithAlpha(this Color color, int alpha) => Color.FromArgb(alpha, color);
|
||||
extension(Color color)
|
||||
{
|
||||
public Color WithAlpha(int alpha) => Color.FromArgb(alpha, color);
|
||||
|
||||
public static Color ResetAlpha(this Color color) => color.WithAlpha(255);
|
||||
public Color ResetAlpha() => color.WithAlpha(255);
|
||||
|
||||
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
|
||||
public int ToRgb() => color.ToArgb() & 0xffffff;
|
||||
|
||||
public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
||||
public string ToHex() => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,27 +5,30 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class ExceptionExtensions
|
||||
{
|
||||
private static void PopulateChildren(this Exception exception, ICollection<Exception> children)
|
||||
extension(Exception exception)
|
||||
{
|
||||
if (exception is AggregateException aggregateException)
|
||||
private void PopulateChildren(ICollection<Exception> children)
|
||||
{
|
||||
foreach (var innerException in aggregateException.InnerExceptions)
|
||||
if (exception is AggregateException aggregateException)
|
||||
{
|
||||
children.Add(innerException);
|
||||
PopulateChildren(innerException, children);
|
||||
foreach (var innerException in aggregateException.InnerExceptions)
|
||||
{
|
||||
children.Add(innerException);
|
||||
PopulateChildren(innerException, children);
|
||||
}
|
||||
}
|
||||
else if (exception.InnerException is not null)
|
||||
{
|
||||
children.Add(exception.InnerException);
|
||||
PopulateChildren(exception.InnerException, children);
|
||||
}
|
||||
}
|
||||
else if (exception.InnerException is not null)
|
||||
|
||||
public IReadOnlyList<Exception> GetSelfAndChildren()
|
||||
{
|
||||
children.Add(exception.InnerException);
|
||||
PopulateChildren(exception.InnerException, children);
|
||||
var children = new List<Exception> { exception };
|
||||
PopulateChildren(exception, children);
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<Exception> GetSelfAndChildren(this Exception exception)
|
||||
{
|
||||
var children = new List<Exception> { exception };
|
||||
PopulateChildren(exception, children);
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,17 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class GenericExtensions
|
||||
{
|
||||
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) =>
|
||||
transform(input);
|
||||
extension<TIn>(TIn input)
|
||||
{
|
||||
public TOut Pipe<TOut>(Func<TIn, TOut> transform) => transform(input);
|
||||
}
|
||||
|
||||
public static T? NullIf<T>(this T value, Func<T, bool> predicate)
|
||||
where T : struct => !predicate(value) ? value : null;
|
||||
extension<T>(T value)
|
||||
where T : struct
|
||||
{
|
||||
public T? NullIf(Func<T, bool> predicate) => !predicate(value) ? value : null;
|
||||
|
||||
public static T? NullIfDefault<T>(this T value)
|
||||
where T : struct => value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
|
||||
public T? NullIfDefault() =>
|
||||
value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class HttpExtensions
|
||||
{
|
||||
public static string? TryGetValue(this HttpHeaders headers, string name) =>
|
||||
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
|
||||
extension(HttpHeaders headers)
|
||||
{
|
||||
public string? TryGetValue(string name) =>
|
||||
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class PathExtensions
|
||||
{
|
||||
// This is a union of invalid characters from Windows (NTFS/FAT32), Linux (ext4/XFS), and macOS (HFS+/APFS).
|
||||
// We use this instead of Path.GetInvalidFileNameChars() because that only returns OS-specific characters,
|
||||
// not filesystem-specific characters. It's possible to use, for example, an NTFS drive on Linux,
|
||||
// which would make some additional characters invalid that are otherwise valid on Linux.
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1452
|
||||
private static readonly char[] InvalidFileNameChars =
|
||||
[
|
||||
'\0', // Null character - invalid on all filesystems
|
||||
'/', // Path separator on Unix and Windows
|
||||
'\\', // Path separator on Windows
|
||||
':', // Reserved on Windows (drive letters, NTFS streams)
|
||||
'*', // Wildcard on Windows
|
||||
'?', // Wildcard on Windows
|
||||
'"', // Reserved on Windows
|
||||
'<', // Redirection on Windows
|
||||
'>', // Redirection on Windows
|
||||
'|', // Pipe on Windows
|
||||
];
|
||||
|
||||
extension(Path)
|
||||
{
|
||||
public static string EscapeFileName(string path)
|
||||
{
|
||||
var buffer = new StringBuilder(path.Length);
|
||||
|
||||
foreach (var c in path)
|
||||
buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_');
|
||||
|
||||
// File names cannot end with a dot on Windows
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
while (buffer.Length > 0 && buffer[^1] == '.')
|
||||
buffer.Remove(buffer.Length - 1, 1);
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,34 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class StringExtensions
|
||||
{
|
||||
public static string? NullIfWhiteSpace(this string str) =>
|
||||
!string.IsNullOrWhiteSpace(str) ? str : null;
|
||||
|
||||
public static string Truncate(this string str, int charCount) =>
|
||||
str.Length > charCount ? str[..charCount] : str;
|
||||
|
||||
public static string ToSpaceSeparatedWords(this string str)
|
||||
extension(string str)
|
||||
{
|
||||
var builder = new StringBuilder(str.Length * 2);
|
||||
public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(str) ? str : null;
|
||||
|
||||
foreach (var c in str)
|
||||
public string Truncate(int charCount) => str.Length > charCount ? str[..charCount] : str;
|
||||
|
||||
public string ToSpaceSeparatedWords()
|
||||
{
|
||||
if (char.IsUpper(c) && builder.Length > 0)
|
||||
builder.Append(' ');
|
||||
var builder = new StringBuilder(str.Length * 2);
|
||||
|
||||
builder.Append(c);
|
||||
foreach (var c in str)
|
||||
{
|
||||
if (char.IsUpper(c) && builder.Length > 0)
|
||||
builder.Append(' ');
|
||||
|
||||
builder.Append(c);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true)
|
||||
where T : struct, Enum => Enum.TryParse<T>(str, ignoreCase, out var result) ? result : null;
|
||||
|
||||
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
|
||||
builder.Length > 0 ? builder.Append(value) : builder;
|
||||
extension(StringBuilder builder)
|
||||
{
|
||||
public StringBuilder AppendIfNotEmpty(char value) =>
|
||||
builder.Length > 0 ? builder.Append(value) : builder;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,21 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class SuperpowerExtensions
|
||||
{
|
||||
public static TextParser<string> Text(this TextParser<char[]> parser) =>
|
||||
parser.Select(chars => new string(chars));
|
||||
extension<T>(TextParser<T> parser)
|
||||
{
|
||||
public TextParser<T> Token() =>
|
||||
parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany());
|
||||
|
||||
public static TextParser<T> Token<T>(this TextParser<T> parser) =>
|
||||
parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany());
|
||||
|
||||
// Only used for debugging while writing Superpower parsers.
|
||||
// From https://twitter.com/nblumhardt/status/1389349059786264578
|
||||
[ExcludeFromCodeCoverage]
|
||||
public static TextParser<T> Log<T>(this TextParser<T> parser, string description) =>
|
||||
i =>
|
||||
{
|
||||
Console.WriteLine($"Trying {description} ->");
|
||||
var r = parser(i);
|
||||
Console.WriteLine($"Result was {r}");
|
||||
return r;
|
||||
};
|
||||
// Only used for debugging while writing Superpower parsers.
|
||||
// From https://twitter.com/nblumhardt/status/1389349059786264578
|
||||
[ExcludeFromCodeCoverage]
|
||||
public TextParser<T> Log(string description) =>
|
||||
i =>
|
||||
{
|
||||
Console.WriteLine($"Trying {description} ->");
|
||||
var r = parser(i);
|
||||
Console.WriteLine($"Result was {r}");
|
||||
return r;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||
|
||||
public static class TimeSpanExtensions
|
||||
{
|
||||
public static TimeSpan Clamp(this TimeSpan value, TimeSpan min, TimeSpan max)
|
||||
extension(TimeSpan value)
|
||||
{
|
||||
if (value < min)
|
||||
return min;
|
||||
public TimeSpan Clamp(TimeSpan min, TimeSpan max)
|
||||
{
|
||||
if (value < min)
|
||||
return min;
|
||||
|
||||
if (value > max)
|
||||
return max;
|
||||
if (value > max)
|
||||
return max;
|
||||
|
||||
return value;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils;
|
||||
|
||||
public static class PathEx
|
||||
{
|
||||
private static readonly HashSet<char> InvalidFileNameChars =
|
||||
[
|
||||
.. Path.GetInvalidFileNameChars(),
|
||||
];
|
||||
|
||||
public static string EscapeFileName(string path)
|
||||
{
|
||||
var buffer = new StringBuilder(path.Length);
|
||||
|
||||
foreach (var c in path)
|
||||
buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_');
|
||||
|
||||
// File names cannot end with a dot on Windows
|
||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
while (buffer.Length > 0 && buffer[^1] == '.')
|
||||
buffer.Remove(buffer.Length - 1, 1);
|
||||
}
|
||||
|
||||
return buffer.ToString();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace DiscordChatExporter.Core.Utils;
|
||||
@@ -10,6 +11,19 @@ public static class Url
|
||||
var buffer = new StringBuilder();
|
||||
var position = 0;
|
||||
|
||||
// For absolute paths, prepend file:// protocol for proper browser handling
|
||||
if (Path.IsPathFullyQualified(filePath))
|
||||
{
|
||||
buffer.Append("file://");
|
||||
|
||||
// On Windows, we need to add an extra slash before the drive letter
|
||||
// e.g., file:///C:/path instead of file://C:/path
|
||||
if (!filePath.StartsWith('/') && !filePath.StartsWith('\\'))
|
||||
{
|
||||
buffer.Append('/');
|
||||
}
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (position >= filePath.Length)
|
||||
@@ -18,12 +32,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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
xmlns:materialControls="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
||||
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
|
||||
Name="DiscordChatExporter"
|
||||
ActualThemeVariantChanged="Application_OnActualThemeVariantChanged">
|
||||
<Application.DataTemplates>
|
||||
<framework:ViewManager />
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using DiscordChatExporter.Core.Discord;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Converters;
|
||||
|
||||
public class RateLimitPreferenceToStringConverter : IValueConverter
|
||||
{
|
||||
public static RateLimitPreferenceToStringConverter Instance { get; } = new();
|
||||
|
||||
public object? Convert(
|
||||
object? value,
|
||||
Type targetType,
|
||||
object? parameter,
|
||||
CultureInfo culture
|
||||
) =>
|
||||
value is RateLimitPreference rateLimitPreference
|
||||
? rateLimitPreference.GetDisplayName()
|
||||
: default;
|
||||
|
||||
public object ConvertBack(
|
||||
object? value,
|
||||
Type targetType,
|
||||
object? parameter,
|
||||
CultureInfo culture
|
||||
) => throw new NotSupportedException();
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<AssemblyName>DiscordChatExporter</AssemblyName>
|
||||
@@ -9,29 +8,49 @@
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<PublishMacOSBundle>false</PublishMacOSBundle>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
|
||||
<PropertyGroup>
|
||||
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
|
||||
<EnableAotAnalyzer>false</EnableAotAnalyzer>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<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="AsyncImageLoader.Avalonia" Version="3.5.0" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.11" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.11" />
|
||||
<PackageReference
|
||||
Include="Avalonia.Diagnostics"
|
||||
Version="11.3.11"
|
||||
Condition="'$(Configuration)' == 'Debug'"
|
||||
/>
|
||||
<PackageReference Include="Cogwheel" Version="2.1.0" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
||||
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||
<PackageReference Include="DialogHost.Avalonia" Version="0.9.2" />
|
||||
<PackageReference Include="DialogHost.Avalonia" Version="0.10.4" />
|
||||
<PackageReference Include="Gress" Version="2.1.1" />
|
||||
<PackageReference Include="Material.Avalonia" Version="3.9.2" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
|
||||
<PackageReference Include="Onova" Version="2.6.12" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||
<PackageReference Include="Onova" Version="2.6.13" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<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>
|
||||
|
||||
@@ -60,7 +60,7 @@ public class DialogManager : IDisposable
|
||||
}
|
||||
);
|
||||
|
||||
return file?.Path.LocalPath;
|
||||
return file?.TryGetLocalPath() ?? file?.Path.ToString();
|
||||
}
|
||||
|
||||
public async Task<string?> PromptDirectoryPathAsync(string defaultDirPath = "")
|
||||
@@ -69,19 +69,21 @@ public class DialogManager : IDisposable
|
||||
Application.Current?.ApplicationLifetime?.TryGetTopLevel()
|
||||
?? throw new ApplicationException("Could not find the top-level visual element.");
|
||||
|
||||
var startLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(
|
||||
defaultDirPath
|
||||
);
|
||||
|
||||
var folderPickResult = await topLevel.StorageProvider.OpenFolderPickerAsync(
|
||||
var result = await topLevel.StorageProvider.OpenFolderPickerAsync(
|
||||
new FolderPickerOpenOptions
|
||||
{
|
||||
AllowMultiple = false,
|
||||
SuggestedStartLocation = startLocation,
|
||||
SuggestedStartLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(
|
||||
defaultDirPath
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
return folderPickResult.FirstOrDefault()?.Path.LocalPath;
|
||||
var directory = result.FirstOrDefault();
|
||||
if (directory is null)
|
||||
return null;
|
||||
|
||||
return directory.TryGetLocalPath() ?? directory.Path.ToString();
|
||||
}
|
||||
|
||||
public void Dispose() => _dialogLock.Dispose();
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -6,28 +6,31 @@ namespace DiscordChatExporter.Gui.Utils.Extensions;
|
||||
|
||||
internal static class AvaloniaExtensions
|
||||
{
|
||||
public static Window? TryGetMainWindow(this IApplicationLifetime lifetime) =>
|
||||
lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime
|
||||
? desktopLifetime.MainWindow
|
||||
: null;
|
||||
|
||||
public static TopLevel? TryGetTopLevel(this IApplicationLifetime lifetime) =>
|
||||
lifetime.TryGetMainWindow()
|
||||
?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel;
|
||||
|
||||
public static bool TryShutdown(this IApplicationLifetime lifetime, int exitCode = 0)
|
||||
extension(IApplicationLifetime lifetime)
|
||||
{
|
||||
if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)
|
||||
{
|
||||
return desktopLifetime.TryShutdown(exitCode);
|
||||
}
|
||||
public Window? TryGetMainWindow() =>
|
||||
lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime
|
||||
? desktopLifetime.MainWindow
|
||||
: null;
|
||||
|
||||
if (lifetime is IControlledApplicationLifetime controlledLifetime)
|
||||
{
|
||||
controlledLifetime.Shutdown(exitCode);
|
||||
return true;
|
||||
}
|
||||
public TopLevel? TryGetTopLevel() =>
|
||||
lifetime.TryGetMainWindow()
|
||||
?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel;
|
||||
|
||||
return false;
|
||||
public bool TryShutdown(int exitCode = 0)
|
||||
{
|
||||
if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)
|
||||
{
|
||||
return desktopLifetime.TryShutdown(exitCode);
|
||||
}
|
||||
|
||||
if (lifetime is IControlledApplicationLifetime controlledLifetime)
|
||||
{
|
||||
controlledLifetime.Shutdown(exitCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,23 +6,26 @@ namespace DiscordChatExporter.Gui.Utils.Extensions;
|
||||
|
||||
internal static class DisposableExtensions
|
||||
{
|
||||
public static void DisposeAll(this IEnumerable<IDisposable> disposables)
|
||||
extension(IEnumerable<IDisposable> disposables)
|
||||
{
|
||||
var exceptions = default(List<Exception>);
|
||||
|
||||
foreach (var disposable in disposables)
|
||||
public void DisposeAll()
|
||||
{
|
||||
try
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
(exceptions ??= []).Add(ex);
|
||||
}
|
||||
}
|
||||
var exceptions = default(List<Exception>);
|
||||
|
||||
if (exceptions?.Any() == true)
|
||||
throw new AggregateException(exceptions);
|
||||
foreach (var disposable in disposables)
|
||||
{
|
||||
try
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
(exceptions ??= []).Add(ex);
|
||||
}
|
||||
}
|
||||
|
||||
if (exceptions?.Any() == true)
|
||||
throw new AggregateException(exceptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,50 +7,47 @@ namespace DiscordChatExporter.Gui.Utils.Extensions;
|
||||
|
||||
internal static class NotifyPropertyChangedExtensions
|
||||
{
|
||||
public static IDisposable WatchProperty<TOwner, TProperty>(
|
||||
this TOwner owner,
|
||||
Expression<Func<TOwner, TProperty>> propertyExpression,
|
||||
Action callback,
|
||||
bool watchInitialValue = false
|
||||
)
|
||||
extension<TOwner>(TOwner owner)
|
||||
where TOwner : INotifyPropertyChanged
|
||||
{
|
||||
var memberExpression = propertyExpression.Body as MemberExpression;
|
||||
if (memberExpression?.Member is not PropertyInfo property)
|
||||
throw new ArgumentException("Provided expression must reference a property.");
|
||||
|
||||
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
|
||||
public IDisposable WatchProperty<TProperty>(
|
||||
Expression<Func<TOwner, TProperty>> propertyExpression,
|
||||
Action callback,
|
||||
bool watchInitialValue = false
|
||||
)
|
||||
{
|
||||
if (
|
||||
string.IsNullOrWhiteSpace(args.PropertyName)
|
||||
|| string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal)
|
||||
)
|
||||
var memberExpression = propertyExpression.Body as MemberExpression;
|
||||
if (memberExpression?.Member is not PropertyInfo property)
|
||||
throw new ArgumentException("Provided expression must reference a property.");
|
||||
|
||||
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
|
||||
{
|
||||
callback();
|
||||
if (
|
||||
string.IsNullOrWhiteSpace(args.PropertyName)
|
||||
|| string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal)
|
||||
)
|
||||
{
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
owner.PropertyChanged += OnPropertyChanged;
|
||||
|
||||
if (watchInitialValue)
|
||||
callback();
|
||||
|
||||
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
|
||||
}
|
||||
|
||||
owner.PropertyChanged += OnPropertyChanged;
|
||||
public IDisposable WatchAllProperties(Action callback, bool watchInitialValues = false)
|
||||
{
|
||||
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback();
|
||||
owner.PropertyChanged += OnPropertyChanged;
|
||||
|
||||
if (watchInitialValue)
|
||||
callback();
|
||||
if (watchInitialValues)
|
||||
callback();
|
||||
|
||||
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
|
||||
}
|
||||
|
||||
public static IDisposable WatchAllProperties<TOwner>(
|
||||
this TOwner owner,
|
||||
Action callback,
|
||||
bool watchInitialValues = false
|
||||
)
|
||||
where TOwner : INotifyPropertyChanged
|
||||
{
|
||||
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback();
|
||||
owner.PropertyChanged += OnPropertyChanged;
|
||||
|
||||
if (watchInitialValues)
|
||||
callback();
|
||||
|
||||
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
|
||||
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Utils.Extensions;
|
||||
|
||||
internal static class ProcessExtensions
|
||||
{
|
||||
extension(Process)
|
||||
{
|
||||
public static void StartShellExecute(string path)
|
||||
{
|
||||
using var process = new Process();
|
||||
process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true };
|
||||
|
||||
process.Start();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace DiscordChatExporter.Gui.Utils;
|
||||
|
||||
internal static class ProcessEx
|
||||
{
|
||||
public static void StartShellExecute(string path)
|
||||
{
|
||||
using var process = new Process();
|
||||
process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true };
|
||||
|
||||
process.Start();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -33,28 +34,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 +63,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]
|
||||
@@ -102,7 +103,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowHelp() => ProcessEx.StartShellExecute(Program.ProjectDocumentationUrl);
|
||||
private void ShowHelp() => Process.StartShellExecute(Program.ProjectDocumentationUrl);
|
||||
|
||||
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
|
||||
|
||||
@@ -123,7 +124,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();
|
||||
@@ -140,7 +141,7 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
catch (Exception ex)
|
||||
{
|
||||
var dialog = _viewModelManager.CreateMessageBoxViewModel(
|
||||
"Error pulling guilds",
|
||||
"Error pulling servers",
|
||||
ex.ToString()
|
||||
);
|
||||
|
||||
@@ -283,6 +284,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('.'));
|
||||
@@ -318,11 +323,11 @@ public partial class DashboardViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenDiscord() => ProcessEx.StartShellExecute("https://discord.com/app");
|
||||
private void OpenDiscord() => Process.StartShellExecute("https://discord.com/app");
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenDiscordDeveloperPortal() =>
|
||||
ProcessEx.StartShellExecute("https://discord.com/developers/applications");
|
||||
Process.StartShellExecute("https://discord.com/developers/applications");
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -53,40 +63,40 @@ public class SettingsViewModel : DialogViewModelBase
|
||||
|
||||
// These items have to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
|
||||
public IReadOnlyList<string> AvailableLocales { get; } =
|
||||
[
|
||||
// Current locale (maps to null downstream)
|
||||
"",
|
||||
// Locales supported by the Discord app
|
||||
"da-DK",
|
||||
"de-DE",
|
||||
"en-GB",
|
||||
"en-US",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"hr-HR",
|
||||
"it-IT",
|
||||
"lt-LT",
|
||||
"hu-HU",
|
||||
"nl-NL",
|
||||
"no-NO",
|
||||
"pl-PL",
|
||||
"pt-BR",
|
||||
"ro-RO",
|
||||
"fi-FI",
|
||||
"sv-SE",
|
||||
"vi-VN",
|
||||
"tr-TR",
|
||||
"cs-CZ",
|
||||
"el-GR",
|
||||
"bg-BG",
|
||||
"ru-RU",
|
||||
"uk-UA",
|
||||
"th-TH",
|
||||
"zh-CN",
|
||||
"ja-JP",
|
||||
"zh-TW",
|
||||
"ko-KR",
|
||||
];
|
||||
[
|
||||
// Current locale (maps to null downstream)
|
||||
"",
|
||||
// Locales supported by the Discord app
|
||||
"da-DK",
|
||||
"de-DE",
|
||||
"en-GB",
|
||||
"en-US",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"hr-HR",
|
||||
"it-IT",
|
||||
"lt-LT",
|
||||
"hu-HU",
|
||||
"nl-NL",
|
||||
"no-NO",
|
||||
"pl-PL",
|
||||
"pt-BR",
|
||||
"ro-RO",
|
||||
"fi-FI",
|
||||
"sv-SE",
|
||||
"vi-VN",
|
||||
"tr-TR",
|
||||
"cs-CZ",
|
||||
"el-GR",
|
||||
"bg-BG",
|
||||
"ru-RU",
|
||||
"uk-UA",
|
||||
"th-TH",
|
||||
"zh-CN",
|
||||
"ja-JP",
|
||||
"zh-TW",
|
||||
"ko-KR",
|
||||
];
|
||||
|
||||
// This has to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
|
||||
public string Locale
|
||||
|
||||
@@ -5,7 +5,6 @@ using Avalonia;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DiscordChatExporter.Gui.Framework;
|
||||
using DiscordChatExporter.Gui.Services;
|
||||
using DiscordChatExporter.Gui.Utils;
|
||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||
using DiscordChatExporter.Gui.ViewModels.Components;
|
||||
|
||||
@@ -44,7 +43,7 @@ public partial class MainViewModel(
|
||||
settingsService.Save();
|
||||
|
||||
if (await dialogManager.ShowDialogAsync(dialog) == true)
|
||||
ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter");
|
||||
Process.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter");
|
||||
}
|
||||
|
||||
private async Task ShowDevelopmentBuildMessageAsync()
|
||||
@@ -61,14 +60,16 @@ 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"
|
||||
);
|
||||
|
||||
if (await dialogManager.ShowDialogAsync(dialog) == true)
|
||||
ProcessEx.StartShellExecute(Program.ProjectReleasesUrl);
|
||||
Process.StartShellExecute(Program.ProjectReleasesUrl);
|
||||
}
|
||||
|
||||
private async Task CheckForUpdatesAsync()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,12 +3,14 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 16
|
||||
VisualStudioVersion = 16.0.28729.10
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
License.txt = License.txt
|
||||
Readme.md = Readme.md
|
||||
Directory.Build.props = Directory.Build.props
|
||||
DiscordChatExporter.Cli.dockerfile = DiscordChatExporter.Cli.dockerfile
|
||||
global.json = global.json
|
||||
NuGet.config = NuGet.config
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-2025 Oleksii Holub
|
||||
Copyright (c) 2017-2026 Oleksii Holub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -50,10 +50,14 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
|
||||
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `discord-chat-exporter-cli`
|
||||
- 📦 [Nix](https://search.nixos.org/packages?query=discordchatexporter-cli): `discordchatexporter-cli`
|
||||
|
||||
> **Note**:
|
||||
> [!IMPORTANT]
|
||||
> To launch the GUI version of the app on MacOS, you need to first remove the downloaded file from quarantine.
|
||||
> You can do that by running the following command in the terminal: `xattr -rd com.apple.quarantine DiscordChatExporter.app`.
|
||||
|
||||
> [!NOTE]
|
||||
> If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture.
|
||||
|
||||
> **Note**:
|
||||
> [!NOTE]
|
||||
> AUR and Nix packages linked above are maintained by the community.
|
||||
> If you have any issues with them, please contact the corresponding maintainers.
|
||||
|
||||
@@ -74,4 +78,4 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
|
||||
## See also
|
||||
|
||||
- [**Chat Analytics**](https://github.com/mlomb/chat-analytics) — solution for analyzing chat patterns of Discord users, using exports produced by **DiscordChatExporter**.
|
||||
- [**DiscordChatExporter-frontend**](https://github.com/slatinsky/DiscordChatExporter-frontend) — convenient viewer for exports produced by **DiscordChatExporter**.
|
||||
- [**DiscordChatExporter-frontend**](https://github.com/slatinsky/DiscordChatExporter-frontend) — convenient viewer for exports produced by **DiscordChatExporter**.
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user