Compare commits

...

67 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] b7f55ff633 Remove TokenPersonalStep1Before/After split, fold step 1 into markdown instructions
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 10:03:48 +00:00
copilot-swe-agent[bot] 5bbd3fa65e Adopt MarkdownToInlinesConverter for instruction text formatting
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 09:55:08 +00:00
copilot-swe-agent[bot] c6871af438 Localize token field watermark and instruction text
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 09:35:40 +00:00
copilot-swe-agent[bot] 2e29d03648 Fix Ukrainian translations per review feedback
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 08:32:00 +00:00
copilot-swe-agent[bot] 7257332914 Fix Ukrainian translation: use 'Гілки' for threads
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 08:26:26 +00:00
copilot-swe-agent[bot] b1b0dc1625 Implement localization in DiscordChatExporter following YoutubeDownloader pattern
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 07:40:35 +00:00
copilot-swe-agent[bot] 10a5d1b2bf Initial plan 2026-02-24 07:24:02 +00:00
Copilot 18086aa209 Fix second dialog not appearing when dialogs are shown sequentially (#1481)
* Initial plan

* Add Task.Yield() to DialogManager to fix sequential dialog display

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-23 07:32:56 +02:00
Copilot dd7196b6a5 Resolve thread mentions on demand (#1480)
* Initial plan

* Fix unresolved thread mentions in HTML export (#1261)

- Add TryGetChannelAsync to DiscordClient for on-demand channel/thread lookup
- Add PopulateChannelAsync to ExportContext with negative caching
- Update HtmlMarkdownVisitor to resolve thread mentions on demand

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Refactor GetChannelAsync to use TryGetChannelAsync for parent resolution

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Add test for thread mention resolution in HTML export

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Apply PopulateChannelAsync to PlainTextMarkdownVisitor; add JSON thread mention test

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-21 23:21:47 +02:00
Copilot 72f9e981de Replace YouTube iframe embeds with clickable thumbnails (#1474)
* Initial plan

* Replace YouTube iframe with clickable thumbnail

- Changed YouTube embed URL from embed format to watch format
- Added ThumbnailUrl property to YouTubeVideoEmbedProjection using YouTube's standard thumbnail URL
- Updated MessageGroupTemplate to render thumbnail image with link instead of iframe
- Updated CSS to style thumbnail appropriately
- Updated test to check for anchor link and thumbnail image instead of iframe

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Address code review feedback

- Use hqdefault.jpg instead of maxresdefault.jpg (guaranteed to exist for all videos)
- Extract thumbnail URL logic to GetThumbnailUrl method for better testability

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Simplify YouTube embed test to focus on data not structure

- Remove checks for HTML structure (class names, nested elements)
- Focus on verifying YouTube URL with correct video ID exists
- Follow same pattern as other embed tests (Spotify, Twitch)

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Fix data-canonical-url and improve test specificity

- Use canonical (non-proxy) URL for data-canonical-url attribute
- Extract thumbnailUrl to local variable to avoid duplicate calls
- Update test to check for img with video ID in src, avoiding false positives
- Test now verifies the actual thumbnail data rather than any link

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Inline thumbnail URL logic directly into attributes

- Remove GetThumbnailUrl method from YouTubeVideoEmbedProjection
- Remove local variables from template
- Put coalescing logic directly in src and data-canonical-url attributes

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-12 19:04:17 +02:00
Copilot 81e286751f Add file:// protocol to absolute asset paths in HTML exports (#1475)
* Initial plan

* Add file:// protocol prefix to absolute paths in HTML exports

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Improve absolute path detection for cross-platform compatibility

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Remove unused System.IO import

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

* Use Path.IsPathFullyQualified for absolute path detection (fixes #1473)

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-12 13:19:12 +02:00
dependabot[bot] 225230a20f Bump the actions group with 2 updates (#1467)
Bumps the actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-dotnet](https://github.com/actions/setup-dotnet).


Updates `actions/checkout` from 6.0.1 to 6.0.2
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/8e8c483db84b4bee98b60c0593521ed34d9990e8...de0fac2e4500dabe0009e67214ff5f5447ce83dd)

Updates `actions/setup-dotnet` from 5.0.1 to 5.1.0
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/2016bd2012dba4e32de620c46fe006a3ac9f0602...baa11fbfe1d6520db94683bd5c7a3818018e4309)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-dotnet
  dependency-version: 5.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 17:28:59 +02:00
dependabot[bot] dea412ff25 Bump the nuget group with 9 updates (#1468)
Bumps AsyncKeyedLock from 7.1.8 to 8.0.1
Bumps Avalonia from 11.3.10 to 11.3.11
Bumps Avalonia.Desktop from 11.3.10 to 11.3.11
Bumps Avalonia.Diagnostics from 11.3.10 to 11.3.11
Bumps DialogHost.Avalonia from 0.10.3 to 0.10.4
Bumps Microsoft.Extensions.Configuration from 10.0.1 to 10.0.2
Bumps Microsoft.Extensions.Configuration.EnvironmentVariables from 10.0.1 to 10.0.2
Bumps Microsoft.Extensions.Configuration.UserSecrets from 10.0.1 to 10.0.2
Bumps Microsoft.Extensions.DependencyInjection from 10.0.1 to 10.0.2

---
updated-dependencies:
- dependency-name: AsyncKeyedLock
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-version: 11.3.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia.Desktop
  dependency-version: 11.3.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia.Diagnostics
  dependency-version: 11.3.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: DialogHost.Avalonia
  dependency-version: 0.10.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration
  dependency-version: 10.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration.EnvironmentVariables
  dependency-version: 10.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration.UserSecrets
  dependency-version: 10.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.DependencyInjection
  dependency-version: 10.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 17:28:47 +02:00
Tyrrrz 95953f8cf0 Refactor 2026-01-01 23:56:12 +02:00
Tyrrrz 6191855e33 Fix invalid issue link in comment 2026-01-01 23:53:57 +02:00
Copilot 33a975b907 Fix case-insensitive lookup for data package index file (#1460)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-01-01 21:19:59 +02:00
Tyrrrz 3d353bc1e6 Format 2026-01-01 20:40:37 +02:00
dependabot[bot] f555ab3f00 Bump the actions group with 5 updates (#1457)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 20:37:11 +02:00
dependabot[bot] a14a17db83 Bump the nuget group with 11 updates (#1458)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-01 20:36:41 +02:00
Tyrrrz 1f63d4fa26 Line breaks 2025-12-31 18:57:08 +02:00
Copilot d3d078735a Preserve original URLs for proxied embed assets (#1456)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-12-31 18:54:04 +02:00
Tyrrrz 58dd40bf3e Refactor last commit 2025-12-29 20:11:05 +02:00
Copilot 7c79e0639b Fix filename sanitization for NTFS filesystems on non-Windows systems (#1455)
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-12-29 19:52:59 +02:00
Tyrrrz a74ba4da0c Update license year 2025-12-06 19:20:21 +02:00
Tyrrrz 8bbb8fa5ee Clean up 2025-12-03 23:37:23 +02:00
CarcaBot a91337ccac Fixed token retrieval through console (#1402) 2025-12-02 16:43:00 +02:00
Tyrrrz 303f5667bf Remove unused package 2025-12-01 20:51:03 +02:00
dependabot[bot] 934cb526e9 Bump the nuget group with 17 updates (#1445)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-01 20:47:52 +02:00
dependabot[bot] c33b742dce Bump the actions group with 2 updates (#1446)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-01 20:47:02 +02:00
Tyrrrz f0a74fbc6e Simplify 2025-11-30 19:24:37 +02:00
Tyrrrz 768e124370 Remove unnecessary extension 2025-11-30 19:23:01 +02:00
Tyrrrz 8dc7eb2937 Use markdown alerts 2025-11-24 17:58:06 +02:00
Tyrrrz 8fff0f4445 Prettify project files 2025-11-18 19:07:17 +02:00
Tyrrrz 0fc8124aa0 Cleanup the trim/aot analyzer hack 2025-11-18 00:55:23 +02:00
Tyrrrz 64ed6acf34 Don't use type aliases 2025-11-18 00:36:47 +02:00
Tyrrrz e958600073 Disable trim warnings in CLI as a workaround for C# 14 extension block issue 2025-11-16 23:28:18 +02:00
Tyrrrz 714d2e6806 Update sln 2025-11-16 20:55:00 +02:00
Tyrrrz fbbac2afaa Make use of C# 14 features 2025-11-16 20:29:39 +02:00
Tyrrrz 380dd6d511 Update to .NET 10 2025-11-11 19:46:22 +02:00
dependabot[bot] 607ec7442f Bump the actions group with 2 updates (#1440)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-01 13:05:14 +02:00
dependabot[bot] e6f675bcaa Bump the nuget group with 14 updates (#1441)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-01 13:05:05 +02:00
Tyrrrz 42ebb9928e Fix CD workflow 2025-10-04 04:46:01 +03:00
Tyrrrz 7e64aa5a8e Add default to PublishMacOSBundle to avoid error when publishing without extra args 2025-10-04 04:25:40 +03:00
dependabot[bot] a383722469 Bump the nuget group with 13 updates (#1431)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 16:08:07 +03:00
dependabot[bot] 8f3fe7e501 Bump the actions group with 2 updates (#1430)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-02 16:07:47 +03:00
Tyrrrz 27a434a663 Format 2025-09-02 19:55:09 +03:00
dependabot[bot] a966bee140 Bump the nuget group with 12 updates (#1429)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 19:50:14 +03:00
dependabot[bot] fb17e86146 Bump the actions group with 3 updates (#1428)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-02 19:49:59 +03:00
dependabot[bot] 8fcf5e5377 Bump the nuget group with 7 updates (#1417)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-01 16:59:39 +03:00
Tyrrrz 845e2a52fe Fix guilds -> servers 2025-07-09 01:10:56 +03:00
Tyrrrz 187befcf11 Remove the now unnecessary extension method 2025-07-01 19:02:48 +03:00
dependabot[bot] 9e685a0375 Bump the nuget group with 15 updates (#1408)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-01 19:00:52 +03:00
dependabot[bot] 0d98e1084d Bump docker/setup-buildx-action in the actions group (#1409)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-01 18:59:12 +03:00
Tyrrrz f149fad406 Potato potato 2025-06-26 17:50:32 +03:00
Oleksii Holub 5405e619a9 Set application name to fix macOS app menu title (#1405) 2025-06-26 00:35:32 +03:00
Tyrrrz adafaaa49d Clean up chmods 2025-06-25 19:27:26 +03:00
Oleksii Holub d071459a5b Fix crash when exporting multiple channels to drive root (#1399) 2025-06-16 20:38:10 +03:00
Tyrrrz cc92b2cbe6 More cleanup 2025-06-08 22:53:10 +03:00
Tyrrrz 08718425f1 Clean up 2025-06-08 22:37:24 +03:00
Tyrrrz 540ba34fb4 Rename solution items 2025-06-08 21:25:01 +03:00
Tyrrrz 22134108a4 Apply new formatting 2025-06-08 21:17:10 +03:00
dependabot[bot] 82499af25a Bump CliFx and 9 others (#1389)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-08 21:13:22 +03:00
Rimi Kanokawa 4a74ff8a02 Make the names assigned to unnamed group DMs predictable (#1386) 2025-06-02 02:01:15 +03:00
Mark Cilia Vincenti 7dfcf5ac19 Change InvalidFileNameChars to use FrozenSet instead of HashSet (#1384) 2025-06-02 01:48:25 +03:00
dependabot[bot] 19c5fb40b2 Bump codecov/codecov-action from 5.4.2 to 5.4.3 in the actions group (#1388)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-02 01:40:30 +03:00
audinowho d4fa8f0954 Support --include-threads in the export command (#1343) 2025-05-27 23:37:54 +03:00
Oleksii Holub 0d9168e0d2 Include notes about running the app on macOS in readme 2025-05-22 21:16:13 +03:00
86 changed files with 2117 additions and 974 deletions
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -27,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.
@@ -120,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.
@@ -306,7 +306,7 @@ If you don't have a bot token yet or if you've lost it, follow these steps to re
> **Tip**: > **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. > As the token is only shown once, make sure to store it in a safe place. If you lose the token, you will have to reset it again.
> **Warning**: > [!WARNING]
> Resetting the token will invalidate the old one. Any integrations relying on the old token will cease to function until they are updated. > Resetting the token will invalidate the old one. Any integrations relying on the old token will cease to function until they are updated.
![https://discord.com/developers/applications/](https://i.imgur.com/soiB8Qc.png) ![https://discord.com/developers/applications/](https://i.imgur.com/soiB8Qc.png)
+1 -1
View File
@@ -31,7 +31,7 @@ If you skip these steps, the "DiscordChatExporter is damaged and cant be open
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.
+6 -6
View File
@@ -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@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 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: >
+14 -23
View File
@@ -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@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 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@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 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@ad3126e916f78f00edff4ed0317cf185271ccc2d # v5.4.2 uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
@@ -121,12 +117,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@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1 uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
with:
dotnet-version: 9.0.x
- name: Publish app - name: Publish app
run: > run: >
@@ -139,8 +133,8 @@ jobs:
--runtime ${{ matrix.rid }} --runtime ${{ matrix.rid }}
--self-contained --self-contained
- name: Upload artifacts - name: Upload app binaries
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 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/
@@ -204,8 +198,8 @@ jobs:
contents: write contents: write
steps: steps:
- name: Download artifacts - name: Download app binaries
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 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 }}/
@@ -213,13 +207,10 @@ jobs:
- name: Set permissions - name: Set permissions
if: ${{ !startsWith(matrix.rid, 'win-') }} if: ${{ !startsWith(matrix.rid, 'win-') }}
run: | run: |
if [ -f ${{ matrix.app }}/${{ matrix.asset }} ]; then [ -f ${{ matrix.app }}/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }} || true
chmod +x ${{ matrix.app }}/${{ matrix.asset }}
fi
if [ -f ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} ]; then # macOS bundle
chmod +x ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} [ -f ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} ] && chmod +x ${{ matrix.app }}/${{ matrix.asset }}.app/Contents/MacOS/${{ matrix.asset }} || true
fi
- name: Create package - name: Create package
# Change into the artifacts directory to avoid including the directory itself in the zip archive # Change into the artifacts directory to avoid including the directory itself in the zip archive
+1 -1
View File
@@ -1,7 +1,7 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Version>999.9.9-dev</Version> <Version>999.9.9-dev</Version>
<Company>Tyrrrz</Company> <Company>Tyrrrz</Company>
<Copyright>Copyright (c) Oleksii Holub</Copyright> <Copyright>Copyright (c) Oleksii Holub</Copyright>
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject> <IsTestProject>true</IsTestProject>
@@ -11,22 +10,24 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.3.0" /> <PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" /> <PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="8.2.0" /> <PackageReference Include="FluentAssertions" Version="8.8.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="3.0.1" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.4" /> <PackageReference
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.4" /> Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" /> Version="10.0.2"
/>
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" PrivateAssets="all" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" /> <ProjectReference Include="..\DiscordChatExporter.Cli\DiscordChatExporter.Cli.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+1 -1
View File
@@ -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.
@@ -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]
@@ -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");
}
} }
@@ -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");
}
} }
@@ -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);
} }
+2 -2
View File
@@ -1,7 +1,7 @@
# -- Build # -- Build
# Specify the platform here so that we pull the SDK image matching the host platform, # Specify the platform here so that we pull the SDK image matching the host platform,
# instead of the target platform specified during build by the `--platform` option. # instead of the target platform specified during build by the `--platform` option.
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-alpine AS build FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
# Expose the target architecture set by the `docker build --platform` option, so that # Expose the target architecture set by the `docker build --platform` option, so that
# we can build the assembly for the correct platform. # we can build the assembly for the correct platform.
@@ -30,7 +30,7 @@ RUN dotnet publish DiscordChatExporter.Cli \
# -- Run # -- Run
# Use `runtime-deps` instead of `runtime` because we have a self-contained assembly # Use `runtime-deps` instead of `runtime` because we have a self-contained assembly
FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:9.0-alpine AS run FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS run
LABEL org.opencontainers.image.title="DiscordChatExporter.Cli" LABEL org.opencontainers.image.title="DiscordChatExporter.Cli"
LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file." LABEL org.opencontainers.image.description="DiscordChatExporter is an application that can be used to export message history from any Discord channel to a file."
@@ -1,4 +1,5 @@
using System; using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx; using CliFx;
using CliFx.Attributes; using CliFx.Attributes;
@@ -34,9 +35,9 @@ public abstract class DiscordCommandBase : ICommand
)] )]
public bool ShouldRespectRateLimits { get; init; } = true; public bool ShouldRespectRateLimits { get; init; } = true;
private DiscordClient? _discordClient; [field: AllowNull, MaybeNull]
protected DiscordClient Discord => protected DiscordClient Discord =>
_discordClient ??= new DiscordClient( field ??= new DiscordClient(
Token, Token,
ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll ShouldRespectRateLimits ? RateLimitPreference.RespectAll : RateLimitPreference.IgnoreAll
); );
@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -8,6 +9,7 @@ using CliFx.Attributes;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Converters; using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
@@ -15,7 +17,6 @@ using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Exporting.Filtering; using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning; using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils.Extensions;
using Gress; using Gress;
using Spectre.Console; using Spectre.Console;
@@ -23,8 +24,6 @@ namespace DiscordChatExporter.Cli.Commands.Base;
public abstract class ExportCommandBase : DiscordCommandBase public abstract class ExportCommandBase : DiscordCommandBase
{ {
private readonly string _outputPath = Directory.GetCurrentDirectory();
[CommandOption( [CommandOption(
"output", "output",
'o', 'o',
@@ -35,11 +34,11 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public string OutputPath public string OutputPath
{ {
get => _outputPath; get;
// Handle ~/ in paths on Unix systems // Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903 // https://github.com/Tyrrrz/DiscordChatExporter/pull/903
init => _outputPath = Path.GetFullPath(value); init => field = Path.GetFullPath(value);
} } = Directory.GetCurrentDirectory();
[CommandOption("format", 'f', Description = "Export format.")] [CommandOption("format", 'f', Description = "Export format.")]
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark; public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
@@ -64,6 +63,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null; public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeBindingConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
[CommandOption( [CommandOption(
"filter", "filter",
Description = "Only include messages that satisfy this filter. " Description = "Only include messages that satisfy this filter. "
@@ -95,8 +101,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public bool ShouldReuseAssets { get; init; } = false; public bool ShouldReuseAssets { get; init; } = false;
private readonly string? _assetsDirPath;
[CommandOption( [CommandOption(
"media-dir", "media-dir",
Description = "Download assets to this directory. " Description = "Download assets to this directory. "
@@ -104,10 +108,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public string? AssetsDirPath public string? AssetsDirPath
{ {
get => _assetsDirPath; get;
// Handle ~/ in paths on Unix systems // Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903 // https://github.com/Tyrrrz/DiscordChatExporter/pull/903
init => _assetsDirPath = value is not null ? Path.GetFullPath(value) : null; init => field = value is not null ? Path.GetFullPath(value) : null;
} }
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")] [Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
@@ -136,11 +140,13 @@ public abstract class ExportCommandBase : DiscordCommandBase
)] )]
public bool IsUkraineSupportMessageDisabled { get; init; } = false; public bool IsUkraineSupportMessageDisabled { get; init; } = false;
private ChannelExporter? _channelExporter; [field: AllowNull, MaybeNull]
protected ChannelExporter Exporter => _channelExporter ??= new ChannelExporter(Discord); protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels) protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Channel> channels)
{ {
var cancellationToken = console.RegisterCancellationHandler();
// Asset reuse can only be enabled if the download assets option is set // Asset reuse can only be enabled if the download assets option is set
// https://github.com/Tyrrrz/DiscordChatExporter/issues/425 // https://github.com/Tyrrrz/DiscordChatExporter/issues/425
if (ShouldReuseAssets && !ShouldDownloadAssets) if (ShouldReuseAssets && !ShouldDownloadAssets)
@@ -154,13 +160,53 @@ public abstract class ExportCommandBase : DiscordCommandBase
throw new CommandException("Option --media-dir cannot be used without --media."); throw new CommandException("Option --media-dir cannot be used without --media.");
} }
var unwrappedChannels = new List<Channel>(channels);
// Unwrap threads
if (ThreadInclusionMode != ThreadInclusionMode.None)
{
await console.Output.WriteLineAsync("Fetching threads...");
var fetchedThreadsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var thread in Discord.GetChannelThreadsAsync(
channels,
ThreadInclusionMode == ThreadInclusionMode.All,
Before,
After,
cancellationToken
)
)
{
unwrappedChannels.Add(thread);
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
fetchedThreadsCount++;
}
}
);
// Remove forums, as they cannot be exported directly and their constituent threads
// have already been fetched.
unwrappedChannels.RemoveAll(channel => channel.Kind == ChannelKind.GuildForum);
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
}
// Make sure the user does not try to export multiple channels into one file. // Make sure the user does not try to export multiple channels into one file.
// Output path must either be a directory or contain template tokens for this to work. // Output path must either be a directory or contain template tokens for this to work.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/799 // https://github.com/Tyrrrz/DiscordChatExporter/issues/799
// https://github.com/Tyrrrz/DiscordChatExporter/issues/917 // https://github.com/Tyrrrz/DiscordChatExporter/issues/917
var isValidOutputPath = var isValidOutputPath =
// Anything is valid when exporting a single channel // Anything is valid when exporting a single channel
channels.Count <= 1 unwrappedChannels.Count <= 1
// When using template tokens, assume the user knows what they're doing // When using template tokens, assume the user knows what they're doing
|| OutputPath.Contains('%') || OutputPath.Contains('%')
// Otherwise, require an existing directory or an unambiguous directory path // Otherwise, require an existing directory or an unambiguous directory path
@@ -177,11 +223,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
} }
// Export // Export
var cancellationToken = console.RegisterCancellationHandler();
var errorsByChannel = new ConcurrentDictionary<Channel, string>(); var errorsByChannel = new ConcurrentDictionary<Channel, string>();
var warningsByChannel = new ConcurrentDictionary<Channel, string>(); 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(
@@ -193,7 +238,7 @@ public abstract class ExportCommandBase : DiscordCommandBase
.StartAsync(async ctx => .StartAsync(async ctx =>
{ {
await Parallel.ForEachAsync( await Parallel.ForEachAsync(
channels, unwrappedChannels,
new ParallelOptions new ParallelOptions
{ {
MaxDegreeOfParallelism = Math.Max(1, ParallelLimit), MaxDegreeOfParallelism = Math.Max(1, ParallelLimit),
@@ -253,7 +298,7 @@ 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)."
); );
} }
@@ -301,48 +346,10 @@ public abstract class ExportCommandBase : DiscordCommandBase
// Fail the command only if ALL channels failed to export. // Fail the command only if ALL channels failed to export.
// If only some channels failed to export, it's okay. // If only some channels failed to export, it's okay.
if (errorsByChannel.Count >= channels.Count) if (errorsByChannel.Count >= unwrappedChannels.Count)
throw new CommandException("Export failed."); throw new CommandException("Export failed.");
} }
protected async ValueTask ExportAsync(IConsole console, IReadOnlyList<Snowflake> channelIds)
{
var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Resolving channel(s)...");
var channels = new List<Channel>();
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
foreach (var channelId in channelIds)
{
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
// Unwrap categories
if (channel.IsCategory)
{
var guildChannels =
channelsByGuild.GetValueOrDefault(channel.GuildId)
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
foreach (var guildChannel in guildChannels)
{
if (guildChannel.Parent?.Id == channel.Id)
channels.Add(guildChannel);
}
// Cache the guild channels to avoid redundant work
channelsByGuild[channel.GuildId] = guildChannels;
}
else
{
channels.Add(channel);
}
}
await ExportAsync(console, channels);
}
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
// Support Ukraine callout // Support Ukraine callout
@@ -5,8 +5,6 @@ using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Dump; using DiscordChatExporter.Core.Discord.Dump;
@@ -27,13 +25,6 @@ public class ExportAllCommand : ExportCommandBase
[CommandOption("include-vc", Description = "Include voice channels.")] [CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true; public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeBindingConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
[CommandOption( [CommandOption(
"data-package", "data-package",
Description = "Path to the personal data package (ZIP file) requested from Discord. " Description = "Path to the personal data package (ZIP file) requested from Discord. "
@@ -90,46 +81,6 @@ public class ExportAllCommand : ExportCommandBase
); );
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s)."); await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
// Threads
if (ThreadInclusionMode != ThreadInclusionMode.None)
{
await console.Output.WriteLineAsync(
$"Fetching threads for server '{guild.Name}'..."
);
var fetchedThreadsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var thread in Discord.GetGuildThreadsAsync(
guild.Id,
ThreadInclusionMode == ThreadInclusionMode.All,
Before,
After,
cancellationToken
)
)
{
channels.Add(thread);
ctx.Status(
Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'.")
);
fetchedThreadsCount++;
}
}
);
await console.Output.WriteLineAsync(
$"Fetched {fetchedThreadsCount} thread(s)."
);
}
} }
} }
// Pull from the data package // Pull from the data package
@@ -199,10 +150,6 @@ public class ExportAllCommand : ExportCommandBase
channels.RemoveAll(c => c.IsGuild); channels.RemoveAll(c => c.IsGuild);
if (!IncludeVoiceChannels) if (!IncludeVoiceChannels)
channels.RemoveAll(c => c.IsVoice); channels.RemoveAll(c => c.IsVoice);
if (ThreadInclusionMode == ThreadInclusionMode.None)
channels.RemoveAll(c => c.IsThread);
if (ThreadInclusionMode != ThreadInclusionMode.All)
channels.RemoveAll(c => c is { IsThread: true, IsArchived: true });
await ExportAsync(console, channels); await ExportAsync(console, channels);
} }
@@ -4,6 +4,8 @@ using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands; namespace DiscordChatExporter.Cli.Commands;
@@ -22,6 +24,40 @@ public class ExportChannelsCommand : ExportCommandBase
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
await base.ExecuteAsync(console); await base.ExecuteAsync(console);
await ExportAsync(console, ChannelIds);
var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Resolving channel(s)...");
var channels = new List<Channel>();
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
foreach (var channelId in ChannelIds)
{
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
// Unwrap categories
if (channel.IsCategory)
{
var guildChannels =
channelsByGuild.GetValueOrDefault(channel.GuildId)
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
foreach (var guildChannel in guildChannels)
{
if (guildChannel.Parent?.Id == channel.Id)
channels.Add(guildChannel);
}
// Cache the guild channels to avoid redundant work
channelsByGuild[channel.GuildId] = guildChannels;
}
else
{
channels.Add(channel);
}
}
await ExportAsync(console, channels);
} }
} }
@@ -3,8 +3,6 @@ using System.Threading.Tasks;
using CliFx.Attributes; using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base; using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
using DiscordChatExporter.Cli.Utils.Extensions; using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
@@ -21,13 +19,6 @@ public class ExportGuildCommand : ExportCommandBase
[CommandOption("include-vc", Description = "Include voice channels.")] [CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true; public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeBindingConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
public override async ValueTask ExecuteAsync(IConsole console) public override async ValueTask ExecuteAsync(IConsole console)
{ {
await base.ExecuteAsync(console); await base.ExecuteAsync(console);
@@ -35,7 +26,6 @@ public class ExportGuildCommand : ExportCommandBase
var cancellationToken = console.RegisterCancellationHandler(); var cancellationToken = console.RegisterCancellationHandler();
var channels = new List<Channel>(); var channels = new List<Channel>();
// Regular channels
await console.Output.WriteLineAsync("Fetching channels..."); await console.Output.WriteLineAsync("Fetching channels...");
var fetchedChannelsCount = 0; var fetchedChannelsCount = 0;
@@ -66,40 +56,6 @@ public class ExportGuildCommand : ExportCommandBase
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s)."); await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
// Threads
if (ThreadInclusionMode != ThreadInclusionMode.None)
{
await console.Output.WriteLineAsync("Fetching threads...");
var fetchedThreadsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var thread in Discord.GetGuildThreadsAsync(
GuildId,
ThreadInclusionMode == ThreadInclusionMode.All,
Before,
After,
cancellationToken
)
)
{
channels.Add(thread);
ctx.Status(Markup.Escape($"Fetched '{thread.GetHierarchicalName()}'."));
fetchedThreadsCount++;
}
}
);
await console.Output.WriteLineAsync($"Fetched {fetchedThreadsCount} thread(s).");
}
await ExportAsync(console, channels); await ExportAsync(console, channels);
} }
} }
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<ApplicationIcon>..\favicon.ico</ApplicationIcon> <ApplicationIcon>..\favicon.ico</ApplicationIcon>
@@ -7,16 +6,21 @@
<CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory> <CopyOutputSymbolsToPublishDirectory>false</CopyOutputSymbolsToPublishDirectory>
</PropertyGroup> </PropertyGroup>
<!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
<PropertyGroup>
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
<EnableAotAnalyzer>false</EnableAotAnalyzer>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CliFx" Version="2.3.5" /> <PackageReference Include="CliFx" Version="2.3.6" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" /> <PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="Spectre.Console" Version="0.50.0" /> <PackageReference Include="Spectre.Console" Version="0.54.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" /> <ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -7,32 +7,35 @@ namespace DiscordChatExporter.Cli.Utils.Extensions;
internal static class ConsoleExtensions internal static class ConsoleExtensions
{ {
public static IAnsiConsole CreateAnsiConsole(this IConsole console) => extension(IConsole console)
AnsiConsole.Create( {
new AnsiConsoleSettings public IAnsiConsole CreateAnsiConsole() =>
{ AnsiConsole.Create(
Ansi = AnsiSupport.Detect, new AnsiConsoleSettings
ColorSystem = ColorSystemSupport.Detect, {
Out = new AnsiConsoleOutput(console.Output), Ansi = AnsiSupport.Detect,
} ColorSystem = ColorSystemSupport.Detect,
); Out = new AnsiConsoleOutput(console.Output),
}
public static Status CreateStatusTicker(this IConsole console) =>
console.CreateAnsiConsole().Status().AutoRefresh(true);
public static Progress CreateProgressTicker(this IConsole console) =>
console
.CreateAnsiConsole()
.Progress()
.AutoClear(false)
.AutoRefresh(true)
.HideCompleted(false)
.Columns(
new TaskDescriptionColumn { Alignment = Justify.Left },
new ProgressBarColumn(),
new PercentageColumn()
); );
public Status CreateStatusTicker() =>
console.CreateAnsiConsole().Status().AutoRefresh(true);
public Progress CreateProgressTicker() =>
console
.CreateAnsiConsole()
.Progress()
.AutoClear(false)
.AutoRefresh(true)
.HideCompleted(false)
.Columns(
new TaskDescriptionColumn { Alignment = Justify.Left },
new ProgressBarColumn(),
new PercentageColumn()
);
}
public static async ValueTask StartTaskAsync( public static async ValueTask StartTaskAsync(
this ProgressContext context, this ProgressContext context,
string description, string description,
@@ -33,9 +33,9 @@ public partial record Channel(
public bool IsThread { get; } = public bool IsThread { get; } =
Kind Kind
is ChannelKind.GuildNewsThread is ChannelKind.GuildNewsThread
or ChannelKind.GuildPublicThread or ChannelKind.GuildPublicThread
or ChannelKind.GuildPrivateThread; or ChannelKind.GuildPrivateThread;
public bool IsEmpty { get; } = LastMessageId is null; public bool IsEmpty { get; } = LastMessageId is null;
@@ -69,7 +69,8 @@ public partial record Channel
var guildId = var guildId =
json.GetPropertyOrNull("guild_id") json.GetPropertyOrNull("guild_id")
?.GetNonWhiteSpaceStringOrNull() ?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse) ?? Guild.DirectMessages.Id; ?.Pipe(Snowflake.Parse)
?? Guild.DirectMessages.Id;
var name = var name =
// Guild channel // Guild channel
@@ -78,6 +79,7 @@ public partial record Channel
?? json.GetPropertyOrNull("recipients") ?? json.GetPropertyOrNull("recipients")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(User.Parse) ?.Select(User.Parse)
.OrderBy(u => u.Id)
.Select(u => u.DisplayName) .Select(u => u.DisplayName)
.Pipe(s => string.Join(", ", s)) .Pipe(s => string.Join(", ", s))
// Fallback // Fallback
@@ -95,7 +97,8 @@ public partial record Channel
var isArchived = var isArchived =
json.GetPropertyOrNull("thread_metadata") json.GetPropertyOrNull("thread_metadata")
?.GetPropertyOrNull("archived") ?.GetPropertyOrNull("archived")
?.GetBooleanOrNull() ?? false; ?.GetBooleanOrNull()
?? false;
var lastMessageId = json.GetPropertyOrNull("last_message_id") var lastMessageId = json.GetPropertyOrNull("last_message_id")
?.GetNonWhiteSpaceStringOrNull() ?.GetNonWhiteSpaceStringOrNull()
@@ -45,7 +45,11 @@ public partial record Embed
var title = json.GetPropertyOrNull("title")?.GetStringOrNull(); var title = json.GetPropertyOrNull("title")?.GetStringOrNull();
var kind = var kind =
json.GetPropertyOrNull("type")?.GetStringOrNull()?.ParseEnumOrNull<EmbedKind>() json.GetPropertyOrNull("type")
?.GetStringOrNull()
?.Pipe(s =>
Enum.TryParse<EmbedKind>(s, true, out var result) ? result : (EmbedKind?)null
)
?? EmbedKind.Rich; ?? EmbedKind.Rich;
var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull();
@@ -63,7 +67,8 @@ public partial record Embed
json.GetPropertyOrNull("fields") json.GetPropertyOrNull("fields")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(EmbedField.Parse) ?.Select(EmbedField.Parse)
.ToArray() ?? []; .ToArray()
?? [];
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse); var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse);
@@ -78,7 +83,8 @@ public partial record Embed
json.GetPropertyOrNull("image") json.GetPropertyOrNull("image")
?.Pipe(EmbedImage.Parse) ?.Pipe(EmbedImage.Parse)
.ToSingletonEnumerable() .ToSingletonEnumerable()
.ToArray() ?? []; .ToArray()
?? [];
var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse); var video = json.GetPropertyOrNull("video")?.Pipe(EmbedVideo.Parse);
@@ -2,7 +2,10 @@
public partial record YouTubeVideoEmbedProjection(string VideoId) public partial record YouTubeVideoEmbedProjection(string VideoId)
{ {
public string Url { get; } = $"https://www.youtube.com/embed/{VideoId}"; public string Url { get; } = $"https://www.youtube.com/watch?v={VideoId}";
// Using hqdefault.jpg which is guaranteed to exist for all YouTube videos
public string ThumbnailUrl { get; } = $"https://i.ytimg.com/vi/{VideoId}/hqdefault.jpg";
} }
public partial record YouTubeVideoEmbedProjection public partial record YouTubeVideoEmbedProjection
@@ -25,7 +25,8 @@ public partial record Guild
var iconUrl = var iconUrl =
json.GetPropertyOrNull("icon") json.GetPropertyOrNull("icon")
?.GetNonWhiteSpaceStringOrNull() ?.GetNonWhiteSpaceStringOrNull()
?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h)) ?? ImageCdn.GetFallbackUserAvatarUrl(); ?.Pipe(h => ImageCdn.GetGuildIconUrl(id, h))
?? ImageCdn.GetFallbackUserAvatarUrl();
return new Guild(id, name, iconUrl); return new Guild(id, name, iconUrl);
} }
@@ -32,7 +32,8 @@ public partial record Member
?.EnumerateArray() ?.EnumerateArray()
.Select(j => j.GetNonWhiteSpaceString()) .Select(j => j.GetNonWhiteSpaceString())
.Select(Snowflake.Parse) .Select(Snowflake.Parse)
.ToArray() ?? []; .ToArray()
?? [];
var avatarUrl = guildId is not null var avatarUrl = guildId is not null
? json.GetPropertyOrNull("avatar") ? json.GetPropertyOrNull("avatar")
@@ -143,7 +143,8 @@ public partial record Message
json.GetPropertyOrNull("attachments") json.GetPropertyOrNull("attachments")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(Attachment.Parse) ?.Select(Attachment.Parse)
.ToArray() ?? []; .ToArray()
?? [];
var embeds = NormalizeEmbeds( var embeds = NormalizeEmbeds(
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
@@ -154,13 +155,15 @@ public partial record Message
json.GetPropertyOrNull("sticker_items") json.GetPropertyOrNull("sticker_items")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(Sticker.Parse) ?.Select(Sticker.Parse)
.ToArray() ?? []; .ToArray()
?? [];
var reactions = var reactions =
json.GetPropertyOrNull("reactions") json.GetPropertyOrNull("reactions")
?.EnumerateArrayOrNull() ?.EnumerateArrayOrNull()
?.Select(Reaction.Parse) ?.Select(Reaction.Parse)
.ToArray() ?? []; .ToArray()
?? [];
var mentionedUsers = var mentionedUsers =
json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray() json.GetPropertyOrNull("mentions")?.EnumerateArrayOrNull()?.Select(User.Parse).ToArray()
+121 -72
View File
@@ -305,7 +305,117 @@ public class DiscordClient(
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);
foreach (
var channel in await GetChannelThreadsAsync(
channels,
includeArchived,
before,
after,
cancellationToken
)
)
{
yield return channel;
}
}
public async IAsyncEnumerable<Role> GetGuildRolesAsync(
Snowflake guildId,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
if (guildId == Guild.DirectMessages.Id)
yield break;
var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken);
foreach (var roleJson in response.EnumerateArray())
yield return Role.Parse(roleJson);
}
public async ValueTask<Member?> TryGetGuildMemberAsync(
Snowflake guildId,
Snowflake memberId,
CancellationToken cancellationToken = default
)
{
if (guildId == Guild.DirectMessages.Id)
return null;
var response = await TryGetJsonResponseAsync(
$"guilds/{guildId}/members/{memberId}",
cancellationToken
);
return response?.Pipe(j => Member.Parse(j, guildId));
}
public async ValueTask<Invite?> TryGetInviteAsync(
string code,
CancellationToken cancellationToken = default
)
{
var response = await TryGetJsonResponseAsync($"invites/{code}", cancellationToken);
return response?.Pipe(Invite.Parse);
}
public async ValueTask<Channel> GetChannelAsync(
Snowflake channelId,
CancellationToken cancellationToken = default
)
{
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
var parentId = response
.GetPropertyOrNull("parent_id")
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse);
// It's possible for the parent channel to be inaccessible, despite the
// child channel being accessible.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
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)
{
// 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 // Categories cannot have threads
.Where(c => !c.IsCategory) .Where(c => !c.IsCategory)
// Voice channels cannot have threads // Voice channels cannot have threads
@@ -322,7 +432,7 @@ public class DiscordClient(
// User accounts can only fetch threads using the search endpoint // User accounts can only fetch threads using the search endpoint
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User) if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
{ {
foreach (var channel in channels) foreach (var channel in filteredChannels)
{ {
// Either include both active and archived threads, or only active threads // Either include both active and archived threads, or only active threads
foreach ( foreach (
@@ -378,9 +488,14 @@ public class DiscordClient(
// Bot accounts can only fetch threads using the threads endpoint // Bot accounts can only fetch threads using the threads endpoint
else else
{ {
var guilds = new HashSet<Snowflake>();
foreach (var channel in filteredChannels)
guilds.Add(channel.GuildId);
// Active threads // Active threads
foreach (var guildId in guilds)
{ {
var parentsById = channels.ToDictionary(c => c.Id); var parentsById = filteredChannels.ToDictionary(c => c.Id);
var response = await GetJsonResponseAsync( var response = await GetJsonResponseAsync(
$"guilds/{guildId}/threads/active", $"guilds/{guildId}/threads/active",
@@ -395,14 +510,15 @@ public class DiscordClient(
?.Pipe(Snowflake.Parse) ?.Pipe(Snowflake.Parse)
.Pipe(parentsById.GetValueOrDefault); .Pipe(parentsById.GetValueOrDefault);
yield return Channel.Parse(threadJson, parent); if (filteredChannels.Contains(parent))
yield return Channel.Parse(threadJson, parent);
} }
} }
// Archived threads // Archived threads
if (includeArchived) if (includeArchived)
{ {
foreach (var channel in channels) foreach (var channel in filteredChannels)
{ {
foreach (var archiveType in new[] { "public", "private" }) foreach (var archiveType in new[] { "public", "private" })
{ {
@@ -448,73 +564,6 @@ public class DiscordClient(
} }
} }
public async IAsyncEnumerable<Role> GetGuildRolesAsync(
Snowflake guildId,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
if (guildId == Guild.DirectMessages.Id)
yield break;
var response = await GetJsonResponseAsync($"guilds/{guildId}/roles", cancellationToken);
foreach (var roleJson in response.EnumerateArray())
yield return Role.Parse(roleJson);
}
public async ValueTask<Member?> TryGetGuildMemberAsync(
Snowflake guildId,
Snowflake memberId,
CancellationToken cancellationToken = default
)
{
if (guildId == Guild.DirectMessages.Id)
return null;
var response = await TryGetJsonResponseAsync(
$"guilds/{guildId}/members/{memberId}",
cancellationToken
);
return response?.Pipe(j => Member.Parse(j, guildId));
}
public async ValueTask<Invite?> TryGetInviteAsync(
string code,
CancellationToken cancellationToken = default
)
{
var response = await TryGetJsonResponseAsync($"invites/{code}", cancellationToken);
return response?.Pipe(Invite.Parse);
}
public async ValueTask<Channel> GetChannelAsync(
Snowflake channelId,
CancellationToken cancellationToken = default
)
{
var response = await GetJsonResponseAsync($"channels/{channelId}", cancellationToken);
var parentId = response
.GetPropertyOrNull("parent_id")
?.GetNonWhiteSpaceStringOrNull()
?.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
// child channel being accessible.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1108
catch (DiscordChatExporterException)
{
return Channel.Parse(response);
}
}
private async ValueTask<Message?> TryGetLastMessageAsync( private async ValueTask<Message?> TryGetLastMessageAsync(
Snowflake channelId, Snowflake channelId,
Snowflake? before = null, Snowflake? before = null,
@@ -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);
@@ -13,24 +13,26 @@ public enum RateLimitPreference
public static class RateLimitPreferenceExtensions public static class RateLimitPreferenceExtensions
{ {
internal static bool IsRespectedFor( extension(RateLimitPreference rateLimitPreference)
this RateLimitPreference rateLimitPreference, {
TokenKind tokenKind internal bool IsRespectedFor(TokenKind tokenKind) =>
) => tokenKind switch
tokenKind switch {
{ TokenKind.User => (rateLimitPreference & RateLimitPreference.RespectForUserTokens)
TokenKind.User => (rateLimitPreference & RateLimitPreference.RespectForUserTokens) != 0, != 0,
TokenKind.Bot => (rateLimitPreference & RateLimitPreference.RespectForBotTokens) != 0, TokenKind.Bot => (rateLimitPreference & RateLimitPreference.RespectForBotTokens)
_ => throw new ArgumentOutOfRangeException(nameof(tokenKind)), != 0,
}; _ => throw new ArgumentOutOfRangeException(nameof(tokenKind)),
};
public static string GetDisplayName(this RateLimitPreference rateLimitPreference) => public string GetDisplayName() =>
rateLimitPreference switch rateLimitPreference switch
{ {
RateLimitPreference.IgnoreAll => "Always ignore", RateLimitPreference.IgnoreAll => "Always ignore",
RateLimitPreference.RespectForUserTokens => "Respect for user tokens", RateLimitPreference.RespectForUserTokens => "Respect for user tokens",
RateLimitPreference.RespectForBotTokens => "Respect for bot tokens", RateLimitPreference.RespectForBotTokens => "Respect for bot tokens",
RateLimitPreference.RespectAll => "Always respect", RateLimitPreference.RespectAll => "Always respect",
_ => throw new ArgumentOutOfRangeException(nameof(rateLimitPreference)), _ => 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.6" /> <PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" /> <PackageReference Include="AsyncKeyedLock" Version="8.0.1" />
<PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="JsonExtensions" Version="1.2.0" /> <PackageReference Include="JsonExtensions" Version="1.2.0" />
<PackageReference Include="Polly" Version="8.5.2" /> <PackageReference Include="Polly" Version="8.6.5" />
<PackageReference Include="RazorBlade" Version="0.9.0" /> <PackageReference Include="RazorBlade" Version="0.11.0" />
<PackageReference Include="Superpower" Version="3.0.0" /> <PackageReference Include="Superpower" Version="3.1.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.17.0" /> <PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
<PackageReference Include="YoutubeExplode" Version="6.5.4" /> <PackageReference Include="YoutubeExplode" Version="6.5.6" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -75,7 +75,7 @@ internal partial class ExportAssetDownloader
return SHA256 return SHA256
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url))) .HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
.ToHex() .Pipe(Convert.ToHexStringLower)
// 5 chars ought to be enough for anybody // 5 chars ought to be enough for anybody
.Truncate(5); .Truncate(5);
} }
@@ -103,7 +103,7 @@ internal partial class ExportAssetDownloader
fileExtension = ""; fileExtension = "";
} }
return PathEx.EscapeFileName( return Path.EscapeFileName(
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
); );
} }
@@ -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;
@@ -145,7 +144,7 @@ public partial class ExportRequest
// File extension // File extension
buffer.Append('.').Append(format.GetFileExtension()); buffer.Append('.').Append(format.GetFileExtension());
return PathEx.EscapeFileName(buffer.ToString()); return Path.EscapeFileName(buffer.ToString());
} }
private static string FormatPath( private static string FormatPath(
@@ -159,7 +158,7 @@ public partial class ExportRequest
path, path,
"%.", "%.",
m => m =>
PathEx.EscapeFileName( Path.EscapeFileName(
m.Value switch m.Value switch
{ {
"%g" => guild.Id.ToString(), "%g" => guild.Id.ToString(),
@@ -179,7 +178,8 @@ public partial class ExportRequest
?? "", ?? "",
"%b" => before "%b" => before
?.ToDate() ?.ToDate()
.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) ?? "", .ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
?? "",
"%d" => DateTimeOffset.Now.ToString( "%d" => DateTimeOffset.Now.ToString(
"yyyy-MM-dd", "yyyy-MM-dd",
CultureInfo.InvariantCulture CultureInfo.InvariantCulture
@@ -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;
}
} }
} }
@@ -138,6 +138,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
cancellationToken cancellationToken
) )
); );
_writer.WriteString("iconCanonicalUrl", embedAuthor.IconUrl);
} }
_writer.WriteEndObject(); _writer.WriteEndObject();
@@ -160,6 +162,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
cancellationToken cancellationToken
) )
); );
_writer.WriteString("canonicalUrl", embedImage.Url);
} }
_writer.WriteNumber("width", embedImage.Width); _writer.WriteNumber("width", embedImage.Width);
@@ -185,6 +189,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
cancellationToken cancellationToken
) )
); );
_writer.WriteString("canonicalUrl", embedVideo.Url);
} }
_writer.WriteNumber("width", embedVideo.Width); _writer.WriteNumber("width", embedVideo.Width);
@@ -212,6 +218,8 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
cancellationToken cancellationToken
) )
); );
_writer.WriteString("iconCanonicalUrl", embedFooter.IconUrl);
} }
_writer.WriteEndObject(); _writer.WriteEndObject();
@@ -388,7 +388,7 @@
<div class="chatlog__embed-author-container"> <div class="chatlog__embed-author-container">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl)) @if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{ {
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'"> <img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
} }
@if (!string.IsNullOrWhiteSpace(embed.Author.Name)) @if (!string.IsNullOrWhiteSpace(embed.Author.Name))
@@ -424,9 +424,11 @@
</div> </div>
} }
@* Video player *@ @* Video thumbnail *@
<div class="chatlog__embed-youtube-container"> <div class="chatlog__embed-youtube-container">
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe> <a href="@youTubeVideoEmbed.Url">
<img class="chatlog__embed-youtube-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)" alt="YouTube video thumbnail" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@(embed.Thumbnail?.Url ?? youTubeVideoEmbed.ThumbnailUrl)">
</a>
</div> </div>
</div> </div>
</div> </div>
@@ -441,9 +443,12 @@
embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ?? embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ??
embed.Url; embed.Url;
var embedImageCanonicalUrl =
embed.Image?.Url ?? embed.Thumbnail?.Url ?? embed.Url;
<div class="chatlog__embed"> <div class="chatlog__embed">
<a href="@await ResolveAssetUrlAsync(embedImageUrl)"> <a href="@await ResolveAssetUrlAsync(embedImageUrl)">
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy"> <img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy" data-canonical-url="@embedImageCanonicalUrl">
</a> </a>
</div> </div>
} }
@@ -457,8 +462,10 @@
embed.Video?.ProxyUrl ?? embed.Video?.Url ?? embed.Video?.ProxyUrl ?? embed.Video?.Url ??
embed.Url; embed.Url;
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
<div class="chatlog__embed"> <div class="chatlog__embed">
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls> <video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls data-canonical-url="@embedVideoCanonicalUrl">
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video"> <source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
</video> </video>
</div> </div>
@@ -470,8 +477,10 @@
embed.Video?.ProxyUrl ?? embed.Video?.Url ?? embed.Video?.ProxyUrl ?? embed.Video?.Url ??
embed.Url; embed.Url;
var embedVideoCanonicalUrl = embed.Video?.Url ?? embed.Url;
<div class="chatlog__embed"> <div class="chatlog__embed">
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()"> <video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()" data-canonical-url="@embedVideoCanonicalUrl">
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv"> <source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv">
</video> </video>
</div> </div>
@@ -499,7 +508,7 @@
<div class="chatlog__embed-author-container"> <div class="chatlog__embed-author-container">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl)) @if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{ {
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'"> <img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'" data-canonical-url="@embed.Author.IconUrl">
} }
@if (!string.IsNullOrWhiteSpace(embed.Author.Name)) @if (!string.IsNullOrWhiteSpace(embed.Author.Name))
@@ -574,7 +583,7 @@
{ {
<div class="chatlog__embed-thumbnail-container"> <div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)"> <a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy"> <img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy" data-canonical-url="@embed.Thumbnail.Url">
</a> </a>
</div> </div>
} }
@@ -590,7 +599,7 @@
{ {
<div class="chatlog__embed-image-container"> <div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)"> <a class="chatlog__embed-image-link" href="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy"> <img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy" data-canonical-url="@image.Url">
</a> </a>
</div> </div>
} }
@@ -605,7 +614,7 @@
@* Footer icon *@ @* Footer icon *@
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl)) @if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
{ {
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy"> <img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy" data-canonical-url="@embed.Footer.IconUrl">
} }
<span class="chatlog__embed-footer-text"> <span class="chatlog__embed-footer-text">
@@ -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,40 @@ namespace DiscordChatExporter.Core.Exporting;
internal static class PlainTextMessageExtensions internal static class PlainTextMessageExtensions
{ {
public static string GetFallbackContent(this Message message) => extension(Message message)
message.Kind switch {
{ public string GetFallbackContent() =>
MessageKind.RecipientAdd => message.MentionedUsers.Any() message.Kind switch
? $"Added {message.MentionedUsers.First().DisplayName} to the group." {
: "Added a recipient.", MessageKind.RecipientAdd => message.MentionedUsers.Any()
? $"Added {message.MentionedUsers.First().DisplayName} to the group."
: "Added a recipient.",
MessageKind.RecipientRemove => message.MentionedUsers.Any() MessageKind.RecipientRemove => message.MentionedUsers.Any()
? message.Author.Id == message.MentionedUsers.First().Id ? message.Author.Id == message.MentionedUsers.First().Id
? "Left the group." ? "Left the group."
: $"Removed {message.MentionedUsers.First().DisplayName} from the group." : $"Removed {message.MentionedUsers.First().DisplayName} from the group."
: "Removed a recipient.", : "Removed a recipient.",
MessageKind.Call => MessageKind.Call =>
$"Started a call that lasted { $"Started a call that lasted {
message message
.CallEndedTimestamp? .CallEndedTimestamp?
.Pipe(t => t - message.Timestamp) .Pipe(t => t - message.Timestamp)
.Pipe(t => t.TotalMinutes) .Pipe(t => t.TotalMinutes)
.ToString("n0", CultureInfo.InvariantCulture) ?? "0" .ToString("n0", CultureInfo.InvariantCulture) ?? "0"
} minutes.", } minutes.",
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content) MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
? $"Changed the channel name: {message.Content}" ? $"Changed the channel name: {message.Content}"
: "Changed the channel name.", : "Changed the channel name.",
MessageKind.ChannelIconChange => "Changed the channel icon.", MessageKind.ChannelIconChange => "Changed the channel icon.",
MessageKind.ChannelPinnedMessage => "Pinned a message.", MessageKind.ChannelPinnedMessage => "Pinned a message.",
MessageKind.ThreadCreated => "Started a thread.", MessageKind.ThreadCreated => "Started a thread.",
MessageKind.GuildMemberJoin => "Joined the server.", MessageKind.GuildMemberJoin => "Joined the server.",
_ => message.Content, _ => message.Content,
}; };
}
} }
@@ -711,9 +711,11 @@
margin-top: 0.6rem; margin-top: 0.6rem;
} }
.chatlog__embed-youtube { .chatlog__embed-youtube-thumbnail {
border: 0; max-width: 400px;
max-height: 225px;
border-radius: 3px; border-radius: 3px;
cursor: pointer;
} }
.chatlog__sticker { .chatlog__sticker {
@@ -6,19 +6,19 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class AsyncCollectionExtensions public static class AsyncCollectionExtensions
{ {
private static async ValueTask<IReadOnlyList<T>> CollectAsync<T>( extension<T>(IAsyncEnumerable<T> asyncEnumerable)
this IAsyncEnumerable<T> asyncEnumerable
)
{ {
var list = new List<T>(); private async ValueTask<IReadOnlyList<T>> CollectAsync()
{
var list = new List<T>();
await foreach (var i in asyncEnumerable) await foreach (var i in asyncEnumerable)
list.Add(i); list.Add(i);
return list; return list;
}
public ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter() =>
asyncEnumerable.CollectAsync().GetAwaiter();
} }
public static ValueTaskAwaiter<IReadOnlyList<T>> GetAwaiter<T>(
this IAsyncEnumerable<T> asyncEnumerable
) => asyncEnumerable.CollectAsync().GetAwaiter();
} }
@@ -1,19 +0,0 @@
using System.Globalization;
using System.Text;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class BinaryExtensions
{
public static string ToHex(this byte[] data, bool isUpperCase = true)
{
var buffer = new StringBuilder(2 * data.Length);
foreach (var b in data)
{
buffer.Append(b.ToString(isUpperCase ? "X2" : "x2", CultureInfo.InvariantCulture));
}
return buffer.ToString();
}
}
@@ -4,25 +4,34 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class CollectionExtensions public static class CollectionExtensions
{ {
public static IEnumerable<T> ToSingletonEnumerable<T>(this T obj) extension<T>(T obj)
{ {
yield return obj; public IEnumerable<T> ToSingletonEnumerable()
{
yield return obj;
}
} }
public static IEnumerable<(T value, int index)> WithIndex<T>(this IEnumerable<T> source) extension<T>(IEnumerable<T> source)
{ {
var i = 0; public IEnumerable<(T value, int index)> WithIndex()
foreach (var o in source) {
yield return (o, i++); var i = 0;
foreach (var o in source)
yield return (o, i++);
}
} }
public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source) extension<T>(IEnumerable<T?> source)
where T : class where T : class
{ {
foreach (var o in source) public IEnumerable<T> WhereNotNull()
{ {
if (o is not null) foreach (var o in source)
yield return o; {
if (o is not null)
yield return o;
}
} }
} }
} }
@@ -4,11 +4,14 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class ColorExtensions public static class ColorExtensions
{ {
public static Color WithAlpha(this Color color, int alpha) => Color.FromArgb(alpha, color); extension(Color color)
{
public Color WithAlpha(int alpha) => Color.FromArgb(alpha, color);
public static Color ResetAlpha(this Color color) => color.WithAlpha(255); public Color ResetAlpha() => color.WithAlpha(255);
public static int ToRgb(this Color color) => color.ToArgb() & 0xffffff; public int ToRgb() => color.ToArgb() & 0xffffff;
public static string ToHex(this Color color) => $"#{color.R:X2}{color.G:X2}{color.B:X2}"; public string ToHex() => $"#{color.R:X2}{color.G:X2}{color.B:X2}";
}
} }
@@ -5,27 +5,30 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class ExceptionExtensions public static class ExceptionExtensions
{ {
private static void PopulateChildren(this Exception exception, ICollection<Exception> children) extension(Exception exception)
{ {
if (exception is AggregateException aggregateException) private void PopulateChildren(ICollection<Exception> children)
{ {
foreach (var innerException in aggregateException.InnerExceptions) if (exception is AggregateException aggregateException)
{ {
children.Add(innerException); foreach (var innerException in aggregateException.InnerExceptions)
PopulateChildren(innerException, children); {
children.Add(innerException);
PopulateChildren(innerException, children);
}
}
else if (exception.InnerException is not null)
{
children.Add(exception.InnerException);
PopulateChildren(exception.InnerException, children);
} }
} }
else if (exception.InnerException is not null)
public IReadOnlyList<Exception> GetSelfAndChildren()
{ {
children.Add(exception.InnerException); var children = new List<Exception> { exception };
PopulateChildren(exception.InnerException, children); PopulateChildren(exception, children);
return children;
} }
} }
public static IReadOnlyList<Exception> GetSelfAndChildren(this Exception exception)
{
var children = new List<Exception> { exception };
PopulateChildren(exception, children);
return children;
}
} }
@@ -5,12 +5,17 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class GenericExtensions public static class GenericExtensions
{ {
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) => extension<TIn>(TIn input)
transform(input); {
public TOut Pipe<TOut>(Func<TIn, TOut> transform) => transform(input);
}
public static T? NullIf<T>(this T value, Func<T, bool> predicate) extension<T>(T value)
where T : struct => !predicate(value) ? value : null; where T : struct
{
public T? NullIf(Func<T, bool> predicate) => !predicate(value) ? value : null;
public static T? NullIfDefault<T>(this T value) public T? NullIfDefault() =>
where T : struct => value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default)); value.NullIf(v => EqualityComparer<T>.Default.Equals(v, default));
}
} }
@@ -4,6 +4,9 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class HttpExtensions public static class HttpExtensions
{ {
public static string? TryGetValue(this HttpHeaders headers, string name) => extension(HttpHeaders headers)
headers.TryGetValues(name, out var values) ? string.Concat(values) : null; {
public string? TryGetValue(string name) =>
headers.TryGetValues(name, out var values) ? string.Concat(values) : null;
}
} }
@@ -0,0 +1,48 @@
using System;
using System.IO;
using System.Text;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class PathExtensions
{
// This is a union of invalid characters from Windows (NTFS/FAT32), Linux (ext4/XFS), and macOS (HFS+/APFS).
// We use this instead of Path.GetInvalidFileNameChars() because that only returns OS-specific characters,
// not filesystem-specific characters. It's possible to use, for example, an NTFS drive on Linux,
// which would make some additional characters invalid that are otherwise valid on Linux.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1452
private static readonly char[] InvalidFileNameChars =
[
'\0', // Null character - invalid on all filesystems
'/', // Path separator on Unix and Windows
'\\', // Path separator on Windows
':', // Reserved on Windows (drive letters, NTFS streams)
'*', // Wildcard on Windows
'?', // Wildcard on Windows
'"', // Reserved on Windows
'<', // Redirection on Windows
'>', // Redirection on Windows
'|', // Pipe on Windows
];
extension(Path)
{
public static string EscapeFileName(string path)
{
var buffer = new StringBuilder(path.Length);
foreach (var c in path)
buffer.Append(!InvalidFileNameChars.Contains(c) ? c : '_');
// File names cannot end with a dot on Windows
// https://github.com/Tyrrrz/DiscordChatExporter/issues/977
if (OperatingSystem.IsWindows())
{
while (buffer.Length > 0 && buffer[^1] == '.')
buffer.Remove(buffer.Length - 1, 1);
}
return buffer.ToString();
}
}
}
@@ -1,34 +1,34 @@
using System; using System.Text;
using System.Text;
namespace DiscordChatExporter.Core.Utils.Extensions; namespace DiscordChatExporter.Core.Utils.Extensions;
public static class StringExtensions public static class StringExtensions
{ {
public static string? NullIfWhiteSpace(this string str) => extension(string str)
!string.IsNullOrWhiteSpace(str) ? str : null;
public static string Truncate(this string str, int charCount) =>
str.Length > charCount ? str[..charCount] : str;
public static string ToSpaceSeparatedWords(this string str)
{ {
var builder = new StringBuilder(str.Length * 2); public string? NullIfWhiteSpace() => !string.IsNullOrWhiteSpace(str) ? str : null;
foreach (var c in str) public string Truncate(int charCount) => str.Length > charCount ? str[..charCount] : str;
public string ToSpaceSeparatedWords()
{ {
if (char.IsUpper(c) && builder.Length > 0) var builder = new StringBuilder(str.Length * 2);
builder.Append(' ');
builder.Append(c); foreach (var c in str)
{
if (char.IsUpper(c) && builder.Length > 0)
builder.Append(' ');
builder.Append(c);
}
return builder.ToString();
} }
return builder.ToString();
} }
public static T? ParseEnumOrNull<T>(this string str, bool ignoreCase = true) extension(StringBuilder builder)
where T : struct, Enum => Enum.TryParse<T>(str, ignoreCase, out var result) ? result : null; {
public StringBuilder AppendIfNotEmpty(char value) =>
public static StringBuilder AppendIfNotEmpty(this StringBuilder builder, char value) => builder.Length > 0 ? builder.Append(value) : builder;
builder.Length > 0 ? builder.Append(value) : builder; }
} }
@@ -7,21 +7,21 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class SuperpowerExtensions public static class SuperpowerExtensions
{ {
public static TextParser<string> Text(this TextParser<char[]> parser) => extension<T>(TextParser<T> parser)
parser.Select(chars => new string(chars)); {
public TextParser<T> Token() =>
parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany());
public static TextParser<T> Token<T>(this TextParser<T> parser) => // Only used for debugging while writing Superpower parsers.
parser.Between(Character.WhiteSpace.IgnoreMany(), Character.WhiteSpace.IgnoreMany()); // From https://twitter.com/nblumhardt/status/1389349059786264578
[ExcludeFromCodeCoverage]
// Only used for debugging while writing Superpower parsers. public TextParser<T> Log(string description) =>
// From https://twitter.com/nblumhardt/status/1389349059786264578 i =>
[ExcludeFromCodeCoverage] {
public static TextParser<T> Log<T>(this TextParser<T> parser, string description) => Console.WriteLine($"Trying {description} ->");
i => var r = parser(i);
{ Console.WriteLine($"Result was {r}");
Console.WriteLine($"Trying {description} ->"); return r;
var r = parser(i); };
Console.WriteLine($"Result was {r}"); }
return r;
};
} }
@@ -4,14 +4,17 @@ namespace DiscordChatExporter.Core.Utils.Extensions;
public static class TimeSpanExtensions public static class TimeSpanExtensions
{ {
public static TimeSpan Clamp(this TimeSpan value, TimeSpan min, TimeSpan max) extension(TimeSpan value)
{ {
if (value < min) public TimeSpan Clamp(TimeSpan min, TimeSpan max)
return min; {
if (value < min)
return min;
if (value > max) if (value > max)
return max; return max;
return value; return value;
}
} }
} }
-32
View File
@@ -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();
}
}
+14
View File
@@ -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
View File
@@ -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 />
+4
View File
@@ -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,160 @@
using System;
using System.Globalization;
using System.Linq;
using Avalonia.Controls.Documents;
using Avalonia.Data.Converters;
using Avalonia.Media;
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 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;
void RenderParagraph(ParagraphBlock 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);
}
void RenderListBlock(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 } p)
foreach (var markdownInline in p.Inline)
ProcessInline(inlines, markdownInline);
}
}
}
foreach (var block in Markdown.Parse(text, MarkdownPipeline))
{
switch (block)
{
case ParagraphBlock { Inline: not null } paragraph:
RenderParagraph(paragraph);
break;
case ListBlock list:
RenderListBlock(list);
break;
}
}
return inlines;
}
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,24 +8,39 @@
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup> </PropertyGroup>
<PropertyGroup>
<PublishMacOSBundle>false</PublishMacOSBundle>
</PropertyGroup>
<!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
<PropertyGroup>
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
<EnableAotAnalyzer>false</EnableAotAnalyzer>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" /> <AvaloniaResource Include="..\favicon.ico" Link="favicon.ico" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncImageLoader.Avalonia" Version="3.3.0" /> <PackageReference Include="AsyncImageLoader.Avalonia" Version="3.5.0" />
<PackageReference Include="Avalonia" Version="11.3.0" /> <PackageReference Include="Avalonia" Version="11.3.11" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.0" /> <PackageReference Include="Avalonia.Desktop" Version="11.3.11" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.0" Condition="'$(Configuration)' == 'Debug'" /> <PackageReference
Include="Avalonia.Diagnostics"
Version="11.3.11"
Condition="'$(Configuration)' == 'Debug'"
/>
<PackageReference Include="Cogwheel" Version="2.1.0" /> <PackageReference Include="Cogwheel" Version="2.1.0" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.6" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="1.2.5" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" /> <PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
<PackageReference Include="DialogHost.Avalonia" Version="0.9.2" /> <PackageReference Include="DialogHost.Avalonia" Version="0.10.4" />
<PackageReference Include="Gress" Version="2.1.1" /> <PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="Markdig" Version="1.0.0" />
<PackageReference Include="Material.Avalonia" Version="3.9.2" /> <PackageReference Include="Material.Avalonia" Version="3.9.2" />
<PackageReference Include="Material.Icons.Avalonia" Version="2.2.0" /> <PackageReference Include="Material.Icons.Avalonia" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.4" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageReference Include="Onova" Version="2.6.13" /> <PackageReference Include="Onova" Version="2.6.13" />
</ItemGroup> </ItemGroup>
@@ -35,7 +49,9 @@
</ItemGroup> </ItemGroup>
<Target Name="PublishMacOSBundle" AfterTargets="Publish" Condition="$(PublishMacOSBundle)"> <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" /> <Exec
Command="pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Publish-MacOSBundle.ps1 -PublishDirPath $(PublishDir) -IconsFilePath $(ProjectDir)/../favicon.icns -FullVersion $(Version) -ShortVersion $(AssemblyVersion)"
LogStandardErrorAsError="true"
/>
</Target> </Target>
</Project> </Project>
@@ -38,6 +38,10 @@ 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;
} }
} }
@@ -60,7 +64,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 +73,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();
@@ -0,0 +1,11 @@
namespace DiscordChatExporter.Gui.Localization;
public enum Language
{
System,
English,
Ukrainian,
German,
French,
Spanish,
}
@@ -0,0 +1,132 @@
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(TokenBotIntro)] =
"The token is generated during bot creation. If you lost it, generate a new one:",
[nameof(TokenBotStep1)] = "1. Open Discord",
[nameof(TokenBotInstructions)] = """
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(TokenDeveloperPortalLinkText)] = "developer portal",
[nameof(TokenDocumentationLinkText)] = "documentation",
[nameof(TokenHelpText)] = "If you have questions or issues, please refer to the",
// 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",
[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(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(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(ErrorPullingServersTitle)] = "Error pulling servers",
[nameof(ErrorPullingChannelsTitle)] = "Error pulling channels",
[nameof(ErrorExportingTitle)] = "Error exporting channel(s)",
[nameof(SuccessfulExportMessage)] = "Successfully exported {0} channel(s)",
};
}
@@ -0,0 +1,134 @@
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(TokenBotIntro)] =
"Le token est généré lors de la création du bot. Si vous l'avez perdu, générez-en un nouveau :",
[nameof(TokenBotStep1)] = "1. Ouvrez Discord",
[nameof(TokenBotInstructions)] = """
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(TokenDeveloperPortalLinkText)] = "portail développeur",
[nameof(TokenDocumentationLinkText)] = "documentation",
[nameof(TokenHelpText)] = "Pour les questions ou problèmes, veuillez consulter la",
// 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",
[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(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(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(ErrorPullingServersTitle)] = "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,138 @@
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(TokenBotIntro)] =
"Der Token wird bei der Bot-Erstellung generiert. Falls er verloren gegangen ist, generieren Sie einen neuen:",
[nameof(TokenBotStep1)] = "1. Öffnen Sie Discord",
[nameof(TokenBotInstructions)] = """
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(TokenDeveloperPortalLinkText)] = "Entwicklerportal",
[nameof(TokenDocumentationLinkText)] = "Dokumentation",
[nameof(TokenHelpText)] = "Bei Fragen oder Problemen lesen Sie die",
// 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",
[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(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(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(ErrorPullingServersTitle)] = "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,132 @@
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(TokenBotIntro)] =
"El token se genera al crear el bot. Si lo perdiste, genera uno nuevo:",
[nameof(TokenBotStep1)] = "1. Abre Discord",
[nameof(TokenBotInstructions)] = """
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(TokenDeveloperPortalLinkText)] = "portal de desarrolladores",
[nameof(TokenDocumentationLinkText)] = "documentación",
[nameof(TokenHelpText)] = "Si tienes preguntas o problemas, consulta la",
// 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",
[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(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(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(ErrorPullingServersTitle)] = "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,131 @@
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(TokenBotIntro)] =
"Токен генерується під час створення бота. Якщо ви його втратили, згенеруйте новий:",
[nameof(TokenBotStep1)] = "1. Відкрийте Discord",
[nameof(TokenBotInstructions)] = """
2. Відкрийте налаштування вашого застосунку
3. Перейдіть до розділу **Bot** ліворуч
4. В розділі **Token** натисніть **Reset Token**
5. Натисніть **Yes, do it!** та підтвердьте
* Інтеграції, що використовують попередній токен, перестануть працювати
* Ваш бот повинен мати включений **Message Content Intent** для читання повідомлень
""",
[nameof(TokenDeveloperPortalLinkText)] = "портал розробника",
[nameof(TokenDocumentationLinkText)] = "документацію",
[nameof(TokenHelpText)] = "Якщо у вас є запитання або проблеми, зверніться до",
// 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(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(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(ErrorPullingServersTitle)] = "Помилка завантаження серверів",
[nameof(ErrorPullingChannelsTitle)] = "Помилка завантаження каналів",
[nameof(ErrorExportingTitle)] = "Помилка експорту каналу(-ів)",
[nameof(SuccessfulExportMessage)] = "Успішно експортовано {0} канал(-ів)",
};
}
@@ -0,0 +1,173 @@
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 TokenBotIntro => Get();
public string TokenBotStep1 => Get();
public string TokenBotInstructions => Get();
public string TokenDeveloperPortalLinkText => Get();
public string TokenDocumentationLinkText => 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 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 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 ErrorPullingServersTitle => Get();
public string ErrorPullingChannelsTitle => Get();
public string ErrorExportingTitle => Get();
public string SuccessfulExportMessage => Get();
}
@@ -6,6 +6,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using DiscordChatExporter.Core.Discord; 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;
@@ -23,6 +24,9 @@ public partial class SettingsService()
[ObservableProperty] [ObservableProperty]
public partial ThemeVariant Theme { get; set; } public partial ThemeVariant Theme { get; set; }
[ObservableProperty]
public partial Language Language { get; set; }
[ObservableProperty] [ObservableProperty]
public partial bool IsAutoUpdateEnabled { get; set; } = true; public partial bool IsAutoUpdateEnabled { get; set; } = true;
@@ -6,28 +6,31 @@ namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class AvaloniaExtensions internal static class AvaloniaExtensions
{ {
public static Window? TryGetMainWindow(this IApplicationLifetime lifetime) => extension(IApplicationLifetime lifetime)
lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime
? desktopLifetime.MainWindow
: null;
public static TopLevel? TryGetTopLevel(this IApplicationLifetime lifetime) =>
lifetime.TryGetMainWindow()
?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel;
public static bool TryShutdown(this IApplicationLifetime lifetime, int exitCode = 0)
{ {
if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime) public Window? TryGetMainWindow() =>
{ lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime
return desktopLifetime.TryShutdown(exitCode); ? desktopLifetime.MainWindow
} : null;
if (lifetime is IControlledApplicationLifetime controlledLifetime) public TopLevel? TryGetTopLevel() =>
{ lifetime.TryGetMainWindow()
controlledLifetime.Shutdown(exitCode); ?? (lifetime as ISingleViewApplicationLifetime)?.MainView?.GetVisualRoot() as TopLevel;
return true;
}
return false; public bool TryShutdown(int exitCode = 0)
{
if (lifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime)
{
return desktopLifetime.TryShutdown(exitCode);
}
if (lifetime is IControlledApplicationLifetime controlledLifetime)
{
controlledLifetime.Shutdown(exitCode);
return true;
}
return false;
}
} }
} }
@@ -6,23 +6,26 @@ namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class DisposableExtensions internal static class DisposableExtensions
{ {
public static void DisposeAll(this IEnumerable<IDisposable> disposables) extension(IEnumerable<IDisposable> disposables)
{ {
var exceptions = default(List<Exception>); public void DisposeAll()
foreach (var disposable in disposables)
{ {
try var exceptions = default(List<Exception>);
{
disposable.Dispose();
}
catch (Exception ex)
{
(exceptions ??= []).Add(ex);
}
}
if (exceptions?.Any() == true) foreach (var disposable in disposables)
throw new AggregateException(exceptions); {
try
{
disposable.Dispose();
}
catch (Exception ex)
{
(exceptions ??= []).Add(ex);
}
}
if (exceptions?.Any() == true)
throw new AggregateException(exceptions);
}
} }
} }
@@ -7,50 +7,47 @@ namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class NotifyPropertyChangedExtensions internal static class NotifyPropertyChangedExtensions
{ {
public static IDisposable WatchProperty<TOwner, TProperty>( extension<TOwner>(TOwner owner)
this TOwner owner,
Expression<Func<TOwner, TProperty>> propertyExpression,
Action callback,
bool watchInitialValue = false
)
where TOwner : INotifyPropertyChanged where TOwner : INotifyPropertyChanged
{ {
var memberExpression = propertyExpression.Body as MemberExpression; public IDisposable WatchProperty<TProperty>(
if (memberExpression?.Member is not PropertyInfo property) Expression<Func<TOwner, TProperty>> propertyExpression,
throw new ArgumentException("Provided expression must reference a property."); Action callback,
bool watchInitialValue = false
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) )
{ {
if ( var memberExpression = propertyExpression.Body as MemberExpression;
string.IsNullOrWhiteSpace(args.PropertyName) if (memberExpression?.Member is not PropertyInfo property)
|| string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal) throw new ArgumentException("Provided expression must reference a property.");
)
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
{ {
callback(); if (
string.IsNullOrWhiteSpace(args.PropertyName)
|| string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal)
)
{
callback();
}
} }
owner.PropertyChanged += OnPropertyChanged;
if (watchInitialValue)
callback();
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
} }
owner.PropertyChanged += OnPropertyChanged; public IDisposable WatchAllProperties(Action callback, bool watchInitialValues = false)
{
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback();
owner.PropertyChanged += OnPropertyChanged;
if (watchInitialValue) if (watchInitialValues)
callback(); callback();
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged); return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
} }
public static IDisposable WatchAllProperties<TOwner>(
this TOwner owner,
Action callback,
bool watchInitialValues = false
)
where TOwner : INotifyPropertyChanged
{
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => callback();
owner.PropertyChanged += OnPropertyChanged;
if (watchInitialValues)
callback();
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
} }
} }
@@ -0,0 +1,17 @@
using System.Diagnostics;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class ProcessExtensions
{
extension(Process)
{
public static void StartShellExecute(string path)
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true };
process.Start();
}
}
}
@@ -1,14 +0,0 @@
using System.Diagnostics;
namespace DiscordChatExporter.Gui.Utils;
internal static class ProcessEx
{
public static void StartShellExecute(string path)
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo { FileName = path, UseShellExecute = true };
process.Start();
}
}
@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -12,6 +13,7 @@ using DiscordChatExporter.Core.Exceptions;
using DiscordChatExporter.Core.Exporting; using DiscordChatExporter.Core.Exporting;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using DiscordChatExporter.Gui.Framework; using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Localization;
using DiscordChatExporter.Gui.Models; using DiscordChatExporter.Gui.Models;
using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils; using DiscordChatExporter.Gui.Utils;
@@ -37,13 +39,15 @@ public partial class DashboardViewModel : ViewModelBase
ViewModelManager viewModelManager, ViewModelManager viewModelManager,
DialogManager dialogManager, DialogManager dialogManager,
SnackbarManager snackbarManager, SnackbarManager snackbarManager,
SettingsService settingsService SettingsService settingsService,
LocalizationManager localizationManager
) )
{ {
_viewModelManager = viewModelManager; _viewModelManager = viewModelManager;
_dialogManager = dialogManager; _dialogManager = dialogManager;
_snackbarManager = snackbarManager; _snackbarManager = snackbarManager;
_settingsService = settingsService; _settingsService = settingsService;
LocalizationManager = localizationManager;
_progressMuxer = Progress.CreateMuxer().WithAutoReset(); _progressMuxer = Progress.CreateMuxer().WithAutoReset();
@@ -69,6 +73,8 @@ public partial class DashboardViewModel : ViewModelBase
[NotifyCanExecuteChangedFor(nameof(ExportCommand))] [NotifyCanExecuteChangedFor(nameof(ExportCommand))]
public partial bool IsBusy { get; set; } public partial bool IsBusy { get; set; }
public LocalizationManager LocalizationManager { get; }
public ProgressContainer<Percentage> Progress { get; } = new(); public ProgressContainer<Percentage> Progress { get; } = new();
public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1; public bool IsProgressIndeterminate => IsBusy && Progress.Current.Fraction is <= 0 or >= 1;
@@ -102,7 +108,7 @@ public partial class DashboardViewModel : ViewModelBase
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel()); await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
[RelayCommand] [RelayCommand]
private void ShowHelp() => ProcessEx.StartShellExecute(Program.ProjectDocumentationUrl); private void ShowHelp() => Process.StartShellExecute(Program.ProjectDocumentationUrl);
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token); private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
@@ -140,7 +146,7 @@ public partial class DashboardViewModel : ViewModelBase
catch (Exception ex) catch (Exception ex)
{ {
var dialog = _viewModelManager.CreateMessageBoxViewModel( var dialog = _viewModelManager.CreateMessageBoxViewModel(
"Error pulling guilds", LocalizationManager.ErrorPullingServersTitle,
ex.ToString() ex.ToString()
); );
@@ -207,7 +213,7 @@ public partial class DashboardViewModel : ViewModelBase
catch (Exception ex) catch (Exception ex)
{ {
var dialog = _viewModelManager.CreateMessageBoxViewModel( var dialog = _viewModelManager.CreateMessageBoxViewModel(
"Error pulling channels", LocalizationManager.ErrorPullingChannelsTitle,
ex.ToString() ex.ToString()
); );
@@ -302,14 +308,17 @@ public partial class DashboardViewModel : ViewModelBase
if (successfulExportCount > 0) if (successfulExportCount > 0)
{ {
_snackbarManager.Notify( _snackbarManager.Notify(
$"Successfully exported {successfulExportCount} channel(s)" string.Format(
LocalizationManager.SuccessfulExportMessage,
successfulExportCount
)
); );
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
var dialog = _viewModelManager.CreateMessageBoxViewModel( var dialog = _viewModelManager.CreateMessageBoxViewModel(
"Error exporting channel(s)", LocalizationManager.ErrorExportingTitle,
ex.ToString() ex.ToString()
); );
@@ -322,11 +331,11 @@ public partial class DashboardViewModel : ViewModelBase
} }
[RelayCommand] [RelayCommand]
private void OpenDiscord() => ProcessEx.StartShellExecute("https://discord.com/app"); private void OpenDiscord() => Process.StartShellExecute("https://discord.com/app");
[RelayCommand] [RelayCommand]
private void OpenDiscordDeveloperPortal() => private void OpenDiscordDeveloperPortal() =>
ProcessEx.StartShellExecute("https://discord.com/developers/applications"); Process.StartShellExecute("https://discord.com/developers/applications");
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
@@ -12,15 +12,19 @@ using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning; using DiscordChatExporter.Core.Exporting.Partitioning;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using DiscordChatExporter.Gui.Framework; using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Localization;
using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Services;
namespace DiscordChatExporter.Gui.ViewModels.Dialogs; namespace DiscordChatExporter.Gui.ViewModels.Dialogs;
public partial class ExportSetupViewModel( public partial class ExportSetupViewModel(
DialogManager dialogManager, DialogManager dialogManager,
SettingsService settingsService SettingsService settingsService,
LocalizationManager localizationManager
) : DialogViewModelBase ) : DialogViewModelBase
{ {
public LocalizationManager LocalizationManager { get; } = localizationManager;
[ObservableProperty] [ObservableProperty]
public partial Guild? Guild { get; set; } public partial Guild? Guild { get; set; }
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using DiscordChatExporter.Core.Discord; using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using DiscordChatExporter.Gui.Framework; using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Localization;
using DiscordChatExporter.Gui.Models; using DiscordChatExporter.Gui.Models;
using DiscordChatExporter.Gui.Services; using DiscordChatExporter.Gui.Services;
using DiscordChatExporter.Gui.Utils; using DiscordChatExporter.Gui.Utils;
@@ -16,13 +17,19 @@ public class SettingsViewModel : DialogViewModelBase
private readonly DisposableCollector _eventRoot = new(); private readonly DisposableCollector _eventRoot = new();
public SettingsViewModel(SettingsService settingsService) public SettingsViewModel(
SettingsService settingsService,
LocalizationManager localizationManager
)
{ {
_settingsService = settingsService; _settingsService = settingsService;
LocalizationManager = localizationManager;
_eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged)); _eventRoot.Add(_settingsService.WatchAllProperties(OnAllPropertiesChanged));
} }
public LocalizationManager LocalizationManager { get; }
public IReadOnlyList<ThemeVariant> AvailableThemes { get; } = Enum.GetValues<ThemeVariant>(); public IReadOnlyList<ThemeVariant> AvailableThemes { get; } = Enum.GetValues<ThemeVariant>();
public ThemeVariant Theme public ThemeVariant Theme
@@ -31,6 +38,14 @@ public class SettingsViewModel : DialogViewModelBase
set => _settingsService.Theme = value; set => _settingsService.Theme = value;
} }
public IReadOnlyList<Language> AvailableLanguages { get; } = Enum.GetValues<Language>();
public Language Language
{
get => _settingsService.Language;
set => _settingsService.Language = value;
}
public bool IsAutoUpdateEnabled public bool IsAutoUpdateEnabled
{ {
get => _settingsService.IsAutoUpdateEnabled; get => _settingsService.IsAutoUpdateEnabled;
@@ -63,40 +78,40 @@ public class SettingsViewModel : DialogViewModelBase
// These items have to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected // These items have to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
public IReadOnlyList<string> AvailableLocales { get; } = public IReadOnlyList<string> AvailableLocales { get; } =
[ [
// Current locale (maps to null downstream) // Current locale (maps to null downstream)
"", "",
// Locales supported by the Discord app // Locales supported by the Discord app
"da-DK", "da-DK",
"de-DE", "de-DE",
"en-GB", "en-GB",
"en-US", "en-US",
"es-ES", "es-ES",
"fr-FR", "fr-FR",
"hr-HR", "hr-HR",
"it-IT", "it-IT",
"lt-LT", "lt-LT",
"hu-HU", "hu-HU",
"nl-NL", "nl-NL",
"no-NO", "no-NO",
"pl-PL", "pl-PL",
"pt-BR", "pt-BR",
"ro-RO", "ro-RO",
"fi-FI", "fi-FI",
"sv-SE", "sv-SE",
"vi-VN", "vi-VN",
"tr-TR", "tr-TR",
"cs-CZ", "cs-CZ",
"el-GR", "el-GR",
"bg-BG", "bg-BG",
"ru-RU", "ru-RU",
"uk-UA", "uk-UA",
"th-TH", "th-TH",
"zh-CN", "zh-CN",
"ja-JP", "ja-JP",
"zh-TW", "zh-TW",
"ko-KR", "ko-KR",
]; ];
// This has to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected // This has to be non-nullable because Avalonia ComboBox doesn't allow a null value to be selected
public string Locale public string Locale
@@ -4,8 +4,8 @@ using System.Threading.Tasks;
using Avalonia; using Avalonia;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
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.Extensions; using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.ViewModels.Components; using DiscordChatExporter.Gui.ViewModels.Components;
@@ -16,7 +16,8 @@ public partial class MainViewModel(
DialogManager dialogManager, DialogManager dialogManager,
SnackbarManager snackbarManager, SnackbarManager snackbarManager,
SettingsService settingsService, SettingsService settingsService,
UpdateService updateService UpdateService updateService,
LocalizationManager localizationManager
) : ViewModelBase ) : ViewModelBase
{ {
public string Title { get; } = $"{Program.Name} v{Program.VersionString}"; public string Title { get; } = $"{Program.Name} v{Program.VersionString}";
@@ -29,14 +30,10 @@ public partial class MainViewModel(
return; return;
var dialog = viewModelManager.CreateMessageBoxViewModel( var dialog = viewModelManager.CreateMessageBoxViewModel(
"Thank you for supporting Ukraine!", localizationManager.UkraineSupportTitle,
""" localizationManager.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. localizationManager.LearnMoreButton,
localizationManager.CloseButton
Click LEARN MORE to find ways that you can help.
""",
"LEARN MORE",
"CLOSE"
); );
// Disable this message in the future // Disable this message in the future
@@ -44,7 +41,7 @@ public partial class MainViewModel(
settingsService.Save(); settingsService.Save();
if (await dialogManager.ShowDialogAsync(dialog) == true) if (await dialogManager.ShowDialogAsync(dialog) == true)
ProcessEx.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter"); Process.StartShellExecute("https://tyrrrz.me/ukraine?source=discordchatexporter");
} }
private async Task ShowDevelopmentBuildMessageAsync() private async Task ShowDevelopmentBuildMessageAsync()
@@ -57,20 +54,14 @@ public partial class MainViewModel(
return; return;
var dialog = viewModelManager.CreateMessageBoxViewModel( var dialog = viewModelManager.CreateMessageBoxViewModel(
"Unstable build warning", localizationManager.UnstableBuildTitle,
$""" string.Format(localizationManager.UnstableBuildMessage, Program.Name),
You're using a development build of {Program.Name}. These builds are not thoroughly tested and may contain bugs. localizationManager.SeeReleasesButton,
localizationManager.CloseButton
Auto-updates are disabled for development builds.
Click SEE RELEASES if you want to download a stable release instead.
""",
"SEE RELEASES",
"CLOSE"
); );
if (await dialogManager.ShowDialogAsync(dialog) == true) if (await dialogManager.ShowDialogAsync(dialog) == true)
ProcessEx.StartShellExecute(Program.ProjectReleasesUrl); Process.StartShellExecute(Program.ProjectReleasesUrl);
} }
private async Task CheckForUpdatesAsync() private async Task CheckForUpdatesAsync()
@@ -81,12 +72,18 @@ public partial class MainViewModel(
if (updateVersion is null) if (updateVersion is null)
return; return;
snackbarManager.Notify($"Downloading update to {Program.Name} v{updateVersion}..."); snackbarManager.Notify(
string.Format(
localizationManager.UpdateDownloadingMessage,
Program.Name,
updateVersion
)
);
await updateService.PrepareUpdateAsync(updateVersion); await updateService.PrepareUpdateAsync(updateVersion);
snackbarManager.Notify( snackbarManager.Notify(
"Update has been downloaded and will be installed when you exit", localizationManager.UpdateReadyMessage,
"INSTALL NOW", localizationManager.UpdateInstallNowButton,
() => () =>
{ {
updateService.FinalizeUpdate(true); updateService.FinalizeUpdate(true);
@@ -99,7 +96,7 @@ public partial class MainViewModel(
catch catch
{ {
// Failure to update shouldn't crash the application // Failure to update shouldn't crash the application
snackbarManager.Notify("Failed to perform application update"); snackbarManager.Notify(localizationManager.UpdateFailedMessage);
} }
} }
@@ -27,7 +27,7 @@
RevealPassword="{Binding $self.IsFocused}" RevealPassword="{Binding $self.IsFocused}"
Text="{Binding Token}" Text="{Binding Token}"
Theme="{DynamicResource SoloTextBox}" Theme="{DynamicResource SoloTextBox}"
Watermark="Token"> Watermark="{Binding LocalizationManager.TokenWatermark}">
<TextBox.InnerLeftContent> <TextBox.InnerLeftContent>
<materialIcons:MaterialIcon <materialIcons:MaterialIcon
Grid.Column="0" Grid.Column="0"
@@ -45,7 +45,7 @@
Command="{Binding PullGuildsCommand}" Command="{Binding PullGuildsCommand}"
IsDefault="True" IsDefault="True"
Theme="{DynamicResource MaterialFlatButton}" Theme="{DynamicResource MaterialFlatButton}"
ToolTip.Tip="Pull available servers and channels (Enter)"> ToolTip.Tip="{Binding LocalizationManager.PullGuildsTooltip}">
<materialIcons:MaterialIcon <materialIcons:MaterialIcon
Width="24" Width="24"
Height="24" Height="24"
@@ -64,7 +64,7 @@
Command="{Binding ShowSettingsCommand}" Command="{Binding ShowSettingsCommand}"
Foreground="{DynamicResource MaterialDarkForegroundBrush}" Foreground="{DynamicResource MaterialDarkForegroundBrush}"
Theme="{DynamicResource MaterialFlatButton}" Theme="{DynamicResource MaterialFlatButton}"
ToolTip.Tip="Settings"> ToolTip.Tip="{Binding LocalizationManager.SettingsTooltip}">
<materialIcons:MaterialIcon <materialIcons:MaterialIcon
Width="24" Width="24"
Height="24" Height="24"
@@ -168,7 +168,7 @@
<Setter Property="ToolTip.Tip"> <Setter Property="ToolTip.Tip">
<Template> <Template>
<TextBlock> <TextBlock>
<Run Text="Last message sent:" /> <Run Text="{Binding #UserControl.DataContext.LocalizationManager.LastMessageSentTooltip}" />
<Run FontWeight="SemiBold" Text="{Binding Channel.LastMessageId, Converter={x:Static converters:SnowflakeToTimestampStringConverter.Instance}, TargetNullValue=never, Mode=OneWay}" /> <Run FontWeight="SemiBold" Text="{Binding Channel.LastMessageId, Converter={x:Static converters:SnowflakeToTimestampStringConverter.Instance}, TargetNullValue=never, Mode=OneWay}" />
</TextBlock> </TextBlock>
</Template> </Template>
@@ -219,172 +219,68 @@
<!-- Placeholder / usage instructions --> <!-- Placeholder / usage instructions -->
<Panel IsVisible="{Binding !AvailableGuilds.Count}"> <Panel IsVisible="{Binding !AvailableGuilds.Count}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> <ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<TextBlock <StackPanel Margin="32,16" Spacing="0">
Margin="32,16" <StackPanel.Styles>
FontSize="14" <Style Selector="TextBlock">
FontWeight="Light" <Setter Property="FontSize" Value="14" />
LineHeight="23"> <Setter Property="FontWeight" Value="Light" />
<Setter Property="LineHeight" Value="23" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
</StackPanel.Styles>
<!-- User token --> <!-- User token -->
<InlineUIContainer> <TextBlock>
<materialIcons:MaterialIcon <InlineUIContainer>
Width="18" <materialIcons:MaterialIcon
Height="18" Width="18"
Margin="0,-2,0,0" Height="18"
Foreground="{DynamicResource PrimaryHueMidBrush}" Margin="0,-2,0,0"
Kind="Account" /> Foreground="{DynamicResource PrimaryHueMidBrush}"
</InlineUIContainer> Kind="Account" />
<Run BaselineAlignment="Center" Text="" /> </InlineUIContainer>
<Run <Run Text=" " />
BaselineAlignment="Center" <Run
FontSize="16" FontSize="16"
FontWeight="SemiBold" FontWeight="SemiBold"
Text="To get the token for your personal account:" /> Text="{Binding LocalizationManager.TokenPersonalHeader}" />
<LineBreak /> </TextBlock>
<Run BaselineAlignment="Center" Text="* Automating user accounts is technically against TOS —" /> <TextBlock Inlines="{Binding LocalizationManager.TokenPersonalTosWarning, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="use at your own risk" /><Run Text="!" />
<LineBreak />
<Run BaselineAlignment="Center" Text="1. Open Discord in your" /> <TextBlock Inlines="{Binding LocalizationManager.TokenPersonalInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" />
<controls:HyperLink Command="{Binding OpenDiscordCommand}" Text="web browser" />
<Run BaselineAlignment="Center" Text="and login" />
<LineBreak />
<Run BaselineAlignment="Center" Text="2. Open any server or direct message channel" />
<LineBreak />
<Run BaselineAlignment="Center" Text="3. Press" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Ctrl+Shift+I" />
<Run BaselineAlignment="Center" Text="to show developer tools" />
<LineBreak />
<Run BaselineAlignment="Center" Text="4. Navigate to the" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Network" />
<Run BaselineAlignment="Center" Text="tab" />
<LineBreak />
<Run BaselineAlignment="Center" Text="5. Press" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Ctrl+R" />
<Run BaselineAlignment="Center" Text="to reload" />
<LineBreak />
<Run BaselineAlignment="Center" Text="6. Switch between random channels to trigger network requests" />
<LineBreak />
<Run BaselineAlignment="Center" Text="7. Search for a request that starts with" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="messages" />
<LineBreak />
<Run BaselineAlignment="Center" Text="8. Select the" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Headers" />
<Run BaselineAlignment="Center" Text="tab on the right" />
<LineBreak />
<Run BaselineAlignment="Center" Text="9. Scroll down to the" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Request Headers" />
<Run BaselineAlignment="Center" Text="section" />
<LineBreak />
<Run BaselineAlignment="Center" Text="10. Copy the value of the" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="authorization" />
<Run BaselineAlignment="Center" Text="header" />
<LineBreak />
<LineBreak />
<!-- Bot token --> <!-- Bot token -->
<InlineUIContainer> <TextBlock Margin="0,12,0,0">
<materialIcons:MaterialIcon <InlineUIContainer>
Width="18" <materialIcons:MaterialIcon
Height="18" Width="18"
Margin="0,-2,0,0" Height="18"
Foreground="{DynamicResource PrimaryHueMidBrush}" Margin="0,-2,0,0"
Kind="Robot" /> Foreground="{DynamicResource PrimaryHueMidBrush}"
</InlineUIContainer> Kind="Robot" />
<Run BaselineAlignment="Center" Text="" /> </InlineUIContainer>
<Run <Run Text=" " />
BaselineAlignment="Center" <Run
FontSize="16" FontSize="16"
FontWeight="SemiBold" FontWeight="SemiBold"
Text="To get the token for your bot:" /> Text="{Binding LocalizationManager.TokenBotHeader}" />
<LineBreak /> </TextBlock>
<Run BaselineAlignment="Center" Text="The token is generated during bot creation. If you lost it, generate a new one:" /> <TextBlock Text="{Binding LocalizationManager.TokenBotIntro}" />
<LineBreak />
<Run BaselineAlignment="Center" Text="1. Open Discord" /> <TextBlock>
<controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="developer portal" /> <Run Text="{Binding LocalizationManager.TokenBotStep1}" />
<LineBreak /> <controls:HyperLink Command="{Binding OpenDiscordDeveloperPortalCommand}" Text="{Binding LocalizationManager.TokenDeveloperPortalLinkText}" />
</TextBlock>
<Run BaselineAlignment="Center" Text="2. Open your application's settings" /> <TextBlock Inlines="{Binding LocalizationManager.TokenBotInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" />
<LineBreak />
<Run BaselineAlignment="Center" Text="3. Navigate to the" /> <TextBlock Margin="0,12,0,0">
<Run <Run Text="{Binding LocalizationManager.TokenHelpText}" />
BaselineAlignment="Center" <controls:HyperLink Command="{Binding ShowHelpCommand}" Text="{Binding LocalizationManager.TokenDocumentationLinkText}" />
FontWeight="SemiBold" </TextBlock>
Text="Bot" /> </StackPanel>
<Run BaselineAlignment="Center" Text="section on the left" />
<LineBreak />
<Run BaselineAlignment="Center" Text="4. Under" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Token" />
<Run BaselineAlignment="Center" Text="click" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Reset Token" />
<LineBreak />
<Run BaselineAlignment="Center" Text="5. Click" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Yes, do it!" />
<Run BaselineAlignment="Center" Text="and authenticate to confirm" />
<LineBreak />
<Run BaselineAlignment="Center" Text="* Integrations using the previous token will stop working until updated" />
<LineBreak />
<Run BaselineAlignment="Center" Text="* Your bot needs to have the" />
<Run
BaselineAlignment="Center"
FontWeight="SemiBold"
Text="Message Content Intent" />
<Run BaselineAlignment="Center" Text="enabled to read messages" />
<LineBreak />
<LineBreak />
<Run BaselineAlignment="Center" Text="If you have questions or issues, please refer to the" />
<controls:HyperLink Command="{Binding ShowHelpCommand}" Text="documentation" />
</TextBlock>
</ScrollViewer> </ScrollViewer>
</Panel> </Panel>
@@ -38,7 +38,7 @@
IsVisible="{Binding !IsSingleChannel}" IsVisible="{Binding !IsSingleChannel}"
TextTrimming="CharacterEllipsis"> TextTrimming="CharacterEllipsis">
<Run Text="{Binding Channels.Count, FallbackValue=0, Mode=OneWay}" /> <Run Text="{Binding Channels.Count, FallbackValue=0, Mode=OneWay}" />
<Run Text="channels selected" /> <Run Text="{Binding LocalizationManager.ChannelsSelectedText}" />
</TextBlock> </TextBlock>
<!-- Category and channel name (for single channel) --> <!-- Category and channel name (for single channel) -->
@@ -69,7 +69,7 @@
<!-- Output path --> <!-- Output path -->
<TextBox <TextBox
Margin="16,8" Margin="16,8"
materialAssists:TextFieldAssist.Label="Output path" materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.OutputPathLabel}"
Text="{Binding OutputPath}" Text="{Binding OutputPath}"
Theme="{DynamicResource FilledTextBox}"> Theme="{DynamicResource FilledTextBox}">
<ToolTip.Tip> <ToolTip.Tip>
@@ -146,11 +146,11 @@
<!-- Format --> <!-- Format -->
<ComboBox <ComboBox
Margin="16,8" Margin="16,8"
materialAssists:ComboBoxAssist.Label="Format" materialAssists:ComboBoxAssist.Label="{Binding LocalizationManager.FormatLabel}"
ItemsSource="{Binding AvailableFormats}" ItemsSource="{Binding AvailableFormats}"
SelectedItem="{Binding SelectedFormat}" SelectedItem="{Binding SelectedFormat}"
Theme="{DynamicResource MaterialFilledComboBox}" Theme="{DynamicResource MaterialFilledComboBox}"
ToolTip.Tip="Export format"> ToolTip.Tip="{Binding LocalizationManager.FormatTooltip}">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" /> <TextBlock Text="{Binding Converter={x:Static converters:ExportFormatToStringConverter.Instance}}" />
@@ -166,9 +166,9 @@
Grid.Row="0" Grid.Row="0"
Grid.Column="0" Grid.Column="0"
Margin="16,8,8,8" Margin="16,8,8,8"
materialAssists:TextFieldAssist.Label="After (date)" materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AfterDateLabel}"
SelectedDate="{Binding AfterDate}" SelectedDate="{Binding AfterDate}"
ToolTip.Tip="Only include messages sent after this date"> ToolTip.Tip="{Binding LocalizationManager.AfterDateTooltip}">
<DatePicker.Styles> <DatePicker.Styles>
<Style Selector="DatePicker"> <Style Selector="DatePicker">
<Style Selector="^ /template/ TextBox#DisplayTextBox"> <Style Selector="^ /template/ TextBox#DisplayTextBox">
@@ -181,9 +181,9 @@
Grid.Row="0" Grid.Row="0"
Grid.Column="1" Grid.Column="1"
Margin="8,8,16,8" Margin="8,8,16,8"
materialAssists:TextFieldAssist.Label="Before (date)" materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.BeforeDateLabel}"
SelectedDate="{Binding BeforeDate}" SelectedDate="{Binding BeforeDate}"
ToolTip.Tip="Only include messages sent before this date"> ToolTip.Tip="{Binding LocalizationManager.BeforeDateTooltip}">
<DatePicker.Styles> <DatePicker.Styles>
<Style Selector="DatePicker"> <Style Selector="DatePicker">
<Style Selector="^ /template/ TextBox#DisplayTextBox"> <Style Selector="^ /template/ TextBox#DisplayTextBox">
@@ -198,11 +198,11 @@
Grid.Row="1" Grid.Row="1"
Grid.Column="0" Grid.Column="0"
Margin="16,8,8,8" Margin="16,8,8,8"
materialAssists:TextFieldAssist.Label="After (time)" materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AfterTimeLabel}"
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}" ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
IsEnabled="{Binding IsAfterDateSet}" IsEnabled="{Binding IsAfterDateSet}"
SelectedTime="{Binding AfterTime}" SelectedTime="{Binding AfterTime}"
ToolTip.Tip="Only include messages sent after this time"> ToolTip.Tip="{Binding LocalizationManager.AfterTimeTooltip}">
<TimePicker.Styles> <TimePicker.Styles>
<Style Selector="TimePicker"> <Style Selector="TimePicker">
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox"> <Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
@@ -215,11 +215,11 @@
Grid.Row="1" Grid.Row="1"
Grid.Column="1" Grid.Column="1"
Margin="8,8,16,8" Margin="8,8,16,8"
materialAssists:TextFieldAssist.Label="Before (time)" materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.BeforeTimeLabel}"
ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}" ClockIdentifier="{x:Static utils:Internationalization.AvaloniaClockIdentifier}"
IsEnabled="{Binding IsBeforeDateSet}" IsEnabled="{Binding IsBeforeDateSet}"
SelectedTime="{Binding BeforeTime}" SelectedTime="{Binding BeforeTime}"
ToolTip.Tip="Only include messages sent before this time"> ToolTip.Tip="{Binding LocalizationManager.BeforeTimeTooltip}">
<TimePicker.Styles> <TimePicker.Styles>
<Style Selector="TimePicker"> <Style Selector="TimePicker">
<Style Selector="^ /template/ TextBox#PART_DisplayTextBox"> <Style Selector="^ /template/ TextBox#PART_DisplayTextBox">
@@ -233,25 +233,25 @@
<!-- Partitioning --> <!-- Partitioning -->
<TextBox <TextBox
Margin="16,8" Margin="16,8"
materialAssists:TextFieldAssist.Label="Partition limit" materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.PartitionLimitLabel}"
Text="{Binding PartitionLimitValue}" Text="{Binding PartitionLimitValue}"
Theme="{DynamicResource FilledTextBox}" Theme="{DynamicResource FilledTextBox}"
ToolTip.Tip="Split the output into partitions, each limited to the specified number of messages (e.g. '100') or file size (e.g. '10mb')" /> ToolTip.Tip="{Binding LocalizationManager.PartitionLimitTooltip}" />
<!-- Filtering --> <!-- Filtering -->
<TextBox <TextBox
Margin="16,8" Margin="16,8"
materialAssists:TextFieldAssist.Label="Message filter" materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.MessageFilterLabel}"
Text="{Binding MessageFilterValue}" Text="{Binding MessageFilterValue}"
Theme="{DynamicResource FilledTextBox}" Theme="{DynamicResource FilledTextBox}"
ToolTip.Tip="Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image'). See the documentation for more info." /> ToolTip.Tip="{Binding LocalizationManager.MessageFilterTooltip}" />
<!-- Markdown formatting --> <!-- Markdown formatting -->
<DockPanel <DockPanel
Margin="16,8" Margin="16,8"
LastChildFill="False" LastChildFill="False"
ToolTip.Tip="Process markdown, mentions, and other special tokens"> ToolTip.Tip="{Binding LocalizationManager.FormatMarkdownTooltip}">
<TextBlock DockPanel.Dock="Left" Text="Format markdown" /> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.FormatMarkdownLabel}" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldFormatMarkdown}" /> <ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldFormatMarkdown}" />
</DockPanel> </DockPanel>
@@ -259,8 +259,8 @@
<DockPanel <DockPanel
Margin="16,8" Margin="16,8"
LastChildFill="False" LastChildFill="False"
ToolTip.Tip="Download assets referenced by the export (user avatars, attached files, embedded images, etc.)"> ToolTip.Tip="{Binding LocalizationManager.DownloadAssetsTooltip}">
<TextBlock DockPanel.Dock="Left" Text="Download assets" /> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.DownloadAssetsLabel}" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldDownloadAssets}" /> <ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldDownloadAssets}" />
</DockPanel> </DockPanel>
@@ -269,19 +269,19 @@
Margin="16,8" Margin="16,8"
IsEnabled="{Binding ShouldDownloadAssets}" IsEnabled="{Binding ShouldDownloadAssets}"
LastChildFill="False" LastChildFill="False"
ToolTip.Tip="Reuse previously downloaded assets to avoid redundant requests"> ToolTip.Tip="{Binding LocalizationManager.ReuseAssetsTooltip}">
<TextBlock DockPanel.Dock="Left" Text="Reuse assets" /> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ReuseAssetsLabel}" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldReuseAssets}" /> <ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding ShouldReuseAssets}" />
</DockPanel> </DockPanel>
<!-- Assets path --> <!-- Assets path -->
<TextBox <TextBox
Margin="16,8" Margin="16,8"
materialAssists:TextFieldAssist.Label="Assets directory path" materialAssists:TextFieldAssist.Label="{Binding LocalizationManager.AssetsDirPathLabel}"
IsEnabled="{Binding ShouldDownloadAssets}" IsEnabled="{Binding ShouldDownloadAssets}"
Text="{Binding AssetsDirPath}" Text="{Binding AssetsDirPath}"
Theme="{DynamicResource FilledTextBox}" Theme="{DynamicResource FilledTextBox}"
ToolTip.Tip="Download assets to this directory. If not specified, the asset directory path will be derived from the output path."> ToolTip.Tip="{Binding LocalizationManager.AssetsDirPathTooltip}">
<TextBox.InnerRightContent> <TextBox.InnerRightContent>
<Button <Button
Margin="8,8,8,6" Margin="8,8,8,6"
@@ -310,7 +310,7 @@
Grid.Column="0" Grid.Column="0"
IsChecked="{Binding IsAdvancedSectionDisplayed}" IsChecked="{Binding IsAdvancedSectionDisplayed}"
Theme="{DynamicResource MaterialOutlineButton}" Theme="{DynamicResource MaterialOutlineButton}"
ToolTip.Tip="Toggle advanced options"> ToolTip.Tip="{Binding LocalizationManager.AdvancedOptionsTooltip}">
<Button.Styles> <Button.Styles>
<Style Selector="ToggleButton"> <Style Selector="ToggleButton">
<Setter Property="Content" Value="MORE" /> <Setter Property="Content" Value="MORE" />
@@ -325,14 +325,14 @@
<Button <Button
Grid.Column="2" Grid.Column="2"
Command="{Binding ConfirmCommand}" Command="{Binding ConfirmCommand}"
Content="EXPORT" Content="{Binding LocalizationManager.ExportButton}"
IsDefault="True" IsDefault="True"
Theme="{DynamicResource MaterialOutlineButton}" /> Theme="{DynamicResource MaterialOutlineButton}" />
<Button <Button
Grid.Column="3" Grid.Column="3"
Margin="16,0,0,0" Margin="16,0,0,0"
Command="{Binding CloseCommand}" Command="{Binding CloseCommand}"
Content="CANCEL" Content="{Binding LocalizationManager.CancelButton}"
IsCancel="True" IsCancel="True"
Theme="{DynamicResource MaterialOutlineButton}" /> Theme="{DynamicResource MaterialOutlineButton}" />
</Grid> </Grid>
@@ -12,7 +12,7 @@
Margin="16" Margin="16"
FontSize="19" FontSize="19"
FontWeight="Light" FontWeight="Light"
Text="Settings" /> Text="{Binding LocalizationManager.SettingsTitle}" />
<Border <Border
Grid.Row="1" Grid.Row="1"
@@ -25,8 +25,8 @@
<DockPanel <DockPanel
Margin="16,8" Margin="16,8"
LastChildFill="False" LastChildFill="False"
ToolTip.Tip="Preferred user interface theme"> ToolTip.Tip="{Binding LocalizationManager.ThemeTooltip}">
<TextBlock DockPanel.Dock="Left" Text="Theme" /> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ThemeLabel}" />
<ComboBox <ComboBox
Width="150" Width="150"
DockPanel.Dock="Right" DockPanel.Dock="Right"
@@ -34,14 +34,27 @@
SelectedItem="{Binding Theme}" /> SelectedItem="{Binding Theme}" />
</DockPanel> </DockPanel>
<!-- Language -->
<DockPanel
Margin="16,8"
LastChildFill="False"
ToolTip.Tip="{Binding LocalizationManager.LanguageTooltip}">
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.LanguageLabel}" />
<ComboBox
Width="150"
DockPanel.Dock="Right"
ItemsSource="{Binding AvailableLanguages}"
SelectedItem="{Binding Language}" />
</DockPanel>
<!-- Auto-updates --> <!-- Auto-updates -->
<DockPanel <DockPanel
Margin="16,8" Margin="16,8"
IsVisible="{OnPlatform False, IsVisible="{OnPlatform False,
Windows=True}" Windows=True}"
LastChildFill="False" LastChildFill="False"
ToolTip.Tip="Perform automatic updates on every launch"> ToolTip.Tip="{Binding LocalizationManager.AutoUpdateTooltip}">
<TextBlock DockPanel.Dock="Left" Text="Auto-update" /> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.AutoUpdateLabel}" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsAutoUpdateEnabled}" /> <ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsAutoUpdateEnabled}" />
</DockPanel> </DockPanel>
@@ -49,8 +62,8 @@
<DockPanel <DockPanel
Margin="16,8" Margin="16,8"
LastChildFill="False" LastChildFill="False"
ToolTip.Tip="Save the last used token to a file so that it can be persisted between sessions"> ToolTip.Tip="{Binding LocalizationManager.PersistTokenTooltip}">
<TextBlock DockPanel.Dock="Left" Text="Persist token" /> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.PersistTokenLabel}" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" /> <ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
</DockPanel> </DockPanel>
@@ -58,8 +71,8 @@
<DockPanel <DockPanel
Margin="16,8" Margin="16,8"
LastChildFill="False" LastChildFill="False"
ToolTip.Tip="Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected."> ToolTip.Tip="{Binding LocalizationManager.RateLimitPreferenceTooltip}">
<TextBlock DockPanel.Dock="Left" Text="Rate limit preference" /> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.RateLimitPreferenceLabel}" />
<ComboBox <ComboBox
Width="150" Width="150"
DockPanel.Dock="Right" DockPanel.Dock="Right"
@@ -77,8 +90,8 @@
<DockPanel <DockPanel
Margin="16,8" Margin="16,8"
LastChildFill="False" LastChildFill="False"
ToolTip.Tip="Which types of threads to show in the channel list"> ToolTip.Tip="{Binding LocalizationManager.ShowThreadsTooltip}">
<TextBlock DockPanel.Dock="Left" Text="Show threads" /> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ShowThreadsLabel}" />
<ComboBox <ComboBox
Width="150" Width="150"
DockPanel.Dock="Right" DockPanel.Dock="Right"
@@ -90,8 +103,8 @@
<DockPanel <DockPanel
Margin="16,8" Margin="16,8"
LastChildFill="False" LastChildFill="False"
ToolTip.Tip="Locale to use when formatting dates and numbers"> ToolTip.Tip="{Binding LocalizationManager.LocaleTooltip}">
<TextBlock DockPanel.Dock="Left" Text="Locale" /> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.LocaleLabel}" />
<ComboBox <ComboBox
Width="150" Width="150"
DockPanel.Dock="Right" DockPanel.Dock="Right"
@@ -109,8 +122,8 @@
<DockPanel <DockPanel
Margin="16,8" Margin="16,8"
LastChildFill="False" LastChildFill="False"
ToolTip.Tip="Normalize all timestamps to UTC+0"> ToolTip.Tip="{Binding LocalizationManager.NormalizeToUtcTooltip}">
<TextBlock DockPanel.Dock="Left" Text="Normalize to UTC" /> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.NormalizeToUtcLabel}" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsUtcNormalizationEnabled}" /> <ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsUtcNormalizationEnabled}" />
</DockPanel> </DockPanel>
@@ -118,8 +131,8 @@
<DockPanel <DockPanel
Margin="16,8" Margin="16,8"
LastChildFill="False" LastChildFill="False"
ToolTip.Tip="How many channels can be exported at the same time"> ToolTip.Tip="{Binding LocalizationManager.ParallelLimitTooltip}">
<TextBlock DockPanel.Dock="Left" Text="Parallel limit" /> <TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ParallelLimitLabel}" />
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal"> <StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<TextBlock Margin="10,0" Text="{Binding ParallelLimit}" /> <TextBlock Margin="10,0" Text="{Binding ParallelLimit}" />
<Slider <Slider
@@ -141,7 +154,7 @@
Margin="16" Margin="16"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Command="{Binding CloseCommand}" Command="{Binding CloseCommand}"
Content="CLOSE" Content="{Binding LocalizationManager.CloseButton}"
IsCancel="True" IsCancel="True"
IsDefault="True" IsDefault="True"
Theme="{DynamicResource MaterialOutlineButton}" /> Theme="{DynamicResource MaterialOutlineButton}" />
+3 -1
View File
@@ -3,12 +3,14 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16 # Visual Studio Version 16
VisualStudioVersion = 16.0.28729.10 VisualStudioVersion = 16.0.28729.10
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EA305DD5-1F98-415D-B6C4-65053A58F914}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{EA305DD5-1F98-415D-B6C4-65053A58F914}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
License.txt = License.txt License.txt = License.txt
Readme.md = Readme.md Readme.md = Readme.md
Directory.Build.props = Directory.Build.props Directory.Build.props = Directory.Build.props
DiscordChatExporter.Cli.dockerfile = DiscordChatExporter.Cli.dockerfile DiscordChatExporter.Cli.dockerfile = DiscordChatExporter.Cli.dockerfile
global.json = global.json
NuGet.config = NuGet.config
EndProjectSection EndProjectSection
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordChatExporter.Gui", "DiscordChatExporter.Gui\DiscordChatExporter.Gui.csproj", "{732A67AF-93DE-49DF-B10F-FD74710B7863}"
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2017-2025 Oleksii Holub Copyright (c) 2017-2026 Oleksii Holub
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+6 -2
View File
@@ -50,10 +50,14 @@ To learn more about the war and how you can help, [click here](https://tyrrrz.me
- 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `discord-chat-exporter-cli` - 📦 [AUR](https://aur.archlinux.org/packages/discord-chat-exporter-cli): `discord-chat-exporter-cli`
- 📦 [Nix](https://search.nixos.org/packages?query=discordchatexporter-cli): `discordchatexporter-cli` - 📦 [Nix](https://search.nixos.org/packages?query=discordchatexporter-cli): `discordchatexporter-cli`
> **Note**: > [!IMPORTANT]
> To launch the GUI version of the app on MacOS, you need to first remove the downloaded file from quarantine.
> You can do that by running the following command in the terminal: `xattr -rd com.apple.quarantine DiscordChatExporter.app`.
> [!NOTE]
> If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture. > If you're unsure which build is right for your system, consult with [this page](https://useragent.cc) to determine your OS and CPU architecture.
> **Note**: > [!NOTE]
> AUR and Nix packages linked above are maintained by the community. > AUR and Nix packages linked above are maintained by the community.
> If you have any issues with them, please contact the corresponding maintainers. > If you have any issues with them, please contact the corresponding maintainers.
+6
View File
@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}