mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-07-07 14:44:39 +02:00
Compare commits
111 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3be5bc6a21 | |||
| c9f152773a | |||
| 5e39611395 | |||
| 40491bdc0f | |||
| 03dd272eaf | |||
| 98e294f7e4 | |||
| b22e786449 | |||
| 951c1e4613 | |||
| 675d910ea3 | |||
| 28f26e45fb | |||
| 4674c517e3 | |||
| b660edfe78 | |||
| 532470e3db | |||
| b533e08d74 | |||
| d490fd0a38 | |||
| 21c2398e2a | |||
| 7ff2347df1 | |||
| 3391e755fc | |||
| f0e9d3be1b | |||
| eef0fc742d | |||
| 2e47c73388 | |||
| 2d49146c74 | |||
| c59374598a | |||
| 01d0203445 | |||
| c4bfb3424e | |||
| 522caba420 | |||
| ad1170b42e | |||
| d2c0e2169b | |||
| e6562de1f5 | |||
| 5f8054e2f0 | |||
| 12d98e9ab0 | |||
| 18086aa209 | |||
| dd7196b6a5 | |||
| 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 |
@@ -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@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
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@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
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@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
run: >
|
run: >
|
||||||
|
|||||||
+18
-18
@@ -26,12 +26,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@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.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
|
# Build the project separately to discern between build and format errors
|
||||||
- name: Build
|
- name: Build
|
||||||
@@ -64,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@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
|
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
|
||||||
with:
|
|
||||||
dotnet-version: 9.0.x
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
env:
|
||||||
@@ -85,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@0565863a31f2c772f9f0395002a31e3f06189574 # v5.4.0
|
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
|
||||||
@@ -121,25 +117,25 @@ 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@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.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:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }}
|
||||||
|
-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@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
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/
|
||||||
@@ -203,15 +199,19 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download artifacts
|
- name: Download app binaries
|
||||||
uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
|
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>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="AngleSharp" Version="1.4.0" />
|
||||||
|
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.5.0" />
|
||||||
|
<PackageVersion Include="AsyncKeyedLock" Version="8.0.1" />
|
||||||
|
<PackageVersion Include="Avalonia" Version="11.3.11" />
|
||||||
|
<PackageVersion Include="Avalonia.Desktop" Version="11.3.11" />
|
||||||
|
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.11" />
|
||||||
|
<PackageVersion Include="CliFx" Version="2.3.6" />
|
||||||
|
<PackageVersion Include="Cogwheel" Version="2.1.0" />
|
||||||
|
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.5" />
|
||||||
|
<PackageVersion Include="Deorcify" Version="1.1.0" />
|
||||||
|
<PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
|
||||||
|
<PackageVersion Include="DialogHost.Avalonia" Version="0.10.4" />
|
||||||
|
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
|
||||||
|
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.1" />
|
||||||
|
<PackageVersion Include="Gress" Version="2.1.1" />
|
||||||
|
<PackageVersion Include="JsonExtensions" Version="1.2.0" />
|
||||||
|
<PackageVersion Include="Markdig" Version="1.0.0" />
|
||||||
|
<PackageVersion Include="Material.Avalonia" Version="3.9.2" />
|
||||||
|
<PackageVersion Include="Material.Icons.Avalonia" Version="2.2.0" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
|
<PackageVersion Include="Onova" Version="2.6.13" />
|
||||||
|
<PackageVersion Include="Polly" Version="8.6.5" />
|
||||||
|
<PackageVersion Include="RazorBlade" Version="0.11.0" />
|
||||||
|
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
|
||||||
|
<PackageVersion Include="Superpower" Version="3.1.0" />
|
||||||
|
<PackageVersion Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
|
<PackageVersion Include="YoutubeExplode" Version="6.5.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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,21 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AngleSharp" Version="1.2.0" />
|
<PackageReference Include="AngleSharp" />
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
|
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
<PackageReference Include="FluentAssertions" Version="8.1.1" />
|
<PackageReference Include="FluentAssertions" />
|
||||||
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
|
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="JsonExtensions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.2" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
<PackageReference Include="xunit" Version="2.9.3" />
|
<PackageReference Include="xunit" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" PrivateAssets="all" />
|
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public static class ChannelIds
|
|||||||
|
|
||||||
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
|
||||||
|
|
||||||
|
public static Snowflake ForwardTestCases { get; } = Snowflake.Parse("1455202357204877477");
|
||||||
|
|
||||||
public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521");
|
public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521");
|
||||||
|
|
||||||
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
using System.Linq;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AngleSharp.Dom;
|
using AngleSharp.Dom;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using DiscordChatExporter.Cli.Commands;
|
||||||
using DiscordChatExporter.Cli.Tests.Infra;
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils;
|
||||||
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -44,4 +49,41 @@ public class HtmlContentSpecs
|
|||||||
"Yeet"
|
"Yeet"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_in_the_HTML_format_in_the_reverse_order()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var file = TempFile.Create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
Token = Secrets.DiscordToken,
|
||||||
|
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||||
|
ExportFormat = ExportFormat.HtmlDark,
|
||||||
|
OutputPath = file.Path,
|
||||||
|
Locale = "en-US",
|
||||||
|
IsUtcNormalizationEnabled = true,
|
||||||
|
IsReverseMessageOrder = true,
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
|
var document = Html.Parse(await File.ReadAllTextAsync(file.Path));
|
||||||
|
var messages = document.QuerySelectorAll("[data-message-id]").ToArray();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
messages
|
||||||
|
.Select(e => e.GetAttribute("data-message-id"))
|
||||||
|
.Should()
|
||||||
|
.Equal(
|
||||||
|
"885169254029213696",
|
||||||
|
"868505973294268457",
|
||||||
|
"868505969821364245",
|
||||||
|
"868505966528835604",
|
||||||
|
"868490009366396958",
|
||||||
|
"866732113319428096",
|
||||||
|
"866710679758045195",
|
||||||
|
"866674314627121232"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using AngleSharp.Dom;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public class HtmlForwardSpecs
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.ForwardTestCases,
|
||||||
|
Snowflake.Parse("1455202427115536514")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
message
|
||||||
|
.Text()
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.ContainAll("Forwarded", @"¯\_(ツ)_/¯", "12/29/2025 2:14 PM");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,4 +61,17 @@ public class HtmlMentionSpecs
|
|||||||
// Assert
|
// Assert
|
||||||
message.Text().Should().Contain("Role mention: @Role 1");
|
message.Text().Should().Contain("Role mention: @Role 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_message_with_a_thread_mention()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsHtmlAsync(
|
||||||
|
ChannelIds.MentionTestCases,
|
||||||
|
Snowflake.Parse("1474874276828938290")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
message.Text().Should().Contain("Thread mention: #Thread starting message");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
using System.Linq;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using CliFx.Infrastructure;
|
||||||
|
using DiscordChatExporter.Cli.Commands;
|
||||||
using DiscordChatExporter.Cli.Tests.Infra;
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils;
|
||||||
|
using DiscordChatExporter.Core.Exporting;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using JsonExtensions;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Cli.Tests.Specs;
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
@@ -43,4 +49,43 @@ public class JsonContentSpecs
|
|||||||
"Yeet"
|
"Yeet"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_in_the_JSON_format_in_the_reverse_order()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
using var file = TempFile.Create();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await new ExportChannelsCommand
|
||||||
|
{
|
||||||
|
Token = Secrets.DiscordToken,
|
||||||
|
ChannelIds = [ChannelIds.DateRangeTestCases],
|
||||||
|
ExportFormat = ExportFormat.Json,
|
||||||
|
OutputPath = file.Path,
|
||||||
|
Locale = "en-US",
|
||||||
|
IsUtcNormalizationEnabled = true,
|
||||||
|
IsReverseMessageOrder = true,
|
||||||
|
}.ExecuteAsync(new FakeConsole());
|
||||||
|
|
||||||
|
var messages = Json.Parse(await File.ReadAllTextAsync(file.Path))
|
||||||
|
.GetProperty("messages")
|
||||||
|
.EnumerateArray()
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
messages
|
||||||
|
.Select(j => j.GetProperty("id").GetString())
|
||||||
|
.Should()
|
||||||
|
.Equal(
|
||||||
|
"885169254029213696",
|
||||||
|
"868505973294268457",
|
||||||
|
"868505969821364245",
|
||||||
|
"868505966528835604",
|
||||||
|
"868490009366396958",
|
||||||
|
"866732113319428096",
|
||||||
|
"866710679758045195",
|
||||||
|
"866674314627121232"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Core.Discord;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public class JsonForwardSpecs
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
|
ChannelIds.ForwardTestCases,
|
||||||
|
Snowflake.Parse("1455202427115536514")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
var reference = message.GetProperty("reference");
|
||||||
|
reference.GetProperty("type").GetString().Should().Be("Forward");
|
||||||
|
reference.GetProperty("guildId").GetString().Should().Be("869237470565392384");
|
||||||
|
|
||||||
|
var forwardedMessage = message.GetProperty("forwardedMessage");
|
||||||
|
forwardedMessage.GetProperty("content").GetString().Should().Contain(@"¯\_(ツ)_/¯");
|
||||||
|
forwardedMessage
|
||||||
|
.GetProperty("timestamp")
|
||||||
|
.GetString()
|
||||||
|
.Should()
|
||||||
|
.StartWith("2025-12-28T22:52:42.175+00:00");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,4 +75,21 @@ public class JsonMentionSpecs
|
|||||||
// Assert
|
// Assert
|
||||||
message.GetProperty("content").GetString().Should().Be("Role mention: @Role 1");
|
message.GetProperty("content").GetString().Should().Be("Role mention: @Role 1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_message_with_a_thread_mention()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var message = await ExportWrapper.GetMessageAsJsonAsync(
|
||||||
|
ChannelIds.MentionTestCases,
|
||||||
|
Snowflake.Parse("1474874276828938290")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
message
|
||||||
|
.GetProperty("content")
|
||||||
|
.GetString()
|
||||||
|
.Should()
|
||||||
|
.Be("Thread mention: #Thread starting message");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Infra;
|
||||||
|
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Cli.Tests.Specs;
|
||||||
|
|
||||||
|
public class PlainTextForwardSpecs
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
|
||||||
|
{
|
||||||
|
// Act
|
||||||
|
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.ForwardTestCases);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
document
|
||||||
|
.ReplaceWhiteSpace()
|
||||||
|
.Should()
|
||||||
|
.ContainAll("{Forwarded Message}", @"¯\_(ツ)_/¯", "12/28/2025 10:52 PM");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,16 @@ namespace DiscordChatExporter.Cli.Tests.Utils.Extensions;
|
|||||||
|
|
||||||
internal static class StringExtensions
|
internal static class StringExtensions
|
||||||
{
|
{
|
||||||
public static string ReplaceWhiteSpace(this string str, string replacement = " ")
|
extension(string str)
|
||||||
{
|
{
|
||||||
var buffer = new StringBuilder(str.Length);
|
public string ReplaceWhiteSpace(string replacement = " ")
|
||||||
|
{
|
||||||
|
var buffer = new StringBuilder(str.Length);
|
||||||
|
|
||||||
foreach (var ch in str)
|
foreach (var ch in str)
|
||||||
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
|
buffer.Append(char.IsWhiteSpace(ch) ? replacement : ch);
|
||||||
|
|
||||||
return buffer.ToString();
|
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.
|
||||||
@@ -15,6 +15,7 @@ WORKDIR /tmp/app
|
|||||||
COPY favicon.ico .
|
COPY favicon.ico .
|
||||||
COPY NuGet.config .
|
COPY NuGet.config .
|
||||||
COPY Directory.Build.props .
|
COPY Directory.Build.props .
|
||||||
|
COPY Directory.Packages.props .
|
||||||
COPY DiscordChatExporter.Core DiscordChatExporter.Core
|
COPY DiscordChatExporter.Core DiscordChatExporter.Core
|
||||||
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
|
||||||
|
|
||||||
@@ -30,7 +31,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. "
|
||||||
@@ -77,6 +83,12 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
)]
|
)]
|
||||||
public int ParallelLimit { get; init; } = 1;
|
public int ParallelLimit { get; init; } = 1;
|
||||||
|
|
||||||
|
[CommandOption(
|
||||||
|
"reverse",
|
||||||
|
Description = "Export messages in reverse chronological order (newest first)."
|
||||||
|
)]
|
||||||
|
public bool IsReverseMessageOrder { get; init; }
|
||||||
|
|
||||||
[CommandOption(
|
[CommandOption(
|
||||||
"markdown",
|
"markdown",
|
||||||
Description = "Process markdown, mentions, and other special tokens."
|
Description = "Process markdown, mentions, and other special tokens."
|
||||||
@@ -95,8 +107,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 +114,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 +146,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 +166,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 +229,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 +244,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),
|
||||||
@@ -221,6 +273,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
|
|||||||
Before,
|
Before,
|
||||||
PartitionLimit,
|
PartitionLimit,
|
||||||
MessageFilter,
|
MessageFilter,
|
||||||
|
IsReverseMessageOrder,
|
||||||
ShouldFormatMarkdown,
|
ShouldFormatMarkdown,
|
||||||
ShouldDownloadAssets,
|
ShouldDownloadAssets,
|
||||||
ShouldReuseAssets,
|
ShouldReuseAssets,
|
||||||
@@ -236,6 +289,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 +305,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 +338,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 +353,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" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" />
|
||||||
<PackageReference Include="Spectre.Console" Version="0.49.1" />
|
<PackageReference Include="Spectre.Console" />
|
||||||
</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
|
||||||
|
|||||||
@@ -8855,6 +8855,8 @@ internal static class EmojiIndex
|
|||||||
["united_nations"] = "🇺🇳",
|
["united_nations"] = "🇺🇳",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static IReadOnlyCollection<string> GetAllNames() => _toCodes.Keys;
|
||||||
|
|
||||||
public static string? TryGetCode(string name) => _toCodes.GetValueOrDefault(name);
|
public static string? TryGetCode(string name) => _toCodes.GetValueOrDefault(name);
|
||||||
|
|
||||||
public static string? TryGetName(string code) => _fromCodes.GetValueOrDefault(code);
|
public static string? TryGetName(string code) => _fromCodes.GetValueOrDefault(code);
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -27,9 +27,16 @@ public partial record Message(
|
|||||||
IReadOnlyList<User> MentionedUsers,
|
IReadOnlyList<User> MentionedUsers,
|
||||||
MessageReference? Reference,
|
MessageReference? Reference,
|
||||||
Message? ReferencedMessage,
|
Message? ReferencedMessage,
|
||||||
|
MessageSnapshot? ForwardedMessage,
|
||||||
Interaction? Interaction
|
Interaction? Interaction
|
||||||
) : IHasId
|
) : IHasId
|
||||||
{
|
{
|
||||||
|
public bool IsEmpty { get; } =
|
||||||
|
string.IsNullOrWhiteSpace(Content)
|
||||||
|
&& !Attachments.Any()
|
||||||
|
&& !Embeds.Any()
|
||||||
|
&& !Stickers.Any();
|
||||||
|
|
||||||
public bool IsSystemNotification { get; } =
|
public bool IsSystemNotification { get; } =
|
||||||
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
||||||
|
|
||||||
@@ -38,11 +45,7 @@ public partial record Message(
|
|||||||
// App interactions are rendered as replies in the Discord client, but they are not actually replies
|
// App interactions are rendered as replies in the Discord client, but they are not actually replies
|
||||||
public bool IsReplyLike => IsReply || Interaction is not null;
|
public bool IsReplyLike => IsReply || Interaction is not null;
|
||||||
|
|
||||||
public bool IsEmpty { get; } =
|
public bool IsForwarded { get; } = Reference?.Kind == MessageReferenceKind.Forward;
|
||||||
string.IsNullOrWhiteSpace(Content)
|
|
||||||
&& !Attachments.Any()
|
|
||||||
&& !Embeds.Any()
|
|
||||||
&& !Stickers.Any();
|
|
||||||
|
|
||||||
public IEnumerable<User> GetReferencedUsers()
|
public IEnumerable<User> GetReferencedUsers()
|
||||||
{
|
{
|
||||||
@@ -143,7 +146,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 +158,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()
|
||||||
@@ -168,7 +174,17 @@ public partial record Message
|
|||||||
|
|
||||||
var messageReference = json.GetPropertyOrNull("message_reference")
|
var messageReference = json.GetPropertyOrNull("message_reference")
|
||||||
?.Pipe(MessageReference.Parse);
|
?.Pipe(MessageReference.Parse);
|
||||||
|
|
||||||
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
|
||||||
|
|
||||||
|
// Currently Discord only supports 1 snapshot per forward
|
||||||
|
var forwardedMessage = json.GetPropertyOrNull("message_snapshots")
|
||||||
|
?.EnumerateArrayOrNull()
|
||||||
|
?.Select(j => j.GetPropertyOrNull("message"))
|
||||||
|
.WhereNotNull()
|
||||||
|
.Select(MessageSnapshot.Parse)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
|
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
|
||||||
|
|
||||||
return new Message(
|
return new Message(
|
||||||
@@ -188,6 +204,7 @@ public partial record Message
|
|||||||
mentionedUsers,
|
mentionedUsers,
|
||||||
messageReference,
|
messageReference,
|
||||||
referencedMessage,
|
referencedMessage,
|
||||||
|
forwardedMessage,
|
||||||
interaction
|
interaction
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,19 @@ using JsonExtensions.Reading;
|
|||||||
namespace DiscordChatExporter.Core.Discord.Data;
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
|
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
|
||||||
public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowflake? GuildId)
|
public record MessageReference(
|
||||||
|
MessageReferenceKind Kind,
|
||||||
|
Snowflake? MessageId,
|
||||||
|
Snowflake? ChannelId,
|
||||||
|
Snowflake? GuildId
|
||||||
|
)
|
||||||
{
|
{
|
||||||
public static MessageReference Parse(JsonElement json)
|
public static MessageReference Parse(JsonElement json)
|
||||||
{
|
{
|
||||||
|
var kind =
|
||||||
|
json.GetPropertyOrNull("type")?.GetInt32OrNull()?.Pipe(t => (MessageReferenceKind)t)
|
||||||
|
?? MessageReferenceKind.Default;
|
||||||
|
|
||||||
var messageId = json.GetPropertyOrNull("message_id")
|
var messageId = json.GetPropertyOrNull("message_id")
|
||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(Snowflake.Parse);
|
?.Pipe(Snowflake.Parse);
|
||||||
@@ -21,6 +30,6 @@ public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowf
|
|||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(Snowflake.Parse);
|
?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
return new MessageReference(messageId, channelId, guildId);
|
return new MessageReference(kind, messageId, channelId, guildId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://discord.com/developers/docs/resources/channel#message-reference-types
|
||||||
|
public enum MessageReferenceKind
|
||||||
|
{
|
||||||
|
Default = 0,
|
||||||
|
Forward = 1,
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DiscordChatExporter.Core.Discord.Data.Embeds;
|
||||||
|
using JsonExtensions.Reading;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Core.Discord.Data;
|
||||||
|
|
||||||
|
// https://docs.discord.com/developers/resources/message#message-snapshot-object
|
||||||
|
public record MessageSnapshot(
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
DateTimeOffset? EditedTimestamp,
|
||||||
|
string Content,
|
||||||
|
IReadOnlyList<Attachment> Attachments,
|
||||||
|
IReadOnlyList<Embed> Embeds,
|
||||||
|
IReadOnlyList<Sticker> Stickers
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public static MessageSnapshot Parse(JsonElement json)
|
||||||
|
{
|
||||||
|
var timestamp =
|
||||||
|
json.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull()
|
||||||
|
?? DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffsetOrNull();
|
||||||
|
|
||||||
|
var content = json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "";
|
||||||
|
|
||||||
|
var attachments =
|
||||||
|
json.GetPropertyOrNull("attachments")
|
||||||
|
?.EnumerateArrayOrNull()
|
||||||
|
?.Select(Attachment.Parse)
|
||||||
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
var embeds =
|
||||||
|
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
var stickers =
|
||||||
|
json.GetPropertyOrNull("sticker_items")
|
||||||
|
?.EnumerateArrayOrNull()
|
||||||
|
?.Select(Sticker.Parse)
|
||||||
|
.ToArray()
|
||||||
|
?? [];
|
||||||
|
|
||||||
|
return new MessageSnapshot(
|
||||||
|
timestamp,
|
||||||
|
editedTimestamp,
|
||||||
|
content,
|
||||||
|
attachments,
|
||||||
|
embeds,
|
||||||
|
stickers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -183,6 +194,23 @@ public class DiscordClient(string token)
|
|||||||
return Application.Parse(response);
|
return Application.Parse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask EnsureMessageContentIntentAsync(
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (await ResolveTokenKindAsync(cancellationToken) != TokenKind.Bot)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var application = await GetApplicationAsync(cancellationToken);
|
||||||
|
if (application.IsMessageContentIntentEnabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
throw new DiscordChatExporterException(
|
||||||
|
"Provided bot account is missing the MESSAGE_CONTENT privileged intent.",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async ValueTask<User?> TryGetUserAsync(
|
public async ValueTask<User?> TryGetUserAsync(
|
||||||
Snowflake userId,
|
Snowflake userId,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
@@ -294,184 +322,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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,21 +388,229 @@ public class DiscordClient(string token)
|
|||||||
?.GetNonWhiteSpaceStringOrNull()
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
?.Pipe(Snowflake.Parse);
|
?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var parent = parentId is not null
|
|
||||||
? await GetChannelAsync(parentId.Value, cancellationToken)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return Channel.Parse(response, parent);
|
|
||||||
}
|
|
||||||
// It's possible for the parent channel to be inaccessible, despite the
|
// It's possible for the parent channel to be inaccessible, despite the
|
||||||
// child channel being accessible.
|
// child channel being accessible.
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
||||||
catch (DiscordChatExporterException)
|
var parent = parentId is not null
|
||||||
|
? await TryGetChannelAsync(parentId.Value, cancellationToken)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Channel.Parse(response, parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask<Channel?> TryGetChannelAsync(
|
||||||
|
Snowflake channelId,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var response = await TryGetJsonResponseAsync($"channels/{channelId}", cancellationToken);
|
||||||
|
if (response is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var parentId = response
|
||||||
|
.Value.GetPropertyOrNull("parent_id")
|
||||||
|
?.GetNonWhiteSpaceStringOrNull()
|
||||||
|
?.Pipe(Snowflake.Parse);
|
||||||
|
|
||||||
|
Channel? parent = null;
|
||||||
|
if (parentId is not null)
|
||||||
{
|
{
|
||||||
return Channel.Parse(response);
|
// It's possible for the parent channel to be inaccessible, despite the
|
||||||
|
// child channel being accessible.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
|
||||||
|
parent = await TryGetChannelAsync(parentId.Value, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Channel.Parse(response.Value, parent);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Track yielded thread IDs to avoid duplicates that can occur when a thread transitions
|
||||||
|
// from active to archived between the two separate API calls used to fetch threads.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1433
|
||||||
|
var seenThreadIds = new HashSet<Snowflake>();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenThreadIds.Add(thread.Id))
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
var thread = Channel.Parse(threadJson, parent);
|
||||||
|
|
||||||
|
if (seenThreadIds.Add(thread.Id))
|
||||||
|
yield return thread;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
currentBefore = threadJson
|
||||||
|
.GetProperty("thread_metadata")
|
||||||
|
.GetProperty("archive_timestamp")
|
||||||
|
.GetString();
|
||||||
|
|
||||||
|
if (seenThreadIds.Add(thread.Id))
|
||||||
|
yield return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.Value.GetProperty("has_more").GetBoolean())
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<Message?> TryGetFirstMessageAsync(
|
||||||
|
Snowflake channelId,
|
||||||
|
Snowflake? after = null,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var url = new UrlBuilder()
|
||||||
|
.SetPath($"channels/{channelId}/messages")
|
||||||
|
.SetQueryParameter("limit", "1")
|
||||||
|
.SetQueryParameter("after", (after ?? Snowflake.Zero).ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||||
|
var message = response.EnumerateArray().Select(Message.Parse).FirstOrDefault();
|
||||||
|
|
||||||
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ValueTask<Message?> TryGetLastMessageAsync(
|
private async ValueTask<Message?> TryGetLastMessageAsync(
|
||||||
@@ -600,22 +671,10 @@ public class DiscordClient(string token)
|
|||||||
yield break;
|
yield break;
|
||||||
|
|
||||||
// If all messages are empty, make sure that it's not because the bot account doesn't
|
// If all messages are empty, make sure that it's not because the bot account doesn't
|
||||||
// have the Message Content Intent enabled.
|
// have the MESSAGE_CONTENT intent enabled.
|
||||||
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||||
if (
|
if (messages.All(m => m.IsEmpty))
|
||||||
messages.All(m => m.IsEmpty)
|
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||||
&& await ResolveTokenKindAsync(cancellationToken) == TokenKind.Bot
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var application = await GetApplicationAsync(cancellationToken);
|
|
||||||
if (!application.IsMessageContentIntentEnabled)
|
|
||||||
{
|
|
||||||
throw new DiscordChatExporterException(
|
|
||||||
"Provided bot account does not have the Message Content Intent enabled.",
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var message in messages)
|
foreach (var message in messages)
|
||||||
{
|
{
|
||||||
@@ -648,6 +707,75 @@ public class DiscordClient(string token)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Message> GetMessagesInReverseAsync(
|
||||||
|
Snowflake channelId,
|
||||||
|
Snowflake? after = null,
|
||||||
|
Snowflake? before = null,
|
||||||
|
IProgress<Percentage>? progress = null,
|
||||||
|
[EnumeratorCancellation] CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
// Get the first message in the specified range, so we can later calculate the
|
||||||
|
// progress based on the difference between message timestamps.
|
||||||
|
// Snapshotting is not necessary here because new messages can't appear in the past.
|
||||||
|
var firstMessage = await TryGetFirstMessageAsync(channelId, after, cancellationToken);
|
||||||
|
if (firstMessage is null || firstMessage.Timestamp > before?.ToDate())
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
// Keep track of the last message in range in order to calculate the progress
|
||||||
|
var lastMessage = default(Message);
|
||||||
|
|
||||||
|
var currentBefore = before;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var url = new UrlBuilder()
|
||||||
|
.SetPath($"channels/{channelId}/messages")
|
||||||
|
.SetQueryParameter("limit", "100")
|
||||||
|
.SetQueryParameter("before", currentBefore?.ToString())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var response = await GetJsonResponseAsync(url, cancellationToken);
|
||||||
|
|
||||||
|
var messages = response.EnumerateArray().Select(Message.Parse).ToArray();
|
||||||
|
|
||||||
|
// Break if there are no messages (can happen if messages are deleted during execution)
|
||||||
|
if (!messages.Any())
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
// If all messages are empty, make sure that it's not because the bot account doesn't
|
||||||
|
// have the MESSAGE_CONTENT intent enabled.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
|
||||||
|
if (messages.All(m => m.IsEmpty))
|
||||||
|
await EnsureMessageContentIntentAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
lastMessage ??= message;
|
||||||
|
|
||||||
|
// Report progress based on timestamps
|
||||||
|
if (progress is not null)
|
||||||
|
{
|
||||||
|
var exportedDuration = (lastMessage.Timestamp - message.Timestamp).Duration();
|
||||||
|
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
|
||||||
|
|
||||||
|
progress.Report(
|
||||||
|
Percentage.FromFraction(
|
||||||
|
// Avoid division by zero if all messages have the exact same timestamp
|
||||||
|
// (which happens when there's only one message in the channel)
|
||||||
|
totalDuration > TimeSpan.Zero
|
||||||
|
? exportedDuration / totalDuration
|
||||||
|
: 1
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBefore = messages.Last().Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async IAsyncEnumerable<User> GetMessageReactionsAsync(
|
public async IAsyncEnumerable<User> GetMessageReactionsAsync(
|
||||||
Snowflake channelId,
|
Snowflake channelId,
|
||||||
Snowflake messageId,
|
Snowflake messageId,
|
||||||
@@ -688,9 +816,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.1.4" />
|
<PackageReference Include="AngleSharp" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
<PackageReference Include="AsyncKeyedLock" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
<PackageReference Include="JsonExtensions" Version="1.2.0" />
|
<PackageReference Include="Gress" />
|
||||||
<PackageReference Include="Polly" Version="8.5.2" />
|
<PackageReference Include="JsonExtensions" />
|
||||||
<PackageReference Include="RazorBlade" Version="0.8.0" />
|
<PackageReference Include="Polly" />
|
||||||
<PackageReference Include="Superpower" Version="3.0.0" />
|
<PackageReference Include="RazorBlade" />
|
||||||
<PackageReference Include="WebMarkupMin.Core" Version="2.17.0" />
|
<PackageReference Include="Superpower" />
|
||||||
<PackageReference Include="YoutubeExplode" Version="6.5.3" />
|
<PackageReference Include="WebMarkupMin.Core" />
|
||||||
|
<PackageReference Include="YoutubeExplode" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace DiscordChatExporter.Core.Exceptions;
|
||||||
|
|
||||||
|
public class ChannelEmptyException(string message) : DiscordChatExporterException(message);
|
||||||
@@ -27,54 +27,60 @@ 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);
|
||||||
await foreach (
|
|
||||||
var message in discord.GetMessagesAsync(
|
// 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."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages = !request.IsReverseMessageOrder
|
||||||
|
? discord.GetMessagesAsync(
|
||||||
request.Channel.Id,
|
request.Channel.Id,
|
||||||
request.After,
|
request.After,
|
||||||
request.Before,
|
request.Before,
|
||||||
progress,
|
progress,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
)
|
: discord.GetMessagesInReverseAsync(
|
||||||
|
request.Channel.Id,
|
||||||
|
request.After,
|
||||||
|
request.Before,
|
||||||
|
progress,
|
||||||
|
cancellationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
await foreach (var message in messages)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -98,15 +104,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."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,28 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
|||||||
if (reuse && File.Exists(filePath))
|
if (reuse && File.Exists(filePath))
|
||||||
return _previousPathsByUrl[url] = filePath;
|
return _previousPathsByUrl[url] = filePath;
|
||||||
|
|
||||||
|
// Check for a file cached by the legacy naming scheme (5-char hash) and rename it
|
||||||
|
// to the new naming scheme to preserve backwards compatibility with existing exports
|
||||||
|
if (reuse)
|
||||||
|
{
|
||||||
|
var legacyFilePath = Path.Combine(workingDirPath, GetLegacyFileNameFromUrl(url));
|
||||||
|
if (File.Exists(legacyFilePath))
|
||||||
|
{
|
||||||
|
// Overwrite in case the destination file was created concurrently between our
|
||||||
|
// earlier existence check and this move operation
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Move(legacyFilePath, filePath, overwrite: true);
|
||||||
|
return _previousPathsByUrl[url] = filePath;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// The legacy file was moved or deleted concurrently or something else happened.
|
||||||
|
// Upgrading old files is not crucial, so we can just move on.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(workingDirPath);
|
Directory.CreateDirectory(workingDirPath);
|
||||||
|
|
||||||
await Http.ResiliencePipeline.ExecuteAsync(
|
await Http.ResiliencePipeline.ExecuteAsync(
|
||||||
@@ -56,34 +78,23 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
|
|||||||
|
|
||||||
internal partial class ExportAssetDownloader
|
internal partial class ExportAssetDownloader
|
||||||
{
|
{
|
||||||
private static string GetUrlHash(string url)
|
private static string NormalizeUrl(string url)
|
||||||
{
|
{
|
||||||
// Remove signature parameters from Discord CDN URLs to normalize them
|
// Remove signature parameters from Discord CDN URLs to normalize them
|
||||||
static string NormalizeUrl(string url)
|
var uri = new Uri(url);
|
||||||
{
|
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
||||||
var uri = new Uri(url);
|
return url;
|
||||||
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
|
|
||||||
return url;
|
|
||||||
|
|
||||||
var query = HttpUtility.ParseQueryString(uri.Query);
|
var query = HttpUtility.ParseQueryString(uri.Query);
|
||||||
query.Remove("ex");
|
query.Remove("ex");
|
||||||
query.Remove("is");
|
query.Remove("is");
|
||||||
query.Remove("hm");
|
query.Remove("hm");
|
||||||
|
|
||||||
return uri.GetLeftPart(UriPartial.Path) + query;
|
return uri.GetLeftPart(UriPartial.Path) + query;
|
||||||
}
|
|
||||||
|
|
||||||
return SHA256
|
|
||||||
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
|
||||||
.ToHex()
|
|
||||||
// 5 chars ought to be enough for anybody
|
|
||||||
.Truncate(5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetFileNameFromUrl(string url)
|
private static string GetFileNameFromUrl(string url, string urlHash)
|
||||||
{
|
{
|
||||||
var urlHash = GetUrlHash(url);
|
|
||||||
|
|
||||||
// Try to extract the file name from URL
|
// Try to extract the file name from URL
|
||||||
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
|
||||||
|
|
||||||
@@ -103,8 +114,29 @@ internal partial class ExportAssetDownloader
|
|||||||
fileExtension = "";
|
fileExtension = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
return PathEx.EscapeFileName(
|
return Path.EscapeFileName(
|
||||||
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
|
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetFileNameFromUrl(string url) =>
|
||||||
|
GetFileNameFromUrl(
|
||||||
|
url,
|
||||||
|
// 16 chars = 64 bits, reaches 1% collision probability at ~609 million files
|
||||||
|
SHA256
|
||||||
|
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||||
|
.Pipe(Convert.ToHexStringLower)
|
||||||
|
.Truncate(16)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Legacy naming used a 5-char hash, kept for backwards compatibility with existing exports
|
||||||
|
private static string GetLegacyFileNameFromUrl(string url) =>
|
||||||
|
GetFileNameFromUrl(
|
||||||
|
url,
|
||||||
|
SHA256
|
||||||
|
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
|
||||||
|
.Pipe(Convert.ToHexStringLower)
|
||||||
|
// 5 chars = 20 bits, reaches 1% collision probability at ~145 files
|
||||||
|
.Truncate(5)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace DiscordChatExporter.Core.Exporting;
|
|||||||
internal class ExportContext(DiscordClient discord, ExportRequest request)
|
internal class ExportContext(DiscordClient discord, ExportRequest request)
|
||||||
{
|
{
|
||||||
private readonly Dictionary<Snowflake, Member?> _membersById = new();
|
private readonly Dictionary<Snowflake, Member?> _membersById = new();
|
||||||
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 = new(
|
private readonly ExportAssetDownloader _assetDownloader = new(
|
||||||
@@ -51,6 +51,21 @@ internal class ExportContext(DiscordClient discord, ExportRequest request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Threads are not preloaded, so we resolve them on demand
|
||||||
|
public async ValueTask PopulateChannelAsync(
|
||||||
|
Snowflake id,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (_channelsById.ContainsKey(id))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var channel = await Discord.TryGetChannelAsync(id, cancellationToken);
|
||||||
|
|
||||||
|
// Store the result even if it's null, to avoid re-fetching non-existing channels
|
||||||
|
_channelsById[id] = channel;
|
||||||
|
}
|
||||||
|
|
||||||
// Because members cannot be pulled in bulk, we need to populate them on demand
|
// Because members cannot be pulled in bulk, we need to populate them on demand
|
||||||
private async ValueTask PopulateMemberAsync(
|
private async ValueTask PopulateMemberAsync(
|
||||||
Snowflake id,
|
Snowflake id,
|
||||||
@@ -99,7 +114,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();
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -34,6 +33,8 @@ public partial class ExportRequest
|
|||||||
|
|
||||||
public MessageFilter MessageFilter { get; }
|
public MessageFilter MessageFilter { get; }
|
||||||
|
|
||||||
|
public bool IsReverseMessageOrder { get; }
|
||||||
|
|
||||||
public bool ShouldFormatMarkdown { get; }
|
public bool ShouldFormatMarkdown { get; }
|
||||||
|
|
||||||
public bool ShouldDownloadAssets { get; }
|
public bool ShouldDownloadAssets { get; }
|
||||||
@@ -56,6 +57,7 @@ public partial class ExportRequest
|
|||||||
Snowflake? before,
|
Snowflake? before,
|
||||||
PartitionLimit partitionLimit,
|
PartitionLimit partitionLimit,
|
||||||
MessageFilter messageFilter,
|
MessageFilter messageFilter,
|
||||||
|
bool isReverseMessageOrder,
|
||||||
bool shouldFormatMarkdown,
|
bool shouldFormatMarkdown,
|
||||||
bool shouldDownloadAssets,
|
bool shouldDownloadAssets,
|
||||||
bool shouldReuseAssets,
|
bool shouldReuseAssets,
|
||||||
@@ -70,6 +72,7 @@ public partial class ExportRequest
|
|||||||
Before = before;
|
Before = before;
|
||||||
PartitionLimit = partitionLimit;
|
PartitionLimit = partitionLimit;
|
||||||
MessageFilter = messageFilter;
|
MessageFilter = messageFilter;
|
||||||
|
IsReverseMessageOrder = isReverseMessageOrder;
|
||||||
ShouldFormatMarkdown = shouldFormatMarkdown;
|
ShouldFormatMarkdown = shouldFormatMarkdown;
|
||||||
ShouldDownloadAssets = shouldDownloadAssets;
|
ShouldDownloadAssets = shouldDownloadAssets;
|
||||||
ShouldReuseAssets = shouldReuseAssets;
|
ShouldReuseAssets = shouldReuseAssets;
|
||||||
@@ -145,7 +148,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 +162,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 +182,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
|
||||||
|
|||||||
@@ -270,6 +270,12 @@ internal partial class HtmlMarkdownVisitor(
|
|||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
else if (mention.Kind == MentionKind.Channel)
|
||||||
{
|
{
|
||||||
|
// Channel/thread mentions may reference threads that are not preloaded,
|
||||||
|
// so we resolve them on demand.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1261
|
||||||
|
if (mention.TargetId is not null)
|
||||||
|
await context.PopulateChannelAsync(mention.TargetId.Value, cancellationToken);
|
||||||
|
|
||||||
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
||||||
var symbol = channel?.IsVoice == true ? "🔊" : "#";
|
var symbol = channel?.IsVoice == true ? "🔊" : "#";
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|||||||
@@ -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,6 +17,7 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
|
|||||||
private readonly HtmlMinifier _minifier = new();
|
private readonly HtmlMinifier _minifier = new();
|
||||||
private readonly List<Message> _messageGroup = [];
|
private readonly List<Message> _messageGroup = [];
|
||||||
|
|
||||||
|
// Note: in reverse order, last message appears earlier than the first message
|
||||||
private bool CanJoinGroup(Message message)
|
private bool CanJoinGroup(Message message)
|
||||||
{
|
{
|
||||||
// If the group is empty, any message can join it
|
// If the group is empty, any message can join it
|
||||||
|
|||||||
@@ -119,6 +119,24 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteAttachmentAsync(
|
||||||
|
Attachment attachment,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("id", attachment.Id.ToString());
|
||||||
|
_writer.WriteString(
|
||||||
|
"url",
|
||||||
|
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
|
||||||
|
);
|
||||||
|
_writer.WriteString("fileName", attachment.FileName);
|
||||||
|
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
private async ValueTask WriteEmbedAuthorAsync(
|
private async ValueTask WriteEmbedAuthorAsync(
|
||||||
EmbedAuthor embedAuthor,
|
EmbedAuthor embedAuthor,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
@@ -138,6 +156,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("iconCanonicalUrl", embedAuthor.IconUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
@@ -160,6 +180,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);
|
||||||
@@ -185,6 +207,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);
|
||||||
@@ -212,6 +236,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
cancellationToken
|
cancellationToken
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_writer.WriteString("iconCanonicalUrl", embedFooter.IconUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
@@ -327,6 +353,24 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
await _writer.FlushAsync(cancellationToken);
|
await _writer.FlushAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteStickerAsync(
|
||||||
|
Sticker sticker,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject();
|
||||||
|
|
||||||
|
_writer.WriteString("id", sticker.Id.ToString());
|
||||||
|
_writer.WriteString("name", sticker.Name);
|
||||||
|
_writer.WriteString("format", sticker.Format.ToString());
|
||||||
|
_writer.WriteString(
|
||||||
|
"sourceUrl",
|
||||||
|
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
|
||||||
|
);
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
public override async ValueTask WritePreambleAsync(
|
public override async ValueTask WritePreambleAsync(
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
)
|
)
|
||||||
@@ -429,19 +473,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
_writer.WriteStartArray("attachments");
|
_writer.WriteStartArray("attachments");
|
||||||
|
|
||||||
foreach (var attachment in message.Attachments)
|
foreach (var attachment in message.Attachments)
|
||||||
{
|
await WriteAttachmentAsync(attachment, cancellationToken);
|
||||||
_writer.WriteStartObject();
|
|
||||||
|
|
||||||
_writer.WriteString("id", attachment.Id.ToString());
|
|
||||||
_writer.WriteString(
|
|
||||||
"url",
|
|
||||||
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
|
|
||||||
);
|
|
||||||
_writer.WriteString("fileName", attachment.FileName);
|
|
||||||
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
@@ -457,19 +489,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
_writer.WriteStartArray("stickers");
|
_writer.WriteStartArray("stickers");
|
||||||
|
|
||||||
foreach (var sticker in message.Stickers)
|
foreach (var sticker in message.Stickers)
|
||||||
{
|
await WriteStickerAsync(sticker, cancellationToken);
|
||||||
_writer.WriteStartObject();
|
|
||||||
|
|
||||||
_writer.WriteString("id", sticker.Id.ToString());
|
|
||||||
_writer.WriteString("name", sticker.Name);
|
|
||||||
_writer.WriteString("format", sticker.Format.ToString());
|
|
||||||
_writer.WriteString(
|
|
||||||
"sourceUrl",
|
|
||||||
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
|
|
||||||
);
|
|
||||||
|
|
||||||
_writer.WriteEndObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
_writer.WriteEndArray();
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
@@ -519,12 +539,58 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
|
|||||||
if (message.Reference is not null)
|
if (message.Reference is not null)
|
||||||
{
|
{
|
||||||
_writer.WriteStartObject("reference");
|
_writer.WriteStartObject("reference");
|
||||||
|
_writer.WriteString("type", message.Reference.Kind.ToString());
|
||||||
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
|
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
|
||||||
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
|
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
|
||||||
_writer.WriteString("guildId", message.Reference.GuildId?.ToString());
|
_writer.WriteString("guildId", message.Reference.GuildId?.ToString());
|
||||||
_writer.WriteEndObject();
|
_writer.WriteEndObject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forwarded message
|
||||||
|
if (message.ForwardedMessage is not null)
|
||||||
|
{
|
||||||
|
_writer.WriteStartObject("forwardedMessage");
|
||||||
|
|
||||||
|
_writer.WriteString(
|
||||||
|
"timestamp",
|
||||||
|
Context.NormalizeDate(message.ForwardedMessage.Timestamp)
|
||||||
|
);
|
||||||
|
|
||||||
|
_writer.WriteString(
|
||||||
|
"timestampEdited",
|
||||||
|
message.ForwardedMessage.EditedTimestamp?.Pipe(Context.NormalizeDate)
|
||||||
|
);
|
||||||
|
|
||||||
|
_writer.WriteString(
|
||||||
|
"content",
|
||||||
|
await FormatMarkdownAsync(message.ForwardedMessage.Content, cancellationToken)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Forwarded attachments
|
||||||
|
_writer.WriteStartArray("attachments");
|
||||||
|
|
||||||
|
foreach (var attachment in message.ForwardedMessage.Attachments)
|
||||||
|
await WriteAttachmentAsync(attachment, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Forwarded embeds
|
||||||
|
_writer.WriteStartArray("embeds");
|
||||||
|
foreach (var embed in message.ForwardedMessage.Embeds)
|
||||||
|
await WriteEmbedAsync(embed, cancellationToken);
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
// Forwarded stickers
|
||||||
|
_writer.WriteStartArray("stickers");
|
||||||
|
|
||||||
|
foreach (var sticker in message.ForwardedMessage.Stickers)
|
||||||
|
await WriteStickerAsync(sticker, cancellationToken);
|
||||||
|
|
||||||
|
_writer.WriteEndArray();
|
||||||
|
|
||||||
|
_writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
// Interaction
|
// Interaction
|
||||||
if (message.Interaction is not null)
|
if (message.Interaction is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="chatlog__message-group">
|
<div class="chatlog__message-group">
|
||||||
@foreach (var (message, i) in Messages.WithIndex())
|
@foreach (var (i, message) in Messages.Index())
|
||||||
{
|
{
|
||||||
var isFirst = i == 0;
|
var isFirst = i == 0;
|
||||||
|
|
||||||
@@ -262,6 +262,95 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* Forwarded message *@
|
||||||
|
@if (message is { IsForwarded: true, ForwardedMessage: not null })
|
||||||
|
{
|
||||||
|
<div class="chatlog__forwarded">
|
||||||
|
<div class="chatlog__forwarded-header">
|
||||||
|
<svg class="chatlog__forwarded-icon">
|
||||||
|
<use href="#forward-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<em>Forwarded</em>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Forwarded content *@
|
||||||
|
@if (!string.IsNullOrWhiteSpace(message.ForwardedMessage.Content))
|
||||||
|
{
|
||||||
|
<div class="chatlog__forwarded-content chatlog__markdown">
|
||||||
|
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.ForwardedMessage.Content))<!--/wmm:ignore--></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Forwarded attachments *@
|
||||||
|
@if (message.ForwardedMessage.Attachments.Any())
|
||||||
|
{
|
||||||
|
<div class="chatlog__forwarded-attachments">
|
||||||
|
@foreach (var attachment in message.ForwardedMessage.Attachments)
|
||||||
|
{
|
||||||
|
@if (attachment.IsImage)
|
||||||
|
{
|
||||||
|
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||||
|
<img class="chatlog__forwarded-attachment" src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else if (attachment.IsVideo)
|
||||||
|
{
|
||||||
|
<video class="chatlog__forwarded-attachment" controls>
|
||||||
|
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
|
||||||
|
</video>
|
||||||
|
}
|
||||||
|
else if (attachment.IsAudio)
|
||||||
|
{
|
||||||
|
<audio class="chatlog__forwarded-attachment" controls>
|
||||||
|
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
|
||||||
|
</audio>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="chatlog__attachment-generic">
|
||||||
|
<svg class="chatlog__attachment-generic-icon">
|
||||||
|
<use href="#attachment-icon"/>
|
||||||
|
</svg>
|
||||||
|
<div class="chatlog__attachment-generic-name">
|
||||||
|
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
|
||||||
|
@attachment.FileName
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="chatlog__attachment-generic-size">
|
||||||
|
@attachment.FileSize
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Forwarded stickers *@
|
||||||
|
@foreach (var sticker in message.ForwardedMessage.Stickers)
|
||||||
|
{
|
||||||
|
<div class="chatlog__sticker" title="@sticker.Name">
|
||||||
|
@if (sticker.IsImage)
|
||||||
|
{
|
||||||
|
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
|
||||||
|
}
|
||||||
|
else if (sticker.Format == StickerFormat.Lottie)
|
||||||
|
{
|
||||||
|
<div class="chatlog__sticker--media" data-source="@await ResolveAssetUrlAsync(sticker.SourceUrl)"></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* Forwarded timestamp *@
|
||||||
|
<div class="chatlog__forwarded-timestamp">
|
||||||
|
<span title="@FormatDate(message.ForwardedMessage.Timestamp, "f")">Originally sent: @FormatDate(message.ForwardedMessage.Timestamp)</span>
|
||||||
|
@if (message.ForwardedMessage.EditedTimestamp is not null)
|
||||||
|
{
|
||||||
|
<span title="@FormatDate(message.ForwardedMessage.EditedTimestamp.Value, "f")"> (edited)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* Attachments *@
|
@* Attachments *@
|
||||||
@foreach (var attachment in message.Attachments)
|
@foreach (var attachment in message.Attachments)
|
||||||
{
|
{
|
||||||
@@ -388,7 +477,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 +513,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 +532,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 +551,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 +566,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 +597,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 +672,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 +688,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 +703,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">
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ internal partial class PlainTextMarkdownVisitor(ExportContext context, StringBui
|
|||||||
}
|
}
|
||||||
else if (mention.Kind == MentionKind.Channel)
|
else if (mention.Kind == MentionKind.Channel)
|
||||||
{
|
{
|
||||||
|
// Channel/thread mentions may reference threads that are not preloaded,
|
||||||
|
// so we resolve them on demand.
|
||||||
|
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1261
|
||||||
|
if (mention.TargetId is not null)
|
||||||
|
await context.PopulateChannelAsync(mention.TargetId.Value, cancellationToken);
|
||||||
|
|
||||||
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
var channel = mention.TargetId?.Pipe(context.TryGetChannel);
|
||||||
var name = channel?.Name ?? "deleted-channel";
|
var name = channel?.Name ?? "deleted-channel";
|
||||||
|
|
||||||
|
|||||||
@@ -7,37 +7,38 @@ 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"} minutes.",
|
||||||
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
|
|
||||||
} 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,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -173,7 +174,7 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
|||||||
|
|
||||||
await _writer.WriteLineAsync("{Reactions}");
|
await _writer.WriteLineAsync("{Reactions}");
|
||||||
|
|
||||||
foreach (var (reaction, i) in reactions.WithIndex())
|
foreach (var (i, reaction) in reactions.Index())
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -224,6 +225,31 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
|||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async ValueTask WriteForwardedMessageAsync(
|
||||||
|
MessageSnapshot forwardedMessage,
|
||||||
|
CancellationToken cancellationToken = default
|
||||||
|
)
|
||||||
|
{
|
||||||
|
await _writer.WriteLineAsync("{Forwarded Message}");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(forwardedMessage.Content))
|
||||||
|
{
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
await FormatMarkdownAsync(forwardedMessage.Content, cancellationToken)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync(
|
||||||
|
$"Originally sent: {Context.FormatDate(forwardedMessage.Timestamp)}"
|
||||||
|
);
|
||||||
|
|
||||||
|
await WriteAttachmentsAsync(forwardedMessage.Attachments, cancellationToken);
|
||||||
|
await WriteEmbedsAsync(forwardedMessage.Embeds, cancellationToken);
|
||||||
|
await WriteStickersAsync(forwardedMessage.Stickers, cancellationToken);
|
||||||
|
|
||||||
|
await _writer.WriteLineAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public override async ValueTask WriteMessageAsync(
|
public override async ValueTask WriteMessageAsync(
|
||||||
Message message,
|
Message message,
|
||||||
CancellationToken cancellationToken = default
|
CancellationToken cancellationToken = default
|
||||||
@@ -248,6 +274,12 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
|
|||||||
|
|
||||||
await _writer.WriteLineAsync();
|
await _writer.WriteLineAsync();
|
||||||
|
|
||||||
|
// Forwarded message content
|
||||||
|
if (message.ForwardedMessage is not null)
|
||||||
|
{
|
||||||
|
await WriteForwardedMessageAsync(message.ForwardedMessage, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
// Attachments, embeds, reactions, etc.
|
// Attachments, embeds, reactions, etc.
|
||||||
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
|
||||||
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
await WriteEmbedsAsync(message.Embeds, cancellationToken);
|
||||||
|
|||||||
@@ -304,6 +304,52 @@
|
|||||||
unicode-bidi: bidi-override;
|
unicode-bidi: bidi-override;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-left: 4px solid @Themed("#4f545c", "#c7ccd1");
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
color: @Themed("#b5b6b8", "#5f5f60");
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-content {
|
||||||
|
color: @Themed("#dcddde", "#2e3338");
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-attachments {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-attachment {
|
||||||
|
max-width: 300px;
|
||||||
|
max-height: 200px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chatlog__forwarded-timestamp {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: @Themed("#a3a6aa", "#5e6772");
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.chatlog__system-notification-icon {
|
.chatlog__system-notification-icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
@@ -711,9 +757,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 {
|
||||||
@@ -997,6 +1045,9 @@
|
|||||||
<path fill="#b9bbbe" d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.40991 9H3.55274C3.47819 9 3.42168 8.93274 3.43453 8.85931L3.74253 7.09931C3.75258 7.04189 3.80244 7 3.86074 7H7.75991L8.45234 3.09903C8.46251 3.04174 8.51231 3 8.57049 3H10.3267C10.4014 3 10.4579 3.06746 10.4449 3.14097L9.75991 7H15.7599L16.4523 3.09903C16.4625 3.04174 16.5123 3 16.5705 3H18.3267C18.4014 3 18.4579 3.06746 18.4449 3.14097L17.7599 7H21.6171C21.6916 7 21.7481 7.06725 21.7353 7.14069L21.4273 8.90069C21.4172 8.95811 21.3674 9 21.3091 9H17.4099L17.0495 11.04H15.05L15.4104 9H9.41035L8.35035 15H10.5599V17H7.99991L7.30749 20.901C7.29732 20.9583 7.24752 21 7.18934 21H5.43309Z" />
|
<path fill="#b9bbbe" d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.40991 9H3.55274C3.47819 9 3.42168 8.93274 3.43453 8.85931L3.74253 7.09931C3.75258 7.04189 3.80244 7 3.86074 7H7.75991L8.45234 3.09903C8.46251 3.04174 8.51231 3 8.57049 3H10.3267C10.4014 3 10.4579 3.06746 10.4449 3.14097L9.75991 7H15.7599L16.4523 3.09903C16.4625 3.04174 16.5123 3 16.5705 3H18.3267C18.4014 3 18.4579 3.06746 18.4449 3.14097L17.7599 7H21.6171C21.6916 7 21.7481 7.06725 21.7353 7.14069L21.4273 8.90069C21.4172 8.95811 21.3674 9 21.3091 9H17.4099L17.0495 11.04H15.05L15.4104 9H9.41035L8.35035 15H10.5599V17H7.99991L7.30749 20.901C7.29732 20.9583 7.24752 21 7.18934 21H5.43309Z" />
|
||||||
<path fill="#b9bbbe" d="M13.4399 12.96C12.9097 12.96 12.4799 13.3898 12.4799 13.92V20.2213C12.4799 20.7515 12.9097 21.1813 13.4399 21.1813H14.3999C14.5325 21.1813 14.6399 21.2887 14.6399 21.4213V23.4597C14.6399 23.6677 14.8865 23.7773 15.0408 23.6378L17.4858 21.4289C17.6622 21.2695 17.8916 21.1813 18.1294 21.1813H22.5599C23.0901 21.1813 23.5199 20.7515 23.5199 20.2213V13.92C23.5199 13.3898 23.0901 12.96 22.5599 12.96H13.4399Z" />
|
<path fill="#b9bbbe" d="M13.4399 12.96C12.9097 12.96 12.4799 13.3898 12.4799 13.92V20.2213C12.4799 20.7515 12.9097 21.1813 13.4399 21.1813H14.3999C14.5325 21.1813 14.6399 21.2887 14.6399 21.4213V23.4597C14.6399 23.6677 14.8865 23.7773 15.0408 23.6378L17.4858 21.4289C17.6622 21.2695 17.8916 21.1813 18.1294 21.1813H22.5599C23.0901 21.1813 23.5199 20.7515 23.5199 20.2213V13.92C23.5199 13.3898 23.0901 12.96 22.5599 12.96H13.4399Z" />
|
||||||
</symbol>
|
</symbol>
|
||||||
|
<symbol id="forward-icon" viewBox="0 0 24 24">
|
||||||
|
<path fill="#b9bbbe" d="M13 4L21 12L13 20V15C8 15 4 17 1 22C2 16 6 10 13 9V4Z" />
|
||||||
|
</symbol>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ internal static class MatcherExtensions
|
|||||||
this IMatcher<TContext, TValue> matcher,
|
this IMatcher<TContext, TValue> matcher,
|
||||||
TContext context,
|
TContext context,
|
||||||
StringSegment segment,
|
StringSegment segment,
|
||||||
Func<TContext, StringSegment, TValue> transformFallback
|
Func<TContext, StringSegment, TValue> fallbackTransform
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Loop through segments divided by individual matches
|
// Loop through segments divided by individual matches
|
||||||
@@ -40,7 +40,7 @@ internal static class MatcherExtensions
|
|||||||
|
|
||||||
yield return new ParsedMatch<TValue>(
|
yield return new ParsedMatch<TValue>(
|
||||||
fallbackSegment,
|
fallbackSegment,
|
||||||
transformFallback(context, fallbackSegment)
|
fallbackTransform(context, fallbackSegment)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ internal static class MatcherExtensions
|
|||||||
|
|
||||||
yield return new ParsedMatch<TValue>(
|
yield return new ParsedMatch<TValue>(
|
||||||
fallbackSegment,
|
fallbackSegment,
|
||||||
transformFallback(context, fallbackSegment)
|
fallbackTransform(context, fallbackSegment)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,53 +208,18 @@ internal static partial class MarkdownParser
|
|||||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
|
||||||
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
new RegexMatcher<MarkdownContext, MarkdownNode>(
|
||||||
new Regex(
|
new Regex(
|
||||||
@"("
|
// Build a pattern from all known emoji, sorted longest-first so that compound
|
||||||
+
|
// emoji (e.g. sequences with ZWJ or skin-tone modifiers) are matched before
|
||||||
// Country flag emoji (two regional indicator surrogate pairs)
|
// their individual components.
|
||||||
@"(?:\uD83C[\uDDE6-\uDDFF]){2}|"
|
"("
|
||||||
+
|
+ string.Join(
|
||||||
// Digit emoji (digit followed by enclosing mark)
|
"|",
|
||||||
@"\d\p{Me}|"
|
EmojiIndex
|
||||||
+
|
.GetAllNames()
|
||||||
// Surrogate pair
|
.OrderByDescending(e => e.Length)
|
||||||
@"\p{Cs}{2}|"
|
.Select(Regex.Escape)
|
||||||
+
|
)
|
||||||
// Miscellaneous characters
|
+ ")",
|
||||||
@"["
|
|
||||||
+ @"\u2600-\u2604"
|
|
||||||
+ @"\u260E\u2611"
|
|
||||||
+ @"\u2614-\u2615"
|
|
||||||
+ @"\u2618\u261D\u2620"
|
|
||||||
+ @"\u2622-\u2623"
|
|
||||||
+ @"\u2626\u262A"
|
|
||||||
+ @"\u262E-\u262F"
|
|
||||||
+ @"\u2638-\u263A"
|
|
||||||
+ @"\u2640\u2642"
|
|
||||||
+ @"\u2648-\u2653"
|
|
||||||
+ @"\u265F-\u2660"
|
|
||||||
+ @"\u2663"
|
|
||||||
+ @"\u2665-\u2666"
|
|
||||||
+ @"\u2668\u267B"
|
|
||||||
+ @"\u267E-\u267F"
|
|
||||||
+ @"\u2692-\u2697"
|
|
||||||
+ @"\u2699"
|
|
||||||
+ @"\u269B-\u269C"
|
|
||||||
+ @"\u26A0-\u26A1"
|
|
||||||
+ @"\u26A7"
|
|
||||||
+ @"\u26AA-\u26AB"
|
|
||||||
+ @"\u26B0-\u26B1"
|
|
||||||
+ @"\u26BD-\u26BE"
|
|
||||||
+ @"\u26C4-\u26C5"
|
|
||||||
+ @"\u26C8"
|
|
||||||
+ @"\u26CE-\u26CF"
|
|
||||||
+ @"\u26D1"
|
|
||||||
+ @"\u26D3-\u26D4"
|
|
||||||
+ @"\u26E9-\u26EA"
|
|
||||||
+ @"\u26F0-\u26F5"
|
|
||||||
+ @"\u26F7-\u26FA"
|
|
||||||
+ @"\u26FD"
|
|
||||||
+ @"]"
|
|
||||||
+ @")",
|
|
||||||
DefaultRegexOptions
|
DefaultRegexOptions
|
||||||
),
|
),
|
||||||
(_, _, m) => new EmojiNode(m.Groups[1].Value)
|
(_, _, m) => new EmojiNode(m.Groups[1].Value)
|
||||||
@@ -310,7 +275,7 @@ internal static partial class MarkdownParser
|
|||||||
// Capture the shrug kaomoji.
|
// Capture the shrug kaomoji.
|
||||||
// This escapes it from matching for formatting.
|
// This escapes it from matching for formatting.
|
||||||
@"¯\_(ツ)_/¯",
|
@"¯\_(ツ)_/¯",
|
||||||
(s, _) => new TextNode(s.ToString())
|
(_, s) => new TextNode(s.ToString())
|
||||||
);
|
);
|
||||||
|
|
||||||
private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
|
private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ internal readonly record struct StringSegment(string Source, int StartIndex, int
|
|||||||
|
|
||||||
public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length);
|
public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length);
|
||||||
|
|
||||||
public override string ToString() => Source.Substring(StartIndex, Length);
|
public override string ToString() => Source[StartIndex..EndIndex];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,37 @@ 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;
|
|
||||||
foreach (var o in source)
|
|
||||||
yield return (o, i++);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IEnumerable<T> WhereNotNull<T>(this 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension<T>(IEnumerable<T?> source)
|
||||||
|
where T : struct
|
||||||
|
{
|
||||||
|
public IEnumerable<T> WhereNotNull()
|
||||||
|
{
|
||||||
|
foreach (var o in source)
|
||||||
|
{
|
||||||
|
if (o is not null)
|
||||||
|
yield return o.Value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Utils;
|
namespace DiscordChatExporter.Core.Utils;
|
||||||
@@ -10,6 +11,19 @@ public static class Url
|
|||||||
var buffer = new StringBuilder();
|
var buffer = new StringBuilder();
|
||||||
var position = 0;
|
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)
|
while (true)
|
||||||
{
|
{
|
||||||
if (position >= filePath.Length)
|
if (position >= filePath.Length)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using DiscordChatExporter.Core.Utils.Extensions;
|
||||||
|
|
||||||
namespace DiscordChatExporter.Core.Utils;
|
namespace DiscordChatExporter.Core.Utils;
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ public class UrlBuilder
|
|||||||
if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value))
|
if (ignoreUnsetValue && string.IsNullOrWhiteSpace(value))
|
||||||
return this;
|
return this;
|
||||||
|
|
||||||
var keyEncoded = WebUtility.UrlEncode(key);
|
var keyEncoded = Uri.EscapeDataString(key);
|
||||||
var valueEncoded = WebUtility.UrlEncode(value);
|
var valueEncoded = value?.Pipe(Uri.EscapeDataString);
|
||||||
_queryParameters[keyEncoded] = valueEncoded;
|
_queryParameters[keyEncoded] = valueEncoded;
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
@@ -39,9 +39,11 @@ public class UrlBuilder
|
|||||||
buffer.Append(_path);
|
buffer.Append(_path);
|
||||||
|
|
||||||
if (_queryParameters.Any())
|
if (_queryParameters.Any())
|
||||||
|
{
|
||||||
buffer
|
buffer
|
||||||
.Append('?')
|
.Append('?')
|
||||||
.AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
.AppendJoin('&', _queryParameters.Select(kvp => $"{kvp.Key}={kvp.Value}"));
|
||||||
|
}
|
||||||
|
|
||||||
return buffer.ToString();
|
return buffer.ToString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
@@ -115,6 +116,11 @@
|
|||||||
</Style>
|
</Style>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- Run -->
|
||||||
|
<Style Selector="Run">
|
||||||
|
<Setter Property="BaselineAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Text box -->
|
<!-- Text box -->
|
||||||
<Style Selector="TextBox">
|
<Style Selector="TextBox">
|
||||||
<Setter Property="FontSize" Value="14" />
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Avalonia.Markup.Xaml;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform;
|
using Avalonia.Platform;
|
||||||
using DiscordChatExporter.Gui.Framework;
|
using DiscordChatExporter.Gui.Framework;
|
||||||
|
using DiscordChatExporter.Gui.Localization;
|
||||||
using DiscordChatExporter.Gui.Services;
|
using DiscordChatExporter.Gui.Services;
|
||||||
using DiscordChatExporter.Gui.Utils;
|
using DiscordChatExporter.Gui.Utils;
|
||||||
using DiscordChatExporter.Gui.Utils.Extensions;
|
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||||
@@ -39,6 +40,9 @@ public class App : Application, IDisposable
|
|||||||
services.AddSingleton<SettingsService>();
|
services.AddSingleton<SettingsService>();
|
||||||
services.AddSingleton<UpdateService>();
|
services.AddSingleton<UpdateService>();
|
||||||
|
|
||||||
|
// Localization
|
||||||
|
services.AddSingleton<LocalizationManager>();
|
||||||
|
|
||||||
// View models
|
// View models
|
||||||
services.AddTransient<MainViewModel>();
|
services.AddTransient<MainViewModel>();
|
||||||
services.AddTransient<DashboardViewModel>();
|
services.AddTransient<DashboardViewModel>();
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Controls.Documents;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||||
|
using DiscordChatExporter.Gui.Views.Controls;
|
||||||
|
using Markdig;
|
||||||
|
using Markdig.Syntax;
|
||||||
|
using Markdig.Syntax.Inlines;
|
||||||
|
using MarkdownInline = Markdig.Syntax.Inlines.Inline;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Converters;
|
||||||
|
|
||||||
|
public class MarkdownToInlinesConverter : IValueConverter
|
||||||
|
{
|
||||||
|
public static readonly MarkdownToInlinesConverter Instance = new();
|
||||||
|
|
||||||
|
private static readonly MarkdownPipeline MarkdownPipeline = new MarkdownPipelineBuilder()
|
||||||
|
.UseEmphasisExtras()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private static void ProcessInline(
|
||||||
|
InlineCollection inlines,
|
||||||
|
MarkdownInline markdownInline,
|
||||||
|
FontWeight? fontWeight = null,
|
||||||
|
FontStyle? fontStyle = null,
|
||||||
|
TextDecorationCollection? textDecorations = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
switch (markdownInline)
|
||||||
|
{
|
||||||
|
case LiteralInline literal:
|
||||||
|
{
|
||||||
|
var run = new Run(literal.Content.ToString());
|
||||||
|
|
||||||
|
if (fontWeight is not null)
|
||||||
|
run.FontWeight = fontWeight.Value;
|
||||||
|
if (fontStyle is not null)
|
||||||
|
run.FontStyle = fontStyle.Value;
|
||||||
|
if (textDecorations is not null)
|
||||||
|
run.TextDecorations = textDecorations;
|
||||||
|
|
||||||
|
inlines.Add(run);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case LineBreakInline:
|
||||||
|
{
|
||||||
|
inlines.Add(new LineBreak());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case EmphasisInline emphasis:
|
||||||
|
{
|
||||||
|
var newWeight = fontWeight;
|
||||||
|
var newStyle = fontStyle;
|
||||||
|
var newDecorations = textDecorations;
|
||||||
|
|
||||||
|
switch (emphasis.DelimiterChar)
|
||||||
|
{
|
||||||
|
case '*' or '_' when emphasis.DelimiterCount == 2:
|
||||||
|
newWeight = FontWeight.SemiBold;
|
||||||
|
break;
|
||||||
|
case '*' or '_':
|
||||||
|
newStyle = FontStyle.Italic;
|
||||||
|
break;
|
||||||
|
case '~':
|
||||||
|
newDecorations = TextDecorations.Strikethrough;
|
||||||
|
break;
|
||||||
|
case '+':
|
||||||
|
newDecorations = TextDecorations.Underline;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var child in emphasis)
|
||||||
|
ProcessInline(inlines, child, newWeight, newStyle, newDecorations);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case LinkInline link:
|
||||||
|
{
|
||||||
|
inlines.Add(new HyperLink { Text = link.GetInnerText(), Url = link.Url });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case ContainerInline container:
|
||||||
|
{
|
||||||
|
foreach (var child in container)
|
||||||
|
ProcessInline(inlines, child, fontWeight, fontStyle, textDecorations);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
{
|
||||||
|
var inlines = new InlineCollection();
|
||||||
|
if (value is not string { Length: > 0 } text)
|
||||||
|
return inlines;
|
||||||
|
|
||||||
|
var isFirst = true;
|
||||||
|
|
||||||
|
foreach (var block in Markdown.Parse(text, MarkdownPipeline))
|
||||||
|
{
|
||||||
|
switch (block)
|
||||||
|
{
|
||||||
|
case ParagraphBlock { Inline: not null } paragraph:
|
||||||
|
{
|
||||||
|
if (!isFirst)
|
||||||
|
{
|
||||||
|
// Insert a blank line between paragraphs
|
||||||
|
inlines.Add(new LineBreak());
|
||||||
|
inlines.Add(new LineBreak());
|
||||||
|
}
|
||||||
|
|
||||||
|
isFirst = false;
|
||||||
|
|
||||||
|
foreach (var markdownInline in paragraph.Inline!)
|
||||||
|
ProcessInline(inlines, markdownInline);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case ListBlock list:
|
||||||
|
{
|
||||||
|
var itemOrder = 1;
|
||||||
|
if (list.IsOrdered && int.TryParse(list.OrderedStart, out var startNum))
|
||||||
|
itemOrder = startNum;
|
||||||
|
|
||||||
|
foreach (var listItem in list.OfType<ListItemBlock>())
|
||||||
|
{
|
||||||
|
if (!isFirst)
|
||||||
|
inlines.Add(new LineBreak());
|
||||||
|
isFirst = false;
|
||||||
|
|
||||||
|
var prefix = list.IsOrdered ? $"{itemOrder++}. " : $"{list.BulletType} ";
|
||||||
|
|
||||||
|
inlines.Add(new Run(prefix));
|
||||||
|
|
||||||
|
foreach (var subBlock in listItem.OfType<ParagraphBlock>())
|
||||||
|
{
|
||||||
|
if (subBlock is { Inline: not null })
|
||||||
|
{
|
||||||
|
foreach (var markdownInline in subBlock.Inline)
|
||||||
|
ProcessInline(inlines, markdownInline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inlines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ConvertBack(
|
||||||
|
object? value,
|
||||||
|
Type targetType,
|
||||||
|
object? parameter,
|
||||||
|
CultureInfo culture
|
||||||
|
) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -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,56 @@
|
|||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<EncryptionSalt>HimalayanPinkSalt</EncryptionSalt>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Expose this property in code -->
|
||||||
|
<ProjectProperty Include="EncryptionSalt" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<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" />
|
||||||
<PackageReference Include="Avalonia" Version="11.2.5" />
|
<PackageReference Include="Avalonia" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
|
<PackageReference Include="Avalonia.Desktop" />
|
||||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.5" Condition="'$(Configuration)' == 'Debug'" />
|
<PackageReference Include="Avalonia.Diagnostics" Condition="'$(Configuration)' == 'Debug'" />
|
||||||
<PackageReference Include="Cogwheel" Version="2.1.0" />
|
<PackageReference Include="Cogwheel" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||||
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" />
|
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
|
||||||
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
|
<PackageReference Include="Deorcify" PrivateAssets="all" />
|
||||||
<PackageReference Include="DialogHost.Avalonia" Version="0.9.2" />
|
<PackageReference Include="DialogHost.Avalonia" />
|
||||||
<PackageReference Include="Gress" Version="2.1.1" />
|
<PackageReference Include="Gress" />
|
||||||
<PackageReference Include="Material.Avalonia" Version="3.9.2" />
|
<PackageReference Include="Markdig" />
|
||||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.2.0" />
|
<PackageReference Include="Material.Avalonia" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
|
<PackageReference Include="Material.Icons.Avalonia" />
|
||||||
<PackageReference Include="Onova" Version="2.6.12" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
<PackageReference Include="Onova" />
|
||||||
|
<PackageReference Include="ThisAssembly.Project" PrivateAssets="all" />
|
||||||
</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>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AsyncKeyedLock;
|
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using DialogHostAvalonia;
|
using DialogHostAvalonia;
|
||||||
@@ -13,11 +13,12 @@ namespace DiscordChatExporter.Gui.Framework;
|
|||||||
|
|
||||||
public class DialogManager : IDisposable
|
public class DialogManager : IDisposable
|
||||||
{
|
{
|
||||||
private readonly AsyncNonKeyedLocker _dialogLock = new();
|
private readonly SemaphoreSlim _dialogLock = new(1, 1);
|
||||||
|
|
||||||
public async Task<T?> ShowDialogAsync<T>(DialogViewModelBase<T> dialog)
|
public async Task<T?> ShowDialogAsync<T>(DialogViewModelBase<T> dialog)
|
||||||
{
|
{
|
||||||
using (await _dialogLock.LockAsync())
|
await _dialogLock.WaitAsync();
|
||||||
|
try
|
||||||
{
|
{
|
||||||
await DialogHost.Show(
|
await DialogHost.Show(
|
||||||
dialog,
|
dialog,
|
||||||
@@ -38,8 +39,16 @@ public class DialogManager : IDisposable
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Yield to allow DialogHost to fully reset its state before
|
||||||
|
// another dialog is shown (e.g. when dialogs are shown sequentially).
|
||||||
|
await Task.Yield();
|
||||||
|
|
||||||
return dialog.DialogResult;
|
return dialog.DialogResult;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_dialogLock.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> PromptSaveFilePathAsync(
|
public async Task<string?> PromptSaveFilePathAsync(
|
||||||
@@ -60,7 +69,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 +78,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();
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ public abstract partial class DialogViewModelBase<T> : ViewModelBase
|
|||||||
);
|
);
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private T? _dialogResult;
|
public partial T? DialogResult { get; set; }
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
protected void Close(T dialogResult)
|
protected void Close(T dialogResult)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace DiscordChatExporter.Gui.Localization;
|
||||||
|
|
||||||
|
public enum Language
|
||||||
|
{
|
||||||
|
System,
|
||||||
|
English,
|
||||||
|
Ukrainian,
|
||||||
|
German,
|
||||||
|
French,
|
||||||
|
Spanish,
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Localization;
|
||||||
|
|
||||||
|
public partial class LocalizationManager
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> EnglishLocalization =
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
// Dashboard
|
||||||
|
[nameof(PullGuildsTooltip)] = "Pull available servers and channels (Enter)",
|
||||||
|
[nameof(SettingsTooltip)] = "Settings",
|
||||||
|
[nameof(LastMessageSentTooltip)] = "Last message sent:",
|
||||||
|
[nameof(TokenWatermark)] = "Token",
|
||||||
|
// Token instructions (personal account)
|
||||||
|
[nameof(TokenPersonalHeader)] = "To get the token for your personal account:",
|
||||||
|
[nameof(TokenPersonalTosWarning)] =
|
||||||
|
"* Automating user accounts is technically against TOS — **use at your own risk**!",
|
||||||
|
[nameof(TokenPersonalInstructions)] = """
|
||||||
|
1. Open Discord in your web browser and login
|
||||||
|
2. Open any server or direct message channel
|
||||||
|
3. Press **Ctrl+Shift+I** to show developer tools
|
||||||
|
4. Navigate to the **Network** tab
|
||||||
|
5. Press **Ctrl+R** to reload
|
||||||
|
6. Switch between random channels to trigger network requests
|
||||||
|
7. Search for a request that starts with **messages**
|
||||||
|
8. Select the **Headers** tab on the right
|
||||||
|
9. Scroll down to the **Request Headers** section
|
||||||
|
10. Copy the value of the **authorization** header
|
||||||
|
""",
|
||||||
|
// Token instructions (bot)
|
||||||
|
[nameof(TokenBotHeader)] = "To get the token for your bot:",
|
||||||
|
[nameof(TokenBotInstructions)] = """
|
||||||
|
The token is generated during bot creation. If you lost it, generate a new one:
|
||||||
|
|
||||||
|
1. Open 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
|
||||||
|
* Integrations using the previous token will stop working until updated
|
||||||
|
* Your bot needs to have the **Message Content Intent** enabled to read messages
|
||||||
|
""",
|
||||||
|
[nameof(TokenHelpText)] =
|
||||||
|
"If you have questions or issues, please refer to the [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
|
||||||
|
// Settings
|
||||||
|
[nameof(SettingsTitle)] = "Settings",
|
||||||
|
[nameof(ThemeLabel)] = "Theme",
|
||||||
|
[nameof(ThemeTooltip)] = "Preferred user interface theme",
|
||||||
|
[nameof(LanguageLabel)] = "Language",
|
||||||
|
[nameof(LanguageTooltip)] = "Preferred user interface language",
|
||||||
|
[nameof(AutoUpdateLabel)] = "Auto-update",
|
||||||
|
[nameof(AutoUpdateTooltip)] = "Perform automatic updates on every launch",
|
||||||
|
[nameof(PersistTokenLabel)] = "Persist token",
|
||||||
|
[nameof(PersistTokenTooltip)] = """
|
||||||
|
Save the last used token to a file so that it can be persisted between sessions.
|
||||||
|
**Warning**: although the token is stored with encryption, it may still be recovered by an attacker who has access to your system.
|
||||||
|
""",
|
||||||
|
[nameof(RateLimitPreferenceLabel)] = "Rate limit preference",
|
||||||
|
[nameof(RateLimitPreferenceTooltip)] =
|
||||||
|
"Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.",
|
||||||
|
[nameof(ShowThreadsLabel)] = "Show threads",
|
||||||
|
[nameof(ShowThreadsTooltip)] = "Which types of threads to show in the channel list",
|
||||||
|
[nameof(LocaleLabel)] = "Locale",
|
||||||
|
[nameof(LocaleTooltip)] = "Locale to use when formatting dates and numbers",
|
||||||
|
[nameof(NormalizeToUtcLabel)] = "Normalize to UTC",
|
||||||
|
[nameof(NormalizeToUtcTooltip)] = "Normalize all timestamps to UTC+0",
|
||||||
|
[nameof(ParallelLimitLabel)] = "Parallel limit",
|
||||||
|
[nameof(ParallelLimitTooltip)] = "How many channels can be exported at the same time",
|
||||||
|
// Export Setup
|
||||||
|
[nameof(ChannelsSelectedText)] = "channels selected",
|
||||||
|
[nameof(OutputPathLabel)] = "Output path",
|
||||||
|
[nameof(OutputPathTooltip)] = """
|
||||||
|
Output file or directory path.
|
||||||
|
|
||||||
|
If a directory is specified, file names will be generated automatically based on the channel names and export parameters.
|
||||||
|
|
||||||
|
Directory paths must end with a slash to avoid ambiguity.
|
||||||
|
|
||||||
|
Available template tokens:
|
||||||
|
**%g** — server ID
|
||||||
|
**%G** — server name
|
||||||
|
**%t** — category ID
|
||||||
|
**%T** — category name
|
||||||
|
**%c** — channel ID
|
||||||
|
**%C** — channel name
|
||||||
|
**%p** — channel position
|
||||||
|
**%P** — category position
|
||||||
|
**%a** — after date
|
||||||
|
**%b** — before date
|
||||||
|
**%d** — current date
|
||||||
|
""",
|
||||||
|
[nameof(FormatLabel)] = "Format",
|
||||||
|
[nameof(FormatTooltip)] = "Export format",
|
||||||
|
[nameof(AfterDateLabel)] = "After (date)",
|
||||||
|
[nameof(AfterDateTooltip)] = "Only include messages sent after this date",
|
||||||
|
[nameof(BeforeDateLabel)] = "Before (date)",
|
||||||
|
[nameof(BeforeDateTooltip)] = "Only include messages sent before this date",
|
||||||
|
[nameof(AfterTimeLabel)] = "After (time)",
|
||||||
|
[nameof(AfterTimeTooltip)] = "Only include messages sent after this time",
|
||||||
|
[nameof(BeforeTimeLabel)] = "Before (time)",
|
||||||
|
[nameof(BeforeTimeTooltip)] = "Only include messages sent before this time",
|
||||||
|
[nameof(PartitionLimitLabel)] = "Partition limit",
|
||||||
|
[nameof(PartitionLimitTooltip)] =
|
||||||
|
"Split the output into partitions, each limited to the specified number of messages (e.g. '100') or file size (e.g. '10mb')",
|
||||||
|
[nameof(MessageFilterLabel)] = "Message filter",
|
||||||
|
[nameof(MessageFilterTooltip)] =
|
||||||
|
"Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image'). See the documentation for more info.",
|
||||||
|
[nameof(ReverseMessageOrderLabel)] = "Reverse messages",
|
||||||
|
[nameof(ReverseMessageOrderTooltip)] =
|
||||||
|
"Export messages in reverse chronological order (newest first)",
|
||||||
|
[nameof(FormatMarkdownLabel)] = "Format markdown",
|
||||||
|
[nameof(FormatMarkdownTooltip)] =
|
||||||
|
"Process markdown, mentions, and other special tokens",
|
||||||
|
[nameof(DownloadAssetsLabel)] = "Download assets",
|
||||||
|
[nameof(DownloadAssetsTooltip)] =
|
||||||
|
"Download assets referenced by the export (user avatars, attached files, embedded images, etc.)",
|
||||||
|
[nameof(ReuseAssetsLabel)] = "Reuse assets",
|
||||||
|
[nameof(ReuseAssetsTooltip)] =
|
||||||
|
"Reuse previously downloaded assets to avoid redundant requests",
|
||||||
|
[nameof(AssetsDirPathLabel)] = "Assets directory path",
|
||||||
|
[nameof(AssetsDirPathTooltip)] =
|
||||||
|
"Download assets to this directory. If not specified, the asset directory path will be derived from the output path.",
|
||||||
|
[nameof(AdvancedOptionsTooltip)] = "Toggle advanced options",
|
||||||
|
[nameof(ExportButton)] = "EXPORT",
|
||||||
|
// Common buttons
|
||||||
|
[nameof(CloseButton)] = "CLOSE",
|
||||||
|
[nameof(CancelButton)] = "CANCEL",
|
||||||
|
// Dialog messages
|
||||||
|
[nameof(UkraineSupportTitle)] = "Thank you for supporting Ukraine!",
|
||||||
|
[nameof(UkraineSupportMessage)] = """
|
||||||
|
As Russia wages a genocidal war against my country, I'm grateful to everyone who continues to stand with Ukraine in our fight for freedom.
|
||||||
|
|
||||||
|
Click LEARN MORE to find ways that you can help.
|
||||||
|
""",
|
||||||
|
[nameof(LearnMoreButton)] = "LEARN MORE",
|
||||||
|
[nameof(UnstableBuildTitle)] = "Unstable build warning",
|
||||||
|
[nameof(UnstableBuildMessage)] = """
|
||||||
|
You're using a development build of {0}. These builds are not thoroughly tested and may contain bugs.
|
||||||
|
|
||||||
|
Auto-updates are disabled for development builds.
|
||||||
|
|
||||||
|
Click SEE RELEASES if you want to download a stable release instead.
|
||||||
|
""",
|
||||||
|
[nameof(SeeReleasesButton)] = "SEE RELEASES",
|
||||||
|
[nameof(UpdateDownloadingMessage)] = "Downloading update to {0} v{1}...",
|
||||||
|
[nameof(UpdateReadyMessage)] =
|
||||||
|
"Update has been downloaded and will be installed when you exit",
|
||||||
|
[nameof(UpdateInstallNowButton)] = "INSTALL NOW",
|
||||||
|
[nameof(UpdateFailedMessage)] = "Failed to perform application update",
|
||||||
|
[nameof(ErrorPullingGuildsTitle)] = "Error pulling servers",
|
||||||
|
[nameof(ErrorPullingChannelsTitle)] = "Error pulling channels",
|
||||||
|
[nameof(ErrorExportingTitle)] = "Error exporting channel(s)",
|
||||||
|
[nameof(SuccessfulExportMessage)] = "Successfully exported {0} channel(s)",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Localization;
|
||||||
|
|
||||||
|
public partial class LocalizationManager
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> FrenchLocalization = new Dictionary<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>
|
||||||
|
{
|
||||||
|
// Dashboard
|
||||||
|
[nameof(PullGuildsTooltip)] = "Charger les serveurs et canaux disponibles (Entrée)",
|
||||||
|
[nameof(SettingsTooltip)] = "Paramètres",
|
||||||
|
[nameof(LastMessageSentTooltip)] = "Dernier message envoyé :",
|
||||||
|
[nameof(TokenWatermark)] = "Token",
|
||||||
|
// Token instructions (personal account)
|
||||||
|
[nameof(TokenPersonalHeader)] = "Obtenir le token pour votre compte personnel :",
|
||||||
|
[nameof(TokenPersonalTosWarning)] =
|
||||||
|
"* L'automatisation des comptes est techniquement contraire aux CGU — **à vos risques et périls**!",
|
||||||
|
[nameof(TokenPersonalInstructions)] = """
|
||||||
|
1. Ouvrez Discord dans votre navigateur web et connectez-vous
|
||||||
|
2. Ouvrez n'importe quel serveur ou canal de message direct
|
||||||
|
3. Appuyez sur **Ctrl+Shift+I** pour afficher les outils de développement
|
||||||
|
4. Naviguez vers l'onglet **Network**
|
||||||
|
5. Appuyez sur **Ctrl+R** pour recharger
|
||||||
|
6. Changez de canal pour déclencher des requêtes réseau
|
||||||
|
7. Cherchez une requête commençant par **messages**
|
||||||
|
8. Sélectionnez l'onglet **Headers** à droite
|
||||||
|
9. Faites défiler jusqu'à la section **Request Headers**
|
||||||
|
10. Copiez la valeur de l'en-tête **authorization**
|
||||||
|
""",
|
||||||
|
// Token instructions (bot)
|
||||||
|
[nameof(TokenBotHeader)] = "Obtenir le token pour votre bot :",
|
||||||
|
[nameof(TokenBotInstructions)] = """
|
||||||
|
Le token est généré lors de la création du bot. Si vous l'avez perdu, générez-en un nouveau :
|
||||||
|
|
||||||
|
1. Ouvrez Discord [portail développeur](https://discord.com/developers/applications)
|
||||||
|
2. Ouvrez les paramètres de votre application
|
||||||
|
3. Naviguez vers la section **Bot** à gauche
|
||||||
|
4. Sous **Token**, cliquez sur **Reset Token**
|
||||||
|
5. Cliquez sur **Yes, do it!** et confirmez
|
||||||
|
* Les intégrations utilisant l'ancien token cesseront de fonctionner jusqu'à leur mise à jour
|
||||||
|
* Votre bot doit avoir l'option **Message Content Intent** activée pour lire les messages
|
||||||
|
""",
|
||||||
|
[nameof(TokenHelpText)] =
|
||||||
|
"Pour les questions ou problèmes, veuillez consulter la [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
|
||||||
|
// Settings
|
||||||
|
[nameof(SettingsTitle)] = "Paramètres",
|
||||||
|
[nameof(ThemeLabel)] = "Thème",
|
||||||
|
[nameof(ThemeTooltip)] = "Thème d'interface préféré",
|
||||||
|
[nameof(LanguageLabel)] = "Langue",
|
||||||
|
[nameof(LanguageTooltip)] = "Langue d'interface préférée",
|
||||||
|
[nameof(AutoUpdateLabel)] = "Mise à jour automatique",
|
||||||
|
[nameof(AutoUpdateTooltip)] = "Effectuer des mises à jour automatiques à chaque lancement",
|
||||||
|
[nameof(PersistTokenLabel)] = "Conserver le token",
|
||||||
|
[nameof(PersistTokenTooltip)] = """
|
||||||
|
Enregistrer le dernier token utilisé dans un fichier pour le conserver entre les sessions.
|
||||||
|
**Avertissement** : bien que le token soit stocké avec chiffrement, il peut toujours être récupéré par un attaquant ayant accès à votre système.
|
||||||
|
""",
|
||||||
|
[nameof(RateLimitPreferenceLabel)] = "Préférence de limite de débit",
|
||||||
|
[nameof(RateLimitPreferenceTooltip)] =
|
||||||
|
"Indique s'il faut respecter les limites de débit recommandées. Si désactivé, seules les limites strictes (réponses 429) seront respectées.",
|
||||||
|
[nameof(ShowThreadsLabel)] = "Afficher les fils",
|
||||||
|
[nameof(ShowThreadsTooltip)] = "Quels types de fils afficher dans la liste des canaux",
|
||||||
|
[nameof(LocaleLabel)] = "Locale",
|
||||||
|
[nameof(LocaleTooltip)] = "Locale à utiliser pour le formatage des dates et des nombres",
|
||||||
|
[nameof(NormalizeToUtcLabel)] = "Normaliser en UTC",
|
||||||
|
[nameof(NormalizeToUtcTooltip)] = "Normaliser tous les horodatages en UTC+0",
|
||||||
|
[nameof(ParallelLimitLabel)] = "Limite parallèle",
|
||||||
|
[nameof(ParallelLimitTooltip)] = "Combien de canaux peuvent être exportés simultanément",
|
||||||
|
// Export Setup
|
||||||
|
[nameof(ChannelsSelectedText)] = "canaux sélectionnés",
|
||||||
|
[nameof(OutputPathLabel)] = "Chemin de sortie",
|
||||||
|
[nameof(OutputPathTooltip)] = """
|
||||||
|
Chemin du fichier ou répertoire de sortie.
|
||||||
|
|
||||||
|
Si un répertoire est spécifié, les noms de fichiers seront générés automatiquement en fonction des noms de canaux et des paramètres d'exportation.
|
||||||
|
|
||||||
|
Les chemins de répertoire doivent se terminer par un slash pour éviter toute ambiguïté.
|
||||||
|
|
||||||
|
Jetons de modèle disponibles :
|
||||||
|
**%g** — ID du serveur
|
||||||
|
**%G** — nom du serveur
|
||||||
|
**%t** — ID de la catégorie
|
||||||
|
**%T** — nom de la catégorie
|
||||||
|
**%c** — ID du canal
|
||||||
|
**%C** — nom du canal
|
||||||
|
**%p** — position du canal
|
||||||
|
**%P** — position de la catégorie
|
||||||
|
**%a** — date après
|
||||||
|
**%b** — date avant
|
||||||
|
**%d** — date actuelle
|
||||||
|
""",
|
||||||
|
[nameof(FormatLabel)] = "Format",
|
||||||
|
[nameof(FormatTooltip)] = "Format d'exportation",
|
||||||
|
[nameof(AfterDateLabel)] = "Après (date)",
|
||||||
|
[nameof(AfterDateTooltip)] = "Inclure uniquement les messages envoyés après cette date",
|
||||||
|
[nameof(BeforeDateLabel)] = "Avant (date)",
|
||||||
|
[nameof(BeforeDateTooltip)] = "Inclure uniquement les messages envoyés avant cette date",
|
||||||
|
[nameof(AfterTimeLabel)] = "Après (heure)",
|
||||||
|
[nameof(AfterTimeTooltip)] = "Inclure uniquement les messages envoyés après cette heure",
|
||||||
|
[nameof(BeforeTimeLabel)] = "Avant (heure)",
|
||||||
|
[nameof(BeforeTimeTooltip)] = "Inclure uniquement les messages envoyés avant cette heure",
|
||||||
|
[nameof(PartitionLimitLabel)] = "Limite de partition",
|
||||||
|
[nameof(PartitionLimitTooltip)] =
|
||||||
|
"Diviser la sortie en partitions, chacune limitée au nombre de messages spécifié (ex. '100') ou à la taille de fichier (ex. '10mb')",
|
||||||
|
[nameof(MessageFilterLabel)] = "Filtre de messages",
|
||||||
|
[nameof(MessageFilterTooltip)] =
|
||||||
|
"Inclure uniquement les messages satisfaisant ce filtre (ex. 'from:foo#1234' ou 'has:image'). Voir la documentation pour plus d'informations.",
|
||||||
|
[nameof(ReverseMessageOrderLabel)] = "Inverser l'ordre des messages",
|
||||||
|
[nameof(ReverseMessageOrderTooltip)] =
|
||||||
|
"Exporter les messages en ordre chronologique inversé (les plus récents en premier)",
|
||||||
|
[nameof(FormatMarkdownLabel)] = "Formater le markdown",
|
||||||
|
[nameof(FormatMarkdownTooltip)] =
|
||||||
|
"Traiter le markdown, les mentions et autres tokens spéciaux",
|
||||||
|
[nameof(DownloadAssetsLabel)] = "Télécharger les ressources",
|
||||||
|
[nameof(DownloadAssetsTooltip)] =
|
||||||
|
"Télécharger les ressources référencées par l'export (avatars, fichiers joints, images intégrées, etc.)",
|
||||||
|
[nameof(ReuseAssetsLabel)] = "Réutiliser les ressources",
|
||||||
|
[nameof(ReuseAssetsTooltip)] =
|
||||||
|
"Réutiliser les ressources précédemment téléchargées pour éviter les requêtes redondantes",
|
||||||
|
[nameof(AssetsDirPathLabel)] = "Chemin du dossier des ressources",
|
||||||
|
[nameof(AssetsDirPathTooltip)] =
|
||||||
|
"Télécharger les ressources dans ce dossier. Si non spécifié, le chemin sera dérivé du chemin de sortie.",
|
||||||
|
[nameof(AdvancedOptionsTooltip)] = "Basculer les options avancées",
|
||||||
|
[nameof(ExportButton)] = "EXPORTER",
|
||||||
|
// Common buttons
|
||||||
|
[nameof(CloseButton)] = "FERMER",
|
||||||
|
[nameof(CancelButton)] = "ANNULER",
|
||||||
|
// Dialog messages
|
||||||
|
[nameof(UkraineSupportTitle)] = "Merci de soutenir l'Ukraine !",
|
||||||
|
[nameof(UkraineSupportMessage)] = """
|
||||||
|
Alors que la Russie mène une guerre génocidaire contre mon pays, je suis reconnaissant envers tous ceux qui continuent à soutenir l'Ukraine dans notre lutte pour la liberté.
|
||||||
|
|
||||||
|
Cliquez sur EN SAVOIR PLUS pour trouver des moyens d'aider.
|
||||||
|
""",
|
||||||
|
[nameof(LearnMoreButton)] = "EN SAVOIR PLUS",
|
||||||
|
[nameof(UnstableBuildTitle)] = "Avertissement : version instable",
|
||||||
|
[nameof(UnstableBuildMessage)] = """
|
||||||
|
Vous utilisez une version de développement de {0}. Ces versions ne sont pas rigoureusement testées et peuvent contenir des bugs.
|
||||||
|
|
||||||
|
Les mises à jour automatiques sont désactivées pour les versions de développement.
|
||||||
|
|
||||||
|
Cliquez sur VOIR LES VERSIONS pour télécharger une version stable.
|
||||||
|
""",
|
||||||
|
[nameof(SeeReleasesButton)] = "VOIR LES VERSIONS",
|
||||||
|
[nameof(UpdateDownloadingMessage)] = "Téléchargement de la mise à jour vers {0} v{1}...",
|
||||||
|
[nameof(UpdateReadyMessage)] =
|
||||||
|
"La mise à jour a été téléchargée et sera installée à la fermeture",
|
||||||
|
[nameof(UpdateInstallNowButton)] = "INSTALLER MAINTENANT",
|
||||||
|
[nameof(UpdateFailedMessage)] = "Échec de la mise à jour de l'application",
|
||||||
|
[nameof(ErrorPullingGuildsTitle)] = "Erreur lors du chargement des serveurs",
|
||||||
|
[nameof(ErrorPullingChannelsTitle)] = "Erreur lors du chargement des canaux",
|
||||||
|
[nameof(ErrorExportingTitle)] = "Erreur lors de l'exportation des canaux",
|
||||||
|
[nameof(SuccessfulExportMessage)] = "{0} canal(-aux) exporté(s) avec succès",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Localization;
|
||||||
|
|
||||||
|
public partial class LocalizationManager
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> GermanLocalization = new Dictionary<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
>
|
||||||
|
{
|
||||||
|
// Dashboard
|
||||||
|
[nameof(PullGuildsTooltip)] = "Verfügbare Server und Kanäle laden (Enter)",
|
||||||
|
[nameof(SettingsTooltip)] = "Einstellungen",
|
||||||
|
[nameof(LastMessageSentTooltip)] = "Letzte Nachricht gesendet:",
|
||||||
|
[nameof(TokenWatermark)] = "Token",
|
||||||
|
// Token instructions (personal account)
|
||||||
|
[nameof(TokenPersonalHeader)] = "Token für Ihr persönliches Konto abrufen:",
|
||||||
|
[nameof(TokenPersonalTosWarning)] =
|
||||||
|
"* Das Automatisieren von Benutzerkonten verstößt technisch gegen die AGB — **auf eigene Gefahr**!",
|
||||||
|
[nameof(TokenPersonalInstructions)] = """
|
||||||
|
1. Öffnen Sie Discord in Ihrem Webbrowser und melden Sie sich an
|
||||||
|
2. Öffnen Sie einen Server oder einen direkten Nachrichtenkanal
|
||||||
|
3. Drücken Sie **Ctrl+Shift+I**, um die Entwicklertools anzuzeigen
|
||||||
|
4. Navigieren Sie zum Reiter **Network**
|
||||||
|
5. Drücken Sie **Ctrl+R** zum Neuladen
|
||||||
|
6. Wechseln Sie zwischen Kanälen, um Netzwerkanfragen auszulösen
|
||||||
|
7. Suchen Sie nach einer Anfrage, die mit **messages** beginnt
|
||||||
|
8. Wählen Sie den Reiter **Headers** auf der rechten Seite
|
||||||
|
9. Scrollen Sie nach unten zum Abschnitt **Request Headers**
|
||||||
|
10. Kopieren Sie den Wert des Headers **authorization**
|
||||||
|
""",
|
||||||
|
// Token instructions (bot)
|
||||||
|
[nameof(TokenBotHeader)] = "Token für Ihren Bot abrufen:",
|
||||||
|
[nameof(TokenBotInstructions)] = """
|
||||||
|
Der Token wird bei der Bot-Erstellung generiert. Falls er verloren gegangen ist, generieren Sie einen neuen:
|
||||||
|
|
||||||
|
1. Öffnen Sie Discord [Entwicklerportal](https://discord.com/developers/applications)
|
||||||
|
2. Öffnen Sie die Einstellungen Ihrer Anwendung
|
||||||
|
3. Navigieren Sie zum Abschnitt **Bot** auf der linken Seite
|
||||||
|
4. Klicken Sie unter **Token** auf **Reset Token**
|
||||||
|
5. Klicken Sie auf **Yes, do it!** und bestätigen Sie
|
||||||
|
* Integrationen, die den alten Token verwenden, hören auf zu funktionieren, bis sie aktualisiert werden
|
||||||
|
* Ihr Bot benötigt die aktivierte **Message Content Intent**, um Nachrichten zu lesen
|
||||||
|
""",
|
||||||
|
[nameof(TokenHelpText)] =
|
||||||
|
"Bei Fragen oder Problemen lesen Sie die [Dokumentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
|
||||||
|
// Settings
|
||||||
|
[nameof(SettingsTitle)] = "Einstellungen",
|
||||||
|
[nameof(ThemeLabel)] = "Design",
|
||||||
|
[nameof(ThemeTooltip)] = "Bevorzugtes Oberflächendesign",
|
||||||
|
[nameof(LanguageLabel)] = "Sprache",
|
||||||
|
[nameof(LanguageTooltip)] = "Bevorzugte Sprache der Benutzeroberfläche",
|
||||||
|
[nameof(AutoUpdateLabel)] = "Automatische Updates",
|
||||||
|
[nameof(AutoUpdateTooltip)] = "Automatische Updates bei jedem Start durchführen",
|
||||||
|
[nameof(PersistTokenLabel)] = "Token speichern",
|
||||||
|
[nameof(PersistTokenTooltip)] = """
|
||||||
|
Den zuletzt verwendeten Token in einer Datei speichern, damit er zwischen Sitzungen erhalten bleibt.
|
||||||
|
**Warnung**: Der Token wird mit Verschlüsselung gespeichert, kann aber dennoch von einem Angreifer mit Zugriff auf Ihr System wiederhergestellt werden.
|
||||||
|
""",
|
||||||
|
[nameof(RateLimitPreferenceLabel)] = "Ratenlimit-Einstellung",
|
||||||
|
[nameof(RateLimitPreferenceTooltip)] =
|
||||||
|
"Ob empfohlene Ratenlimits eingehalten werden sollen. Wenn deaktiviert, werden nur harte Ratenlimits (d. h. 429-Antworten) eingehalten.",
|
||||||
|
[nameof(ShowThreadsLabel)] = "Threads anzeigen",
|
||||||
|
[nameof(ShowThreadsTooltip)] = "Welche Thread-Typen in der Kanalliste angezeigt werden",
|
||||||
|
[nameof(LocaleLabel)] = "Gebietsschema",
|
||||||
|
[nameof(LocaleTooltip)] = "Gebietsschema für die Formatierung von Daten und Zahlen",
|
||||||
|
[nameof(NormalizeToUtcLabel)] = "Auf UTC normalisieren",
|
||||||
|
[nameof(NormalizeToUtcTooltip)] = "Alle Zeitstempel auf UTC+0 normalisieren",
|
||||||
|
[nameof(ParallelLimitLabel)] = "Paralleles Limit",
|
||||||
|
[nameof(ParallelLimitTooltip)] = "Wie viele Kanäle gleichzeitig exportiert werden können",
|
||||||
|
// Export Setup
|
||||||
|
[nameof(ChannelsSelectedText)] = "Kanäle ausgewählt",
|
||||||
|
[nameof(OutputPathLabel)] = "Ausgabepfad",
|
||||||
|
[nameof(OutputPathTooltip)] = """
|
||||||
|
Ausgabedatei- oder Verzeichnispfad.
|
||||||
|
|
||||||
|
Wenn ein Verzeichnis angegeben wird, werden Dateinamen automatisch basierend auf den Kanalnamen und Exportparametern generiert.
|
||||||
|
|
||||||
|
Verzeichnispfade müssen mit einem Schrägstrich enden, um Mehrdeutigkeiten zu vermeiden.
|
||||||
|
|
||||||
|
Verfügbare Vorlagen-Token:
|
||||||
|
**%g** — Server-ID
|
||||||
|
**%G** — Servername
|
||||||
|
**%t** — Kategorie-ID
|
||||||
|
**%T** — Kategoriename
|
||||||
|
**%c** — Kanal-ID
|
||||||
|
**%C** — Kanalname
|
||||||
|
**%p** — Kanalposition
|
||||||
|
**%P** — Kategorieposition
|
||||||
|
**%a** — Datum ab
|
||||||
|
**%b** — Datum bis
|
||||||
|
**%d** — aktuelles Datum
|
||||||
|
""",
|
||||||
|
[nameof(FormatLabel)] = "Format",
|
||||||
|
[nameof(FormatTooltip)] = "Exportformat",
|
||||||
|
[nameof(AfterDateLabel)] = "Nach (Datum)",
|
||||||
|
[nameof(AfterDateTooltip)] =
|
||||||
|
"Nur Nachrichten einschließen, die nach diesem Datum gesendet wurden",
|
||||||
|
[nameof(BeforeDateLabel)] = "Vor (Datum)",
|
||||||
|
[nameof(BeforeDateTooltip)] =
|
||||||
|
"Nur Nachrichten einschließen, die vor diesem Datum gesendet wurden",
|
||||||
|
[nameof(AfterTimeLabel)] = "Nach (Uhrzeit)",
|
||||||
|
[nameof(AfterTimeTooltip)] =
|
||||||
|
"Nur Nachrichten einschließen, die nach dieser Uhrzeit gesendet wurden",
|
||||||
|
[nameof(BeforeTimeLabel)] = "Vor (Uhrzeit)",
|
||||||
|
[nameof(BeforeTimeTooltip)] =
|
||||||
|
"Nur Nachrichten einschließen, die vor dieser Uhrzeit gesendet wurden",
|
||||||
|
[nameof(PartitionLimitLabel)] = "Partitionslimit",
|
||||||
|
[nameof(PartitionLimitTooltip)] =
|
||||||
|
"Die Ausgabe in Partitionen aufteilen, jede begrenzt auf die angegebene Anzahl von Nachrichten (z. B. '100') oder Dateigröße (z. B. '10mb')",
|
||||||
|
[nameof(MessageFilterLabel)] = "Nachrichtenfilter",
|
||||||
|
[nameof(MessageFilterTooltip)] =
|
||||||
|
"Nur Nachrichten einschließen, die diesem Filter entsprechen (z. B. 'from:foo#1234' oder 'has:image'). Weitere Informationen finden Sie in der Dokumentation.",
|
||||||
|
[nameof(ReverseMessageOrderLabel)] = "Nachrichtenreihenfolge umkehren",
|
||||||
|
[nameof(ReverseMessageOrderTooltip)] =
|
||||||
|
"Nachrichten in umgekehrter chronologischer Reihenfolge exportieren (neueste zuerst)",
|
||||||
|
[nameof(FormatMarkdownLabel)] = "Markdown formatieren",
|
||||||
|
[nameof(FormatMarkdownTooltip)] =
|
||||||
|
"Markdown, Erwähnungen und andere spezielle Token verarbeiten",
|
||||||
|
[nameof(DownloadAssetsLabel)] = "Assets herunterladen",
|
||||||
|
[nameof(DownloadAssetsTooltip)] =
|
||||||
|
"Vom Export referenzierte Assets herunterladen (Benutzeravatare, angehängte Dateien, eingebettete Bilder usw.)",
|
||||||
|
[nameof(ReuseAssetsLabel)] = "Assets wiederverwenden",
|
||||||
|
[nameof(ReuseAssetsTooltip)] =
|
||||||
|
"Zuvor heruntergeladene Assets wiederverwenden, um redundante Anfragen zu vermeiden",
|
||||||
|
[nameof(AssetsDirPathLabel)] = "Asset-Verzeichnispfad",
|
||||||
|
[nameof(AssetsDirPathTooltip)] =
|
||||||
|
"Assets in dieses Verzeichnis herunterladen. Wenn nicht angegeben, wird der Asset-Verzeichnispfad vom Ausgabepfad abgeleitet.",
|
||||||
|
[nameof(AdvancedOptionsTooltip)] = "Erweiterte Optionen umschalten",
|
||||||
|
[nameof(ExportButton)] = "EXPORTIEREN",
|
||||||
|
// Common buttons
|
||||||
|
[nameof(CloseButton)] = "SCHLIESSEN",
|
||||||
|
[nameof(CancelButton)] = "ABBRECHEN",
|
||||||
|
// Dialog messages
|
||||||
|
[nameof(UkraineSupportTitle)] = "Danke für Ihre Unterstützung der Ukraine!",
|
||||||
|
[nameof(UkraineSupportMessage)] = """
|
||||||
|
Während Russland einen Vernichtungskrieg gegen mein Land führt, bin ich jedem dankbar, der weiterhin an der Seite der Ukraine in unserem Kampf für die Freiheit steht.
|
||||||
|
|
||||||
|
Klicken Sie auf MEHR ERFAHREN, um Möglichkeiten der Unterstützung zu finden.
|
||||||
|
""",
|
||||||
|
[nameof(LearnMoreButton)] = "MEHR ERFAHREN",
|
||||||
|
[nameof(UnstableBuildTitle)] = "Warnung: Instabile Version",
|
||||||
|
[nameof(UnstableBuildMessage)] = """
|
||||||
|
Sie verwenden eine Entwicklungsversion von {0}. Diese Versionen wurden nicht gründlich getestet und können Fehler enthalten.
|
||||||
|
|
||||||
|
Automatische Updates sind für Entwicklungsversionen deaktiviert.
|
||||||
|
|
||||||
|
Klicken Sie auf RELEASES ANZEIGEN, wenn Sie stattdessen eine stabile Version herunterladen möchten.
|
||||||
|
""",
|
||||||
|
[nameof(SeeReleasesButton)] = "RELEASES ANZEIGEN",
|
||||||
|
[nameof(UpdateDownloadingMessage)] = "Update auf {0} v{1} wird heruntergeladen...",
|
||||||
|
[nameof(UpdateReadyMessage)] =
|
||||||
|
"Update wurde heruntergeladen und wird beim Beenden installiert",
|
||||||
|
[nameof(UpdateInstallNowButton)] = "JETZT INSTALLIEREN",
|
||||||
|
[nameof(UpdateFailedMessage)] = "Anwendungsupdate konnte nicht durchgeführt werden",
|
||||||
|
[nameof(ErrorPullingGuildsTitle)] = "Fehler beim Laden der Server",
|
||||||
|
[nameof(ErrorPullingChannelsTitle)] = "Fehler beim Laden der Kanäle",
|
||||||
|
[nameof(ErrorExportingTitle)] = "Fehler beim Exportieren der Kanäle",
|
||||||
|
[nameof(SuccessfulExportMessage)] = "{0} Kanal/-äle erfolgreich exportiert",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Localization;
|
||||||
|
|
||||||
|
public partial class LocalizationManager
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> SpanishLocalization =
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
// Dashboard
|
||||||
|
[nameof(PullGuildsTooltip)] = "Cargar servidores y canales disponibles (Enter)",
|
||||||
|
[nameof(SettingsTooltip)] = "Ajustes",
|
||||||
|
[nameof(LastMessageSentTooltip)] = "Último mensaje enviado:",
|
||||||
|
[nameof(TokenWatermark)] = "Token",
|
||||||
|
// Token instructions (personal account)
|
||||||
|
[nameof(TokenPersonalHeader)] = "Cómo obtener el token para tu cuenta personal:",
|
||||||
|
[nameof(TokenPersonalTosWarning)] =
|
||||||
|
"* Automatizar cuentas de usuario técnicamente va en contra de los ToS — **bajo tu propio riesgo**!",
|
||||||
|
[nameof(TokenPersonalInstructions)] = """
|
||||||
|
1. Abre Discord en tu navegador web e inicia sesión
|
||||||
|
2. Abre cualquier servidor o canal de mensaje directo
|
||||||
|
3. Presiona **Ctrl+Shift+I** para mostrar las herramientas de desarrollo
|
||||||
|
4. Navega a la pestaña **Network**
|
||||||
|
5. Presiona **Ctrl+R** para recargar
|
||||||
|
6. Cambia entre canales para activar solicitudes de red
|
||||||
|
7. Busca una solicitud que comience con **messages**
|
||||||
|
8. Selecciona la pestaña **Headers** a la derecha
|
||||||
|
9. Desplázate hasta la sección **Request Headers**
|
||||||
|
10. Copia el valor del encabezado **authorization**
|
||||||
|
""",
|
||||||
|
// Token instructions (bot)
|
||||||
|
[nameof(TokenBotHeader)] = "Cómo obtener el token para tu bot:",
|
||||||
|
[nameof(TokenBotInstructions)] = """
|
||||||
|
El token se genera al crear el bot. Si lo perdiste, genera uno nuevo:
|
||||||
|
|
||||||
|
1. Abre Discord [portal de desarrolladores](https://discord.com/developers/applications)
|
||||||
|
2. Abre la configuración de tu aplicación
|
||||||
|
3. Navega a la sección **Bot** en el lado izquierdo
|
||||||
|
4. En **Token**, haz clic en **Reset Token**
|
||||||
|
5. Haz clic en **Yes, do it!** y autentica para confirmar
|
||||||
|
* Las integraciones que usen el token anterior dejarán de funcionar hasta que se actualicen
|
||||||
|
* Tu bot necesita tener habilitado **Message Content Intent** para leer mensajes
|
||||||
|
""",
|
||||||
|
[nameof(TokenHelpText)] =
|
||||||
|
"Si tienes preguntas o problemas, consulta la [documentación](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
|
||||||
|
// Settings
|
||||||
|
[nameof(SettingsTitle)] = "Ajustes",
|
||||||
|
[nameof(ThemeLabel)] = "Tema",
|
||||||
|
[nameof(ThemeTooltip)] = "Tema de interfaz preferido",
|
||||||
|
[nameof(LanguageLabel)] = "Idioma",
|
||||||
|
[nameof(LanguageTooltip)] = "Idioma de interfaz preferido",
|
||||||
|
[nameof(AutoUpdateLabel)] = "Actualización automática",
|
||||||
|
[nameof(AutoUpdateTooltip)] = "Realizar actualizaciones automáticas en cada inicio",
|
||||||
|
[nameof(PersistTokenLabel)] = "Guardar token",
|
||||||
|
[nameof(PersistTokenTooltip)] = """
|
||||||
|
Guardar el último token utilizado en un archivo para conservarlo entre sesiones.
|
||||||
|
**Advertencia**: aunque el token se almacena con cifrado, aún puede ser recuperado por un atacante con acceso a tu sistema.
|
||||||
|
""",
|
||||||
|
[nameof(RateLimitPreferenceLabel)] = "Preferencia de límite de velocidad",
|
||||||
|
[nameof(RateLimitPreferenceTooltip)] =
|
||||||
|
"Si se deben respetar los límites de velocidad recomendados. Si está desactivado, solo se respetarán los límites estrictos (respuestas 429).",
|
||||||
|
[nameof(ShowThreadsLabel)] = "Mostrar hilos",
|
||||||
|
[nameof(ShowThreadsTooltip)] = "Qué tipos de hilos mostrar en la lista de canales",
|
||||||
|
[nameof(LocaleLabel)] = "Configuración regional",
|
||||||
|
[nameof(LocaleTooltip)] = "Configuración regional para el formato de fechas y números",
|
||||||
|
[nameof(NormalizeToUtcLabel)] = "Normalizar a UTC",
|
||||||
|
[nameof(NormalizeToUtcTooltip)] = "Normalizar todas las marcas de tiempo a UTC+0",
|
||||||
|
[nameof(ParallelLimitLabel)] = "Límite paralelo",
|
||||||
|
[nameof(ParallelLimitTooltip)] = "Cuántos canales pueden exportarse al mismo tiempo",
|
||||||
|
// Export Setup
|
||||||
|
[nameof(ChannelsSelectedText)] = "canales seleccionados",
|
||||||
|
[nameof(OutputPathLabel)] = "Ruta de salida",
|
||||||
|
[nameof(OutputPathTooltip)] = """
|
||||||
|
Ruta del archivo o directorio de salida.
|
||||||
|
|
||||||
|
Si se especifica un directorio, los nombres de archivo se generarán automáticamente según los nombres de los canales y los parámetros de exportación.
|
||||||
|
|
||||||
|
Las rutas de directorio deben terminar con una barra diagonal para evitar ambigüedades.
|
||||||
|
|
||||||
|
Tokens de plantilla disponibles:
|
||||||
|
**%g** — ID del servidor
|
||||||
|
**%G** — nombre del servidor
|
||||||
|
**%t** — ID de categoría
|
||||||
|
**%T** — nombre de categoría
|
||||||
|
**%c** — ID del canal
|
||||||
|
**%C** — nombre del canal
|
||||||
|
**%p** — posición del canal
|
||||||
|
**%P** — posición de la categoría
|
||||||
|
**%a** — fecha desde
|
||||||
|
**%b** — fecha hasta
|
||||||
|
**%d** — fecha actual
|
||||||
|
""",
|
||||||
|
[nameof(FormatLabel)] = "Formato",
|
||||||
|
[nameof(FormatTooltip)] = "Formato de exportación",
|
||||||
|
[nameof(AfterDateLabel)] = "Después (fecha)",
|
||||||
|
[nameof(AfterDateTooltip)] = "Solo incluir mensajes enviados después de esta fecha",
|
||||||
|
[nameof(BeforeDateLabel)] = "Antes (fecha)",
|
||||||
|
[nameof(BeforeDateTooltip)] = "Solo incluir mensajes enviados antes de esta fecha",
|
||||||
|
[nameof(AfterTimeLabel)] = "Después (hora)",
|
||||||
|
[nameof(AfterTimeTooltip)] = "Solo incluir mensajes enviados después de esta hora",
|
||||||
|
[nameof(BeforeTimeLabel)] = "Antes (hora)",
|
||||||
|
[nameof(BeforeTimeTooltip)] = "Solo incluir mensajes enviados antes de esta hora",
|
||||||
|
[nameof(PartitionLimitLabel)] = "Límite de partición",
|
||||||
|
[nameof(PartitionLimitTooltip)] =
|
||||||
|
"Dividir la salida en particiones, cada una limitada al número de mensajes especificado (p. ej. '100') o tamaño de archivo (p. ej. '10mb')",
|
||||||
|
[nameof(MessageFilterLabel)] = "Filtro de mensajes",
|
||||||
|
[nameof(MessageFilterTooltip)] =
|
||||||
|
"Solo incluir mensajes que satisfagan este filtro (p. ej. 'from:foo#1234' o 'has:image'). Consulte la documentación para más información.",
|
||||||
|
[nameof(ReverseMessageOrderLabel)] = "Invertir orden de mensajes",
|
||||||
|
[nameof(ReverseMessageOrderTooltip)] =
|
||||||
|
"Exportar mensajes en orden cronológico inverso (los más recientes primero)",
|
||||||
|
[nameof(FormatMarkdownLabel)] = "Formatear markdown",
|
||||||
|
[nameof(FormatMarkdownTooltip)] =
|
||||||
|
"Procesar markdown, menciones y otros tokens especiales",
|
||||||
|
[nameof(DownloadAssetsLabel)] = "Descargar recursos",
|
||||||
|
[nameof(DownloadAssetsTooltip)] =
|
||||||
|
"Descargar los recursos referenciados por la exportación (avatares, archivos adjuntos, imágenes incrustadas, etc.)",
|
||||||
|
[nameof(ReuseAssetsLabel)] = "Reutilizar recursos",
|
||||||
|
[nameof(ReuseAssetsTooltip)] =
|
||||||
|
"Reutilizar recursos previamente descargados para evitar solicitudes redundantes",
|
||||||
|
[nameof(AssetsDirPathLabel)] = "Ruta del directorio de recursos",
|
||||||
|
[nameof(AssetsDirPathTooltip)] =
|
||||||
|
"Descargar recursos en este directorio. Si no se especifica, la ruta se derivará de la ruta de salida.",
|
||||||
|
[nameof(AdvancedOptionsTooltip)] = "Alternar opciones avanzadas",
|
||||||
|
[nameof(ExportButton)] = "EXPORTAR",
|
||||||
|
// Common buttons
|
||||||
|
[nameof(CloseButton)] = "CERRAR",
|
||||||
|
[nameof(CancelButton)] = "CANCELAR",
|
||||||
|
// Dialog messages
|
||||||
|
[nameof(UkraineSupportTitle)] = "¡Gracias por apoyar a Ucrania!",
|
||||||
|
[nameof(UkraineSupportMessage)] = """
|
||||||
|
Mientras Rusia libra una guerra genocida contra mi país, estoy agradecido con todos los que continúan apoyando a Ucrania en nuestra lucha por la libertad.
|
||||||
|
|
||||||
|
Haga clic en MÁS INFORMACIÓN para encontrar formas de ayudar.
|
||||||
|
""",
|
||||||
|
[nameof(LearnMoreButton)] = "MÁS INFORMACIÓN",
|
||||||
|
[nameof(UnstableBuildTitle)] = "Advertencia de versión inestable",
|
||||||
|
[nameof(UnstableBuildMessage)] = """
|
||||||
|
Está usando una versión de desarrollo de {0}. Estas versiones no han sido probadas exhaustivamente y pueden contener errores.
|
||||||
|
|
||||||
|
Las actualizaciones automáticas están desactivadas para las versiones de desarrollo.
|
||||||
|
|
||||||
|
Haga clic en VER VERSIONES si desea descargar una versión estable.
|
||||||
|
""",
|
||||||
|
[nameof(SeeReleasesButton)] = "VER VERSIONES",
|
||||||
|
[nameof(UpdateDownloadingMessage)] = "Descargando actualización a {0} v{1}...",
|
||||||
|
[nameof(UpdateReadyMessage)] =
|
||||||
|
"La actualización se ha descargado y se instalará al salir",
|
||||||
|
[nameof(UpdateInstallNowButton)] = "INSTALAR AHORA",
|
||||||
|
[nameof(UpdateFailedMessage)] = "Error al realizar la actualización de la aplicación",
|
||||||
|
[nameof(ErrorPullingGuildsTitle)] = "Error al cargar servidores",
|
||||||
|
[nameof(ErrorPullingChannelsTitle)] = "Error al cargar canales",
|
||||||
|
[nameof(ErrorExportingTitle)] = "Error al exportar canal(es)",
|
||||||
|
[nameof(SuccessfulExportMessage)] = "{0} canal(es) exportado(s) con éxito",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Localization;
|
||||||
|
|
||||||
|
public partial class LocalizationManager
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, string> UkrainianLocalization =
|
||||||
|
new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
// Dashboard
|
||||||
|
[nameof(PullGuildsTooltip)] = "Завантажити доступні сервери та канали (Enter)",
|
||||||
|
[nameof(SettingsTooltip)] = "Налаштування",
|
||||||
|
[nameof(LastMessageSentTooltip)] = "Останнє повідомлення:",
|
||||||
|
[nameof(TokenWatermark)] = "Токен",
|
||||||
|
// Token instructions (personal account)
|
||||||
|
[nameof(TokenPersonalHeader)] = "Як отримати токен для персонального акаунту:",
|
||||||
|
[nameof(TokenPersonalTosWarning)] =
|
||||||
|
"* Автоматизація облікових записів технічно порушує Умови обслуговування — **на власний ризик**!",
|
||||||
|
[nameof(TokenPersonalInstructions)] = """
|
||||||
|
1. Відкрийте Discord у вашому веб-браузері та увійдіть
|
||||||
|
2. Відкрийте будь-який сервер або канал особистих повідомлень
|
||||||
|
3. Натисніть **Ctrl+Shift+I**, щоб відкрити інструменти розробника
|
||||||
|
4. Перейдіть на вкладку **Network**
|
||||||
|
5. Натисніть **Ctrl+R** для перезавантаження
|
||||||
|
6. Перемикайтеся між каналами, щоб викликати мережеві запити
|
||||||
|
7. Знайдіть запит, що починається з **messages**
|
||||||
|
8. Виберіть вкладку **Headers** праворуч
|
||||||
|
9. Прокрутіть до розділу **Request Headers**
|
||||||
|
10. Скопіюйте значення заголовка **authorization**
|
||||||
|
""",
|
||||||
|
// Token instructions (bot)
|
||||||
|
[nameof(TokenBotHeader)] = "Як отримати токен для бота:",
|
||||||
|
[nameof(TokenBotInstructions)] = """
|
||||||
|
Токен генерується під час створення бота. Якщо ви його втратили, згенеруйте новий:
|
||||||
|
|
||||||
|
1. Відкрийте Discord [портал розробника](https://discord.com/developers/applications)
|
||||||
|
2. Відкрийте налаштування вашого застосунку
|
||||||
|
3. Перейдіть до розділу **Bot** ліворуч
|
||||||
|
4. В розділі **Token** натисніть **Reset Token**
|
||||||
|
5. Натисніть **Yes, do it!** та підтвердьте
|
||||||
|
* Інтеграції, що використовують попередній токен, перестануть працювати
|
||||||
|
* Ваш бот повинен мати включений **Message Content Intent** для читання повідомлень
|
||||||
|
""",
|
||||||
|
[nameof(TokenHelpText)] =
|
||||||
|
"Якщо у вас є запитання або проблеми, зверніться до [документації](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
|
||||||
|
// Settings
|
||||||
|
[nameof(SettingsTitle)] = "Налаштування",
|
||||||
|
[nameof(ThemeLabel)] = "Тема",
|
||||||
|
[nameof(ThemeTooltip)] = "Бажана тема інтерфейсу",
|
||||||
|
[nameof(LanguageLabel)] = "Мова",
|
||||||
|
[nameof(LanguageTooltip)] = "Бажана мова інтерфейсу",
|
||||||
|
[nameof(AutoUpdateLabel)] = "Авто-оновлення",
|
||||||
|
[nameof(AutoUpdateTooltip)] = "Виконувати автоматичні оновлення при кожному запуску",
|
||||||
|
[nameof(PersistTokenLabel)] = "Зберігати токен",
|
||||||
|
[nameof(PersistTokenTooltip)] = """
|
||||||
|
Зберігати останній використаний токен у файлі для збереження між сеансами.
|
||||||
|
**Увага**: хоча токен зберігається із шифруванням, він може бути відновлений зловмисником, який має доступ до вашої системи.
|
||||||
|
""",
|
||||||
|
[nameof(RateLimitPreferenceLabel)] = "Ліміт запитів",
|
||||||
|
[nameof(RateLimitPreferenceTooltip)] =
|
||||||
|
"Чи дотримуватись рекомендованих лімітів запитів. Якщо вимкнено, будуть дотримуватись лише жорсткі ліміти (тобто відповіді 429).",
|
||||||
|
[nameof(ShowThreadsLabel)] = "Показувати гілки",
|
||||||
|
[nameof(ShowThreadsTooltip)] = "Які типи гілок показувати у списку каналів",
|
||||||
|
[nameof(LocaleLabel)] = "Локаль",
|
||||||
|
[nameof(LocaleTooltip)] = "Локаль для форматування дат та чисел",
|
||||||
|
[nameof(NormalizeToUtcLabel)] = "Нормалізувати до UTC",
|
||||||
|
[nameof(NormalizeToUtcTooltip)] = "Нормалізувати всі часові мітки до UTC+0",
|
||||||
|
[nameof(ParallelLimitLabel)] = "Ліміт паралелізації",
|
||||||
|
[nameof(ParallelLimitTooltip)] = "Скільки каналів може експортуватись одночасно",
|
||||||
|
// Export Setup
|
||||||
|
[nameof(ChannelsSelectedText)] = "каналів вибрано",
|
||||||
|
[nameof(OutputPathLabel)] = "Шлях збереження",
|
||||||
|
[nameof(OutputPathTooltip)] = """
|
||||||
|
Шлях до файлу або директорії виводу.
|
||||||
|
|
||||||
|
Якщо вказано директорію, імена файлів генеруватимуться автоматично на основі назв каналів та параметрів експорту.
|
||||||
|
|
||||||
|
Шляхи до директорій повинні закінчуватись слешем для уникнення неоднозначності.
|
||||||
|
|
||||||
|
Доступні шаблонні токени:
|
||||||
|
**%g** — ID сервера
|
||||||
|
**%G** — назва сервера
|
||||||
|
**%t** — ID категорії
|
||||||
|
**%T** — назва категорії
|
||||||
|
**%c** — ID каналу
|
||||||
|
**%C** — назва каналу
|
||||||
|
**%p** — позиція каналу
|
||||||
|
**%P** — позиція категорії
|
||||||
|
**%a** — дата після
|
||||||
|
**%b** — дата до
|
||||||
|
**%d** — поточна дата
|
||||||
|
""",
|
||||||
|
[nameof(FormatLabel)] = "Формат",
|
||||||
|
[nameof(FormatTooltip)] = "Формат експорту",
|
||||||
|
[nameof(AfterDateLabel)] = "Після (дата)",
|
||||||
|
[nameof(AfterDateTooltip)] = "Включати лише повідомлення, надіслані після цієї дати",
|
||||||
|
[nameof(BeforeDateLabel)] = "До (дата)",
|
||||||
|
[nameof(BeforeDateTooltip)] = "Включати лише повідомлення, надіслані до цієї дати",
|
||||||
|
[nameof(AfterTimeLabel)] = "Після (час)",
|
||||||
|
[nameof(AfterTimeTooltip)] = "Включати лише повідомлення, надіслані після цього часу",
|
||||||
|
[nameof(BeforeTimeLabel)] = "До (час)",
|
||||||
|
[nameof(BeforeTimeTooltip)] = "Включати лише повідомлення, надіслані до цього часу",
|
||||||
|
[nameof(PartitionLimitLabel)] = "Розділяти експорт",
|
||||||
|
[nameof(PartitionLimitTooltip)] =
|
||||||
|
"Розділити вивід на частини, кожна обмежена вказаною кількістю повідомлень (напр. '100') або розміром файлу (напр. '10mb')",
|
||||||
|
[nameof(MessageFilterLabel)] = "Фільтр повідомлень",
|
||||||
|
[nameof(MessageFilterTooltip)] =
|
||||||
|
"Включати лише повідомлення, що відповідають цьому фільтру (напр. 'from:foo#1234' або 'has:image'). Дивіться документацію для більш детальної інформації.",
|
||||||
|
[nameof(ReverseMessageOrderLabel)] = "Зворотній порядок повідомлень",
|
||||||
|
[nameof(ReverseMessageOrderTooltip)] =
|
||||||
|
"Експортувати повідомлення у зворотному хронологічному порядку (найновіші спочатку)",
|
||||||
|
[nameof(FormatMarkdownLabel)] = "Форматувати markdown",
|
||||||
|
[nameof(FormatMarkdownTooltip)] =
|
||||||
|
"Обробляти markdown, згадки та інші спеціальні токени",
|
||||||
|
[nameof(DownloadAssetsLabel)] = "Завантажувати ресурси",
|
||||||
|
[nameof(DownloadAssetsTooltip)] =
|
||||||
|
"Завантажувати ресурси, на які посилається експорт (аватари, вкладені файли, вбудовані зображення тощо)",
|
||||||
|
[nameof(ReuseAssetsLabel)] = "Повторно використовувати ресурси",
|
||||||
|
[nameof(ReuseAssetsTooltip)] =
|
||||||
|
"Повторно використовувати раніше завантажені ресурси, щоб уникнути зайвих запитів",
|
||||||
|
[nameof(AssetsDirPathLabel)] = "Шлях до директорії ресурсів",
|
||||||
|
[nameof(AssetsDirPathTooltip)] =
|
||||||
|
"Завантажувати ресурси до цієї директорії. Якщо не вказано, шлях до директорії ресурсів буде визначено з шляху збереження.",
|
||||||
|
[nameof(AdvancedOptionsTooltip)] = "Перемкнути розширені параметри",
|
||||||
|
[nameof(ExportButton)] = "ЕКСПОРТУВАТИ",
|
||||||
|
// Common buttons
|
||||||
|
[nameof(CloseButton)] = "ЗАКРИТИ",
|
||||||
|
[nameof(CancelButton)] = "СКАСУВАТИ",
|
||||||
|
// Dialog messages
|
||||||
|
[nameof(UkraineSupportTitle)] = "Дякуємо за підтримку України!",
|
||||||
|
[nameof(UkraineSupportMessage)] = """
|
||||||
|
Поки Росія веде геноцидну війну проти моєї країни, я вдячний кожному, хто продовжує підтримувати Україну у нашій боротьбі за свободу.
|
||||||
|
|
||||||
|
Натисніть ДІЗНАТИСЬ БІЛЬШЕ, щоб знайти способи допомогти.
|
||||||
|
""",
|
||||||
|
[nameof(LearnMoreButton)] = "ДІЗНАТИСЬ БІЛЬШЕ",
|
||||||
|
[nameof(UnstableBuildTitle)] = "Попередження про нестабільну збірку",
|
||||||
|
[nameof(UnstableBuildMessage)] = """
|
||||||
|
Ви використовуєте збірку розробки {0}. Ці збірки не пройшли ретельного тестування та можуть містити помилки.
|
||||||
|
|
||||||
|
Авто-оновлення вимкнено для збірок розробки.
|
||||||
|
|
||||||
|
Натисніть ПЕРЕГЛЯНУТИ РЕЛІЗИ, щоб завантажити стабільний реліз.
|
||||||
|
""",
|
||||||
|
[nameof(SeeReleasesButton)] = "ПЕРЕГЛЯНУТИ РЕЛІЗИ",
|
||||||
|
[nameof(UpdateDownloadingMessage)] = "Завантаження оновлення {0} v{1}...",
|
||||||
|
[nameof(UpdateReadyMessage)] = "Оновлення завантажено та буде встановлено після виходу",
|
||||||
|
[nameof(UpdateInstallNowButton)] = "ВСТАНОВИТИ ЗАРАЗ",
|
||||||
|
[nameof(UpdateFailedMessage)] = "Не вдалося виконати оновлення програми",
|
||||||
|
[nameof(ErrorPullingGuildsTitle)] = "Помилка завантаження серверів",
|
||||||
|
[nameof(ErrorPullingChannelsTitle)] = "Помилка завантаження каналів",
|
||||||
|
[nameof(ErrorExportingTitle)] = "Помилка експорту каналу(-ів)",
|
||||||
|
[nameof(SuccessfulExportMessage)] = "Успішно експортовано {0} канал(-ів)",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using DiscordChatExporter.Gui.Services;
|
||||||
|
using DiscordChatExporter.Gui.Utils;
|
||||||
|
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Localization;
|
||||||
|
|
||||||
|
public partial class LocalizationManager : ObservableObject, IDisposable
|
||||||
|
{
|
||||||
|
private readonly DisposableCollector _eventRoot = new();
|
||||||
|
|
||||||
|
public LocalizationManager(SettingsService settingsService)
|
||||||
|
{
|
||||||
|
_eventRoot.Add(
|
||||||
|
settingsService.WatchProperty(
|
||||||
|
o => o.Language,
|
||||||
|
() => Language = settingsService.Language,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
_eventRoot.Add(
|
||||||
|
this.WatchProperty(
|
||||||
|
o => o.Language,
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
foreach (var propertyName in EnglishLocalization.Keys)
|
||||||
|
OnPropertyChanged(propertyName);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial Language Language { get; set; } = Language.System;
|
||||||
|
|
||||||
|
private string Get([CallerMemberName] string? key = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(key))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var localization = Language switch
|
||||||
|
{
|
||||||
|
Language.System =>
|
||||||
|
CultureInfo.CurrentUICulture.ThreeLetterISOLanguageName.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"ukr" => UkrainianLocalization,
|
||||||
|
"deu" => GermanLocalization,
|
||||||
|
"fra" => FrenchLocalization,
|
||||||
|
"spa" => SpanishLocalization,
|
||||||
|
_ => EnglishLocalization,
|
||||||
|
},
|
||||||
|
Language.Ukrainian => UkrainianLocalization,
|
||||||
|
Language.German => GermanLocalization,
|
||||||
|
Language.French => FrenchLocalization,
|
||||||
|
Language.Spanish => SpanishLocalization,
|
||||||
|
_ => EnglishLocalization,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
localization.TryGetValue(key, out var value)
|
||||||
|
// English is used as a fallback
|
||||||
|
|| EnglishLocalization.TryGetValue(key, out value)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"Missing localization for '{key}'";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => _eventRoot.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class LocalizationManager
|
||||||
|
{
|
||||||
|
// ---- Dashboard ----
|
||||||
|
|
||||||
|
public string PullGuildsTooltip => Get();
|
||||||
|
public string SettingsTooltip => Get();
|
||||||
|
public string LastMessageSentTooltip => Get();
|
||||||
|
public string TokenWatermark => Get();
|
||||||
|
|
||||||
|
// Token instructions (personal account)
|
||||||
|
public string TokenPersonalHeader => Get();
|
||||||
|
public string TokenPersonalTosWarning => Get();
|
||||||
|
public string TokenPersonalInstructions => Get();
|
||||||
|
|
||||||
|
// Token instructions (bot)
|
||||||
|
public string TokenBotHeader => Get();
|
||||||
|
public string TokenBotInstructions => Get();
|
||||||
|
public string TokenHelpText => Get();
|
||||||
|
|
||||||
|
// ---- Settings ----
|
||||||
|
|
||||||
|
public string SettingsTitle => Get();
|
||||||
|
public string ThemeLabel => Get();
|
||||||
|
public string ThemeTooltip => Get();
|
||||||
|
public string LanguageLabel => Get();
|
||||||
|
public string LanguageTooltip => Get();
|
||||||
|
public string AutoUpdateLabel => Get();
|
||||||
|
public string AutoUpdateTooltip => Get();
|
||||||
|
public string PersistTokenLabel => Get();
|
||||||
|
public string PersistTokenTooltip => Get();
|
||||||
|
public string RateLimitPreferenceLabel => Get();
|
||||||
|
public string RateLimitPreferenceTooltip => Get();
|
||||||
|
public string ShowThreadsLabel => Get();
|
||||||
|
public string ShowThreadsTooltip => Get();
|
||||||
|
public string LocaleLabel => Get();
|
||||||
|
public string LocaleTooltip => Get();
|
||||||
|
public string NormalizeToUtcLabel => Get();
|
||||||
|
public string NormalizeToUtcTooltip => Get();
|
||||||
|
public string ParallelLimitLabel => Get();
|
||||||
|
public string ParallelLimitTooltip => Get();
|
||||||
|
|
||||||
|
// ---- Export Setup ----
|
||||||
|
|
||||||
|
public string ChannelsSelectedText => Get();
|
||||||
|
public string OutputPathLabel => Get();
|
||||||
|
public string OutputPathTooltip => Get();
|
||||||
|
public string FormatLabel => Get();
|
||||||
|
public string FormatTooltip => Get();
|
||||||
|
public string AfterDateLabel => Get();
|
||||||
|
public string AfterDateTooltip => Get();
|
||||||
|
public string BeforeDateLabel => Get();
|
||||||
|
public string BeforeDateTooltip => Get();
|
||||||
|
public string AfterTimeLabel => Get();
|
||||||
|
public string AfterTimeTooltip => Get();
|
||||||
|
public string BeforeTimeLabel => Get();
|
||||||
|
public string BeforeTimeTooltip => Get();
|
||||||
|
public string PartitionLimitLabel => Get();
|
||||||
|
public string PartitionLimitTooltip => Get();
|
||||||
|
public string MessageFilterLabel => Get();
|
||||||
|
public string MessageFilterTooltip => Get();
|
||||||
|
public string ReverseMessageOrderLabel => Get();
|
||||||
|
public string ReverseMessageOrderTooltip => Get();
|
||||||
|
public string FormatMarkdownLabel => Get();
|
||||||
|
public string FormatMarkdownTooltip => Get();
|
||||||
|
public string DownloadAssetsLabel => Get();
|
||||||
|
public string DownloadAssetsTooltip => Get();
|
||||||
|
public string ReuseAssetsLabel => Get();
|
||||||
|
public string ReuseAssetsTooltip => Get();
|
||||||
|
public string AssetsDirPathLabel => Get();
|
||||||
|
public string AssetsDirPathTooltip => Get();
|
||||||
|
public string AdvancedOptionsTooltip => Get();
|
||||||
|
public string ExportButton => Get();
|
||||||
|
|
||||||
|
// ---- Common buttons ----
|
||||||
|
|
||||||
|
public string CloseButton => Get();
|
||||||
|
public string CancelButton => Get();
|
||||||
|
|
||||||
|
// ---- Dialog messages ----
|
||||||
|
|
||||||
|
public string UkraineSupportTitle => Get();
|
||||||
|
public string UkraineSupportMessage => Get();
|
||||||
|
public string LearnMoreButton => Get();
|
||||||
|
public string UnstableBuildTitle => Get();
|
||||||
|
public string UnstableBuildMessage => Get();
|
||||||
|
public string SeeReleasesButton => Get();
|
||||||
|
public string UpdateDownloadingMessage => Get();
|
||||||
|
public string UpdateReadyMessage => Get();
|
||||||
|
public string UpdateInstallNowButton => Get();
|
||||||
|
public string UpdateFailedMessage => Get();
|
||||||
|
public string ErrorPullingGuildsTitle => Get();
|
||||||
|
public string ErrorPullingChannelsTitle => Get();
|
||||||
|
public string ErrorExportingTitle => Get();
|
||||||
|
public string SuccessfulExportMessage => Get();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
using System;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using DiscordChatExporter.Gui.Utils.Extensions;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Services;
|
||||||
|
|
||||||
|
public partial class SettingsService
|
||||||
|
{
|
||||||
|
private class TokenEncryptionConverter : JsonConverter<string?>
|
||||||
|
{
|
||||||
|
private const string Prefix = "enc:";
|
||||||
|
|
||||||
|
private static readonly Lazy<byte[]> Key = new(() =>
|
||||||
|
Rfc2898DeriveBytes.Pbkdf2(
|
||||||
|
Encoding.UTF8.GetBytes(Environment.TryGetMachineId() ?? string.Empty),
|
||||||
|
Encoding.UTF8.GetBytes(ThisAssembly.Project.EncryptionSalt),
|
||||||
|
600_000,
|
||||||
|
HashAlgorithmName.SHA256,
|
||||||
|
16
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
public override string? Read(
|
||||||
|
ref Utf8JsonReader reader,
|
||||||
|
Type typeToConvert,
|
||||||
|
JsonSerializerOptions options
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var value = reader.GetString();
|
||||||
|
|
||||||
|
// No prefix means the token is stored as plain text, which was the case for older
|
||||||
|
// versions of the application. Load it as is and encrypt it on next save.
|
||||||
|
if (
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
|| !value.StartsWith(Prefix, StringComparison.Ordinal)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var encryptedData = Convert.FromHexString(value[Prefix.Length..]);
|
||||||
|
var tokenData = new byte[encryptedData.AsSpan(28).Length];
|
||||||
|
|
||||||
|
// Layout: nonce (12 bytes) | tag (16 bytes) | cipher
|
||||||
|
using var aes = new AesGcm(Key.Value, 16);
|
||||||
|
aes.Decrypt(
|
||||||
|
encryptedData.AsSpan(0, 12),
|
||||||
|
encryptedData.AsSpan(28),
|
||||||
|
encryptedData.AsSpan(12, 16),
|
||||||
|
tokenData
|
||||||
|
);
|
||||||
|
|
||||||
|
return Encoding.UTF8.GetString(tokenData);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
when (ex
|
||||||
|
is FormatException
|
||||||
|
or CryptographicException
|
||||||
|
or ArgumentException
|
||||||
|
or IndexOutOfRangeException
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(
|
||||||
|
Utf8JsonWriter writer,
|
||||||
|
string? value,
|
||||||
|
JsonSerializerOptions options
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
writer.WriteNullValue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenData = Encoding.UTF8.GetBytes(value);
|
||||||
|
var encryptedData = new byte[28 + tokenData.Length];
|
||||||
|
|
||||||
|
// Nonce
|
||||||
|
RandomNumberGenerator.Fill(encryptedData.AsSpan(0, 12));
|
||||||
|
|
||||||
|
// Layout: nonce (12 bytes) | tag (16 bytes) | cipher
|
||||||
|
using var aes = new AesGcm(Key.Value, 16);
|
||||||
|
aes.Encrypt(
|
||||||
|
encryptedData.AsSpan(0, 12),
|
||||||
|
tokenData,
|
||||||
|
encryptedData.AsSpan(28),
|
||||||
|
encryptedData.AsSpan(12, 16)
|
||||||
|
);
|
||||||
|
|
||||||
|
writer.WriteStringValue(Prefix + Convert.ToHexStringLower(encryptedData));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,132 +3,79 @@ 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.Localization;
|
||||||
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 Language Language { get; set; }
|
||||||
{
|
|
||||||
get => _isAutoUpdateEnabled;
|
|
||||||
set => SetProperty(ref _isAutoUpdateEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _isTokenPersisted = true;
|
[ObservableProperty]
|
||||||
public bool IsTokenPersisted
|
public partial bool IsAutoUpdateEnabled { get; set; } = true;
|
||||||
{
|
|
||||||
get => _isTokenPersisted;
|
|
||||||
set => SetProperty(ref _isTokenPersisted, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ThreadInclusionMode _threadInclusionMode;
|
[ObservableProperty]
|
||||||
public ThreadInclusionMode ThreadInclusionMode
|
public partial bool IsTokenPersisted { get; set; } = true;
|
||||||
{
|
|
||||||
get => _threadInclusionMode;
|
|
||||||
set => SetProperty(ref _threadInclusionMode, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _locale;
|
[ObservableProperty]
|
||||||
public string? Locale
|
public partial RateLimitPreference RateLimitPreference { get; set; } =
|
||||||
{
|
RateLimitPreference.RespectAll;
|
||||||
get => _locale;
|
|
||||||
set => SetProperty(ref _locale, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _isUtcNormalizationEnabled;
|
[ObservableProperty]
|
||||||
public bool IsUtcNormalizationEnabled
|
public partial ThreadInclusionMode ThreadInclusionMode { get; set; }
|
||||||
{
|
|
||||||
get => _isUtcNormalizationEnabled;
|
|
||||||
set => SetProperty(ref _isUtcNormalizationEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int _parallelLimit = 1;
|
[ObservableProperty]
|
||||||
public int ParallelLimit
|
public partial string? Locale { get; set; }
|
||||||
{
|
|
||||||
get => _parallelLimit;
|
|
||||||
set => SetProperty(ref _parallelLimit, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _lastToken;
|
[ObservableProperty]
|
||||||
public string? LastToken
|
public partial bool IsUtcNormalizationEnabled { get; set; }
|
||||||
{
|
|
||||||
get => _lastToken;
|
|
||||||
set => SetProperty(ref _lastToken, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ExportFormat _lastExportFormat = ExportFormat.HtmlDark;
|
[ObservableProperty]
|
||||||
public ExportFormat LastExportFormat
|
public partial int ParallelLimit { get; set; } = 1;
|
||||||
{
|
|
||||||
get => _lastExportFormat;
|
|
||||||
set => SetProperty(ref _lastExportFormat, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _lastPartitionLimitValue;
|
[ObservableProperty]
|
||||||
public string? LastPartitionLimitValue
|
[JsonConverter(typeof(TokenEncryptionConverter))]
|
||||||
{
|
public partial string? LastToken { get; set; }
|
||||||
get => _lastPartitionLimitValue;
|
|
||||||
set => SetProperty(ref _lastPartitionLimitValue, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _lastMessageFilterValue;
|
[ObservableProperty]
|
||||||
public string? LastMessageFilterValue
|
public partial ExportFormat LastExportFormat { get; set; } = ExportFormat.HtmlDark;
|
||||||
{
|
|
||||||
get => _lastMessageFilterValue;
|
|
||||||
set => SetProperty(ref _lastMessageFilterValue, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _lastShouldFormatMarkdown = true;
|
[ObservableProperty]
|
||||||
public bool LastShouldFormatMarkdown
|
public partial string? LastPartitionLimitValue { get; set; }
|
||||||
{
|
|
||||||
get => _lastShouldFormatMarkdown;
|
|
||||||
set => SetProperty(ref _lastShouldFormatMarkdown, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _lastShouldDownloadAssets;
|
[ObservableProperty]
|
||||||
public bool LastShouldDownloadAssets
|
public partial string? LastMessageFilterValue { get; set; }
|
||||||
{
|
|
||||||
get => _lastShouldDownloadAssets;
|
|
||||||
set => SetProperty(ref _lastShouldDownloadAssets, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _lastShouldReuseAssets;
|
[ObservableProperty]
|
||||||
public bool LastShouldReuseAssets
|
public partial bool LastIsReverseMessageOrder { get; set; }
|
||||||
{
|
|
||||||
get => _lastShouldReuseAssets;
|
|
||||||
set => SetProperty(ref _lastShouldReuseAssets, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? _lastAssetsDirPath;
|
[ObservableProperty]
|
||||||
public string? LastAssetsDirPath
|
public partial bool LastShouldFormatMarkdown { get; set; } = true;
|
||||||
{
|
|
||||||
get => _lastAssetsDirPath;
|
[ObservableProperty]
|
||||||
set => SetProperty(ref _lastAssetsDirPath, value);
|
public partial bool LastShouldDownloadAssets { get; set; }
|
||||||
}
|
|
||||||
|
[ObservableProperty]
|
||||||
|
public partial bool LastShouldReuseAssets { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace DiscordChatExporter.Gui.Utils.Extensions;
|
||||||
|
|
||||||
|
internal static class EnvironmentExtensions
|
||||||
|
{
|
||||||
|
extension(Environment)
|
||||||
|
{
|
||||||
|
public static string? TryGetMachineId()
|
||||||
|
{
|
||||||
|
// Windows: stable GUID written during OS installation
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var regKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(
|
||||||
|
@"SOFTWARE\Microsoft\Cryptography"
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
regKey?.GetValue("MachineGuid") is string guid
|
||||||
|
&& !string.IsNullOrWhiteSpace(guid)
|
||||||
|
)
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Unix: /etc/machine-id (set once by systemd at first boot)
|
||||||
|
foreach (var path in new[] { "/etc/machine-id", "/var/lib/dbus/machine-id" })
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var id = File.ReadAllText(path).Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(id))
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-resort fallback
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Environment.MachineName;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user