Compare commits

..

79 Commits

Author SHA1 Message Date
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
Tyrrrz aa8ace8389 Streamline dev build dialog 2025-05-13 19:28:40 +03:00
Tyrrrz 97cb8e2347 Allow snackbar to show multiple elements 2025-05-13 19:26:35 +03:00
Tyrrrz 92ee97a5da Clean up 2025-05-12 23:22:54 +03:00
Oleksii Holub 612ae2e894 Add a setting to control whether to respect advisory rate limits (#1342) 2025-05-12 19:52:47 +03:00
Tyrrrz 1506afc4a2 $ErrorActionPreference 2025-05-11 01:33:43 +03:00
Oleksii Holub b56c7db3ec Create MacOS app bundle via a project target (#1376) 2025-05-11 01:29:55 +03:00
Yudi 59d803d9f1 Bundle the app for macOS (#1375) 2025-05-10 03:03:55 +03:00
Yudi 30ba273fb1 Update bot token docs (#1371)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
2025-05-04 19:49:42 +03:00
Tyrrrz f9fa35b6ad Downgrade CSharpier 2025-05-03 23:40:59 +03:00
dependabot[bot] 7d2ebb04de Bump the nuget group across 1 directory with 11 updates (#1374)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 23:40:26 +03:00
dependabot[bot] 1c9f4275b2 Bump the actions group with 2 updates (#1372)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 23:27:34 +03:00
Tyrrrz 1fadc0755b Refactor after last changes 2025-04-21 00:45:26 +03:00
masmc05 7ddd55951c Don't ignore next pages of archived threads 2025-04-20 00:25:35 +03:00
Tyrrrz 6fb197cf0b Refactor 2025-04-02 00:34:52 +03:00
Leonardo Mosquera 7add81a472 Don't consider it an error if there is nothing to export (#1349) 2025-04-02 00:14:35 +03:00
dependabot[bot] cf7580014c Bump the nuget group across 1 directory with 12 updates (#1361)
Bumps the nuget group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [FluentAssertions](https://github.com/fluentassertions/fluentassertions) | `8.1.1` | `8.2.0` |
| [Microsoft.Extensions.Configuration](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Microsoft.Extensions.Configuration](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Microsoft.Extensions.Configuration.EnvironmentVariables](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Microsoft.Extensions.Configuration](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Microsoft.Extensions.Configuration.UserSecrets](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [RazorBlade](https://github.com/ltrzesniewski/RazorBlade) | `0.8.0` | `0.9.0` |
| [AngleSharp](https://github.com/AngleSharp/AngleSharp) | `1.2.0` | `1.2.0` |
| [YoutubeExplode](https://github.com/Tyrrrz/YoutubeExplode) | `6.5.3` | `6.5.4` |
| [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Avalonia.Desktop](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Avalonia](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Avalonia.Diagnostics](https://github.com/AvaloniaUI/Avalonia) | `11.2.5` | `11.2.6` |
| [Microsoft.Extensions.DependencyInjection](https://github.com/dotnet/runtime) | `9.0.2` | `9.0.3` |
| [Onova](https://github.com/Tyrrrz/Onova) | `2.6.12` | `2.6.13` |



Updates `FluentAssertions` from 8.1.1 to 8.2.0
- [Release notes](https://github.com/fluentassertions/fluentassertions/releases)
- [Changelog](https://github.com/fluentassertions/fluentassertions/blob/main/AcceptApiChanges.ps1)
- [Commits](https://github.com/fluentassertions/fluentassertions/compare/8.1.1...8.2.0)

Updates `Microsoft.Extensions.Configuration` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Microsoft.Extensions.Configuration` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Microsoft.Extensions.Configuration.EnvironmentVariables` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Microsoft.Extensions.Configuration` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Microsoft.Extensions.Configuration.UserSecrets` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `RazorBlade` from 0.8.0 to 0.9.0
- [Release notes](https://github.com/ltrzesniewski/RazorBlade/releases)
- [Commits](https://github.com/ltrzesniewski/RazorBlade/compare/v0.8.0...v0.9.0)

Updates `AngleSharp` from 1.2.0 to 1.2.0
- [Release notes](https://github.com/AngleSharp/AngleSharp/releases)
- [Changelog](https://github.com/AngleSharp/AngleSharp/blob/devel/CHANGELOG.md)
- [Commits](https://github.com/AngleSharp/AngleSharp/compare/1.2.0...1.2.0)

Updates `YoutubeExplode` from 6.5.3 to 6.5.4
- [Release notes](https://github.com/Tyrrrz/YoutubeExplode/releases)
- [Commits](https://github.com/Tyrrrz/YoutubeExplode/compare/6.5.3...6.5.4)

Updates `Avalonia` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Avalonia` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Avalonia.Desktop` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Avalonia` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Avalonia.Diagnostics` from 11.2.5 to 11.2.6
- [Release notes](https://github.com/AvaloniaUI/Avalonia/releases)
- [Commits](https://github.com/AvaloniaUI/Avalonia/compare/11.2.5...11.2.6)

Updates `Microsoft.Extensions.DependencyInjection` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `Onova` from 2.6.12 to 2.6.13
- [Release notes](https://github.com/Tyrrrz/Onova/releases)
- [Commits](https://github.com/Tyrrrz/Onova/compare/2.6.12...2.6.13)

---
updated-dependencies:
- dependency-name: FluentAssertions
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration.EnvironmentVariables
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.Configuration.UserSecrets
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: RazorBlade
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: AngleSharp
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: YoutubeExplode
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia.Desktop
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Avalonia.Diagnostics
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.DependencyInjection
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: Onova
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 18:58:39 +03:00
dependabot[bot] 606c082759 Bump the actions group with 3 updates (#1360)
Bumps the actions group with 3 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact), [actions/setup-dotnet](https://github.com/actions/setup-dotnet) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `actions/upload-artifact` from 4.6.1 to 4.6.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1...ea165f8d65b6e75b540449e92b4886f43607fa02)

Updates `actions/setup-dotnet` from 4.3.0 to 4.3.1
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/3951f0dfe7a07e2313ec93c75700083e2005cbab...67a3573c9a986a3f9c594539f4ab511d57bb3ce9)

Updates `actions/download-artifact` from 4.1.9 to 4.2.1
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/cc203385981b70ca67e1cc392babf9cc229d5806...95815c38cf2ff2164869cbab79da8d1f422bc89e)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-dotnet
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

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