mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-10 16:01:55 +02:00
Compare commits
95 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 | |||
| 62babc0de5 | |||
| 9e115562dc | |||
| db50a2bb96 | |||
| b39d015133 | |||
| b4c3dce760 | |||
| f31e73bb7a | |||
| 75ff5c2f7e | |||
| 7663f8ed31 | |||
| c0e7334084 | |||
| a9eca1ceb2 | |||
| 45ff1daff3 | |||
| 5299c2ebc4 | |||
| f6c189f57c | |||
| a9acf17375 | |||
| bf417db80c | |||
| d78d22d066 |
@@ -33,7 +33,7 @@ The HTML format replicates Discord's interface, making it the most user-friendly
|
|||||||
It's the best format for attachment preview and sharing.
|
It's the best format for attachment preview and sharing.
|
||||||
You can open `.html` files with a web browser, such as Google Chrome.
|
You can open `.html` files with a web browser, such as Google Chrome.
|
||||||
|
|
||||||
> **Warning**:
|
> [!WARNING]
|
||||||
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed.
|
> If a picture is deleted, or if a user changes its avatar, the respective images will no longer be displayed.
|
||||||
> Export using the "Download assets" (`--media`) option to avoid this.
|
> Export using the "Download assets" (`--media`) option to avoid this.
|
||||||
|
|
||||||
|
|||||||
+56
-11
@@ -1,10 +1,11 @@
|
|||||||
# Obtaining Token and Channel IDs
|
# Obtaining Token and Channel IDs
|
||||||
|
|
||||||
> **Warning**:
|
> [!WARNING]
|
||||||
> **Do not share your token!**
|
> **Do not share your token!** A token gives full access to an account.
|
||||||
> A token gives full access to an account. To reset a user token, change your account password. To reset a bot token, click on [Regenerate](#how-to-get-a-bot-token) in the bot settings.
|
> To reset a user token, change your account password.
|
||||||
|
> To reset a bot token, click on [Reset Token](#how-to-export-with-a-bot-token) in the bot settings.
|
||||||
|
|
||||||
## How to get a User Token
|
## How to get a user token
|
||||||
|
|
||||||
**Caution:** [Automating user accounts violates Discord's terms of service](https://support.discord.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-) and may result in account termination. Use at your own risk.
|
**Caution:** [Automating user accounts violates Discord's terms of service](https://support.discord.com/hc/en-us/articles/115002192352-Automated-user-accounts-self-bots-) and may result in account termination. Use at your own risk.
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
|||||||
3. Type
|
3. Type
|
||||||
|
|
||||||
```js
|
```js
|
||||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.$8&&x.exports.LP&&x.exports.gK){m=x;break}}}]);m&&console.log("Token:",m.exports.LP());
|
||||||
```
|
```
|
||||||
|
|
||||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||||
@@ -119,7 +120,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
|||||||
1. Type
|
1. Type
|
||||||
|
|
||||||
```js
|
```js
|
||||||
(webpackChunkdiscord_app.push([[''],{},e=>{m=[];for(let c in e.c)m.push(e.c[c])}]),m).find(m=>m?.exports?.default?.getToken!==void 0).exports.default.getToken()
|
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.$8&&x.exports.LP&&x.exports.gK){m=x;break}}}]);m&&console.log("Token:",m.exports.LP());
|
||||||
```
|
```
|
||||||
|
|
||||||
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
into the console and press <kbd>Enter</kbd>. The console will display your user token.
|
||||||
@@ -254,17 +255,61 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
|
|||||||
4. Press <kbd>Esc</kbd>. The settings page will close.
|
4. Press <kbd>Esc</kbd>. The settings page will close.
|
||||||
5. To find your user token, continue [here](#in-chrome).
|
5. To find your user token, continue [here](#in-chrome).
|
||||||
|
|
||||||
## How to get a Bot Token
|
## How to export with a bot token
|
||||||
|
|
||||||
|
### Step 1 - Create an application
|
||||||
|
|
||||||
|
You can create a new application or use an existing one. If you want to create a new one:
|
||||||
|
|
||||||
|
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||||
|
2. Click on **New Application** in the top right corner
|
||||||
|
3. Enter a name for your application and click **Create**
|
||||||
|
|
||||||
|
### Step 2 - Invite the bot to your server
|
||||||
|
|
||||||
|
The bot needs to be invited to the server you'd like to export from.
|
||||||
|
|
||||||
|
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||||
|
2. Navigate to **General Information** on the left
|
||||||
|
3. Copy the **Application ID**
|
||||||
|
4. Open the following URL in your browser, replacing `YOUR_APP_ID` with the copied Client ID:
|
||||||
|
|
||||||
|
<!-- Permission code 66560 corresponds to "View Channels" and "Read Message History" permissions.
|
||||||
|
User can uncheck these when adding the bot to their server. -->
|
||||||
|
|
||||||
|
```
|
||||||
|
https://discord.com/oauth2/authorize?scope=bot&permissions=66560&client_id=YOUR_APP_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3 - Ensure message content intent is enabled
|
||||||
|
|
||||||
|
If this option is not enabled, the exported files will be empty.
|
||||||
|
|
||||||
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
1. Go to [Discord developer portal](https://discord.com/developers/applications)
|
||||||
2. Open your Application's settings
|
2. Open your Application's settings
|
||||||
3. Navigate to the **Bot** section on the left
|
3. Navigate to the **Bot** section on the left
|
||||||
4. Under **Token** click **Copy**
|
4. Scroll down to the **Privileged Gateway Intents** section
|
||||||
|
5. Enable **Message Content Intent** by toggling the switch
|
||||||
|
|
||||||
> **Warning**:
|
<img width="500" align="right" src="https://i.imgur.com/PPm2KKn.png" />
|
||||||
> Your bot needs to have **Message Content Intent** enabled for it to be able to read messages!
|
|
||||||
|
|
||||||

|
### 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?
|
### Will I get banned if I use this?
|
||||||
|
|
||||||
Automating user accounts is technically against [TOS](https://discord.com/terms), use at your discretion. [Bot accounts](https://discord.com/developers/docs/topics/oauth2#bots) don't have this restriction.
|
Automating user accounts is technically against [TOS](https://discord.com/terms), use at your discretion. [Bot accounts](https://discord.com/developers/docs/topics/oauth2#bot-users) don't have this restriction.
|
||||||
|
|
||||||
### Will the messages disappear from the exported file if I delete a message, delete my account or block a person?
|
### Will the messages disappear from the exported file if I delete a message, delete my account or block a person?
|
||||||
|
|
||||||
@@ -146,6 +146,12 @@ Red Hat: `cert-sync --user /etc/pki/tls/certs/ca-bundle.crt`
|
|||||||
|
|
||||||
If it still doesn't work, try mozroots: `mozroots --import --ask-remove`
|
If it still doesn't work, try mozroots: `mozroots --import --ask-remove`
|
||||||
|
|
||||||
|
## macOS-specific
|
||||||
|
|
||||||
|
### DiscordChatExporter is damaged and can’t be opened. You should move it to the Trash.
|
||||||
|
|
||||||
|
Check the [Using the GUI page](Using-the-GUI.md#step-1) for instructions on how to run the app.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> ❓ If you still have unanswered questions, feel free to [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new).
|
> ❓ If you still have unanswered questions, feel free to [create a new discussion](https://github.com/Tyrrrz/DiscordChatExporter/discussions/new).
|
||||||
|
|||||||
+17
-2
@@ -10,13 +10,28 @@
|
|||||||
|
|
||||||
### Step 1
|
### Step 1
|
||||||
|
|
||||||
After extracting the `.zip`, run `DiscordChatExporter.exe` (Windows), or `DiscordChatExporter` (Mac OS and Linux).
|
After extracting the `.zip`, run `DiscordChatExporter.exe` **(Windows)**, or `DiscordChatExporter` **(Linux)**.
|
||||||
|
|
||||||
|
If you're using **macOS**, you'll need to manually grant permission for the app to run.
|
||||||
|
If you skip these steps, the "DiscordChatExporter is damaged and can’t be opened" error will be shown.
|
||||||
|
|
||||||
|
1. Open Terminal.app. You can search for it in Spotlight (press <kbd>⌘</kbd> + <kbd>Space</kbd> and type "Terminal").
|
||||||
|
2. Paste the following into the terminal window:
|
||||||
|
```bash
|
||||||
|
xattr -rd com.apple.quarantine
|
||||||
|
```
|
||||||
|
3. Hit <kbd>Space</kbd> once to add a space after the command
|
||||||
|
4. Drag and drop DiscordChatExporter.app into the terminal window
|
||||||
|
5. Press <kbd>Return</kbd> to run the command
|
||||||
|
6. Open DiscordChatExporter.app normally
|
||||||
|
|
||||||
|
> Apple requires apps to be notarized and signed in order to run on macOS without warnings, which in turn requires an Apple Developer membership ($99/year). This open-source project is distributed for free and without commercial intent.
|
||||||
|
|
||||||
### Step 2
|
### Step 2
|
||||||
|
|
||||||
Please refer to the on-screen instructions to get your token, then paste your token in the upper text box and hit ENTER or click the arrow (→).
|
Please refer to the on-screen instructions to get your token, then paste your token in the upper text box and hit ENTER or click the arrow (→).
|
||||||
|
|
||||||
> **Warning**:
|
> [!WARNING]
|
||||||
> **Never share your token!**
|
> **Never share your token!**
|
||||||
> A token gives full access to an account, treat it like a password.
|
> A token gives full access to an account, treat it like a password.
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install Docker Buildx
|
- name: Install Docker Buildx
|
||||||
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
run: >
|
run: >
|
||||||
@@ -36,8 +36,8 @@ jobs:
|
|||||||
--build-arg VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
--build-arg VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||||
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload image
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: DiscordChatExporter.Cli.Docker
|
name: DiscordChatExporter.Cli.Docker
|
||||||
path: DiscordChatExporter.Cli.Docker.tar
|
path: DiscordChatExporter.Cli.Docker.tar
|
||||||
@@ -55,10 +55,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install Docker Buildx
|
- name: Install Docker Buildx
|
||||||
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
run: >
|
run: >
|
||||||
|
|||||||
+30
-18
@@ -26,18 +26,29 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||||
with:
|
|
||||||
dotnet-version: 9.0.x
|
# Build the project separately to discern between build and format errors
|
||||||
|
- name: Build
|
||||||
|
run: >
|
||||||
|
dotnet build
|
||||||
|
-p:CSharpier_Bypass=true
|
||||||
|
--configuration Release
|
||||||
|
|
||||||
- name: Verify formatting
|
- name: Verify formatting
|
||||||
|
id: verify
|
||||||
run: >
|
run: >
|
||||||
dotnet build
|
dotnet build
|
||||||
-t:CSharpierFormat
|
-t:CSharpierFormat
|
||||||
--configuration Release
|
--configuration Release
|
||||||
|
--no-restore
|
||||||
|
|
||||||
|
- name: Report issues
|
||||||
|
if: ${{ failure() && steps.verify.outcome == 'failure' }}
|
||||||
|
run: echo "::error title=Bad formatting::Formatting issues detected. Please build the solution locally to fix them."
|
||||||
|
|
||||||
test:
|
test:
|
||||||
# Tests need access to secrets, so we can't run them against PRs because of limited trust
|
# Tests need access to secrets, so we can't run them against PRs because of limited trust
|
||||||
@@ -51,12 +62,10 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||||
with:
|
|
||||||
dotnet-version: 9.0.x
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
@@ -72,7 +81,7 @@ jobs:
|
|||||||
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0
|
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
@@ -108,25 +117,24 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install .NET
|
- name: Install .NET
|
||||||
uses: actions/setup-dotnet@3e891b0cb619bf60e2c25674b222b8940e2c1c25 # v4.1.0
|
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||||
with:
|
|
||||||
dotnet-version: 9.0.x
|
|
||||||
|
|
||||||
- name: Publish app
|
- name: Publish app
|
||||||
run: >
|
run: >
|
||||||
dotnet publish ${{ matrix.app }}
|
dotnet publish ${{ matrix.app }}
|
||||||
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
-p:Version=${{ github.ref_type == 'tag' && github.ref_name || format('999.9.9-ci-{0}', github.sha) }}
|
||||||
-p:CSharpier_Bypass=true
|
-p:CSharpier_Bypass=true
|
||||||
|
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
|
||||||
--output ${{ matrix.app }}/bin/publish/
|
--output ${{ matrix.app }}/bin/publish/
|
||||||
--configuration Release
|
--configuration Release
|
||||||
--runtime ${{ matrix.rid }}
|
--runtime ${{ matrix.rid }}
|
||||||
--self-contained
|
--self-contained
|
||||||
|
|
||||||
- name: Upload artifacts
|
- name: Upload app binaries
|
||||||
uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||||
path: ${{ matrix.app }}/bin/publish/
|
path: ${{ matrix.app }}/bin/publish/
|
||||||
@@ -190,15 +198,19 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download app binaries
|
||||||
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
name: ${{ matrix.asset }}.${{ matrix.rid }}
|
||||||
path: ${{ matrix.app }}/
|
path: ${{ matrix.app }}/
|
||||||
|
|
||||||
- name: Set permissions
|
- name: Set permissions
|
||||||
if: ${{ !startsWith(matrix.rid, 'win-') }}
|
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
|
- name: Create package
|
||||||
# Change into the artifacts directory to avoid including the directory itself in the zip archive
|
# Change into the artifacts directory to avoid including the directory itself in the zip archive
|
||||||
|
|||||||
+1
-1
@@ -9,4 +9,4 @@ bin/
|
|||||||
obj/
|
obj/
|
||||||
|
|
||||||
# Test results
|
# Test results
|
||||||
TestResults/
|
TestResults/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net9.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Version>999.9.9-dev</Version>
|
<Version>999.9.9-dev</Version>
|
||||||
<Company>Tyrrrz</Company>
|
<Company>Tyrrrz</Company>
|
||||||
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
<Copyright>Copyright (c) Oleksii Holub</Copyright>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
@@ -11,22 +10,24 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="1.1.2" />
|
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.2" PrivateAssets="all" />
|
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.29.2" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.12.1" />
|
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
||||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
|
<PackageReference Include="GitHubActionsTestLogger" Version="3.0.1" PrivateAssets="all" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
<PackageReference
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.1" />
|
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
Version="10.0.2"
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
/>
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" PrivateAssets="all" />
|
<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.1.5" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ In order to run these tests locally, you need to join the test server and config
|
|||||||
3. Add your token to user secrets: `dotnet user-secrets set DISCORD_TOKEN <token>`
|
3. Add your token to user secrets: `dotnet user-secrets set DISCORD_TOKEN <token>`
|
||||||
4. Run the tests: `dotnet test`
|
4. Run the tests: `dotnet test`
|
||||||
|
|
||||||
> **Note**:
|
> [!NOTE]
|
||||||
> If you want to add a new test case, please let me know and I will give you the required permissions on the server.
|
> If you want to add a new test case, please let me know and I will give you the required permissions on the server.
|
||||||
@@ -146,4 +146,31 @@ public class DateRangeSpecs
|
|||||||
.WhenTypeIs<DateTimeOffset>()
|
.WhenTypeIs<DateTimeOffset>()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_filter_the_export_to_not_include_any_messages()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var before = new DateTimeOffset(2020, 08, 01, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
using var file = TempFile.Create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
Token = Secrets.DiscordToken,
|
||||||
|
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = file.Path,
|
||||||
|
Before = Snowflake.FromDate(before),
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var timestamps = Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||||
|
.GetProperty("messages")
|
||||||
|
.EnumerateArray()
|
||||||
|
.Select(j => j.GetProperty("timestamp").GetDateTimeOffset())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
timestamps.Should().BeEmpty();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,8 +181,14 @@ public class HtmlEmbedSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
var iframeUrl = message.QuerySelector("iframe")?.GetAttribute("src");
|
// Check that the YouTube video thumbnail image exists with the correct video ID
|
||||||
iframeUrl.Should().StartWith("https://www.youtube.com/embed/qOWW4OlgbvE");
|
var youtubeThumbnailSrc = message
|
||||||
|
.QuerySelectorAll("img")
|
||||||
|
.Select(e => e.GetAttribute("src"))
|
||||||
|
.WhereNotNull()
|
||||||
|
.FirstOrDefault(s => s.Contains("qOWW4OlgbvE", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
youtubeThumbnailSrc.Should().NotBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AngleSharp.Dom;
|
using AngleSharp.Dom;
|
||||||
using DiscordChatExporter.Cli.Tests.Infra;
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -19,8 +20,13 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Default timestamp: 2/12/2023 1:36 PM");
|
message
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
.Text()
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.Contain("Default timestamp: 2/12/2023 1:36 PM");
|
||||||
|
|
||||||
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -33,8 +39,8 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Short time timestamp: 1:36 PM");
|
message.Text().ReplaceWhiteSpace().Should().Contain("Short time timestamp: 1:36 PM");
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -47,8 +53,8 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Long time timestamp: 1:36:12 PM");
|
message.Text().ReplaceWhiteSpace().Should().Contain("Long time timestamp: 1:36:12 PM");
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -61,8 +67,8 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Short date timestamp: 2/12/2023");
|
message.Text().ReplaceWhiteSpace().Should().Contain("Short date timestamp: 2/12/2023");
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -75,8 +81,13 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Long date timestamp: Sunday, February 12, 2023");
|
message
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
.Text()
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.Contain("Long date timestamp: Sunday, February 12, 2023");
|
||||||
|
|
||||||
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -89,8 +100,13 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
|
message
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
.Text()
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.Contain("Full timestamp: Sunday, February 12, 2023 1:36 PM");
|
||||||
|
|
||||||
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -105,9 +121,11 @@ public class HtmlMarkdownSpecs
|
|||||||
// Assert
|
// Assert
|
||||||
message
|
message
|
||||||
.Text()
|
.Text()
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
.Should()
|
.Should()
|
||||||
.Contain("Full long timestamp: Sunday, February 12, 2023 1:36:12 PM");
|
.Contain("Full long timestamp: Sunday, February 12, 2023 1:36:12 PM");
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
|
||||||
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -120,8 +138,13 @@ public class HtmlMarkdownSpecs
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Relative timestamp: 2/12/2023 1:36 PM");
|
message
|
||||||
message.InnerHtml.Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
.Text()
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.Contain("Relative timestamp: 2/12/2023 1:36 PM");
|
||||||
|
|
||||||
|
message.InnerHtml.ReplaceWhiteSpace().Should().Contain("Sunday, February 12, 2023 1:36 PM");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||||
|
|
||||||
|
internal static class StringExtensions
|
||||||
|
{
|
||||||
|
extension(string str)
|
||||||
|
{
|
||||||
|
public string ReplaceWhiteSpace(string replacement = " ")
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder(str.Length);
|
||||||
|
|
||||||
|
foreach (var ch in str)
|
||||||
|
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
|
||||||
|
|
||||||
|
return buffer.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using PathEx = System.IO.Path;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||||
|
|
||||||
@@ -23,8 +22,8 @@ internal partial class TempDir
|
|||||||
{
|
{
|
||||||
public static TempDir Create()
|
public static TempDir Create()
|
||||||
{
|
{
|
||||||
var dirPath = PathEx.Combine(
|
var dirPath = System.IO.Path.Combine(
|
||||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||||
?? Directory.GetCurrentDirectory(),
|
?? Directory.GetCurrentDirectory(),
|
||||||
"Temp",
|
"Temp",
|
||||||
Guid.NewGuid().ToString()
|
Guid.NewGuid().ToString()
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using PathEx = System.IO.Path;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Utils;
|
namespace DiscordChatExporter.Cli.Tests.Utils;
|
||||||
|
|
||||||
@@ -23,15 +22,15 @@ internal partial class TempFile
|
|||||||
{
|
{
|
||||||
public static TempFile Create()
|
public static TempFile Create()
|
||||||
{
|
{
|
||||||
var dirPath = PathEx.Combine(
|
var dirPath = System.IO.Path.Combine(
|
||||||
PathEx.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
System.IO.Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)
|
||||||
?? Directory.GetCurrentDirectory(),
|
?? Directory.GetCurrentDirectory(),
|
||||||
"Temp"
|
"Temp"
|
||||||
);
|
);
|
||||||
|
|
||||||
Directory.CreateDirectory(dirPath);
|
Directory.CreateDirectory(dirPath);
|
||||||
|
|
||||||
var filePath = PathEx.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
var filePath = System.IO.Path.Combine(dirPath, Guid.NewGuid() + ".tmp");
|
||||||
|
|
||||||
return new TempFile(filePath);
|
return new TempFile(filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# -- Build
|
# -- Build
|
||||||
# Specify the platform here so that we pull the SDK image matching the host platform,
|
# Specify the platform here so that we pull the SDK image matching the host platform,
|
||||||
# instead of the target platform specified during build by the `--platform` option.
|
# instead of the target platform specified during build by the `--platform` option.
|
||||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build
|
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||||
|
|
||||||
# Expose the target architecture set by the `docker build --platform` option, so that
|
# Expose the target architecture set by the `docker build --platform` option, so that
|
||||||
# we can build the assembly for the correct platform.
|
# we can build the assembly for the correct platform.
|
||||||
@@ -30,7 +30,7 @@ RUN dotnet publish DiscordChatExporter.Cli \
|
|||||||
|
|
||||||
# -- Run
|
# -- Run
|
||||||
# Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
|
# Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
|
||||||
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:9.0-alpine AS run
|
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS run
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="DiscordChatExporter.Cli"
|
LABEL org.opencontainers.image.title="DiscordChatExporter.Cli"
|
||||||
LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
|
LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CliFx;
|
using CliFx;
|
||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
@@ -27,8 +28,19 @@ public abstract class DiscordCommandBase : ICommand
|
|||||||
)]
|
)]
|
||||||
public bool IsBotToken { get; init; } = false;
|
public bool IsBotToken { get; init; } = false;
|
||||||
|
|
||||||
private DiscordClient? _discordClient;
|
[CommandOption(
|
||||||
protected DiscordClient Discord => _discordClient ??= new DiscordClient(Token);
|
"respect-rate-limits",
|
||||||
|
Description = "Whether to respect advisory rate limits. "
|
||||||
|
+ "If disabled, only hard rate limits (i.e. 429 responses) will be respected."
|
||||||
|
)]
|
||||||
|
public bool ShouldRespectRateLimits { get; init; } = true;
|
||||||
|
|
||||||
|
[field: AllowNull, MaybeNull]
|
||||||
|
protected DiscordClient Discord =>
|
||||||
|
field ??= new DiscordClient(
|
||||||
|
Token,
|
||||||
|
ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
|
||||||
|
);
|
||||||
|
|
||||||
public virtual ValueTask ExecuteAsync(IConsole console)
|
public virtual ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -8,6 +9,7 @@ using CliFx.Attributes;
|
|||||||
using CliFx.Exceptions;
|
using CliFx.Exceptions;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Converters;
|
using DiscordChatExporter.Cli.Commands.Converters;
|
||||||
|
using DiscordChatExporter.Cli.Commands.Shared;
|
||||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
@@ -15,7 +17,6 @@ using DiscordChatExporter.Core.Exceptions;
|
|||||||
using DiscordChatExporter.Core.Exporting;
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
|
||||||
using Gress;
|
using Gress;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
|
|
||||||
@@ -23,8 +24,6 @@ namespace DiscordChatExporter.Cli.Commands.Base;
|
|||||||
|
|
||||||
public abstract class ExportCommandBase : DiscordCommandBase
|
public abstract class ExportCommandBase : DiscordCommandBase
|
||||||
{
|
{
|
||||||
private readonly string _outputPath = Directory.GetCurrentDirectory();
|
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"output",
|
"output",
|
||||||
'o',
|
'o',
|
||||||
@@ -35,11 +34,11 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public string OutputPath
|
public string OutputPath
|
||||||
{
|
{
|
||||||
get => _outputPath;
|
get;
|
||||||
// Handle ~/ in paths on Unix systems
|
// Handle ~/ in paths on Unix systems
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||||
init => _outputPath = Path.GetFullPath(value);
|
init => field = Path.GetFullPath(value);
|
||||||
}
|
} = Directory.GetCurrentDirectory();
|
||||||
|
|
||||||
[CommandOption("format", 'f', Description = "Export format.")]
|
[CommandOption("format", 'f', Description = "Export format.")]
|
||||||
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
|
||||||
@@ -64,6 +63,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
|
||||||
|
|
||||||
|
[CommandOption(
|
||||||
|
"include-threads",
|
||||||
|
Description = "Which types of threads should be included.",
|
||||||
|
Converter = typeof(ThreadInclusionModeBindingConverter)
|
||||||
|
)]
|
||||||
|
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"filter",
|
"filter",
|
||||||
Description = "Only include messages that satisfy this filter. "
|
Description = "Only include messages that satisfy this filter. "
|
||||||
@@ -95,8 +101,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public bool ShouldReuseAssets { get; init; } = false;
|
public bool ShouldReuseAssets { get; init; } = false;
|
||||||
|
|
||||||
private readonly string? _assetsDirPath;
|
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"media-dir",
|
"media-dir",
|
||||||
Description = "Download assets to this directory. "
|
Description = "Download assets to this directory. "
|
||||||
@@ -104,10 +108,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public string? AssetsDirPath
|
public string? AssetsDirPath
|
||||||
{
|
{
|
||||||
get => _assetsDirPath;
|
get;
|
||||||
// Handle ~/ in paths on Unix systems
|
// Handle ~/ in paths on Unix systems
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
|
||||||
init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null;
|
init => field = value is not null ? Path.GetFullPath(value) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
|
||||||
@@ -136,11 +140,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
|
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
|
||||||
|
|
||||||
private ChannelExporter? _channelExporter;
|
[field: AllowNull, MaybeNull]
|
||||||
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord);
|
protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
|
||||||
|
|
||||||
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
|
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
|
||||||
{
|
{
|
||||||
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
// Asset reuse can only be enabled if the download assets option is set
|
// Asset reuse can only be enabled if the download assets option is set
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425
|
||||||
if (ShouldReuseAssets && !ShouldDownloadAssets)
|
if (ShouldReuseAssets && !ShouldDownloadAssets)
|
||||||
@@ -154,13 +160,53 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
throw new CommandException("Option --media-dir cannot be used without --media.");
|
throw new CommandException("Option --media-dir cannot be used without --media.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var unwrappedChannels = new List<Channel>(channels);
|
||||||
|
|
||||||
|
// Unwrap threads
|
||||||
|
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
||||||
|
{
|
||||||
|
await console.Output.WriteLineAsync("Fetching threads...");
|
||||||
|
|
||||||
|
var fetchedThreadsCount = 0;
|
||||||
|
await console
|
||||||
|
.CreateStatusTicker()
|
||||||
|
.StartAsync(
|
||||||
|
"...",
|
||||||
|
async ctx =>
|
||||||
|
{
|
||||||
|
await foreach (
|
||||||
|
var thread in Discord.GetChannelThreadsAsync(
|
||||||
|
channels,
|
||||||
|
ThreadInclusionMode == ThreadInclusionMode.All,
|
||||||
|
Before,
|
||||||
|
After,
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
unwrappedChannels.Add(thread);
|
||||||
|
|
||||||
|
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
|
||||||
|
|
||||||
|
fetchedThreadsCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove forums, as they cannot be exported directly and their constituent threads
|
||||||
|
// have already been fetched.
|
||||||
|
unwrappedChannels.RemoveAll(channel => channel.Kind == ChannelKind.GuildForum);
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
||||||
|
}
|
||||||
|
|
||||||
// Make sure the user does not try to export multiple channels into one file.
|
// Make sure the user does not try to export multiple channels into one file.
|
||||||
// Output path must either be a directory or contain template tokens for this to work.
|
// Output path must either be a directory or contain template tokens for this to work.
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917
|
||||||
var isValidOutputPath =
|
var isValidOutputPath =
|
||||||
// Anything is valid when exporting a single channel
|
// Anything is valid when exporting a single channel
|
||||||
channels.Count <= 1
|
unwrappedChannels.Count <= 1
|
||||||
// When using template tokens, assume the user knows what they're doing
|
// When using template tokens, assume the user knows what they're doing
|
||||||
|| OutputPath.Contains('%')
|
|| OutputPath.Contains('%')
|
||||||
// Otherwise, require an existing directory or an unambiguous directory path
|
// Otherwise, require an existing directory or an unambiguous directory path
|
||||||
@@ -177,10 +223,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Export
|
// Export
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
|
||||||
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
|
var errorsByChannel = new ConcurrentDictionary<Channel, string>();
|
||||||
|
var warningsByChannel = new ConcurrentDictionary<Channel, string>();
|
||||||
|
|
||||||
await console.Output.WriteLineAsync($"Exporting {channels.Count} channel(s)...");
|
await console.Output.WriteLineAsync($"Exporting {unwrappedChannels.Count} channel(s)...");
|
||||||
await console
|
await console
|
||||||
.CreateProgressTicker()
|
.CreateProgressTicker()
|
||||||
.HideCompleted(
|
.HideCompleted(
|
||||||
@@ -192,7 +238,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
.StartAsync(async ctx =>
|
.StartAsync(async ctx =>
|
||||||
{
|
{
|
||||||
await Parallel.ForEachAsync(
|
await Parallel.ForEachAsync(
|
||||||
channels,
|
unwrappedChannels,
|
||||||
new ParallelOptions
|
new ParallelOptions
|
||||||
{
|
{
|
||||||
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
|
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
|
||||||
@@ -236,6 +282,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
catch (ChannelEmptyException ex)
|
||||||
|
{
|
||||||
|
warningsByChannel[channel] = ex.Message;
|
||||||
|
}
|
||||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||||
{
|
{
|
||||||
errorsByChannel[channel] = ex.Message;
|
errorsByChannel[channel] = ex.Message;
|
||||||
@@ -248,10 +298,32 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
{
|
{
|
||||||
await console.Output.WriteLineAsync(
|
await console.Output.WriteLineAsync(
|
||||||
$"Successfully exported {channels.Count - errorsByChannel.Count} channel(s)."
|
$"Successfully exported {unwrappedChannels.Count - errorsByChannel.Count} channel(s)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Print warnings
|
||||||
|
if (warningsByChannel.Any())
|
||||||
|
{
|
||||||
|
await console.Output.WriteLineAsync();
|
||||||
|
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.Yellow))
|
||||||
|
{
|
||||||
|
await console.Error.WriteLineAsync(
|
||||||
|
"Warnings reported for the following channel(s):"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (channel, message) in warningsByChannel)
|
||||||
|
{
|
||||||
|
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
|
||||||
|
using (console.WithForegroundColor(ConsoleColor.Yellow))
|
||||||
|
await console.Error.WriteLineAsync(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await console.Error.WriteLineAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// Print errors
|
// Print errors
|
||||||
if (errorsByChannel.Any())
|
if (errorsByChannel.Any())
|
||||||
{
|
{
|
||||||
@@ -259,16 +331,14 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
|
|
||||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||||
{
|
{
|
||||||
await console.Error.WriteLineAsync(
|
await console.Error.WriteLineAsync("Failed to export the following channel(s):");
|
||||||
$"Failed to export {errorsByChannel.Count} the following channel(s):"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var (channel, error) in errorsByChannel)
|
foreach (var (channel, message) in errorsByChannel)
|
||||||
{
|
{
|
||||||
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
|
await console.Error.WriteAsync($"{channel.GetHierarchicalName()}: ");
|
||||||
using (console.WithForegroundColor(ConsoleColor.Red))
|
using (console.WithForegroundColor(ConsoleColor.Red))
|
||||||
await console.Error.WriteLineAsync(error);
|
await console.Error.WriteLineAsync(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
await console.Error.WriteLineAsync();
|
await console.Error.WriteLineAsync();
|
||||||
@@ -276,48 +346,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
|
|
||||||
// Fail the command only if ALL channels failed to export.
|
// Fail the command only if ALL channels failed to export.
|
||||||
// If only some channels failed to export, it's okay.
|
// If only some channels failed to export, it's okay.
|
||||||
if (errorsByChannel.Count >= channels.Count)
|
if (errorsByChannel.Count >= unwrappedChannels.Count)
|
||||||
throw new CommandException("Export failed.");
|
throw new CommandException("Export failed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Snowflake> channelIds)
|
|
||||||
{
|
|
||||||
var cancellationToken = console.RegisterCancellationHandler();
|
|
||||||
|
|
||||||
await console.Output.WriteLineAsync("Resolving channel(s)...");
|
|
||||||
|
|
||||||
var channels = new List<Channel>();
|
|
||||||
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
|
||||||
|
|
||||||
foreach (var channelId in channelIds)
|
|
||||||
{
|
|
||||||
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
|
|
||||||
|
|
||||||
// Unwrap categories
|
|
||||||
if (channel.IsCategory)
|
|
||||||
{
|
|
||||||
var guildChannels =
|
|
||||||
channelsByGuild.GetValueOrDefault(channel.GuildId)
|
|
||||||
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
|
||||||
|
|
||||||
foreach (var guildChannel in guildChannels)
|
|
||||||
{
|
|
||||||
if (guildChannel.Parent?.Id == channel.Id)
|
|
||||||
channels.Add(guildChannel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the guild channels to avoid redundant work
|
|
||||||
channelsByGuild[channel.GuildId] = guildChannels;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
channels.Add(channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ExportAsync(console, channels);
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
// Support Ukraine callout
|
// Support Ukraine callout
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ using System.Threading.Tasks;
|
|||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Cli.Commands.Converters;
|
|
||||||
using DiscordChatExporter.Cli.Commands.Shared;
|
|
||||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Discord.Dump;
|
using DiscordChatExporter.Core.Discord.Dump;
|
||||||
@@ -27,13 +25,6 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||||
public bool IncludeVoiceChannels { get; init; } = true;
|
public bool IncludeVoiceChannels { get; init; } = true;
|
||||||
|
|
||||||
[CommandOption(
|
|
||||||
"include-threads",
|
|
||||||
Description = "Which types of threads should be included.",
|
|
||||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
|
||||||
)]
|
|
||||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"data-package",
|
"data-package",
|
||||||
Description = "Path to the personal data package (ZIP file) requested from Discord. "
|
Description = "Path to the personal data package (ZIP file) requested from Discord. "
|
||||||
@@ -90,46 +81,6 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
);
|
);
|
||||||
|
|
||||||
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
||||||
|
|
||||||
// Threads
|
|
||||||
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
|
||||||
{
|
|
||||||
await console.Output.WriteLineAsync(
|
|
||||||
$"Fetching threads for server '{guild.Name}'..."
|
|
||||||
);
|
|
||||||
|
|
||||||
var fetchedThreadsCount = 0;
|
|
||||||
await console
|
|
||||||
.CreateStatusTicker()
|
|
||||||
.StartAsync(
|
|
||||||
"...",
|
|
||||||
async ctx =>
|
|
||||||
{
|
|
||||||
await foreach (
|
|
||||||
var thread in Discord.GetGuildThreadsAsync(
|
|
||||||
guild.Id,
|
|
||||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
|
||||||
Before,
|
|
||||||
After,
|
|
||||||
cancellationToken
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
channels.Add(thread);
|
|
||||||
|
|
||||||
ctx.Status(
|
|
||||||
Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'.")
|
|
||||||
);
|
|
||||||
|
|
||||||
fetchedThreadsCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await console.Output.WriteLineAsync(
|
|
||||||
$"Fetched {fetchedThreadsCount} thread(s)."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pull from the data package
|
// Pull from the data package
|
||||||
@@ -199,10 +150,6 @@ public class ExportAllCommand : ExportCommandBase
|
|||||||
channels.RemoveAll(c => c.IsGuild);
|
channels.RemoveAll(c => c.IsGuild);
|
||||||
if (!IncludeVoiceChannels)
|
if (!IncludeVoiceChannels)
|
||||||
channels.RemoveAll(c => c.IsVoice);
|
channels.RemoveAll(c => c.IsVoice);
|
||||||
if (ThreadInclusionMode == ThreadInclusionMode.None)
|
|
||||||
channels.RemoveAll(c => c.IsThread);
|
|
||||||
if (ThreadInclusionMode != ThreadInclusionMode.All)
|
|
||||||
channels.RemoveAll(c => c is { IsThread: true, IsArchived: true });
|
|
||||||
|
|
||||||
await ExportAsync(console, channels);
|
await ExportAsync(console, channels);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using CliFx.Attributes;
|
|||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Commands;
|
namespace DiscordChatExporter.Cli.Commands;
|
||||||
|
|
||||||
@@ -22,6 +24,40 @@ public class ExportChannelsCommand : ExportCommandBase
|
|||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
await base.ExecuteAsync(console);
|
await base.ExecuteAsync(console);
|
||||||
await ExportAsync(console, ChannelIds);
|
|
||||||
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
|
|
||||||
|
await console.Output.WriteLineAsync("Resolving channel(s)...");
|
||||||
|
|
||||||
|
var channels = new List<Channel>();
|
||||||
|
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
|
||||||
|
|
||||||
|
foreach (var channelId in ChannelIds)
|
||||||
|
{
|
||||||
|
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
|
||||||
|
|
||||||
|
// Unwrap categories
|
||||||
|
if (channel.IsCategory)
|
||||||
|
{
|
||||||
|
var guildChannels =
|
||||||
|
channelsByGuild.GetValueOrDefault(channel.GuildId)
|
||||||
|
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
|
||||||
|
|
||||||
|
foreach (var guildChannel in guildChannels)
|
||||||
|
{
|
||||||
|
if (guildChannel.Parent?.Id == channel.Id)
|
||||||
|
channels.Add(guildChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the guild channels to avoid redundant work
|
||||||
|
channelsByGuild[channel.GuildId] = guildChannels;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
channels.Add(channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ExportAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ using System.Threading.Tasks;
|
|||||||
using CliFx.Attributes;
|
using CliFx.Attributes;
|
||||||
using CliFx.Infrastructure;
|
using CliFx.Infrastructure;
|
||||||
using DiscordChatExporter.Cli.Commands.Base;
|
using DiscordChatExporter.Cli.Commands.Base;
|
||||||
using DiscordChatExporter.Cli.Commands.Converters;
|
|
||||||
using DiscordChatExporter.Cli.Commands.Shared;
|
|
||||||
using DiscordChatExporter.Cli.Utils.Extensions;
|
using DiscordChatExporter.Cli.Utils.Extensions;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
@@ -21,13 +19,6 @@ public class ExportGuildCommand : ExportCommandBase
|
|||||||
[CommandOption("include-vc", Description = "Include voice channels.")]
|
[CommandOption("include-vc", Description = "Include voice channels.")]
|
||||||
public bool IncludeVoiceChannels { get; init; } = true;
|
public bool IncludeVoiceChannels { get; init; } = true;
|
||||||
|
|
||||||
[CommandOption(
|
|
||||||
"include-threads",
|
|
||||||
Description = "Which types of threads should be included.",
|
|
||||||
Converter = typeof(ThreadInclusionModeBindingConverter)
|
|
||||||
)]
|
|
||||||
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
|
|
||||||
|
|
||||||
public override async ValueTask ExecuteAsync(IConsole console)
|
public override async ValueTask ExecuteAsync(IConsole console)
|
||||||
{
|
{
|
||||||
await base.ExecuteAsync(console);
|
await base.ExecuteAsync(console);
|
||||||
@@ -35,7 +26,6 @@ public class ExportGuildCommand : ExportCommandBase
|
|||||||
var cancellationToken = console.RegisterCancellationHandler();
|
var cancellationToken = console.RegisterCancellationHandler();
|
||||||
var channels = new List<Channel>();
|
var channels = new List<Channel>();
|
||||||
|
|
||||||
// Regular channels
|
|
||||||
await console.Output.WriteLineAsync("Fetching channels...");
|
await console.Output.WriteLineAsync("Fetching channels...");
|
||||||
|
|
||||||
var fetchedChannelsCount = 0;
|
var fetchedChannelsCount = 0;
|
||||||
@@ -66,40 +56,6 @@ public class ExportGuildCommand : ExportCommandBase
|
|||||||
|
|
||||||
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
|
||||||
|
|
||||||
// Threads
|
|
||||||
if (ThreadInclusionMode != ThreadInclusionMode.None)
|
|
||||||
{
|
|
||||||
await console.Output.WriteLineAsync("Fetching threads...");
|
|
||||||
|
|
||||||
var fetchedThreadsCount = 0;
|
|
||||||
await console
|
|
||||||
.CreateStatusTicker()
|
|
||||||
.StartAsync(
|
|
||||||
"...",
|
|
||||||
async ctx =>
|
|
||||||
{
|
|
||||||
await foreach (
|
|
||||||
var thread in Discord.GetGuildThreadsAsync(
|
|
||||||
GuildId,
|
|
||||||
ThreadInclusionMode == ThreadInclusionMode.All,
|
|
||||||
Before,
|
|
||||||
After,
|
|
||||||
cancellationToken
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
channels.Add(thread);
|
|
||||||
|
|
||||||
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
|
|
||||||
|
|
||||||
fetchedThreadsCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
|
|
||||||
}
|
|
||||||
|
|
||||||
await ExportAsync(console, channels);
|
await ExportAsync(console, channels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,10 +34,17 @@ public class GuideCommand : ICommand
|
|||||||
using (console.WithForegroundColor(ConsoleColor.White))
|
using (console.WithForegroundColor(ConsoleColor.White))
|
||||||
console.Output.WriteLine("To get the token for your bot:");
|
console.Output.WriteLine("To get the token for your bot:");
|
||||||
|
|
||||||
|
console.Output.WriteLine(
|
||||||
|
" The token is generated during bot creation. If you lost it, generate a new one:"
|
||||||
|
);
|
||||||
console.Output.WriteLine(" 1. Go to Discord developer portal");
|
console.Output.WriteLine(" 1. Go to Discord developer portal");
|
||||||
console.Output.WriteLine(" 2. Open your application's settings");
|
console.Output.WriteLine(" 2. Open your application's settings");
|
||||||
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
console.Output.WriteLine(" 3. Navigate to the Bot section on the left");
|
||||||
console.Output.WriteLine(" 4. Under Token click Copy");
|
console.Output.WriteLine(" 4. Under Token click Reset Token");
|
||||||
|
console.Output.WriteLine(" 5. Click Yes, do it! and authenticate to confirm");
|
||||||
|
console.Output.WriteLine(
|
||||||
|
" * Integrations using the previous token will stop working until updated"
|
||||||
|
);
|
||||||
console.Output.WriteLine(
|
console.Output.WriteLine(
|
||||||
" * Your bot needs to have the Message Content Intent enabled to read messages"
|
" * Your bot needs to have the Message Content Intent enabled to read messages"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
<ApplicationIcon>..\favicon.ico</ApplicationIcon>
|
||||||
@@ -7,16 +6,21 @@
|
|||||||
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
|
||||||
|
<EnableAotAnalyzer>false</EnableAotAnalyzer>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="CliFx" Version="2.3.5" />
|
<PackageReference Include="CliFx" Version="2.3.6" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.29.2" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||||
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
|
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" Version="2.1.1" />
|
||||||
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
<PackageReference Include="Spectre.Console" Version="0.54.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
|
|||||||
@@ -7,32 +7,35 @@ namespace DiscordChatExporter.Cli.Utils.Extensions;
|
|||||||
|
|
||||||
internal static class ConsoleExtensions
|
internal static class ConsoleExtensions
|
||||||
{
|
{
|
||||||
public static IAnsiConsole CreateAnsiConsole(this IConsole console) =>
|
extension(IConsole console)
|
||||||
AnsiConsole.Create(
|
{
|
||||||
new AnsiConsoleSettings
|
public IAnsiConsole CreateAnsiConsole() =>
|
||||||
{
|
AnsiConsole.Create(
|
||||||
Ansi = AnsiSupport.Detect,
|
new AnsiConsoleSettings
|
||||||
ColorSystem = ColorSystemSupport.Detect,
|
{
|
||||||
Out = new AnsiConsoleOutput(console.Output),
|
Ansi = AnsiSupport.Detect,
|
||||||
}
|
ColorSystem = ColorSystemSupport.Detect,
|
||||||
);
|
Out = new AnsiConsoleOutput(console.Output),
|
||||||
|
}
|
||||||
public static Status CreateStatusTicker(this IConsole console) =>
|
|
||||||
console.CreateAnsiConsole().Status().AutoRefresh(true);
|
|
||||||
|
|
||||||
public static Progress CreateProgressTicker(this IConsole console) =>
|
|
||||||
console
|
|
||||||
.CreateAnsiConsole()
|
|
||||||
.Progress()
|
|
||||||
.AutoClear(false)
|
|
||||||
.AutoRefresh(true)
|
|
||||||
.HideCompleted(false)
|
|
||||||
.Columns(
|
|
||||||
new TaskDescriptionColumn { Alignment = Justify.Left },
|
|
||||||
new ProgressBarColumn(),
|
|
||||||
new PercentageColumn()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public Status CreateStatusTicker() =>
|
||||||
|
console.CreateAnsiConsole().Status().AutoRefresh(true);
|
||||||
|
|
||||||
|
public Progress CreateProgressTicker() =>
|
||||||
|
console
|
||||||
|
.CreateAnsiConsole()
|
||||||
|
.Progress()
|
||||||
|
.AutoClear(false)
|
||||||
|
.AutoRefresh(true)
|
||||||
|
.HideCompleted(false)
|
||||||
|
.Columns(
|
||||||
|
new TaskDescriptionColumn { Alignment = Justify.Left },
|
||||||
|
new ProgressBarColumn(),
|
||||||
|
new PercentageColumn()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static async ValueTask StartTaskAsync(
|
public static async ValueTask StartTaskAsync(
|
||||||
this ProgressContext context,
|
this ProgressContext context,
|
||||||
string description,
|
string description,
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ public partial record Channel(
|
|||||||
|
|
||||||
public bool IsThread { get; } =
|
public bool IsThread { get; } =
|
||||||
Kind
|
Kind
|
||||||
is ChannelKind.GuildNewsThread
|
is ChannelKind.GuildNewsThread
|
||||||
or ChannelKind.GuildPublicThread
|
or ChannelKind.GuildPublicThread
|
||||||
or ChannelKind.GuildPrivateThread;
|
or ChannelKind.GuildPrivateThread;
|
||||||
|
|
||||||
public bool IsEmpty { get; } = LastMessageId is null;
|
public bool IsEmpty { get; } = LastMessageId is null;
|
||||||
|
|
||||||
@@ -69,7 +69,8 @@ public partial record Channel
|
|||||||
var guildId =
|
var guildId =
|
||||||
json.GetPropertyOrNull("guild_id")
|
json.GetPropertyOrNull("guild_id")
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(Snowflake.Parse) ?? Guild.DirectMessages.Id;
|
?.Pipe(Snowflake.Parse)
|
||||||
|
?? Guild.DirectMessages.Id;
|
||||||
|
|
||||||
var name =
|
var name =
|
||||||
// Guild channel
|
// Guild channel
|
||||||
@@ -78,6 +79,7 @@ public partial record Channel
|
|||||||
?? json.GetPropertyOrNull("recipients")
|
?? json.GetPropertyOrNull("recipients")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(User.Parse)
|
?.Select(User.Parse)
|
||||||
|
.OrderBy(u => u.Id)
|
||||||
.Select(u => u.DisplayName)
|
.Select(u => u.DisplayName)
|
||||||
.Pipe(s => string.Join(", ", s))
|
.Pipe(s => string.Join(", ", s))
|
||||||
// Fallback
|
// Fallback
|
||||||
@@ -95,7 +97,8 @@ public partial record Channel
|
|||||||
var isArchived =
|
var isArchived =
|
||||||
json.GetPropertyOrNull("thread_metadata")
|
json.GetPropertyOrNull("thread_metadata")
|
||||||
?.GetPropertyOrNull("archived")
|
?.GetPropertyOrNull("archived")
|
||||||
?.GetBooleanOrNull() ?? false;
|
?.GetBooleanOrNull()
|
||||||
|
?? false;
|
||||||
|
|
||||||
var lastMessageId = json.GetPropertyOrNull("last_message_id")
|
var lastMessageId = json.GetPropertyOrNull("last_message_id")
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ public partial record Embed
|
|||||||
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
|
||||||
|
|
||||||
var kind =
|
var kind =
|
||||||
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>()
|
json.GetPropertyOrNull("type")
|
||||||
|
?.GetStringOrNull()
|
||||||
|
?.Pipe(s =>
|
||||||
|
Enum.TryParse<EmbedKind>(s, true, out var result) ? result : (EmbedKind?)null
|
||||||
|
)
|
||||||
?? EmbedKind.Rich;
|
?? EmbedKind.Rich;
|
||||||
|
|
||||||
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
|
||||||
@@ -63,7 +67,8 @@ public partial record Embed
|
|||||||
json.GetPropertyOrNull("fields")
|
json.GetPropertyOrNull("fields")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(EmbedField.Parse)
|
?.Select(EmbedField.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
|
||||||
|
|
||||||
@@ -78,7 +83,8 @@ public partial record Embed
|
|||||||
json.GetPropertyOrNull("image")
|
json.GetPropertyOrNull("image")
|
||||||
?.Pipe(EmbedImage.Parse)
|
?.Pipe(EmbedImage.Parse)
|
||||||
.ToSingletonEnumerable()
|
.ToSingletonEnumerable()
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
public partial record YouTubeVideoEmbedProjection(string VideoId)
|
||||||
{
|
{
|
||||||
public string Url { get; } = $"https://www.youtube.com/embed/{VideoId}";
|
public string Url { get; } = $"https://www.youtube.com/watch?v={VideoId}";
|
||||||
|
|
||||||
|
// Using hqdefault.jpg which is guaranteed to exist for all YouTube videos
|
||||||
|
public string ThumbnailUrl { get; } = $"https://i.ytimg.com/vi/{VideoId}/hqdefault.jpg";
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial record YouTubeVideoEmbedProjection
|
public partial record YouTubeVideoEmbedProjection
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,8 @@ public partial record Guild
|
|||||||
var iconUrl =
|
var iconUrl =
|
||||||
json.GetPropertyOrNull("icon")
|
json.GetPropertyOrNull("icon")
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? ImageCdn.GetFallbackUserAvatarUrl();
|
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h))
|
||||||
|
?? ImageCdn.GetFallbackUserAvatarUrl();
|
||||||
|
|
||||||
return new Guild(id, name, iconUrl);
|
return new Guild(id, name, iconUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ public partial record Member
|
|||||||
?.EnumerateArray()
|
?.EnumerateArray()
|
||||||
.Select(j => j.GetNonWhiteSpaceString())
|
.Select(j => j.GetNonWhiteSpaceString())
|
||||||
.Select(Snowflake.Parse)
|
.Select(Snowflake.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var avatarUrl = guildId is not null
|
var avatarUrl = guildId is not null
|
||||||
? json.GetPropertyOrNull("avatar")
|
? json.GetPropertyOrNull("avatar")
|
||||||
|
|||||||
@@ -143,7 +143,8 @@ public partial record Message
|
|||||||
json.GetPropertyOrNull("attachments")
|
json.GetPropertyOrNull("attachments")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(Attachment.Parse)
|
?.Select(Attachment.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var embeds = NormalizeEmbeds(
|
var embeds = NormalizeEmbeds(
|
||||||
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||||
@@ -154,13 +155,15 @@ public partial record Message
|
|||||||
json.GetPropertyOrNull("sticker_items")
|
json.GetPropertyOrNull("sticker_items")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(Sticker.Parse)
|
?.Select(Sticker.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var reactions =
|
var reactions =
|
||||||
json.GetPropertyOrNull("reactions")
|
json.GetPropertyOrNull("reactions")
|
||||||
?.EnumerateArrayOrNull()
|
?.EnumerateArrayOrNull()
|
||||||
?.Select(Reaction.Parse)
|
?.Select(Reaction.Parse)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
var mentionedUsers =
|
var mentionedUsers =
|
||||||
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ using JsonExtensions.Reading;
|
|||||||
|
|
||||||
namespace DiscordChatExporter.Core.Discord;
|
namespace DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
public class DiscordClient(string token)
|
public class DiscordClient(
|
||||||
|
string token,
|
||||||
|
RateLimitPreference rateLimitPreference = RateLimitPreference.RespectAll
|
||||||
|
)
|
||||||
{
|
{
|
||||||
private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute);
|
private readonly Uri _baseUri = new("https://discord.com/api/v10/", UriKind.Absolute);
|
||||||
private TokenKind? _resolvedTokenKind;
|
private TokenKind? _resolvedTokenKind;
|
||||||
@@ -47,33 +50,41 @@ public class DiscordClient(string token)
|
|||||||
innerCancellationToken
|
innerCancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
// If this was the last request available before hitting the rate limit,
|
// Discord has advisory rate limits (communicated via response headers), but they are typically
|
||||||
// wait out the reset time so that future requests can succeed.
|
// way stricter than the actual rate limits enforced by the server.
|
||||||
// This may add an unnecessary delay in case the user doesn't intend to
|
// The user may choose to ignore the advisory rate limits and only retry on hard rate limits,
|
||||||
// make any more requests, but implementing a smarter solution would
|
// if they want to prioritize speed over compliance (and safety of their account/bot).
|
||||||
// require properly keeping track of Discord's global/per-route/per-resource
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1021
|
||||||
// rate limits and that's just way too much effort.
|
if (rateLimitPreference.IsRespectedFor(tokenKind))
|
||||||
// https://discord.com/developers/docs/topics/rate-limits
|
|
||||||
var remainingRequestCount = response
|
|
||||||
.Headers.TryGetValue("X-RateLimit-Remaining")
|
|
||||||
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
|
|
||||||
|
|
||||||
var resetAfterDelay = response
|
|
||||||
.Headers.TryGetValue("X-RateLimit-Reset-After")
|
|
||||||
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
|
|
||||||
.Pipe(TimeSpan.FromSeconds);
|
|
||||||
|
|
||||||
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
|
|
||||||
{
|
{
|
||||||
var delay =
|
var remainingRequestCount = response
|
||||||
// Adding a small buffer to the reset time reduces the chance of getting
|
.Headers.TryGetValue("X-RateLimit-Remaining")
|
||||||
// rate limited again, because it allows for more requests to be released.
|
?.Pipe(s => int.Parse(s, CultureInfo.InvariantCulture));
|
||||||
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
|
|
||||||
// Sometimes Discord returns an absurdly high value for the reset time, which
|
|
||||||
// is not actually enforced by the server. So we cap it at a reasonable value.
|
|
||||||
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
|
|
||||||
|
|
||||||
await Task.Delay(delay, innerCancellationToken);
|
var resetAfterDelay = response
|
||||||
|
.Headers.TryGetValue("X-RateLimit-Reset-After")
|
||||||
|
?.Pipe(s => double.Parse(s, CultureInfo.InvariantCulture))
|
||||||
|
.Pipe(TimeSpan.FromSeconds);
|
||||||
|
|
||||||
|
// If this was the last request available before hitting the rate limit,
|
||||||
|
// wait out the reset time so that future requests can succeed.
|
||||||
|
// This may add an unnecessary delay in case the user doesn't intend to
|
||||||
|
// make any more requests, but implementing a smarter solution would
|
||||||
|
// require properly keeping track of Discord's global/per-route/per-resource
|
||||||
|
// rate limits and that's just way too much effort.
|
||||||
|
// https://discord.com/developers/docs/topics/rate-limits
|
||||||
|
if (remainingRequestCount <= 0 && resetAfterDelay is not null)
|
||||||
|
{
|
||||||
|
var delay =
|
||||||
|
// Adding a small buffer to the reset time reduces the chance of getting
|
||||||
|
// rate limited again, because it allows for more requests to be released.
|
||||||
|
(resetAfterDelay.Value + TimeSpan.FromSeconds(1))
|
||||||
|
// Sometimes Discord returns an absurdly high value for the reset time, which
|
||||||
|
// is not actually enforced by the server. So we cap it at a reasonable value.
|
||||||
|
.Clamp(TimeSpan.Zero, TimeSpan.FromSeconds(60));
|
||||||
|
|
||||||
|
await Task.Delay(delay, innerCancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
@@ -148,14 +159,14 @@ public class DiscordClient(string token)
|
|||||||
|
|
||||||
_ => throw new DiscordChatExporterException(
|
_ => throw new DiscordChatExporterException(
|
||||||
$"""
|
$"""
|
||||||
Request to '{url}' failed: {response
|
Request to '{url}' failed: {response
|
||||||
.StatusCode.ToString()
|
.StatusCode.ToString()
|
||||||
.ToSpaceSeparatedWords()
|
.ToSpaceSeparatedWords()
|
||||||
.ToLowerInvariant()}.
|
.ToLowerInvariant()}.
|
||||||
Response content: {await response.Content.ReadAsStringAsync(
|
Response content: {await response.Content.ReadAsStringAsync(
|
||||||
cancellationToken
|
cancellationToken
|
||||||
)}
|
)}
|
||||||
""",
|
""",
|
||||||
true
|
true
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -294,184 +305,19 @@ public class DiscordClient(string token)
|
|||||||
if (guildId == Guild.DirectMessages.Id)
|
if (guildId == Guild.DirectMessages.Id)
|
||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
var channels = (await GetGuildChannelsAsync(guildId, cancellationToken))
|
var channels = await GetGuildChannelsAsync(guildId, cancellationToken);
|
||||||
// Categories cannot have threads
|
|
||||||
.Where(c => !c.IsCategory)
|
|
||||||
// Voice channels cannot have threads
|
|
||||||
.Where(c => !c.IsVoice)
|
|
||||||
// Empty channels cannot have threads
|
|
||||||
.Where(c => !c.IsEmpty)
|
|
||||||
// If the 'before' boundary is specified, skip channels that don't have messages
|
|
||||||
// for that range, because thread-start event should always be accompanied by a message.
|
|
||||||
// Note that we don't perform a similar check for the 'after' boundary, because
|
|
||||||
// threads may have messages in range, even if the parent channel doesn't.
|
|
||||||
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
// User accounts can only fetch threads using the search endpoint
|
foreach (
|
||||||
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
var channel in await GetChannelThreadsAsync(
|
||||||
|
channels,
|
||||||
|
includeArchived,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
cancellationToken
|
||||||
|
)
|
||||||
|
)
|
||||||
{
|
{
|
||||||
// Active threads
|
yield return channel;
|
||||||
foreach (var channel in channels)
|
|
||||||
{
|
|
||||||
var currentOffset = 0;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var url = new UrlBuilder()
|
|
||||||
.SetPath($"channels/{channel.Id}/threads/search")
|
|
||||||
.SetQueryParameter("sort_by", "last_message_time")
|
|
||||||
.SetQueryParameter("sort_order", "desc")
|
|
||||||
.SetQueryParameter("archived", "false")
|
|
||||||
.SetQueryParameter("offset", currentOffset.ToString())
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
// Can be null on channels that the user cannot access or channels without threads
|
|
||||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
|
||||||
if (response is null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
var breakOuter = false;
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var thread = Channel.Parse(threadJson, channel);
|
|
||||||
|
|
||||||
// If the 'after' boundary is specified, we can break early,
|
|
||||||
// because threads are sorted by last message time.
|
|
||||||
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
|
||||||
{
|
|
||||||
breakOuter = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
yield return thread;
|
|
||||||
currentOffset++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (breakOuter)
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Archived threads
|
|
||||||
if (includeArchived)
|
|
||||||
{
|
|
||||||
foreach (var channel in channels)
|
|
||||||
{
|
|
||||||
var currentOffset = 0;
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
var url = new UrlBuilder()
|
|
||||||
.SetPath($"channels/{channel.Id}/threads/search")
|
|
||||||
.SetQueryParameter("sort_by", "last_message_time")
|
|
||||||
.SetQueryParameter("sort_order", "desc")
|
|
||||||
.SetQueryParameter("archived", "true")
|
|
||||||
.SetQueryParameter("offset", currentOffset.ToString())
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
// Can be null on channels that the user cannot access or channels without threads
|
|
||||||
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
|
||||||
if (response is null)
|
|
||||||
break;
|
|
||||||
|
|
||||||
var breakOuter = false;
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var thread = Channel.Parse(threadJson, channel);
|
|
||||||
|
|
||||||
// If the 'after' boundary is specified, we can break early,
|
|
||||||
// because threads are sorted by last message time.
|
|
||||||
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
|
||||||
{
|
|
||||||
breakOuter = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
yield return thread;
|
|
||||||
currentOffset++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (breakOuter)
|
|
||||||
break;
|
|
||||||
|
|
||||||
if (!response.Value.GetProperty("has_more").GetBoolean())
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Bot accounts can only fetch threads using the threads endpoint
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Active threads
|
|
||||||
{
|
|
||||||
var parentsById = channels.ToDictionary(c => c.Id);
|
|
||||||
|
|
||||||
var response = await GetJsonResponseAsync(
|
|
||||||
$"guilds/{guildId}/threads/active",
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
|
||||||
{
|
|
||||||
var parent = threadJson
|
|
||||||
.GetPropertyOrNull("parent_id")
|
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
|
||||||
?.Pipe(Snowflake.Parse)
|
|
||||||
.Pipe(parentsById.GetValueOrDefault);
|
|
||||||
|
|
||||||
yield return Channel.Parse(threadJson, parent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Archived threads
|
|
||||||
if (includeArchived)
|
|
||||||
{
|
|
||||||
foreach (var channel in channels)
|
|
||||||
{
|
|
||||||
// Public archived threads
|
|
||||||
{
|
|
||||||
// Can be null on certain channels
|
|
||||||
var response = await TryGetJsonResponseAsync(
|
|
||||||
$"channels/{channel.Id}/threads/archived/public",
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
|
||||||
)
|
|
||||||
yield return Channel.Parse(threadJson, channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private archived threads
|
|
||||||
{
|
|
||||||
// Can be null on certain channels
|
|
||||||
var response = await TryGetJsonResponseAsync(
|
|
||||||
$"channels/{channel.Id}/threads/archived/private",
|
|
||||||
cancellationToken
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
foreach (
|
|
||||||
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
|
||||||
)
|
|
||||||
yield return Channel.Parse(threadJson, channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,6 +388,163 @@ public class DiscordClient(string token)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Channel> GetChannelThreadsAsync(
|
||||||
|
IReadOnlyList<Channel> channels,
|
||||||
|
bool includeArchived = false,
|
||||||
|
Snowflake? before = null,
|
||||||
|
Snowflake? after = null,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var filteredChannels = channels
|
||||||
|
// Categories cannot have threads
|
||||||
|
.Where(c => !c.IsCategory)
|
||||||
|
// Voice channels cannot have threads
|
||||||
|
.Where(c => !c.IsVoice)
|
||||||
|
// Empty channels cannot have threads
|
||||||
|
.Where(c => !c.IsEmpty)
|
||||||
|
// If the 'before' boundary is specified, skip channels that don't have messages
|
||||||
|
// for that range, because thread-start event should always be accompanied by a message.
|
||||||
|
// Note that we don't perform a similar check for the 'after' boundary, because
|
||||||
|
// threads may have messages in range, even if the parent channel doesn't.
|
||||||
|
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// User accounts can only fetch threads using the search endpoint
|
||||||
|
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
|
||||||
|
{
|
||||||
|
foreach (var channel in filteredChannels)
|
||||||
|
{
|
||||||
|
// Either include both active and archived threads, or only active threads
|
||||||
|
foreach (
|
||||||
|
var isArchived in includeArchived ? new[] { false, true } : new[] { false }
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Offset is just the index of the last thread in the previous batch
|
||||||
|
var currentOffset = 0;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var url = new UrlBuilder()
|
||||||
|
.SetPath($"channels/{channel.Id}/threads/search")
|
||||||
|
.SetQueryParameter("sort_by", "last_message_time")
|
||||||
|
.SetQueryParameter("sort_order", "desc")
|
||||||
|
.SetQueryParameter("archived", isArchived.ToString().ToLowerInvariant())
|
||||||
|
.SetQueryParameter("offset", currentOffset.ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Can be null on channels that the user cannot access or channels without threads
|
||||||
|
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||||
|
if (response is null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var breakOuter = false;
|
||||||
|
|
||||||
|
foreach (
|
||||||
|
var threadJson in response.Value.GetProperty("threads").EnumerateArray()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var thread = Channel.Parse(threadJson, channel);
|
||||||
|
|
||||||
|
// If the 'after' boundary is specified, we can break early,
|
||||||
|
// because threads are sorted by last message timestamp.
|
||||||
|
if (after is not null && !thread.MayHaveMessagesAfter(after.Value))
|
||||||
|
{
|
||||||
|
breakOuter = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return thread;
|
||||||
|
currentOffset++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (breakOuter)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bot accounts can only fetch threads using the threads endpoint
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var guilds = new HashSet<Snowflake>();
|
||||||
|
foreach (var channel in filteredChannels)
|
||||||
|
guilds.Add(channel.GuildId);
|
||||||
|
|
||||||
|
// Active threads
|
||||||
|
foreach (var guildId in guilds)
|
||||||
|
{
|
||||||
|
var parentsById = filteredChannels.ToDictionary(c => c.Id);
|
||||||
|
|
||||||
|
var response = await GetJsonResponseAsync(
|
||||||
|
$"guilds/{guildId}/threads/active",
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach (var threadJson in response.GetProperty("threads").EnumerateArray())
|
||||||
|
{
|
||||||
|
var parent = threadJson
|
||||||
|
.GetPropertyOrNull("parent_id")
|
||||||
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
|
?.Pipe(Snowflake.Parse)
|
||||||
|
.Pipe(parentsById.GetValueOrDefault);
|
||||||
|
|
||||||
|
if (filteredChannels.Contains(parent))
|
||||||
|
yield return Channel.Parse(threadJson, parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archived threads
|
||||||
|
if (includeArchived)
|
||||||
|
{
|
||||||
|
foreach (var channel in filteredChannels)
|
||||||
|
{
|
||||||
|
foreach (var archiveType in new[] { "public", "private" })
|
||||||
|
{
|
||||||
|
// This endpoint parameter expects an ISO8601 timestamp, not a snowflake
|
||||||
|
var currentBefore = before
|
||||||
|
?.ToDate()
|
||||||
|
.ToString("O", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
// Threads are sorted by archive timestamp, not by last message timestamp
|
||||||
|
var url = new UrlBuilder()
|
||||||
|
.SetPath($"channels/{channel.Id}/threads/archived/{archiveType}")
|
||||||
|
.SetQueryParameter("before", currentBefore)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
// Can be null on certain channels
|
||||||
|
var response = await TryGetJsonResponseAsync(url, cancellationToken);
|
||||||
|
if (response is null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
foreach (
|
||||||
|
var threadJson in response
|
||||||
|
.Value.GetProperty("threads")
|
||||||
|
.EnumerateArray()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var thread = Channel.Parse(threadJson, channel);
|
||||||
|
yield return thread;
|
||||||
|
|
||||||
|
currentBefore = threadJson
|
||||||
|
.GetProperty("thread_metadata")
|
||||||
|
.GetProperty("archive_timestamp")
|
||||||
|
.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||||
Snowflake channelId,
|
Snowflake channelId,
|
||||||
Snowflake? before = null,
|
Snowflake? before = null,
|
||||||
@@ -688,9 +691,7 @@ public class DiscordClient(string token)
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each batch can contain up to 100 users.
|
if (count <= 0)
|
||||||
// If we got fewer, then it's definitely the last batch.
|
|
||||||
if (count < 100)
|
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -40,17 +41,19 @@ public partial class DataDump
|
|||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
using var archive = ZipFile.OpenRead(zipFilePath);
|
await using var archive = await ZipFile.OpenReadAsync(zipFilePath, cancellationToken);
|
||||||
|
|
||||||
var entry = archive.GetEntry("messages/index.json");
|
// Use case-insensitive search to accommodate for different data dump versions
|
||||||
if (entry is null)
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1459
|
||||||
{
|
var entry =
|
||||||
throw new InvalidOperationException(
|
archive.Entries.FirstOrDefault(e =>
|
||||||
|
e.FullName.Equals("messages/index.json", StringComparison.OrdinalIgnoreCase)
|
||||||
|
)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
"Failed to locate the channel index inside the data package."
|
"Failed to locate the channel index inside the data package."
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
await using var stream = entry.Open();
|
await using var stream = await entry.OpenAsync(cancellationToken);
|
||||||
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
|
using var document = await JsonDocument.ParseAsync(stream, default, cancellationToken);
|
||||||
|
|
||||||
return Parse(document.RootElement);
|
return Parse(document.RootElement);
|
||||||
|
|||||||
@@ -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">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AsyncKeyedLock" Version="7.0.2" />
|
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.29.2" PrivateAssets="all" />
|
<PackageReference Include="AsyncKeyedLock" Version="8.0.1" />
|
||||||
|
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" Version="2.1.1" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
||||||
<PackageReference Include="Polly" Version="8.4.2" />
|
<PackageReference Include="Polly" Version="8.6.5" />
|
||||||
<PackageReference Include="RazorBlade" Version="0.6.0" />
|
<PackageReference Include="RazorBlade" Version="0.11.0" />
|
||||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
<PackageReference Include="Superpower" Version="3.1.0" />
|
||||||
<PackageReference Include="WebMarkupMin.Core" Version="2.17.0" />
|
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||||
<PackageReference Include="YoutubeExplode" Version="6.4.3" />
|
<PackageReference Include="YoutubeExplode" Version="6.5.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Exceptions;
|
||||||
|
|
||||||
|
public class ChannelEmptyException(string message) : DiscordChatExporterException(message);
|
||||||
@@ -27,45 +27,43 @@ public class ChannelExporter(DiscordClient discord)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the channel is empty
|
|
||||||
if (request.Channel.IsEmpty)
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
$"Channel '{request.Channel.Name}' "
|
|
||||||
+ $"of guild '{request.Guild.Name}' "
|
|
||||||
+ $"does not contain any messages."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the 'after' boundary is valid
|
|
||||||
if (request.After is not null && !request.Channel.MayHaveMessagesAfter(request.After.Value))
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
$"Channel '{request.Channel.Name}' "
|
|
||||||
+ $"of guild '{request.Guild.Name}' "
|
|
||||||
+ $"does not contain any messages within the specified period."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the 'before' boundary is valid
|
|
||||||
if (
|
|
||||||
request.Before is not null
|
|
||||||
&& !request.Channel.MayHaveMessagesBefore(request.Before.Value)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
$"Channel '{request.Channel.Name}' "
|
|
||||||
+ $"of guild '{request.Guild.Name}' "
|
|
||||||
+ $"does not contain any messages within the specified period."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build context
|
// Build context
|
||||||
var context = new ExportContext(discord, request);
|
var context = new ExportContext(discord, request);
|
||||||
await context.PopulateChannelsAndRolesAsync(cancellationToken);
|
await context.PopulateChannelsAndRolesAsync(cancellationToken);
|
||||||
|
|
||||||
// Export messages
|
// Initialize the exporter before further checks to ensure the file is created even if
|
||||||
|
// an exception is thrown after this point.
|
||||||
await using var messageExporter = new MessageExporter(context);
|
await using var messageExporter = new MessageExporter(context);
|
||||||
|
|
||||||
|
// Check if the channel is empty
|
||||||
|
if (request.Channel.IsEmpty)
|
||||||
|
{
|
||||||
|
throw new ChannelEmptyException(
|
||||||
|
$"Channel '{request.Channel.Name}' "
|
||||||
|
+ $"of guild '{request.Guild.Name}' "
|
||||||
|
+ $"does not contain any messages; an empty file will be created."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the 'before' and 'after' boundaries are valid
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
request.Before is not null
|
||||||
|
&& !request.Channel.MayHaveMessagesBefore(request.Before.Value)
|
||||||
|
)
|
||||||
|
|| (
|
||||||
|
request.After is not null
|
||||||
|
&& !request.Channel.MayHaveMessagesAfter(request.After.Value)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
throw new ChannelEmptyException(
|
||||||
|
$"Channel '{request.Channel.Name}' "
|
||||||
|
+ $"of guild '{request.Guild.Name}' "
|
||||||
|
+ $"does not contain any messages within the specified period; an empty file will be created."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await foreach (
|
await foreach (
|
||||||
var message in discord.GetMessagesAsync(
|
var message in discord.GetMessagesAsync(
|
||||||
request.Channel.Id,
|
request.Channel.Id,
|
||||||
@@ -98,15 +96,5 @@ public class ChannelExporter(DiscordClient discord)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throw if no messages were exported
|
|
||||||
if (messageExporter.MessagesExported <= 0)
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
$"Channel '{request.Channel.Name}' (#{request.Channel.Id}) "
|
|
||||||
+ $"of guild '{request.Guild.Name}' (#{request.Guild.Id}) "
|
|
||||||
+ $"does not contain any matching messages within the specified period."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -45,38 +44,8 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
|||||||
{
|
{
|
||||||
// Download the file
|
// Download the file
|
||||||
using var response = await Http.Client.GetAsync(url, innerCancellationToken);
|
using var response = await Http.Client.GetAsync(url, innerCancellationToken);
|
||||||
await using (var output = File.Create(filePath))
|
await using var output = File.Create(filePath);
|
||||||
await response.Content.CopyToAsync(output, innerCancellationToken);
|
await response.Content.CopyToAsync(output, innerCancellationToken);
|
||||||
|
|
||||||
// Try to set the file date according to the last-modified header
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lastModified = response
|
|
||||||
.Content.Headers.TryGetValue("Last-Modified")
|
|
||||||
?.Pipe(s =>
|
|
||||||
DateTimeOffset.TryParse(
|
|
||||||
s,
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
DateTimeStyles.None,
|
|
||||||
out var instant
|
|
||||||
)
|
|
||||||
? instant
|
|
||||||
: (DateTimeOffset?)null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (lastModified is not null)
|
|
||||||
{
|
|
||||||
File.SetCreationTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
File.SetLastWriteTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
File.SetLastAccessTimeUtc(filePath, lastModified.Value.UtcDateTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// This can apparently fail for some reason.
|
|
||||||
// Updating the file date is not a critical task, so we'll just ignore exceptions thrown here.
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/585
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
@@ -106,7 +75,7 @@ internal partial class ExportAssetDownloader
|
|||||||
|
|
||||||
return SHA256
|
return SHA256
|
||||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||||
.ToHex()
|
.Pipe(Convert.ToHexStringLower)
|
||||||
// 5 chars ought to be enough for anybody
|
// 5 chars ought to be enough for anybody
|
||||||
.Truncate(5);
|
.Truncate(5);
|
||||||
}
|
}
|
||||||
@@ -134,7 +103,7 @@ internal partial class ExportAssetDownloader
|
|||||||
fileExtension = "";
|
fileExtension = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return PathEx.EscapeFileName(
|
return Path.EscapeFileName(
|
||||||
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
|
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using DiscordChatExporter.Core.Discord;
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
|
using DiscordChatExporter.Core.Utils;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting;
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
@@ -18,8 +19,10 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
|||||||
private readonly Dictionary<Snowflake, Channel> _channelsById = new();
|
private readonly Dictionary<Snowflake, Channel> _channelsById = new();
|
||||||
private readonly Dictionary<Snowflake, Role> _rolesById = new();
|
private readonly Dictionary<Snowflake, Role> _rolesById = new();
|
||||||
|
|
||||||
private readonly ExportAssetDownloader _assetDownloader =
|
private readonly ExportAssetDownloader _assetDownloader = new(
|
||||||
new(request.AssetsDirPath, request.ShouldReuseAssets);
|
request.AssetsDirPath,
|
||||||
|
request.ShouldReuseAssets
|
||||||
|
);
|
||||||
|
|
||||||
public DiscordClient Discord { get; } = discord;
|
public DiscordClient Discord { get; } = discord;
|
||||||
|
|
||||||
@@ -96,7 +99,8 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
|||||||
?.RoleIds.Select(TryGetRole)
|
?.RoleIds.Select(TryGetRole)
|
||||||
.WhereNotNull()
|
.WhereNotNull()
|
||||||
.OrderByDescending(r => r.Position)
|
.OrderByDescending(r => r.Position)
|
||||||
.ToArray() ?? [];
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
public Color? TryGetUserColor(Snowflake id) =>
|
public Color? TryGetUserColor(Snowflake id) =>
|
||||||
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
|
GetUserRoles(id).Where(r => r.Color is not null).Select(r => r.Color).FirstOrDefault();
|
||||||
@@ -115,7 +119,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
|||||||
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
|
var relativeFilePath = Path.GetRelativePath(Request.OutputDirPath, filePath);
|
||||||
|
|
||||||
// Prefer the relative path so that the export package can be copied around without breaking references.
|
// Prefer the relative path so that the export package can be copied around without breaking references.
|
||||||
// However, if the assets directory lies outside of the export directory, use the absolute path instead.
|
// However, if the assets directory lies outside the export directory, use the absolute path instead.
|
||||||
var shouldUseAbsoluteFilePath =
|
var shouldUseAbsoluteFilePath =
|
||||||
relativeFilePath.StartsWith(
|
relativeFilePath.StartsWith(
|
||||||
".." + Path.DirectorySeparatorChar,
|
".." + Path.DirectorySeparatorChar,
|
||||||
@@ -130,14 +134,7 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
|||||||
|
|
||||||
// For HTML, the path needs to be properly formatted
|
// For HTML, the path needs to be properly formatted
|
||||||
if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
|
if (Request.Format is ExportFormat.HtmlDark or ExportFormat.HtmlLight)
|
||||||
{
|
return Url.EncodeFilePath(optimalFilePath);
|
||||||
// Format the path into a valid file URI
|
|
||||||
var href = new Uri(new Uri("file:///"), optimalFilePath).ToString();
|
|
||||||
|
|
||||||
// File schema does not support relative paths, so strip it if that's the case
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1155
|
|
||||||
return shouldUseAbsoluteFilePath ? href : href[8..];
|
|
||||||
}
|
|
||||||
|
|
||||||
return optimalFilePath;
|
return optimalFilePath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,25 +13,28 @@ public enum ExportFormat
|
|||||||
|
|
||||||
public static class ExportFormatExtensions
|
public static class ExportFormatExtensions
|
||||||
{
|
{
|
||||||
public static string GetFileExtension(this ExportFormat format) =>
|
extension(ExportFormat format)
|
||||||
format switch
|
{
|
||||||
{
|
public string GetFileExtension() =>
|
||||||
ExportFormat.PlainText => "txt",
|
format switch
|
||||||
ExportFormat.HtmlDark => "html",
|
{
|
||||||
ExportFormat.HtmlLight => "html",
|
ExportFormat.PlainText => "txt",
|
||||||
ExportFormat.Csv => "csv",
|
ExportFormat.HtmlDark => "html",
|
||||||
ExportFormat.Json => "json",
|
ExportFormat.HtmlLight => "html",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
ExportFormat.Csv => "csv",
|
||||||
};
|
ExportFormat.Json => "json",
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||||
|
};
|
||||||
|
|
||||||
public static string GetDisplayName(this ExportFormat format) =>
|
public string GetDisplayName() =>
|
||||||
format switch
|
format switch
|
||||||
{
|
{
|
||||||
ExportFormat.PlainText => "TXT",
|
ExportFormat.PlainText => "TXT",
|
||||||
ExportFormat.HtmlDark => "HTML (Dark)",
|
ExportFormat.HtmlDark => "HTML (Dark)",
|
||||||
ExportFormat.HtmlLight => "HTML (Light)",
|
ExportFormat.HtmlLight => "HTML (Light)",
|
||||||
ExportFormat.Csv => "CSV",
|
ExportFormat.Csv => "CSV",
|
||||||
ExportFormat.Json => "JSON",
|
ExportFormat.Json => "JSON",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
_ => throw new ArgumentOutOfRangeException(nameof(format)),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ using DiscordChatExporter.Core.Discord;
|
|||||||
using DiscordChatExporter.Core.Discord.Data;
|
using DiscordChatExporter.Core.Discord.Data;
|
||||||
using DiscordChatExporter.Core.Exporting.Filtering;
|
using DiscordChatExporter.Core.Exporting.Filtering;
|
||||||
using DiscordChatExporter.Core.Exporting.Partitioning;
|
using DiscordChatExporter.Core.Exporting.Partitioning;
|
||||||
using DiscordChatExporter.Core.Utils;
|
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Exporting;
|
namespace DiscordChatExporter.Core.Exporting;
|
||||||
@@ -145,7 +144,7 @@ public partial class ExportRequest
|
|||||||
// File extension
|
// File extension
|
||||||
buffer.Append('.').Append(format.GetFileExtension());
|
buffer.Append('.').Append(format.GetFileExtension());
|
||||||
|
|
||||||
return PathEx.EscapeFileName(buffer.ToString());
|
return Path.EscapeFileName(buffer.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatPath(
|
private static string FormatPath(
|
||||||
@@ -159,7 +158,7 @@ public partial class ExportRequest
|
|||||||
path,
|
path,
|
||||||
"%.",
|
"%.",
|
||||||
m =>
|
m =>
|
||||||
PathEx.EscapeFileName(
|
Path.EscapeFileName(
|
||||||
m.Value switch
|
m.Value switch
|
||||||
{
|
{
|
||||||
"%g" => guild.Id.ToString(),
|
"%g" => guild.Id.ToString(),
|
||||||
@@ -179,7 +178,8 @@ public partial class ExportRequest
|
|||||||
?? "",
|
?? "",
|
||||||
"%b" => before
|
"%b" => before
|
||||||
?.ToDate()
|
?.ToDate()
|
||||||
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "",
|
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||||
|
?? "",
|
||||||
"%d" => DateTimeOffset.Now.ToString(
|
"%d" => DateTimeOffset.Now.ToString(
|
||||||
"yyyy-MM-dd",
|
"yyyy-MM-dd",
|
||||||
CultureInfo.InvariantCulture
|
CultureInfo.InvariantCulture
|
||||||
|
|||||||
@@ -8,14 +8,20 @@ internal static class HtmlMessageExtensions
|
|||||||
{
|
{
|
||||||
// Message content is hidden if it's a link to an embedded media
|
// Message content is hidden if it's a link to an embedded media
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/682
|
||||||
public static bool IsContentHidden(this Message message)
|
extension(Message message)
|
||||||
{
|
{
|
||||||
if (message.Embeds.Count != 1)
|
public bool IsContentHidden()
|
||||||
return false;
|
{
|
||||||
|
if (message.Embeds.Count != 1)
|
||||||
|
return false;
|
||||||
|
|
||||||
var embed = message.Embeds[0];
|
var embed = message.Embeds[0];
|
||||||
|
|
||||||
return string.Equals(message.Content.Trim(), embed.Url, StringComparison.OrdinalIgnoreCase)
|
return string.Equals(
|
||||||
&& embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
message.Content.Trim(),
|
||||||
|
embed.Url,
|
||||||
|
StringComparison.OrdinalIgnoreCase
|
||||||
|
) && embed.Kind is EmbedKind.Image or EmbedKind.Gifv;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,19 +17,18 @@ namespace DiscordChatExporter.Core.Exporting;
|
|||||||
internal class JsonMessageWriter(Stream stream, ExportContext context)
|
internal class JsonMessageWriter(Stream stream, ExportContext context)
|
||||||
: MessageWriter(stream, context)
|
: MessageWriter(stream, context)
|
||||||
{
|
{
|
||||||
private readonly Utf8JsonWriter _writer =
|
private readonly Utf8JsonWriter _writer = new(
|
||||||
new(
|
stream,
|
||||||
stream,
|
new JsonWriterOptions
|
||||||
new JsonWriterOptions
|
{
|
||||||
{
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
Indented = true,
|
||||||
Indented = true,
|
// Validation errors may mask actual failures
|
||||||
// Validation errors may mask actual failures
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/413
|
SkipValidation = true,
|
||||||
SkipValidation = true,
|
}
|
||||||
}
|
);
|
||||||
);
|
|
||||||
|
|
||||||
private async ValueTask<string> FormatMarkdownAsync(
|
private async ValueTask<string> FormatMarkdownAsync(
|
||||||
string markdown,
|
string markdown,
|
||||||
@@ -139,6 +138,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("iconCanonicalUrl", embedAuthor.IconUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
@@ -161,6 +162,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("canonicalUrl", embedImage.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteNumber("width", embedImage.Width);
|
_writer.WriteNumber("width", embedImage.Width);
|
||||||
@@ -186,6 +189,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("canonicalUrl", embedVideo.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteNumber("width", embedVideo.Width);
|
_writer.WriteNumber("width", embedVideo.Width);
|
||||||
@@ -213,6 +218,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("iconCanonicalUrl", embedFooter.IconUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
|
|||||||
@@ -13,24 +13,7 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
|||||||
|
|
||||||
public long MessagesExported { get; private set; }
|
public long MessagesExported { get; private set; }
|
||||||
|
|
||||||
private async ValueTask ResetWriterAsync(CancellationToken cancellationToken = default)
|
private async ValueTask<MessageWriter> InitializeWriterAsync(
|
||||||
{
|
|
||||||
if (_writer is not null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _writer.WritePostambleAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
// Writer must be disposed, even if it fails to write the postamble
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await _writer.DisposeAsync();
|
|
||||||
_writer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask<MessageWriter> GetWriterAsync(
|
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -43,7 +26,7 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await ResetWriterAsync(cancellationToken);
|
await UninitializeWriterAsync(cancellationToken);
|
||||||
_partitionIndex++;
|
_partitionIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,17 +43,41 @@ internal partial class MessageExporter(ExportContext context) : IAsyncDisposable
|
|||||||
return _writer = writer;
|
return _writer = writer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask UninitializeWriterAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_writer is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _writer.WritePostambleAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
// Writer must be disposed, even if it fails to write the postamble
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _writer.DisposeAsync();
|
||||||
|
_writer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask ExportMessageAsync(
|
public async ValueTask ExportMessageAsync(
|
||||||
Message message,
|
Message message,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var writer = await GetWriterAsync(cancellationToken);
|
var writer = await InitializeWriterAsync(cancellationToken);
|
||||||
await writer.WriteMessageAsync(message, cancellationToken);
|
await writer.WriteMessageAsync(message, cancellationToken);
|
||||||
MessagesExported++;
|
MessagesExported++;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync() => await ResetWriterAsync();
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
// If not messages were written, force the creation of an empty file
|
||||||
|
if (MessagesExported <= 0)
|
||||||
|
_ = await InitializeWriterAsync();
|
||||||
|
|
||||||
|
await UninitializeWriterAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal partial class MessageExporter
|
internal partial class MessageExporter
|
||||||
|
|||||||
@@ -388,7 +388,7 @@
|
|||||||
<div class="chatlog__embed-author-container">
|
<div class="chatlog__embed-author-container">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||||
@@ -424,9 +424,11 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@* Video player *@
|
@* Video thumbnail *@
|
||||||
<div class="chatlog__embed-youtube-container">
|
<div class="chatlog__embed-youtube-container">
|
||||||
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
|
<a href="@youTubeVideoEmbed.Url">
|
||||||
|
<img class="chatlog__embed-youtube-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)" alt="YouTube video thumbnail" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@(embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)">
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -441,9 +443,12 @@
|
|||||||
embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ??
|
embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ??
|
||||||
embed.Url;
|
embed.Url;
|
||||||
|
|
||||||
|
var embedImageCanonicalUrl =
|
||||||
|
embed.Image?.Url ?? embed.Thumbnail?.Url ?? embed.Url;
|
||||||
|
|
||||||
<div class="chatlog__embed">
|
<div class="chatlog__embed">
|
||||||
<a href="@await ResolveAssetUrlAsync(embedImageUrl)">
|
<a href="@await ResolveAssetUrlAsync(embedImageUrl)">
|
||||||
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy">
|
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy" data-canonical-url="@embedImageCanonicalUrl">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -457,8 +462,10 @@
|
|||||||
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
||||||
embed.Url;
|
embed.Url;
|
||||||
|
|
||||||
|
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
|
||||||
|
|
||||||
<div class="chatlog__embed">
|
<div class="chatlog__embed">
|
||||||
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls>
|
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls data-canonical-url="@embedVideoCanonicalUrl">
|
||||||
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
|
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
@@ -470,8 +477,10 @@
|
|||||||
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
|
||||||
embed.Url;
|
embed.Url;
|
||||||
|
|
||||||
|
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
|
||||||
|
|
||||||
<div class="chatlog__embed">
|
<div class="chatlog__embed">
|
||||||
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()">
|
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()" data-canonical-url="@embedVideoCanonicalUrl">
|
||||||
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv">
|
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv">
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,7 +508,7 @@
|
|||||||
<div class="chatlog__embed-author-container">
|
<div class="chatlog__embed-author-container">
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
|
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
|
||||||
@@ -574,7 +583,7 @@
|
|||||||
{
|
{
|
||||||
<div class="chatlog__embed-thumbnail-container">
|
<div class="chatlog__embed-thumbnail-container">
|
||||||
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
|
||||||
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
|
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy" data-canonical-url="@embed.Thumbnail.Url">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -590,7 +599,7 @@
|
|||||||
{
|
{
|
||||||
<div class="chatlog__embed-image-container">
|
<div class="chatlog__embed-image-container">
|
||||||
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
|
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
|
||||||
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
|
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy" data-canonical-url="@image.Url">
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -605,7 +614,7 @@
|
|||||||
@* Footer icon *@
|
@* Footer icon *@
|
||||||
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
|
||||||
{
|
{
|
||||||
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
|
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy" data-canonical-url="@embed.Footer.IconUrl">
|
||||||
}
|
}
|
||||||
|
|
||||||
<span class="chatlog__embed-footer-text">
|
<span class="chatlog__embed-footer-text">
|
||||||
|
|||||||
@@ -7,37 +7,40 @@ namespace DiscordChatExporter.Core.Exporting;
|
|||||||
|
|
||||||
internal static class PlainTextMessageExtensions
|
internal static class PlainTextMessageExtensions
|
||||||
{
|
{
|
||||||
public static string GetFallbackContent(this Message message) =>
|
extension(Message message)
|
||||||
message.Kind switch
|
{
|
||||||
{
|
public string GetFallbackContent() =>
|
||||||
MessageKind.RecipientAdd => message.MentionedUsers.Any()
|
message.Kind switch
|
||||||
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
{
|
||||||
: "Added a recipient.",
|
MessageKind.RecipientAdd => message.MentionedUsers.Any()
|
||||||
|
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
|
||||||
|
: "Added a recipient.",
|
||||||
|
|
||||||
MessageKind.RecipientRemove => message.MentionedUsers.Any()
|
MessageKind.RecipientRemove => message.MentionedUsers.Any()
|
||||||
? message.Author.Id == message.MentionedUsers.First().Id
|
? message.Author.Id == message.MentionedUsers.First().Id
|
||||||
? "Left the group."
|
? "Left the group."
|
||||||
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
|
: $"Removed {message.MentionedUsers.First().DisplayName} from the group."
|
||||||
: "Removed a recipient.",
|
: "Removed a recipient.",
|
||||||
|
|
||||||
MessageKind.Call =>
|
MessageKind.Call =>
|
||||||
$"Started a call that lasted {
|
$"Started a call that lasted {
|
||||||
message
|
message
|
||||||
.CallEndedTimestamp?
|
.CallEndedTimestamp?
|
||||||
.Pipe(t => t - message.Timestamp)
|
.Pipe(t => t - message.Timestamp)
|
||||||
.Pipe(t => t.TotalMinutes)
|
.Pipe(t => t.TotalMinutes)
|
||||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
||||||
} minutes.",
|
} minutes.",
|
||||||
|
|
||||||
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
|
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
|
||||||
? $"Changed the channel name: {message.Content}"
|
? $"Changed the channel name: {message.Content}"
|
||||||
: "Changed the channel name.",
|
: "Changed the channel name.",
|
||||||
|
|
||||||
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
MessageKind.ChannelIconChange => "Changed the channel icon.",
|
||||||
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
MessageKind.ChannelPinnedMessage => "Pinned a message.",
|
||||||
MessageKind.ThreadCreated => "Started a thread.",
|
MessageKind.ThreadCreated => "Started a thread.",
|
||||||
MessageKind.GuildMemberJoin => "Joined the server.",
|
MessageKind.GuildMemberJoin => "Joined the server.",
|
||||||
|
|
||||||
_ => message.Content,
|
_ => message.Content,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -711,9 +711,11 @@
|
|||||||
margin-top: 0.6rem;
|
margin-top: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__embed-youtube {
|
.chatlog__embed-youtube-thumbnail {
|
||||||
border: 0;
|
max-width: 400px;
|
||||||
|
max-height: 225px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chatlog__sticker {
|
.chatlog__sticker {
|
||||||
|
|||||||
@@ -6,19 +6,19 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class AsyncCollectionExtensions
|
public static class AsyncCollectionExtensions
|
||||||
{
|
{
|
||||||
private static async ValueTask<IReadOnlyList<T>> CollectAsync<T>(
|
extension<T>(IAsyncEnumerable<T> asyncEnumerable)
|
||||||
this IAsyncEnumerable<T> asyncEnumerable
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
var list = new List<T>();
|
private async ValueTask<IReadOnlyList<T>> CollectAsync()
|
||||||
|
{
|
||||||
|
var list = new List<T>();
|
||||||
|
|
||||||
await foreach (var i in asyncEnumerable)
|
await foreach (var i in asyncEnumerable)
|
||||||
list.Add(i);
|
list.Add(i);
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter() =>
|
||||||
|
asyncEnumerable.CollectAsync().GetAwaiter();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
|
|
||||||
this IAsyncEnumerable<T> asyncEnumerable
|
|
||||||
) => asyncEnumerable.CollectAsync().GetAwaiter();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
|
||||||
|
|
||||||
public static class BinaryExtensions
|
|
||||||
{
|
|
||||||
public static string ToHex(this byte[] data, bool isUpperCase = true)
|
|
||||||
{
|
|
||||||
var buffer = new StringBuilder(2 * data.Length);
|
|
||||||
|
|
||||||
foreach (var b in data)
|
|
||||||
{
|
|
||||||
buffer.Append(b.ToString(isUpperCase ? "X2" : "x2", CultureInfo.InvariantCulture));
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,25 +4,34 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class CollectionExtensions
|
public static class CollectionExtensions
|
||||||
{
|
{
|
||||||
public static IEnumerable<T> ToSingletonEnumerable<T>(this T obj)
|
extension<T>(T obj)
|
||||||
{
|
{
|
||||||
yield return obj;
|
public IEnumerable<T> ToSingletonEnumerable()
|
||||||
|
{
|
||||||
|
yield return obj;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IEnumerable<(T value, int index)> WithIndex<T>(this IEnumerable<T> source)
|
extension<T>(IEnumerable<T> source)
|
||||||
{
|
{
|
||||||
var i = 0;
|
public IEnumerable<(T value, int index)> WithIndex()
|
||||||
foreach (var o in source)
|
{
|
||||||
yield return (o, i++);
|
var i = 0;
|
||||||
|
foreach (var o in source)
|
||||||
|
yield return (o, i++);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source)
|
extension<T>(IEnumerable<T?> source)
|
||||||
where T : class
|
where T : class
|
||||||
{
|
{
|
||||||
foreach (var o in source)
|
public IEnumerable<T> WhereNotNull()
|
||||||
{
|
{
|
||||||
if (o is not null)
|
foreach (var o in source)
|
||||||
yield return o;
|
{
|
||||||
|
if (o is not null)
|
||||||
|
yield return o;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class ColorExtensions
|
public static class ColorExtensions
|
||||||
{
|
{
|
||||||
public static Color WithAlpha(this Color color, int alpha) => Color.FromArgb(alpha, color);
|
extension(Color color)
|
||||||
|
{
|
||||||
|
public Color WithAlpha(int alpha) => Color.FromArgb(alpha, color);
|
||||||
|
|
||||||
public static Color ResetAlpha(this Color color) => color.WithAlpha(255);
|
public Color ResetAlpha() => color.WithAlpha(255);
|
||||||
|
|
||||||
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff;
|
public int ToRgb() => color.ToArgb() & 0xffffff;
|
||||||
|
|
||||||
public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
public string ToHex() => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,27 +5,30 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class ExceptionExtensions
|
public static class ExceptionExtensions
|
||||||
{
|
{
|
||||||
private static void PopulateChildren(this Exception exception, ICollection<Exception> children)
|
extension(Exception exception)
|
||||||
{
|
{
|
||||||
if (exception is AggregateException aggregateException)
|
private void PopulateChildren(ICollection<Exception> children)
|
||||||
{
|
{
|
||||||
foreach (var innerException in aggregateException.InnerExceptions)
|
if (exception is AggregateException aggregateException)
|
||||||
{
|
{
|
||||||
children.Add(innerException);
|
foreach (var innerException in aggregateException.InnerExceptions)
|
||||||
PopulateChildren(innerException, children);
|
{
|
||||||
|
children.Add(innerException);
|
||||||
|
PopulateChildren(innerException, children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (exception.InnerException is not null)
|
||||||
|
{
|
||||||
|
children.Add(exception.InnerException);
|
||||||
|
PopulateChildren(exception.InnerException, children);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (exception.InnerException is not null)
|
|
||||||
|
public IReadOnlyList<Exception> GetSelfAndChildren()
|
||||||
{
|
{
|
||||||
children.Add(exception.InnerException);
|
var children = new List<Exception> { exception };
|
||||||
PopulateChildren(exception.InnerException, children);
|
PopulateChildren(exception, children);
|
||||||
|
return children;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IReadOnlyList<Exception> GetSelfAndChildren(this Exception exception)
|
|
||||||
{
|
|
||||||
var children = new List<Exception> { exception };
|
|
||||||
PopulateChildren(exception, children);
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,17 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class GenericExtensions
|
public static class GenericExtensions
|
||||||
{
|
{
|
||||||
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) =>
|
extension<TIn>(TIn input)
|
||||||
transform(input);
|
{
|
||||||
|
public TOut Pipe<TOut>(Func<TIn, TOut> transform) => transform(input);
|
||||||
|
}
|
||||||
|
|
||||||
public static T? NullIf<T>(this T value, Func<T, bool> predicate)
|
extension<T>(T value)
|
||||||
where T : struct => !predicate(value) ? value : null;
|
where T : struct
|
||||||
|
{
|
||||||
|
public T? NullIf(Func<T, bool> predicate) => !predicate(value) ? value : null;
|
||||||
|
|
||||||
public static T? NullIfDefault<T>(this T value)
|
public T? NullIfDefault() =>
|
||||||
where T : struct => value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
|
value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class HttpExtensions
|
public static class HttpExtensions
|
||||||
{
|
{
|
||||||
public static string? TryGetValue(this HttpHeaders headers, string name) =>
|
extension(HttpHeaders headers)
|
||||||
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
|
{
|
||||||
|
public string? TryGetValue(string name) =>
|
||||||
|
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
|
public static class PathExtensions
|
||||||
|
{
|
||||||
|
// This is a union of invalid characters from Windows (NTFS/FAT32), Linux (ext4/XFS), and macOS (HFS+/APFS).
|
||||||
|
// We use this instead of Path.GetInvalidFileNameChars() because that only returns OS-specific characters,
|
||||||
|
// not filesystem-specific characters. It's possible to use, for example, an NTFS drive on Linux,
|
||||||
|
// which would make some additional characters invalid that are otherwise valid on Linux.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1452
|
||||||
|
private static readonly char[] InvalidFileNameChars =
|
||||||
|
[
|
||||||
|
'\0', // Null character - invalid on all filesystems
|
||||||
|
'/', // Path separator on Unix and Windows
|
||||||
|
'\\', // Path separator on Windows
|
||||||
|
':', // Reserved on Windows (drive letters, NTFS streams)
|
||||||
|
'*', // Wildcard on Windows
|
||||||
|
'?', // Wildcard on Windows
|
||||||
|
'"', // Reserved on Windows
|
||||||
|
'<', // Redirection on Windows
|
||||||
|
'>', // Redirection on Windows
|
||||||
|
'|', // Pipe on Windows
|
||||||
|
];
|
||||||
|
|
||||||
|
extension(Path)
|
||||||
|
{
|
||||||
|
public static string EscapeFileName(string path)
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder(path.Length);
|
||||||
|
|
||||||
|
foreach (var c in path)
|
||||||
|
buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_');
|
||||||
|
|
||||||
|
// File names cannot end with a dot on Windows
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
while (buffer.Length > 0 && buffer[^1] == '.')
|
||||||
|
buffer.Remove(buffer.Length - 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,34 @@
|
|||||||
using System;
|
using System.Text;
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Utils.Extensions;
|
namespace DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
public static class StringExtensions
|
public static class StringExtensions
|
||||||
{
|
{
|
||||||
public static string? NullIfWhiteSpace(this string str) =>
|
extension(string str)
|
||||||
!string.IsNullOrWhiteSpace(str) ? str : null;
|
|
||||||
|
|
||||||
public static string Truncate(this string str, int charCount) =>
|
|
||||||
str.Length > charCount ? str[..charCount] : str;
|
|
||||||
|
|
||||||
public static string ToSpaceSeparatedWords(this string str)
|
|
||||||
{
|
{
|
||||||
var builder = new StringBuilder(str.Length * 2);
|
public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(str) ? str : null;
|
||||||
|
|
||||||
foreach (var c in str)
|
public string Truncate(int charCount) => str.Length > charCount ? str[..charCount] : str;
|
||||||
|
|
||||||
|
public string ToSpaceSeparatedWords()
|
||||||
{
|
{
|
||||||
if (char.IsUpper(c) && builder.Length > 0)
|
var builder = new StringBuilder(str.Length * 2);
|
||||||
builder.Append(' ');
|
|
||||||
|
|
||||||
builder.Append(c);
|
foreach (var c in str)
|
||||||
|
{
|
||||||
|
if (char.IsUpper(c) && builder.Length > 0)
|
||||||
|
builder.Append(' ');
|
||||||
|
|
||||||
|
builder.Append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
return builder.ToString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true)
|
extension(StringBuilder builder)
|
||||||
where T : struct, Enum => Enum.TryParse<T>(str, ignoreCase, out var result) ? result : null;
|
{
|
||||||
|
public StringBuilder AppendIfNotEmpty(char value) =>
|
||||||
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) =>
|
builder.Length > 0 ? builder.Append(value) : builder;
|
||||||
builder.Length > 0 ? builder.Append(value) : builder;
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,21 +7,21 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class SuperpowerExtensions
|
public static class SuperpowerExtensions
|
||||||
{
|
{
|
||||||
public static TextParser<string> Text(this TextParser<char[]> parser) =>
|
extension<T>(TextParser<T> parser)
|
||||||
parser.Select(chars => new string(chars));
|
{
|
||||||
|
public TextParser<T> Token() =>
|
||||||
|
parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany());
|
||||||
|
|
||||||
public static TextParser<T> Token<T>(this TextParser<T> parser) =>
|
// Only used for debugging while writing Superpower parsers.
|
||||||
parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany());
|
// From https://twitter.com/nblumhardt/status/1389349059786264578
|
||||||
|
[ExcludeFromCodeCoverage]
|
||||||
// Only used for debugging while writing Superpower parsers.
|
public TextParser<T> Log(string description) =>
|
||||||
// From https://twitter.com/nblumhardt/status/1389349059786264578
|
i =>
|
||||||
[ExcludeFromCodeCoverage]
|
{
|
||||||
public static TextParser<T> Log<T>(this TextParser<T> parser, string description) =>
|
Console.WriteLine($"Trying {description} ->");
|
||||||
i =>
|
var r = parser(i);
|
||||||
{
|
Console.WriteLine($"Result was {r}");
|
||||||
Console.WriteLine($"Trying {description} ->");
|
return r;
|
||||||
var r = parser(i);
|
};
|
||||||
Console.WriteLine($"Result was {r}");
|
}
|
||||||
return r;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
|
|||||||
|
|
||||||
public static class TimeSpanExtensions
|
public static class TimeSpanExtensions
|
||||||
{
|
{
|
||||||
public static TimeSpan Clamp(this TimeSpan value, TimeSpan min, TimeSpan max)
|
extension(TimeSpan value)
|
||||||
{
|
{
|
||||||
if (value < min)
|
public TimeSpan Clamp(TimeSpan min, TimeSpan max)
|
||||||
return min;
|
{
|
||||||
|
if (value < min)
|
||||||
|
return min;
|
||||||
|
|
||||||
if (value > max)
|
if (value > max)
|
||||||
return max;
|
return max;
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Utils;
|
|
||||||
|
|
||||||
public static class PathEx
|
|
||||||
{
|
|
||||||
private static readonly HashSet<char> InvalidFileNameChars =
|
|
||||||
[
|
|
||||||
.. Path.GetInvalidFileNameChars(),
|
|
||||||
];
|
|
||||||
|
|
||||||
public static string EscapeFileName(string path)
|
|
||||||
{
|
|
||||||
var buffer = new StringBuilder(path.Length);
|
|
||||||
|
|
||||||
foreach (var c in path)
|
|
||||||
buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_');
|
|
||||||
|
|
||||||
// File names cannot end with a dot on Windows
|
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
|
|
||||||
if (OperatingSystem.IsWindows())
|
|
||||||
{
|
|
||||||
while (buffer.Length > 0 && buffer[^1] == '.')
|
|
||||||
buffer.Remove(buffer.Length - 1, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Utils;
|
||||||
|
|
||||||
|
public static class Url
|
||||||
|
{
|
||||||
|
public static string EncodeFilePath(string filePath)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
break;
|
||||||
|
|
||||||
|
var separatorIndex = filePath.IndexOfAny([':', '/', '\\'], position);
|
||||||
|
if (separatorIndex < 0)
|
||||||
|
{
|
||||||
|
buffer.Append(Uri.EscapeDataString(filePath[position..]));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the segment
|
||||||
|
buffer.Append(Uri.EscapeDataString(filePath[position..separatorIndex]));
|
||||||
|
|
||||||
|
// Append the separator
|
||||||
|
buffer.Append(
|
||||||
|
filePath[separatorIndex] switch
|
||||||
|
{
|
||||||
|
// Normalize slashes
|
||||||
|
'\\' => '/',
|
||||||
|
var c => c,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
position = separatorIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Utils;
|
namespace DiscordChatExporter.Core.Utils;
|
||||||
|
|
||||||
@@ -10,8 +10,9 @@ public class UrlBuilder
|
|||||||
{
|
{
|
||||||
private string _path = "";
|
private string _path = "";
|
||||||
|
|
||||||
private readonly Dictionary<string, string?> _queryParameters =
|
private readonly Dictionary<string, string?> _queryParameters = new(
|
||||||
new(StringComparer.OrdinalIgnoreCase);
|
StringComparer.OrdinalIgnoreCase
|
||||||
|
);
|
||||||
|
|
||||||
public UrlBuilder SetPath(string path)
|
public UrlBuilder SetPath(string path)
|
||||||
{
|
{
|
||||||
@@ -24,8 +25,8 @@ public class UrlBuilder
|
|||||||
if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value))
|
if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value))
|
||||||
return this;
|
return this;
|
||||||
|
|
||||||
var keyEncoded = WebUtility.UrlEncode(key);
|
var keyEncoded = Uri.EscapeDataString(key);
|
||||||
var valueEncoded = WebUtility.UrlEncode(value);
|
var valueEncoded = value?.Pipe(Uri.EscapeDataString);
|
||||||
_queryParameters[keyEncoded] = valueEncoded;
|
_queryParameters[keyEncoded] = valueEncoded;
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
@@ -38,9 +39,11 @@ public class UrlBuilder
|
|||||||
buffer.Append(_path);
|
buffer.Append(_path);
|
||||||
|
|
||||||
if (_queryParameters.Any())
|
if (_queryParameters.Any())
|
||||||
|
{
|
||||||
buffer
|
buffer
|
||||||
.Append('?')
|
.Append('?')
|
||||||
.AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
.AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||||
|
}
|
||||||
|
|
||||||
return buffer.ToString();
|
return buffer.ToString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
xmlns:materialControls="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
xmlns:materialControls="clr-namespace:Material.Styles.Controls;assembly=Material.Styles"
|
||||||
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||||
xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
|
xmlns:materialStyles="clr-namespace:Material.Styles.Themes;assembly=Material.Styles"
|
||||||
|
Name="DiscordChatExporter"
|
||||||
ActualThemeVariantChanged="Application_OnActualThemeVariantChanged">
|
ActualThemeVariantChanged="Application_OnActualThemeVariantChanged">
|
||||||
<Application.DataTemplates>
|
<Application.DataTemplates>
|
||||||
<framework:ViewManager />
|
<framework:ViewManager />
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Converters;
|
||||||
|
|
||||||
|
public class RateLimitPreferenceToStringConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static RateLimitPreferenceToStringConverter Instance { get; } = new();
|
||||||
|
|
||||||
|
public object? Convert(
|
||||||
|
object? value,
|
||||||
|
Type targetType,
|
||||||
|
object? parameter,
|
||||||
|
CultureInfo culture
|
||||||
|
) =>
|
||||||
|
value is RateLimitPreference rateLimitPreference
|
||||||
|
? rateLimitPreference.GetDisplayName()
|
||||||
|
: default;
|
||||||
|
|
||||||
|
public object ConvertBack(
|
||||||
|
object? value,
|
||||||
|
Type targetType,
|
||||||
|
object? parameter,
|
||||||
|
CultureInfo culture
|
||||||
|
) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<AssemblyName>DiscordChatExporter</AssemblyName>
|
<AssemblyName>DiscordChatExporter</AssemblyName>
|
||||||
@@ -9,29 +8,49 @@
|
|||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<PublishMacOSBundle>false</PublishMacOSBundle>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
|
||||||
|
<EnableAotAnalyzer>false</EnableAotAnalyzer>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" />
|
<AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.3.0" />
|
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.5.0" />
|
||||||
<PackageReference Include="Avalonia" Version="11.2.0" />
|
<PackageReference Include="Avalonia" Version="11.3.11" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.0" />
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.11" />
|
||||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.0" Condition="'$(Configuration)' == 'Debug'" />
|
<PackageReference
|
||||||
|
Include="Avalonia.Diagnostics"
|
||||||
|
Version="11.3.11"
|
||||||
|
Condition="'$(Configuration)' == 'Debug'"
|
||||||
|
/>
|
||||||
<PackageReference Include="Cogwheel" Version="2.1.0" />
|
<PackageReference Include="Cogwheel" Version="2.1.0" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.3.2" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.29.2" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
|
||||||
<PackageReference Include="Deorcify" Version="1.0.2" PrivateAssets="all" />
|
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
||||||
<PackageReference Include="DialogHost.Avalonia" Version="0.8.1" />
|
<PackageReference Include="DialogHost.Avalonia" Version="0.10.4" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" Version="2.1.1" />
|
||||||
<PackageReference Include="Material.Avalonia" Version="3.7.4" />
|
<PackageReference Include="Material.Avalonia" Version="3.9.2" />
|
||||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.1.10" />
|
<PackageReference Include="Material.Icons.Avalonia" Version="2.2.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||||
<PackageReference Include="Onova" Version="2.6.12" />
|
<PackageReference Include="Onova" Version="2.6.13" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</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 = "")
|
public async Task<string?> PromptDirectoryPathAsync(string defaultDirPath = "")
|
||||||
@@ -69,19 +69,21 @@ public class DialogManager : IDisposable
|
|||||||
Application.Current?.ApplicationLifetime?.TryGetTopLevel()
|
Application.Current?.ApplicationLifetime?.TryGetTopLevel()
|
||||||
?? throw new ApplicationException("Could not find the top-level visual element.");
|
?? throw new ApplicationException("Could not find the top-level visual element.");
|
||||||
|
|
||||||
var startLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(
|
var result = await topLevel.StorageProvider.OpenFolderPickerAsync(
|
||||||
defaultDirPath
|
|
||||||
);
|
|
||||||
|
|
||||||
var folderPickResult = await topLevel.StorageProvider.OpenFolderPickerAsync(
|
|
||||||
new FolderPickerOpenOptions
|
new FolderPickerOpenOptions
|
||||||
{
|
{
|
||||||
AllowMultiple = false,
|
AllowMultiple = false,
|
||||||
SuggestedStartLocation = startLocation,
|
SuggestedStartLocation = await topLevel.StorageProvider.TryGetFolderFromPathAsync(
|
||||||
|
defaultDirPath
|
||||||
|
),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return folderPickResult.FirstOrDefault()?.Path.LocalPath;
|
var directory = result.FirstOrDefault();
|
||||||
|
if (directory is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return directory.TryGetLocalPath() ?? directory.Path.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => _dialogLock.Dispose();
|
public void Dispose() => _dialogLock.Dispose();
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ namespace DiscordChatExporter.Gui.Framework;
|
|||||||
|
|
||||||
public abstract partial class DialogViewModelBase<T> : ViewModelBase
|
public abstract partial class DialogViewModelBase<T> : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly TaskCompletionSource<T> _closeTcs =
|
private readonly TaskCompletionSource<T> _closeTcs = new(
|
||||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
TaskCreationOptions.RunContinuationsAsynchronously
|
||||||
|
);
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private T? _dialogResult;
|
public partial T? DialogResult { get; set; }
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
protected void Close(T dialogResult)
|
protected void Close(T dialogResult)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$PublishDirPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$IconsFilePath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$FullVersion,
|
||||||
|
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$ShortVersion
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Setup paths
|
||||||
|
$tempDirPath = Join-Path $PublishDirPath "../publish-macos-app-temp"
|
||||||
|
$bundleName = "DiscordChatExporter.app"
|
||||||
|
$bundleDirPath = Join-Path $tempDirPath $bundleName
|
||||||
|
$contentsDirPath = Join-Path $bundleDirPath "Contents"
|
||||||
|
$macosDirPath = Join-Path $contentsDirPath "MacOS"
|
||||||
|
$resourcesDirPath = Join-Path $contentsDirPath "Resources"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Initialize the bundle's directory structure
|
||||||
|
New-Item -Path $bundleDirPath -ItemType Directory -Force
|
||||||
|
New-Item -Path $contentsDirPath -ItemType Directory -Force
|
||||||
|
New-Item -Path $macosDirPath -ItemType Directory -Force
|
||||||
|
New-Item -Path $resourcesDirPath -ItemType Directory -Force
|
||||||
|
|
||||||
|
# Copy icons into the .app's Resources folder
|
||||||
|
Copy-Item -Path $IconsFilePath -Destination (Join-Path $resourcesDirPath "AppIcon.icns") -Force
|
||||||
|
|
||||||
|
# Generate the Info.plist metadata file with the app information
|
||||||
|
$plistContent = @"
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>DiscordChatExporter</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>DiscordChatExporter</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>DiscordChatExporter</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>© Oleksii Holub</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>me.Tyrrrz.DiscordChatExporter</string>
|
||||||
|
<key>CFBundleSpokenName</key>
|
||||||
|
<string>Discord Chat Exporter</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>CFBundleIconName</key>
|
||||||
|
<string>AppIcon</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$FullVersion</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$ShortVersion</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true />
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
"@
|
||||||
|
|
||||||
|
Set-Content -Path (Join-Path $contentsDirPath "Info.plist") -Value $plistContent
|
||||||
|
|
||||||
|
# Delete the previous bundle if it exists
|
||||||
|
if (Test-Path (Join-Path $PublishDirPath $bundleName)) {
|
||||||
|
Remove-Item -Path (Join-Path $PublishDirPath $bundleName) -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Move all files from the publish directory into the MacOS directory
|
||||||
|
Get-ChildItem -Path $PublishDirPath | ForEach-Object {
|
||||||
|
Move-Item -Path $_.FullName -Destination $macosDirPath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Move the final bundle into the publish directory for upload
|
||||||
|
Move-Item -Path $bundleDirPath -Destination $PublishDirPath -Force
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
# Clean up the temporary directory
|
||||||
|
Remove-Item -Path $tempDirPath -Recurse -Force
|
||||||
|
}
|
||||||
@@ -3,132 +3,71 @@ using System.IO;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Cogwheel;
|
using Cogwheel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Exporting;
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
using DiscordChatExporter.Gui.Models;
|
using DiscordChatExporter.Gui.Models;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.Services;
|
namespace DiscordChatExporter.Gui.Services;
|
||||||
|
|
||||||
// Can't use [ObservableProperty] here because System.Text.Json's source generator doesn't see
|
[ObservableObject]
|
||||||
// the generated properties.
|
|
||||||
[INotifyPropertyChanged]
|
|
||||||
public partial class SettingsService()
|
public partial class SettingsService()
|
||||||
: SettingsBase(
|
: SettingsBase(
|
||||||
Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
|
Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
|
||||||
SerializerContext.Default
|
SerializerContext.Default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private bool _isUkraineSupportMessageEnabled = true;
|
[ObservableProperty]
|
||||||
public bool IsUkraineSupportMessageEnabled
|
public partial bool IsUkraineSupportMessageEnabled { get; set; } = true;
|
||||||
{
|
|
||||||
get => _isUkraineSupportMessageEnabled;
|
|
||||||
set => SetProperty(ref _isUkraineSupportMessageEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ThemeVariant _theme;
|
[ObservableProperty]
|
||||||
public ThemeVariant Theme
|
public partial ThemeVariant Theme { get; set; }
|
||||||
{
|
|
||||||
get => _theme;
|
|
||||||
set => SetProperty(ref _theme, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _isAutoUpdateEnabled = true;
|
[ObservableProperty]
|
||||||
public bool IsAutoUpdateEnabled
|
public partial bool IsAutoUpdateEnabled { get; set; } = true;
|
||||||
{
|
|
||||||
get => _isAutoUpdateEnabled;
|
|
||||||
set => SetProperty(ref _isAutoUpdateEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _isTokenPersisted = true;
|
[ObservableProperty]
|
||||||
public bool IsTokenPersisted
|
public partial bool IsTokenPersisted { get; set; } = true;
|
||||||
{
|
|
||||||
get => _isTokenPersisted;
|
|
||||||
set => SetProperty(ref _isTokenPersisted, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ThreadInclusionMode _threadInclusionMode;
|
[ObservableProperty]
|
||||||
public ThreadInclusionMode ThreadInclusionMode
|
public partial RateLimitPreference RateLimitPreference { get; set; } =
|
||||||
{
|
RateLimitPreference.RespectAll;
|
||||||
get => _threadInclusionMode;
|
|
||||||
set => SetProperty(ref _threadInclusionMode, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _locale;
|
[ObservableProperty]
|
||||||
public string? Locale
|
public partial ThreadInclusionMode ThreadInclusionMode { get; set; }
|
||||||
{
|
|
||||||
get => _locale;
|
|
||||||
set => SetProperty(ref _locale, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _isUtcNormalizationEnabled;
|
[ObservableProperty]
|
||||||
public bool IsUtcNormalizationEnabled
|
public partial string? Locale { get; set; }
|
||||||
{
|
|
||||||
get => _isUtcNormalizationEnabled;
|
|
||||||
set => SetProperty(ref _isUtcNormalizationEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int _parallelLimit = 1;
|
[ObservableProperty]
|
||||||
public int ParallelLimit
|
public partial bool IsUtcNormalizationEnabled { get; set; }
|
||||||
{
|
|
||||||
get => _parallelLimit;
|
|
||||||
set => SetProperty(ref _parallelLimit, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _lastToken;
|
[ObservableProperty]
|
||||||
public string? LastToken
|
public partial int ParallelLimit { get; set; } = 1;
|
||||||
{
|
|
||||||
get => _lastToken;
|
|
||||||
set => SetProperty(ref _lastToken, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ExportFormat _lastExportFormat = ExportFormat.HtmlDark;
|
[ObservableProperty]
|
||||||
public ExportFormat LastExportFormat
|
public partial string? LastToken { get; set; }
|
||||||
{
|
|
||||||
get => _lastExportFormat;
|
|
||||||
set => SetProperty(ref _lastExportFormat, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _lastPartitionLimitValue;
|
[ObservableProperty]
|
||||||
public string? LastPartitionLimitValue
|
public partial ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
{
|
|
||||||
get => _lastPartitionLimitValue;
|
|
||||||
set => SetProperty(ref _lastPartitionLimitValue, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _lastMessageFilterValue;
|
[ObservableProperty]
|
||||||
public string? LastMessageFilterValue
|
public partial string? LastPartitionLimitValue { get; set; }
|
||||||
{
|
|
||||||
get => _lastMessageFilterValue;
|
|
||||||
set => SetProperty(ref _lastMessageFilterValue, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _lastShouldFormatMarkdown = true;
|
[ObservableProperty]
|
||||||
public bool LastShouldFormatMarkdown
|
public partial string? LastMessageFilterValue { get; set; }
|
||||||
{
|
|
||||||
get => _lastShouldFormatMarkdown;
|
|
||||||
set => SetProperty(ref _lastShouldFormatMarkdown, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _lastShouldDownloadAssets;
|
[ObservableProperty]
|
||||||
public bool LastShouldDownloadAssets
|
public partial bool LastShouldFormatMarkdown { get; set; } = true;
|
||||||
{
|
|
||||||
get => _lastShouldDownloadAssets;
|
|
||||||
set => SetProperty(ref _lastShouldDownloadAssets, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _lastShouldReuseAssets;
|
[ObservableProperty]
|
||||||
public bool LastShouldReuseAssets
|
public partial bool LastShouldDownloadAssets { get; set; }
|
||||||
{
|
|
||||||
get => _lastShouldReuseAssets;
|
|
||||||
set => SetProperty(ref _lastShouldReuseAssets, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _lastAssetsDirPath;
|
[ObservableProperty]
|
||||||
public string? LastAssetsDirPath
|
public partial bool LastShouldReuseAssets { get; set; }
|
||||||
{
|
|
||||||
get => _lastAssetsDirPath;
|
[ObservableProperty]
|
||||||
set => SetProperty(ref _lastAssetsDirPath, value);
|
public partial string? LastAssetsDirPath { get; set; }
|
||||||
}
|
|
||||||
|
|
||||||
public override void Save()
|
public override void Save()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,28 +6,31 @@ namespace DiscordChatExporter.Gui.Utils.Extensions;
|
|||||||
|
|
||||||
internal static class AvaloniaExtensions
|
internal static class AvaloniaExtensions
|
||||||
{
|
{
|
||||||
public static Window? TryGetMainWindow(this IApplicationLifetime lifetime) =>
|
extension(IApplicationLifetime lifetime)
|
||||||
lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime
|
|
||||||
? desktopLifetime.MainWindow
|
|
||||||
: null;
|
|
||||||
|
|
||||||
public static TopLevel? TryGetTopLevel(this IApplicationLifetime lifetime) =>
|
|
||||||
lifetime.TryGetMainWindow()
|
|
||||||
?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel;
|
|
||||||
|
|
||||||
public static bool TryShutdown(this IApplicationLifetime lifetime, int exitCode = 0)
|
|
||||||
{
|
{
|
||||||
if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)
|
public Window? TryGetMainWindow() =>
|
||||||
{
|
lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime
|
||||||
return desktopLifetime.TryShutdown(exitCode);
|
? desktopLifetime.MainWindow
|
||||||
}
|
: null;
|
||||||
|
|
||||||
if (lifetime is IControlledApplicationLifetime controlledLifetime)
|
public TopLevel? TryGetTopLevel() =>
|
||||||
{
|
lifetime.TryGetMainWindow()
|
||||||
controlledLifetime.Shutdown(exitCode);
|
?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
public bool TryShutdown(int exitCode = 0)
|
||||||
|
{
|
||||||
|
if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)
|
||||||
|
{
|
||||||
|
return desktopLifetime.TryShutdown(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lifetime is IControlledApplicationLifetime controlledLifetime)
|
||||||
|
{
|
||||||
|
controlledLifetime.Shutdown(exitCode);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,26 @@ namespace DiscordChatExporter.Gui.Utils.Extensions;
|
|||||||
|
|
||||||
internal static class DisposableExtensions
|
internal static class DisposableExtensions
|
||||||
{
|
{
|
||||||
public static void DisposeAll(this IEnumerable<IDisposable> disposables)
|
extension(IEnumerable<IDisposable> disposables)
|
||||||
{
|
{
|
||||||
var exceptions = default(List<Exception>);
|
public void DisposeAll()
|
||||||
|
|
||||||
foreach (var disposable in disposables)
|
|
||||||
{
|
{
|
||||||
try
|
var exceptions = default(List<Exception>);
|
||||||
{
|
|
||||||
disposable.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
(exceptions ??= []).Add(ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (exceptions?.Any() == true)
|
foreach (var disposable in disposables)
|
||||||
throw new AggregateException(exceptions);
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
disposable.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
(exceptions ??= []).Add(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exceptions?.Any() == true)
|
||||||
|
throw new AggregateException(exceptions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,50 +7,47 @@ namespace DiscordChatExporter.Gui.Utils.Extensions;
|
|||||||
|
|
||||||
internal static class NotifyPropertyChangedExtensions
|
internal static class NotifyPropertyChangedExtensions
|
||||||
{
|
{
|
||||||
public static IDisposable WatchProperty<TOwner, TProperty>(
|
extension<TOwner>(TOwner owner)
|
||||||
this TOwner owner,
|
|
||||||
Expression<Func<TOwner, TProperty>> propertyExpression,
|
|
||||||
Action callback,
|
|
||||||
bool watchInitialValue = false
|
|
||||||
)
|
|
||||||
where TOwner : INotifyPropertyChanged
|
where TOwner : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
var memberExpression = propertyExpression.Body as MemberExpression;
|
public IDisposable WatchProperty<TProperty>(
|
||||||
if (memberExpression?.Member is not PropertyInfo property)
|
Expression<Func<TOwner, TProperty>> propertyExpression,
|
||||||
throw new ArgumentException("Provided expression must reference a property.");
|
Action callback,
|
||||||
|
bool watchInitialValue = false
|
||||||
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
|
)
|
||||||
{
|
{
|
||||||
if (
|
var memberExpression = propertyExpression.Body as MemberExpression;
|
||||||
string.IsNullOrWhiteSpace(args.PropertyName)
|
if (memberExpression?.Member is not PropertyInfo property)
|
||||||
|| string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal)
|
throw new ArgumentException("Provided expression must reference a property.");
|
||||||
)
|
|
||||||
|
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
|
||||||
{
|
{
|
||||||
callback();
|
if (
|
||||||
|
string.IsNullOrWhiteSpace(args.PropertyName)
|
||||||
|
|| string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
callback();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
owner.PropertyChanged += OnPropertyChanged;
|
||||||
|
|
||||||
|
if (watchInitialValue)
|
||||||
|
callback();
|
||||||
|
|
||||||
|
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
owner.PropertyChanged += OnPropertyChanged;
|
public IDisposable WatchAllProperties(Action callback, bool watchInitialValues = false)
|
||||||
|
{
|
||||||
|
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback();
|
||||||
|
owner.PropertyChanged += OnPropertyChanged;
|
||||||
|
|
||||||
if (watchInitialValue)
|
if (watchInitialValues)
|
||||||
callback();
|
callback();
|
||||||
|
|
||||||
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
|
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IDisposable WatchAllProperties<TOwner>(
|
|
||||||
this TOwner owner,
|
|
||||||
Action callback,
|
|
||||||
bool watchInitialValues = false
|
|
||||||
)
|
|
||||||
where TOwner : INotifyPropertyChanged
|
|
||||||
{
|
|
||||||
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback();
|
|
||||||
owner.PropertyChanged += OnPropertyChanged;
|
|
||||||
|
|
||||||
if (watchInitialValues)
|
|
||||||
callback();
|
|
||||||
|
|
||||||
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Utils.Extensions;
|
||||||
|
|
||||||
|
internal static class ProcessExtensions
|
||||||
|
{
|
||||||
|
extension(Process)
|
||||||
|
{
|
||||||
|
public static void StartShellExecute(string path)
|
||||||
|
{
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true };
|
||||||
|
|
||||||
|
process.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
|
|
||||||
namespace DiscordChatExporter.Gui.Utils;
|
|
||||||
|
|
||||||
internal static class ProcessEx
|
|
||||||
{
|
|
||||||
public static void StartShellExecute(string path)
|
|
||||||
{
|
|
||||||
using var process = new Process();
|
|
||||||
process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true };
|
|
||||||
|
|
||||||
process.Start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -33,28 +34,6 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
|
|
||||||
private DiscordClient? _discord;
|
private DiscordClient? _discord;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsProgressIndeterminate))]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
|
||||||
private bool _isBusy;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
|
|
||||||
private string? _token;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private IReadOnlyList<Guild>? _availableGuilds;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
|
||||||
private Guild? _selectedGuild;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private IReadOnlyList<ChannelConnection>? _availableChannels;
|
|
||||||
|
|
||||||
public DashboardViewModel(
|
public DashboardViewModel(
|
||||||
ViewModelManager viewModelManager,
|
ViewModelManager viewModelManager,
|
||||||
DialogManager dialogManager,
|
DialogManager dialogManager,
|
||||||
@@ -84,10 +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 ProgressContainer<Percentage> Progress { get; } = new();
|
||||||
|
|
||||||
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
|
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(PullGuildsCommand))]
|
||||||
|
public partial string? Token { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial IReadOnlyList<Guild>? AvailableGuilds { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(PullChannelsCommand))]
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(ExportCommand))]
|
||||||
|
public partial Guild? SelectedGuild { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial IReadOnlyList<ChannelConnection>? AvailableChannels { get; set; }
|
||||||
|
|
||||||
public ObservableCollection<ChannelConnection> SelectedChannels { get; } = [];
|
public ObservableCollection<ChannelConnection> SelectedChannels { get; } = [];
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -102,7 +103,7 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
|
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ShowHelp() => ProcessEx.StartShellExecute(Program.ProjectDocumentationUrl);
|
private void ShowHelp() => Process.StartShellExecute(Program.ProjectDocumentationUrl);
|
||||||
|
|
||||||
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
|
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
|
||||||
|
|
||||||
@@ -123,7 +124,7 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
AvailableChannels = null;
|
AvailableChannels = null;
|
||||||
SelectedChannels.Clear();
|
SelectedChannels.Clear();
|
||||||
|
|
||||||
_discord = new DiscordClient(token);
|
_discord = new DiscordClient(token, _settingsService.RateLimitPreference);
|
||||||
_settingsService.LastToken = token;
|
_settingsService.LastToken = token;
|
||||||
|
|
||||||
var guilds = await _discord.GetUserGuildsAsync();
|
var guilds = await _discord.GetUserGuildsAsync();
|
||||||
@@ -140,7 +141,7 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var dialog = _viewModelManager.CreateMessageBoxViewModel(
|
var dialog = _viewModelManager.CreateMessageBoxViewModel(
|
||||||
"Error pulling guilds",
|
"Error pulling servers",
|
||||||
ex.ToString()
|
ex.ToString()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -283,6 +284,10 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
|
|
||||||
Interlocked.Increment(ref successfulExportCount);
|
Interlocked.Increment(ref successfulExportCount);
|
||||||
}
|
}
|
||||||
|
catch (ChannelEmptyException ex)
|
||||||
|
{
|
||||||
|
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
|
||||||
|
}
|
||||||
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
catch (DiscordChatExporterException ex) when (!ex.IsFatal)
|
||||||
{
|
{
|
||||||
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
|
_snackbarManager.Notify(ex.Message.TrimEnd('.'));
|
||||||
@@ -318,11 +323,11 @@ public partial class DashboardViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenDiscord() => ProcessEx.StartShellExecute("https://discord.com/app");
|
private void OpenDiscord() => Process.StartShellExecute("https://discord.com/app");
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void OpenDiscordDeveloperPortal() =>
|
private void OpenDiscordDeveloperPortal() =>
|
||||||
ProcessEx.StartShellExecute("https://discord.com/developers/applications");
|
Process.StartShellExecute("https://discord.com/developers/applications");
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,56 +22,56 @@ public partial class ExportSetupViewModel(
|
|||||||
) : DialogViewModelBase
|
) : DialogViewModelBase
|
||||||
{
|
{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private Guild? _guild;
|
public partial Guild? Guild { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsSingleChannel))]
|
[NotifyPropertyChangedFor(nameof(IsSingleChannel))]
|
||||||
private IReadOnlyList<Channel>? _channels;
|
public partial IReadOnlyList<Channel>? Channels { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? _outputPath;
|
public partial string? OutputPath { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ExportFormat _selectedFormat;
|
public partial ExportFormat SelectedFormat { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsAfterDateSet))]
|
[NotifyPropertyChangedFor(nameof(IsAfterDateSet))]
|
||||||
[NotifyPropertyChangedFor(nameof(After))]
|
[NotifyPropertyChangedFor(nameof(After))]
|
||||||
private DateTimeOffset? _afterDate;
|
public partial DateTimeOffset? AfterDate { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private TimeSpan? _afterTime;
|
public partial TimeSpan? AfterTime { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsBeforeDateSet))]
|
[NotifyPropertyChangedFor(nameof(IsBeforeDateSet))]
|
||||||
[NotifyPropertyChangedFor(nameof(Before))]
|
[NotifyPropertyChangedFor(nameof(Before))]
|
||||||
private DateTimeOffset? _beforeDate;
|
public partial DateTimeOffset? BeforeDate { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private TimeSpan? _beforeTime;
|
public partial TimeSpan? BeforeTime { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(PartitionLimit))]
|
[NotifyPropertyChangedFor(nameof(PartitionLimit))]
|
||||||
private string? _partitionLimitValue;
|
public partial string? PartitionLimitValue { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(MessageFilter))]
|
[NotifyPropertyChangedFor(nameof(MessageFilter))]
|
||||||
private string? _messageFilterValue;
|
public partial string? MessageFilterValue { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _shouldFormatMarkdown;
|
public partial bool ShouldFormatMarkdown { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _shouldDownloadAssets;
|
public partial bool ShouldDownloadAssets { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _shouldReuseAssets;
|
public partial bool ShouldReuseAssets { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? _assetsDirPath;
|
public partial string? AssetsDirPath { get; set; }
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isAdvancedSectionDisplayed;
|
public partial bool IsAdvancedSectionDisplayed { get; set; }
|
||||||
|
|
||||||
public bool IsSingleChannel => Channels?.Count == 1;
|
public bool IsSingleChannel => Channels?.Count == 1;
|
||||||
|
|
||||||
|
|||||||
@@ -6,20 +6,20 @@ namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
|
|||||||
public partial class MessageBoxViewModel : DialogViewModelBase
|
public partial class MessageBoxViewModel : DialogViewModelBase
|
||||||
{
|
{
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? _title = "Title";
|
public partial string? Title { get; set; } = "Title";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string? _message = "Message";
|
public partial string? Message { get; set; } = "Message";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsDefaultButtonVisible))]
|
[NotifyPropertyChangedFor(nameof(IsDefaultButtonVisible))]
|
||||||
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
|
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
|
||||||
private string? _defaultButtonText = "OK";
|
public partial string? DefaultButtonText { get; set; } = "OK";
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsCancelButtonVisible))]
|
[NotifyPropertyChangedFor(nameof(IsCancelButtonVisible))]
|
||||||
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
|
[NotifyPropertyChangedFor(nameof(ButtonsCount))]
|
||||||
private string? _cancelButtonText = "Cancel";
|
public partial string? CancelButtonText { get; set; } = "Cancel";
|
||||||
|
|
||||||
public bool IsDefaultButtonVisible => !string.IsNullOrWhiteSpace(DefaultButtonText);
|
public bool IsDefaultButtonVisible => !string.IsNullOrWhiteSpace(DefaultButtonText);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
using DiscordChatExporter.Core.Utils.Extensions;
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
using DiscordChatExporter.Gui.Models;
|
using DiscordChatExporter.Gui.Models;
|
||||||
@@ -42,6 +43,15 @@ public class SettingsViewModel : DialogViewModelBase
|
|||||||
set => _settingsService.IsTokenPersisted = value;
|
set => _settingsService.IsTokenPersisted = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<RateLimitPreference> AvailableRateLimitPreferences { get; } =
|
||||||
|
Enum.GetValues<RateLimitPreference>();
|
||||||
|
|
||||||
|
public RateLimitPreference RateLimitPreference
|
||||||
|
{
|
||||||
|
get => _settingsService.RateLimitPreference;
|
||||||
|
set => _settingsService.RateLimitPreference = value;
|
||||||
|
}
|
||||||
|
|
||||||
public IReadOnlyList<ThreadInclusionMode> AvailableThreadInclusionModes { get; } =
|
public IReadOnlyList<ThreadInclusionMode> AvailableThreadInclusionModes { get; } =
|
||||||
Enum.GetValues<ThreadInclusionMode>();
|
Enum.GetValues<ThreadInclusionMode>();
|
||||||
|
|
||||||
@@ -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
|
// These items have to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
|
||||||
public IReadOnlyList<string> AvailableLocales { get; } =
|
public IReadOnlyList<string> AvailableLocales { get; } =
|
||||||
[
|
[
|
||||||
// Current locale (maps to null downstream)
|
// Current locale (maps to null downstream)
|
||||||
"",
|
"",
|
||||||
// Locales supported by the Discord app
|
// Locales supported by the Discord app
|
||||||
"da-DK",
|
"da-DK",
|
||||||
"de-DE",
|
"de-DE",
|
||||||
"en-GB",
|
"en-GB",
|
||||||
"en-US",
|
"en-US",
|
||||||
"es-ES",
|
"es-ES",
|
||||||
"fr-FR",
|
"fr-FR",
|
||||||
"hr-HR",
|
"hr-HR",
|
||||||
"it-IT",
|
"it-IT",
|
||||||
"lt-LT",
|
"lt-LT",
|
||||||
"hu-HU",
|
"hu-HU",
|
||||||
"nl-NL",
|
"nl-NL",
|
||||||
"no-NO",
|
"no-NO",
|
||||||
"pl-PL",
|
"pl-PL",
|
||||||
"pt-BR",
|
"pt-BR",
|
||||||
"ro-RO",
|
"ro-RO",
|
||||||
"fi-FI",
|
"fi-FI",
|
||||||
"sv-SE",
|
"sv-SE",
|
||||||
"vi-VN",
|
"vi-VN",
|
||||||
"tr-TR",
|
"tr-TR",
|
||||||
"cs-CZ",
|
"cs-CZ",
|
||||||
"el-GR",
|
"el-GR",
|
||||||
"bg-BG",
|
"bg-BG",
|
||||||
"ru-RU",
|
"ru-RU",
|
||||||
"uk-UA",
|
"uk-UA",
|
||||||
"th-TH",
|
"th-TH",
|
||||||
"zh-CN",
|
"zh-CN",
|
||||||
"ja-JP",
|
"ja-JP",
|
||||||
"zh-TW",
|
"zh-TW",
|
||||||
"ko-KR",
|
"ko-KR",
|
||||||
];
|
];
|
||||||
|
|
||||||
// This has to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
|
// This has to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
|
||||||
public string Locale
|
public string Locale
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using Avalonia;
|
|||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
using DiscordChatExporter.Gui.Services;
|
using DiscordChatExporter.Gui.Services;
|
||||||
using DiscordChatExporter.Gui.Utils;
|
|
||||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||||
using DiscordChatExporter.Gui.ViewModels.Components;
|
using DiscordChatExporter.Gui.ViewModels.Components;
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ public partial class MainViewModel(
|
|||||||
settingsService.Save();
|
settingsService.Save();
|
||||||
|
|
||||||
if (await dialogManager.ShowDialogAsync(dialog) == true)
|
if (await dialogManager.ShowDialogAsync(dialog) == true)
|
||||||
ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter");
|
Process.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ShowDevelopmentBuildMessageAsync()
|
private async Task ShowDevelopmentBuildMessageAsync()
|
||||||
@@ -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.
|
You're using a development build of {Program.Name}. These builds are not thoroughly tested and may contain bugs.
|
||||||
|
|
||||||
Auto-updates are disabled for development builds. If you want to switch to a stable release, please download it manually.
|
Auto-updates are disabled for development builds.
|
||||||
|
|
||||||
|
Click SEE RELEASES if you want to download a stable release instead.
|
||||||
""",
|
""",
|
||||||
"SEE RELEASES",
|
"SEE RELEASES",
|
||||||
"CLOSE"
|
"CLOSE"
|
||||||
);
|
);
|
||||||
|
|
||||||
if (await dialogManager.ShowDialogAsync(dialog) == true)
|
if (await dialogManager.ShowDialogAsync(dialog) == true)
|
||||||
ProcessEx.StartShellExecute(Program.ProjectReleasesUrl);
|
Process.StartShellExecute(Program.ProjectReleasesUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CheckForUpdatesAsync()
|
private async Task CheckForUpdatesAsync()
|
||||||
|
|||||||
@@ -332,6 +332,9 @@
|
|||||||
Text="To get the token for your bot:" />
|
Text="To get the token for your bot:" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
|
|
||||||
|
<Run BaselineAlignment="Center" Text="The token is generated during bot creation. If you lost it, generate a new one:" />
|
||||||
|
<LineBreak />
|
||||||
|
|
||||||
<Run BaselineAlignment="Center" Text="1. Open Discord" />
|
<Run BaselineAlignment="Center" Text="1. Open Discord" />
|
||||||
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="developer portal" />
|
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="developer portal" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
@@ -356,7 +359,18 @@
|
|||||||
<Run
|
<Run
|
||||||
BaselineAlignment="Center"
|
BaselineAlignment="Center"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
Text="Copy" />
|
Text="Reset Token" />
|
||||||
|
<LineBreak />
|
||||||
|
|
||||||
|
<Run BaselineAlignment="Center" Text="5. Click" />
|
||||||
|
<Run
|
||||||
|
BaselineAlignment="Center"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
Text="Yes, do it!" />
|
||||||
|
<Run BaselineAlignment="Center" Text="and authenticate to confirm" />
|
||||||
|
<LineBreak />
|
||||||
|
|
||||||
|
<Run BaselineAlignment="Center" Text="* Integrations using the previous token will stop working until updated" />
|
||||||
<LineBreak />
|
<LineBreak />
|
||||||
|
|
||||||
<Run BaselineAlignment="Center" Text="* Your bot needs to have the" />
|
<Run BaselineAlignment="Center" Text="* Your bot needs to have the" />
|
||||||
|
|||||||
@@ -54,6 +54,25 @@
|
|||||||
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
|
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
|
<!-- Rate limit preference -->
|
||||||
|
<DockPanel
|
||||||
|
Margin="16,8"
|
||||||
|
LastChildFill="False"
|
||||||
|
ToolTip.Tip="Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.">
|
||||||
|
<TextBlock DockPanel.Dock="Left" Text="Rate limit preference" />
|
||||||
|
<ComboBox
|
||||||
|
Width="150"
|
||||||
|
DockPanel.Dock="Right"
|
||||||
|
ItemsSource="{Binding AvailableRateLimitPreferences}"
|
||||||
|
SelectedItem="{Binding RateLimitPreference}">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding Converter={x:Static converters:RateLimitPreferenceToStringConverter.Instance}}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Thread inclusion mode -->
|
<!-- Thread inclusion mode -->
|
||||||
<DockPanel
|
<DockPanel
|
||||||
Margin="16,8"
|
Margin="16,8"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
|
xmlns:viewModels="clr-namespace:DiscordChatExporter.Gui.ViewModels"
|
||||||
Title="{Binding Title}"
|
Title="{Binding Title}"
|
||||||
Width="625"
|
Width="625"
|
||||||
Height="625"
|
Height="665"
|
||||||
MinWidth="600"
|
MinWidth="600"
|
||||||
MinHeight="400"
|
MinHeight="400"
|
||||||
x:DataType="viewModels:MainViewModel"
|
x:DataType="viewModels:MainViewModel"
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
x:Name="DialogHost"
|
x:Name="DialogHost"
|
||||||
CloseOnClickAway="False"
|
CloseOnClickAway="False"
|
||||||
Loaded="DialogHost_OnLoaded">
|
Loaded="DialogHost_OnLoaded">
|
||||||
<materialStyles:SnackbarHost HostName="Root">
|
<materialStyles:SnackbarHost HostName="Root" SnackbarMaxCounts="3">
|
||||||
<ContentControl Content="{Binding Dashboard}" />
|
<ContentControl Content="{Binding Dashboard}" />
|
||||||
</materialStyles:SnackbarHost>
|
</materialStyles:SnackbarHost>
|
||||||
</dialogHostAvalonia:DialogHost>
|
</dialogHostAvalonia:DialogHost>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ Microsoft Visual Studio Solution File, Format Version 12.00
|
|||||||
# Visual Studio Version 16
|
# Visual Studio Version 16
|
||||||
VisualStudioVersion = 16.0.28729.10
|
VisualStudioVersion = 16.0.28729.10
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
|
||||||
ProjectSection(SolutionItems) = preProject
|
ProjectSection(SolutionItems) = preProject
|
||||||
License.txt = License.txt
|
License.txt = License.txt
|
||||||
Readme.md = Readme.md
|
Readme.md = Readme.md
|
||||||
Directory.Build.props = Directory.Build.props
|
Directory.Build.props = Directory.Build.props
|
||||||
DiscordChatExporter.Cli.dockerfile = DiscordChatExporter.Cli.dockerfile
|
DiscordChatExporter.Cli.dockerfile = DiscordChatExporter.Cli.dockerfile
|
||||||
|
global.json = global.json
|
||||||
|
NuGet.config = NuGet.config
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2017-2024 Oleksii Holub
|
Copyright (c) 2017-2026 Oleksii Holub
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -50,10 +50,14 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
|
|||||||
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `discord-chat-exporter-cli`
|
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `discord-chat-exporter-cli`
|
||||||
- 📦 [Nix](https://search.nixos.org/packages?query=discordchatexporter-cli): `discordchatexporter-cli`
|
- 📦 [Nix](https://search.nixos.org/packages?query=discordchatexporter-cli): `discordchatexporter-cli`
|
||||||
|
|
||||||
> **Note**:
|
> [!IMPORTANT]
|
||||||
|
> To launch the GUI version of the app on MacOS, you need to first remove the downloaded file from quarantine.
|
||||||
|
> You can do that by running the following command in the terminal: `xattr -rd com.apple.quarantine DiscordChatExporter.app`.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
> If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture.
|
> If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture.
|
||||||
|
|
||||||
> **Note**:
|
> [!NOTE]
|
||||||
> AUR and Nix packages linked above are maintained by the community.
|
> AUR and Nix packages linked above are maintained by the community.
|
||||||
> If you have any issues with them, please contact the corresponding maintainers.
|
> If you have any issues with them, please contact the corresponding maintainers.
|
||||||
|
|
||||||
@@ -74,4 +78,4 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
|
|||||||
## See also
|
## See also
|
||||||
|
|
||||||
- [**Chat Analytics**](https://github.com/mlomb/chat-analytics) — solution for analyzing chat patterns of Discord users, using exports produced by **DiscordChatExporter**.
|
- [**Chat Analytics**](https://github.com/mlomb/chat-analytics) — solution for analyzing chat patterns of Discord users, using exports produced by **DiscordChatExporter**.
|
||||||
- [**DiscordChatExporter-frontend**](https://github.com/slatinsky/DiscordChatExporter-frontend) — convenient viewer for exports produced by **DiscordChatExporter**.
|
- [**DiscordChatExporter-frontend**](https://github.com/slatinsky/DiscordChatExporter-frontend) — convenient viewer for exports produced by **DiscordChatExporter**.
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.100",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user