Compare commits

..

16 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 32f93a670e Fix link text extraction in MarkdownToInlinesConverter to handle formatted inline children
- Add GetPlainText helper that recursively collects text from all inline children
  (LiteralInline for leaf text, ContainerInline recurses into children)
- Use GetPlainText(link) instead of .OfType<LiteralInline>() so bold/italic
  labels like [**text**](url) are rendered correctly
- Fix BulletType: ListBlock.BulletType is a char in Markdig, not an enum;
  remove the incorrect switch expression and use the char directly

Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 18:20:58 +00:00
Oleksii Holub 752263a4da Update DiscordChatExporter.Gui/Converters/MarkdownToInlinesConverter.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-24 20:07:27 +02:00
copilot-swe-agent[bot] 910b1d9334 Rename ErrorPullingServersTitle to ErrorPullingGuildsTitle for naming consistency
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 17:54:59 +00:00
copilot-swe-agent[bot] 81fd5001b7 Use dash lists in OutputPathTooltip; add curly braces to HyperLink else-if
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 17:45:13 +00:00
copilot-swe-agent[bot] 2efddb3b2c Inline local functions; simplify URL handling; localize OutputPathTooltip
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 17:13:16 +00:00
Oleksii Holub 2b20bf97e3 Apply suggestion from @Tyrrrz 2026-02-24 18:55:23 +02:00
copilot-swe-agent[bot] dbc16f9f63 Render HyperLink for all links; allow any valid URI scheme
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 16:38:24 +00:00
copilot-swe-agent[bot] 3daf49cb52 Add Url property to HyperLink; simplify converter link handling; add http/https scheme guard
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 15:58:26 +00:00
copilot-swe-agent[bot] f6ded3fc8c Add markdown link support; fold bot instructions into markdown; set TextBlock properties directly
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 10:20:25 +00:00
copilot-swe-agent[bot] b7f55ff633 Remove TokenPersonalStep1Before/After split, fold step 1 into markdown instructions
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 10:03:48 +00:00
copilot-swe-agent[bot] 5bbd3fa65e Adopt MarkdownToInlinesConverter for instruction text formatting
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 09:55:08 +00:00
copilot-swe-agent[bot] c6871af438 Localize token field watermark and instruction text
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 09:35:40 +00:00
copilot-swe-agent[bot] 2e29d03648 Fix Ukrainian translations per review feedback
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 08:32:00 +00:00
copilot-swe-agent[bot] 7257332914 Fix Ukrainian translation: use 'Гілки' for threads
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 08:26:26 +00:00
copilot-swe-agent[bot] b1b0dc1625 Implement localization in DiscordChatExporter following YoutubeDownloader pattern
Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com>
2026-02-24 07:40:35 +00:00
copilot-swe-agent[bot] 10a5d1b2bf Initial plan 2026-02-24 07:24:02 +00:00
106 changed files with 1107 additions and 2008 deletions
+2 -2
View File
@@ -17,7 +17,7 @@ Note the `:stable` tag. DiscordChatExporter images are tagged according to the f
- `stable` — latest stable version release. This tag is updated with each release of a new project version. Recommended for personal use.
- `x.y.z` (e.g. `2.30.1`) — specific stable version release. This tag is pushed when the corresponding version is released and never updated thereafter. Recommended for use in automation scenarios.
- `latest` — latest (potentially unstable) build. This tag is updated with each new commit to the `prime` branch. Not recommended, unless you want to test a new feature that has not been released in a stable version yet.
- `latest` — latest (potentially unstable) build. This tag is updated with each new commit to the `master` branch. Not recommended, unless you want to test a new feature that has not been released in a stable version yet.
You can see all available tags [here](https://hub.docker.com/r/tyrrrz/discordchatexporter/tags?ordering=name).
@@ -57,7 +57,7 @@ You can also use the current working directory as the output directory by specif
- `-v $PWD:/out` in Bash
- `-v $pwd.Path:/out` in PowerShell
For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/DiscordChatExporter.Cli.dockerfile) and [Docker documentation](https://docs.docker.com/engine/reference/run).
For more information, please refer to the [Dockerfile](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/DiscordChatExporter.Cli.dockerfile) and [Docker documentation](https://docs.docker.com/engine/reference/run).
To get your Token and Channel IDs, please refer to [this page](Token-and-IDs.md).
+1 -1
View File
@@ -11,7 +11,7 @@
```bash
#!/bin/bash
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
TOKEN=tokenhere
CHANNELID=channelhere
+1 -1
View File
@@ -12,7 +12,7 @@
```bash
#!/bin/bash
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
TOKEN=tokenhere
CHANNELID=channelhere
+1 -1
View File
@@ -5,7 +5,7 @@
1. Open a text editor such as Notepad and paste:
```console
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
# Info: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
$TOKEN = "tokenhere"
$CHANNEL = "channelhere"
+2 -2
View File
@@ -27,7 +27,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
3. Type
```js
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.getToken){m=x;break}}}]);m&&console.log("Token:",m.exports.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.
@@ -120,7 +120,7 @@ Prerequisite step: Navigate to [discord.com](https://discord.com) and login.
1. Type
```js
let m;webpackChunkdiscord_app.push([[Math.random()],{},e=>{for(let i in e.c){let x=e.c[i];if(x?.exports?.getToken){m=x;break}}}]);m&&console.log("Token:",m.exports.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.
+61 -116
View File
@@ -23,104 +23,85 @@ Now we're ready to run the commands.
Type the following command in your terminal of choice, then press ENTER to run it. This will list all available subcommands and options.
```console
./dce
./DiscordChatExporter.Cli
```
> **Note**:
> On Windows, use `dce` instead of `./dce`.
> On Windows, if you're using the default Command Prompt (`cmd`), omit the leading `./` at the start of the command.
> **Docker** users, please refer to the [Docker usage instructions](Docker.md).
## CLI commands
| Command | Description |
| ----------------- | -------------------------------------------------------------------- |
| export | Exports one or more channels |
| list channels | Outputs the list of channels in the given server(s) |
| list channels dm | Outputs the list of direct message channels |
| list servers | Outputs the list of accessible servers |
| list unwrap | Resolves categories and forums in a channel list to their child channels and threads |
| guide | Explains how to obtain token, server, and channel ID |
| Command | Description |
|-------------------------|------------------------------------------------------|
| export | Exports a channel |
| exportdm | Exports all direct message channels |
| exportguild | Exports all channels within the specified server |
| exportall | Exports all accessible channels |
| channels | Outputs the list of channels in the given server |
| dm | Outputs the list of direct message channels |
| guilds | Outputs the list of accessible servers |
| guide | Explains how to obtain token, server, and channel ID |
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `./dce guide`.
To pass the token, use the `-t|--token` option:
```console
./dce export 53555 -t "mfa.Ifrn"
```
Alternatively, you can set the `DISCORD_TOKEN` environment variable and omit `-t`:
**Linux/macOS:**
```console
export DISCORD_TOKEN="mfa.Ifrn"
```
**Windows:**
```console
set DISCORD_TOKEN=mfa.Ifrn
```
The pipeline examples in this guide assume `DISCORD_TOKEN` is already set.
To use the commands, you'll need a token. For the instructions on how to get a token, please refer to [this page](Token-and-IDs.md), or run `./DiscordChatExporter.Cli guide`.
To get help with a specific command, run:
```console
./dce command --help
./DiscordChatExporter.Cli command --help
```
For example, to figure out how to use the `export` command, run:
```console
./dce export --help
./DiscordChatExporter.Cli export --help
```
## Export a specific channel
You can quickly export with DCE's default settings by providing the channel ID as a positional argument and `-t|--token`.
You can quickly export with DCE's default settings by using just `-t token` and `-c channelid`.
```console
./dce export 53555 -t "mfa.Ifrn"
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555
```
#### Changing the format
You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json` or `Csv` with `-f|--format`. The default
You can change the export format to `HtmlDark`, `HtmlLight`, `PlainText` `Json` or `Csv` with `-f format`. The default
format is `HtmlDark`.
```console
./dce export 53555 -t "mfa.Ifrn" -f Json
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -f Json
```
#### Changing the output filename
You can change the filename by using `-o|--output`. e.g. for the `HTML` format:
You can change the filename by using `-o name.ext`. e.g. for the `HTML` format:
```console
./dce export 53555 -t "mfa.Ifrn" -o myserver.html
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o myserver.html
```
#### Changing the output directory
You can change the export directory by using `-o|--output` and providing a path that ends with a slash or does not have a file
You can change the export directory by using `-o` and providing a path that ends with a slash or does not have a file
extension.
If any of the folders in the path have a space in its name, escape them with quotes (").
```console
./dce export 53555 -t "mfa.Ifrn" -o "C:\Discord Exports"
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports"
```
#### Changing the filename and output directory
You can change both the filename and export directory by using `-o|--output`.
You can change both the filename and export directory by using `-o directory\name.ext`.
Note that the filename must have an extension, otherwise it will be considered a directory name.
If any of the folders in the path have a space in its name, escape them with quotes (").
```console
./dce export 53555 -t "mfa.Ifrn" -o "C:\Discord Exports\myserver.html"
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\myserver.html"
```
#### Generating the filename and output directory dynamically
@@ -128,7 +109,7 @@ If any of the folders in the path have a space in its name, escape them with quo
You can use template tokens to generate the output file path based on the server and channel metadata.
```console
./dce export 53555 -t "mfa.Ifrn" -o "C:\Discord Exports\%G\%T\%C.html"
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -o "C:\Discord Exports\%G\%T\%C.html"
```
Assuming you are exporting a channel named `"my-channel"` in the `"Text channels"` category from a server
@@ -156,13 +137,13 @@ You can use partitioning to split files after a given number of messages or file
For example, a channel with 36 messages set to be partitioned every 10 messages will output 4 files.
```console
./dce export 53555 -t "mfa.Ifrn" -p 10
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 10
```
A 45 MB channel set to be partitioned every 20 MB will output 3 files.
```console
./dce export 53555 -t "mfa.Ifrn" -p 20mb
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 -p 20mb
```
#### Downloading assets
@@ -173,7 +154,7 @@ downloaded when using the plain text (TXT) export format.
A folder containing the assets will be created along with the exported chat. They must be kept together.
```console
./dce export 53555 -t "mfa.Ifrn" --media
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media
```
#### Reusing assets
@@ -182,7 +163,7 @@ Previously downloaded assets can be reused to skip redundant downloads as long a
same folder. Using this option can speed up future exports. This option requires the `--media` option.
```console
./dce export 53555 -t "mfa.Ifrn" --media --reuse-media
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --reuse-media
```
#### Changing the media directory
@@ -191,7 +172,7 @@ By default, the media directory is created alongside the exported chat. You can
providing a path that ends with a slash. All of the exported media will be stored in this directory.
```console
./dce export 53555 -t "mfa.Ifrn" --media --media-dir "C:\Discord Media"
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --media --media-dir "C:\Discord Media"
```
#### Changing the date format
@@ -200,7 +181,7 @@ You can customize how dates are formatted in the exported files by using `--loca
locales. The default locale is `en-US`.
```console
./dce export 53555 -t "mfa.Ifrn" --locale "de-DE"
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --locale "de-DE"
```
#### Date ranges
@@ -209,14 +190,14 @@ locales. The default locale is `en-US`.
Use `--before` to export messages sent before the provided date. E.g. messages sent before September 18th, 2019:
```console
./dce export 53555 -t "mfa.Ifrn" --before 2019-09-18
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --before 2019-09-18
```
**Messages sent after a date**
Use `--after` to export messages sent after the provided date. E.g. messages sent after September 17th, 2019 11:34 PM:
```console
./dce export 53555 -t "mfa.Ifrn" --after "2019-09-17 23:34"
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34"
```
**Messages sent in a date range**
@@ -224,7 +205,7 @@ Use `--before` and `--after` to export messages sent during the provided date ra
September 17th, 2019 11:34 PM and September 18th:
```console
./dce export 53555 -t "mfa.Ifrn" --after "2019-09-17 23:34" --before "2019-09-18"
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --after "2019-09-17 23:34" --before "2019-09-18"
```
You can try different formats like `17-SEP-2019 11:34 PM` or even refine your ranges down to
@@ -238,111 +219,75 @@ formats [here](https://docs.microsoft.com/en-us/dotnet/standard/base-types/custo
Use `--filter` to filter what messages are included in the export.
```console
./dce export 53555 -t "mfa.Ifrn" --filter "from:Tyrrrz has:image"
./DiscordChatExporter.Cli export -t "mfa.Ifrn" -c 53555 --filter "from:Tyrrrz has:image"
```
Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs/Message-filters.md).
Documentation on message filter syntax can be found [here](https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs/Message-filters.md).
### Export channels from a specific server
> [!IMPORTANT]
> The following examples assume `DISCORD_TOKEN` is already set. See [CLI commands](#cli-commands) for instructions.
To export all channels in a specific server, use `list channels` to list channels and pipe the result to `export`.
**Linux/macOS:**
To export all channels in a specific server, use the `exportguild` command and provide the server ID through the `-g|--guild` option:
```console
./dce list channels 21814 | ./dce export
```
**Windows:**
```console
dce list channels 21814 | dce export
```
You can also list channels for multiple servers at once:
```console
./dce list channels 21814 35930 | ./dce export
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814
```
#### Including threads
By default, threads are not included. You can change this behavior by passing `--include-threads` to the `list channels` command. It has possible values of `none`, `active`, or `all`, indicating which threads should be included. To include both active and archived threads, use `--include-threads all`.
By default, threads are not included in the export. You can change this behavior by using `--include-threads` and
specifying which threads should be included. It has possible values of `none`, `active`, or `all`, indicating which
threads should be included. To include both active and archived threads, use `--include-threads all`.
```console
./dce list channels 21814 --include-threads all | ./dce export
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-threads all
```
#### Including voice channels
By default, voice channels are included. You can change this behavior by passing `--include-vc false` to the `list channels` command.
By default, voice channels are included in the export. You can change this behavior by using `--include-vc` and
specifying whether to include voice channels in the export. It has possible values of `true` or `false`, to exclude
voice channels, use `--include-vc false`.
```console
./dce list channels 21814 --include-vc false | ./dce export
./DiscordChatExporter.Cli exportguild -t "mfa.Ifrn" -g 21814 --include-vc false
```
### Export all DMs
### Export all channels
> [!IMPORTANT]
> The following examples assume `DISCORD_TOKEN` is already set. See [CLI commands](#cli-commands) for instructions.
To export all DMs:
**Linux/macOS:**
To export all accessible channels, use the `exportall` command:
```console
./dce list channels dm | ./dce export
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn"
```
**Windows:**
#### Excluding DMs
To exclude DMs, add the `--include-dm false` option.
```console
dce list channels dm | dce export
./DiscordChatExporter.Cli exportall -t "mfa.Ifrn" --include-dm false
```
### List channels in a server
To list the channels available in a specific server, use the `list channels` command and provide the server ID as an argument:
To list the channels available in a specific server, use the `channels` command and provide the server ID through the `-g|--guild` option:
```console
./dce list channels 21814 -t "mfa.Ifrn"
```
The `list channels` command outputs a JSON array of channel objects. You can pipe this directly to the `export` command:
```console
./dce list channels 21814 | ./dce export
./DiscordChatExporter.Cli channels -t "mfa.Ifrn" -g 21814
```
### List direct message channels
To list all DM channels accessible to the current account, use the `list channels dm` command:
To list all DM channels accessible to the current account, use the `dm` command:
```console
./dce list channels dm -t "mfa.Ifrn"
```
The `list channels dm` command outputs a JSON array of channel objects. You can pipe this directly to the `export` command:
```console
./dce list channels dm | ./dce export
```
### Unwrap categories and forums
To resolve category and forum channels in a list to their child channels and thread posts, use the `list unwrap` command:
```console
./dce list channels 21814 | ./dce list unwrap | ./dce export
./DiscordChatExporter.Cli dm -t "mfa.Ifrn"
```
### List servers
To list all servers accessible by the current account, use the `list servers` command:
To list all servers accessible by the current account, use the `guilds` command:
```console
./dce list servers -t "mfa.Ifrn" > C:\path\to\output.txt
./DiscordChatExporter.Cli guilds -t "mfa.Ifrn" > C:\path\to\output.txt
```
+2 -2
View File
@@ -1,10 +1,10 @@
blank_issues_enabled: false
contact_links:
- name: ⚠ Feature request
url: https://github.com/Tyrrrz/.github/blob/prime/docs/project-status.md
url: https://github.com/Tyrrrz/.github/blob/master/docs/project-status.md
about: Sorry, but this project is in maintenance mode and no longer accepts new feature requests.
- name: 📖 Documentation
url: https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs
url: https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs
about: Find usage guides and frequently asked questions.
- name: 🗨 Discussions
url: https://github.com/Tyrrrz/DiscordChatExporter/discussions/new
+7 -7
View File
@@ -4,12 +4,12 @@ on:
workflow_dispatch:
push:
branches:
- prime
- master
tags:
- "*"
pull_request:
branches:
- prime
- master
jobs:
# Outputs from this job aren't really used, but it's here to verify that the Dockerfile builds correctly
@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build image
run: >
@@ -37,15 +37,15 @@ jobs:
--output type=tar,dest=DiscordChatExporter.Cli.Docker.tar
- name: Upload image
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: DiscordChatExporter.Cli.Docker
path: DiscordChatExporter.Cli.Docker.tar
if-no-files-found: error
deploy:
# Deploy to DockerHub only on tag push or prime branch push
if: ${{ github.ref_type == 'tag' || github.ref_type == 'branch' && github.ref_name == 'prime' }}
# Deploy to DockerHub only on tag push or master branch push
if: ${{ github.ref_type == 'tag' || github.ref_type == 'branch' && github.ref_name == 'master' }}
runs-on: ubuntu-latest
timeout-minutes: 10
@@ -58,7 +58,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to DockerHub
run: >
+9 -10
View File
@@ -4,12 +4,12 @@ on:
workflow_dispatch:
push:
branches:
- prime
- master
tags:
- "*"
pull_request:
branches:
- prime
- master
env:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
@@ -29,7 +29,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install .NET
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
# Build the project separately to discern between build and format errors
- name: Build
@@ -65,7 +65,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install .NET
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Run tests
env:
@@ -81,7 +81,7 @@ jobs:
DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover
- name: Upload coverage
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -120,14 +120,13 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install .NET
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
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:EncryptionSalt=${{ secrets.ENCRYPTION_SALT || 'HimalayanPinkSalt' }}
-p:PublishMacOSBundle=${{ startsWith(matrix.rid, 'osx-') }}
--output ${{ matrix.app }}/bin/publish/
--configuration Release
@@ -135,7 +134,7 @@ jobs:
--self-contained
- name: Upload app binaries
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/bin/publish/
@@ -200,7 +199,7 @@ jobs:
steps:
- name: Download app binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: ${{ matrix.asset }}.${{ matrix.rid }}
path: ${{ matrix.app }}/
@@ -236,7 +235,7 @@ jobs:
steps:
- name: Notify Discord
uses: tyrrrz/action-http-request@25f132e48dea89c0f6b7955398270b506e1d51cb # 1.1.4
uses: tyrrrz/action-http-request@1dd7ad841a34b9299f3741f7c7399f9feefdfb08 # 1.1.3
with:
url: ${{ secrets.DISCORD_WEBHOOK }}
method: POST
+3 -2
View File
@@ -1,4 +1,5 @@
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>999.9.9-dev</Version>
@@ -6,8 +7,8 @@
<Copyright>Copyright (c) Oleksii Holub</Copyright>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ILLinkTreatWarningsAsErrors>false</ILLinkTreatWarningsAsErrors>
</PropertyGroup>
</Project>
</Project>
-46
View File
@@ -1,46 +0,0 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="AngleSharp" Version="1.4.0" />
<PackageVersion Include="AsyncImageLoader.Avalonia" Version="3.7.0" />
<PackageVersion Include="AsyncKeyedLock" Version="8.0.2" />
<PackageVersion Include="Avalonia" Version="11.3.13" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.13" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.13" />
<PackageVersion Include="CliFx" Version="3.0.0" />
<PackageVersion Include="Cogwheel" Version="2.1.1" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="coverlet.collector" Version="8.0.1" />
<PackageVersion Include="CSharpier.MsBuild" Version="1.2.6" />
<PackageVersion Include="Deorcify" Version="1.1.0" />
<PackageVersion Include="ThisAssembly.Project" Version="2.1.2" />
<PackageVersion Include="DialogHost.Avalonia" Version="0.11.0" />
<PackageVersion Include="FluentAssertions" Version="8.9.0" />
<PackageVersion Include="GitHubActionsTestLogger" Version="3.0.3" />
<PackageVersion Include="Gress" Version="2.1.1" />
<PackageVersion Include="JsonExtensions" Version="1.2.0" />
<PackageVersion Include="Markdig" Version="1.1.2" />
<PackageVersion Include="Material.Avalonia" Version="3.9.2" />
<PackageVersion Include="Material.Icons.Avalonia" Version="2.2.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.5" />
<PackageVersion
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
Version="10.0.5"
/>
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.5" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageVersion Include="Onova" Version="2.6.13" />
<PackageVersion Include="Polly" Version="8.6.6" />
<PackageVersion Include="RazorBlade" Version="0.11.0" />
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
<PackageVersion Include="Superpower" Version="3.1.0" />
<PackageVersion Include="WebMarkupMin.Core" Version="2.21.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="YoutubeExplode" Version="6.5.7" />
</ItemGroup>
</Project>
@@ -10,18 +10,21 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" />
<PackageReference Include="coverlet.collector" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="GitHubActionsTestLogger" PrivateAssets="all" />
<PackageReference Include="JsonExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
<PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="coverlet.collector" Version="6.0.4" 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="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.1.5" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
@@ -16,8 +16,6 @@ public static class ChannelIds
public static Snowflake FilterTestCases { get; } = Snowflake.Parse("866744075033641020");
public static Snowflake ForwardTestCases { get; } = Snowflake.Parse("1455202357204877477");
public static Snowflake MarkdownTestCases { get; } = Snowflake.Parse("866459526819348521");
public static Snowflake MentionTestCases { get; } = Snowflake.Parse("866458801389174794");
@@ -1,12 +1,7 @@
using System.IO;
using System.Linq;
using System.Linq;
using System.Threading.Tasks;
using AngleSharp.Dom;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using Xunit;
@@ -49,41 +44,4 @@ public class HtmlContentSpecs
"Yeet"
);
}
[Fact]
public async Task I_can_export_a_channel_in_the_HTML_format_in_the_reverse_order()
{
// Arrange
using var file = TempFile.Create();
// Act
await new ExportChannelsCommand
{
Token = Secrets.DiscordToken,
ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.HtmlDark,
OutputPath = file.Path,
Locale = "en-US",
IsUtcNormalizationEnabled = true,
IsReverseMessageOrder = true,
}.ExecuteAsync(new FakeConsole());
var document = Html.Parse(await File.ReadAllTextAsync(file.Path));
var messages = document.QuerySelectorAll("[data-message-id]").ToArray();
// Assert
messages
.Select(e => e.GetAttribute("data-message-id"))
.Should()
.Equal(
"885169254029213696",
"868505973294268457",
"868505969821364245",
"868505966528835604",
"868490009366396958",
"866732113319428096",
"866710679758045195",
"866674314627121232"
);
}
}
@@ -1,29 +0,0 @@
using System.Threading.Tasks;
using AngleSharp.Dom;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
public class HtmlForwardSpecs
{
[Fact]
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
{
// Act
var message = await ExportWrapper.GetMessageAsHtmlAsync(
ChannelIds.ForwardTestCases,
Snowflake.Parse("1455202427115536514")
);
// Assert
message
.Text()
.ReplaceWhiteSpace()
.Should()
.ContainAll("Forwarded", @"¯\_(ツ)_/¯", "12/29/2025 2:14 PM");
}
}
@@ -1,13 +1,7 @@
using System.IO;
using System.Linq;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils;
using DiscordChatExporter.Core.Exporting;
using FluentAssertions;
using JsonExtensions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
@@ -49,43 +43,4 @@ public class JsonContentSpecs
"Yeet"
);
}
[Fact]
public async Task I_can_export_a_channel_in_the_JSON_format_in_the_reverse_order()
{
// Arrange
using var file = TempFile.Create();
// Act
await new ExportChannelsCommand
{
Token = Secrets.DiscordToken,
ChannelIds = [ChannelIds.DateRangeTestCases],
ExportFormat = ExportFormat.Json,
OutputPath = file.Path,
Locale = "en-US",
IsUtcNormalizationEnabled = true,
IsReverseMessageOrder = true,
}.ExecuteAsync(new FakeConsole());
var messages = Json.Parse(await File.ReadAllTextAsync(file.Path))
.GetProperty("messages")
.EnumerateArray()
.ToArray();
// Assert
messages
.Select(j => j.GetProperty("id").GetString())
.Should()
.Equal(
"885169254029213696",
"868505973294268457",
"868505969821364245",
"868505966528835604",
"868490009366396958",
"866732113319428096",
"866710679758045195",
"866674314627121232"
);
}
}
@@ -1,33 +0,0 @@
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Core.Discord;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
public class JsonForwardSpecs
{
[Fact]
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
{
// Act
var message = await ExportWrapper.GetMessageAsJsonAsync(
ChannelIds.ForwardTestCases,
Snowflake.Parse("1455202427115536514")
);
// Assert
var reference = message.GetProperty("reference");
reference.GetProperty("type").GetString().Should().Be("Forward");
reference.GetProperty("guildId").GetString().Should().Be("869237470565392384");
var forwardedMessage = message.GetProperty("forwardedMessage");
forwardedMessage.GetProperty("content").GetString().Should().Contain(@"¯\_(ツ)_/¯");
forwardedMessage
.GetProperty("timestamp")
.GetString()
.Should()
.StartWith("2025-12-28T22:52:42.175+00:00");
}
}
@@ -1,23 +0,0 @@
using System.Threading.Tasks;
using DiscordChatExporter.Cli.Tests.Infra;
using DiscordChatExporter.Cli.Tests.Utils.Extensions;
using FluentAssertions;
using Xunit;
namespace DiscordChatExporter.Cli.Tests.Specs;
public class PlainTextForwardSpecs
{
[Fact]
public async Task I_can_export_a_channel_that_contains_a_forwarded_message()
{
// Act
var document = await ExportWrapper.ExportAsPlainTextAsync(ChannelIds.ForwardTestCases);
// Assert
document
.ReplaceWhiteSpace()
.Should()
.ContainAll("{Forwarded Message}", @"¯\_(ツ)_/¯", "12/28/2025 10:52 PM");
}
}
-1
View File
@@ -15,7 +15,6 @@ WORKDIR /tmp/app
COPY favicon.ico .
COPY NuGet.config .
COPY Directory.Build.props .
COPY Directory.Packages.props .
COPY DiscordChatExporter.Core DiscordChatExporter.Core
COPY DiscordChatExporter.Cli DiscordChatExporter.Cli
@@ -2,7 +2,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CliFx;
using CliFx.Binding;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Utils;
@@ -17,22 +17,23 @@ public abstract class DiscordCommandBase : ICommand
EnvironmentVariable = "DISCORD_TOKEN",
Description = "Authentication token."
)]
public required string Token { get; set; }
public required string Token { get; init; }
[Obsolete("This option doesn't do anything. Kept for backwards compatibility.")]
[CommandOption(
"bot",
'b',
EnvironmentVariable = "DISCORD_TOKEN_BOT",
Description = "This option doesn't do anything. Kept for backwards compatibility."
)]
public bool IsBotToken { get; set; } = false;
public bool IsBotToken { get; init; } = false;
[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; set; } = true;
public bool ShouldRespectRateLimits { get; init; } = true;
[field: AllowNull, MaybeNull]
protected DiscordClient Discord =>
@@ -5,8 +5,8 @@ using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using CliFx.Binding;
using CliFx.Attributes;
using CliFx.Exceptions;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Cli.Commands.Shared;
@@ -37,23 +37,23 @@ public abstract class ExportCommandBase : DiscordCommandBase
get;
// Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
set => field = Path.GetFullPath(value);
init => field = Path.GetFullPath(value);
} = Directory.GetCurrentDirectory();
[CommandOption("format", 'f', Description = "Export format.")]
public ExportFormat ExportFormat { get; set; } = ExportFormat.HtmlDark;
public ExportFormat ExportFormat { get; init; } = ExportFormat.HtmlDark;
[CommandOption(
"after",
Description = "Only include messages sent after this date or message ID."
)]
public Snowflake? After { get; set; }
public Snowflake? After { get; init; }
[CommandOption(
"before",
Description = "Only include messages sent before this date or message ID."
)]
public Snowflake? Before { get; set; }
public Snowflake? Before { get; init; }
[CommandOption(
"partition",
@@ -61,51 +61,45 @@ public abstract class ExportCommandBase : DiscordCommandBase
Description = "Split the output into partitions, each limited to the specified "
+ "number of messages (e.g. '100') or file size (e.g. '10mb')."
)]
public PartitionLimit PartitionLimit { get; set; } = PartitionLimit.Null;
public PartitionLimit PartitionLimit { get; init; } = PartitionLimit.Null;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeInputConverter)
Converter = typeof(ThreadInclusionModeBindingConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None;
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
[CommandOption(
"filter",
Description = "Only include messages that satisfy this filter. "
+ "See the documentation for more info."
)]
public MessageFilter MessageFilter { get; set; } = MessageFilter.Null;
public MessageFilter MessageFilter { get; init; } = MessageFilter.Null;
[CommandOption(
"parallel",
Description = "Limits how many channels can be exported in parallel."
)]
public int ParallelLimit { get; set; } = 1;
[CommandOption(
"reverse",
Description = "Export messages in reverse chronological order (newest first)."
)]
public bool IsReverseMessageOrder { get; set; }
public int ParallelLimit { get; init; } = 1;
[CommandOption(
"markdown",
Description = "Process markdown, mentions, and other special tokens."
)]
public bool ShouldFormatMarkdown { get; set; } = true;
public bool ShouldFormatMarkdown { get; init; } = true;
[CommandOption(
"media",
Description = "Download assets referenced by the export (user avatars, attached files, embedded images, etc.)."
)]
public bool ShouldDownloadAssets { get; set; }
public bool ShouldDownloadAssets { get; init; }
[CommandOption(
"reuse-media",
Description = "Reuse previously downloaded assets to avoid redundant requests."
)]
public bool ShouldReuseAssets { get; set; } = false;
public bool ShouldReuseAssets { get; init; } = false;
[CommandOption(
"media-dir",
@@ -117,33 +111,34 @@ public abstract class ExportCommandBase : DiscordCommandBase
get;
// Handle ~/ in paths on Unix systems
// https://github.com/Tyrrrz/DiscordChatExporter/pull/903
set => field = 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.")]
[CommandOption(
"dateformat",
Description = "This option doesn't do anything. Kept for backwards compatibility."
)]
public string DateFormat { get; set; } = "MM/dd/yyyy h:mm tt";
public string DateFormat { get; init; } = "MM/dd/yyyy h:mm tt";
[CommandOption(
"locale",
Description = "Locale to use when formatting dates and numbers. "
+ "If not specified, the default system locale will be used."
)]
public string? Locale { get; set; }
public string? Locale { get; init; }
[CommandOption("utc", Description = "Normalize all timestamps to UTC+0.")]
public bool IsUtcNormalizationEnabled { get; set; } = false;
public bool IsUtcNormalizationEnabled { get; init; } = false;
[CommandOption(
"fuck-russia",
EnvironmentVariable = "FUCK_RUSSIA",
Description = "Don't print the Support Ukraine message to the console.",
// Use a converter to accept '1' as 'true' to reuse the existing environment variable
Converter = typeof(TruthyBooleanInputConverter)
Converter = typeof(TruthyBooleanBindingConverter)
)]
public bool IsUkraineSupportMessageDisabled { get; set; } = false;
public bool IsUkraineSupportMessageDisabled { get; init; } = false;
[field: AllowNull, MaybeNull]
protected ChannelExporter Exporter => field ??= new ChannelExporter(Discord);
@@ -272,7 +267,6 @@ public abstract class ExportCommandBase : DiscordCommandBase
Before,
PartitionLimit,
MessageFilter,
IsReverseMessageOrder,
ShouldFormatMarkdown,
ShouldDownloadAssets,
ShouldReuseAssets,
@@ -1,10 +1,10 @@
using System;
using CliFx.Activation;
using CliFx.Extensibility;
using DiscordChatExporter.Cli.Commands.Shared;
namespace DiscordChatExporter.Cli.Commands.Converters;
internal class ThreadInclusionModeInputConverter : ScalarInputConverter<ThreadInclusionMode>
internal class ThreadInclusionModeBindingConverter : BindingConverter<ThreadInclusionMode>
{
public override ThreadInclusionMode Convert(string? rawValue)
{
@@ -1,9 +1,9 @@
using System.Globalization;
using CliFx.Activation;
using CliFx.Extensibility;
namespace DiscordChatExporter.Cli.Commands.Converters;
internal class TruthyBooleanInputConverter : ScalarInputConverter<bool>
internal class TruthyBooleanBindingConverter : BindingConverter<bool>
{
public override bool Convert(string? rawValue)
{
@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Dump;
using DiscordChatExporter.Core.Exceptions;
using Spectre.Console;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportall", Description = "Exports all accessible channels.")]
public class ExportAllCommand : ExportCommandBase
{
[CommandOption("include-dm", Description = "Include direct message channels.")]
public bool IncludeDirectChannels { get; init; } = true;
[CommandOption("include-guilds", Description = "Include server channels.")]
public bool IncludeGuildChannels { get; init; } = true;
[CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption(
"data-package",
Description = "Path to the personal data package (ZIP file) requested from Discord. "
+ "If provided, only channels referenced in the dump will be exported."
)]
public string? DataPackageFilePath { get; init; }
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
var channels = new List<Channel>();
// Pull from the API
if (string.IsNullOrWhiteSpace(DataPackageFilePath))
{
await foreach (var guild in Discord.GetUserGuildsAsync(cancellationToken))
{
// Regular channels
await console.Output.WriteLineAsync(
$"Fetching channels for server '{guild.Name}'..."
);
var fetchedChannelsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var channel in Discord.GetGuildChannelsAsync(
guild.Id,
cancellationToken
)
)
{
if (channel.IsCategory)
continue;
if (!IncludeVoiceChannels && channel.IsVoice)
continue;
channels.Add(channel);
ctx.Status(
Markup.Escape($"Fetched '{channel.GetHierarchicalName()}'.")
);
fetchedChannelsCount++;
}
}
);
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
}
}
// Pull from the data package
else
{
await console.Output.WriteLineAsync("Extracting channels...");
var dump = await DataDump.LoadAsync(DataPackageFilePath, cancellationToken);
var inaccessibleChannels = new List<DataDumpChannel>();
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
foreach (var dumpChannel in dump.Channels)
{
ctx.Status(
Markup.Escape(
$"Fetching '{dumpChannel.Name}' ({dumpChannel.Id})..."
)
);
try
{
var channel = await Discord.GetChannelAsync(
dumpChannel.Id,
cancellationToken
);
channels.Add(channel);
}
catch (DiscordChatExporterException)
{
inaccessibleChannels.Add(dumpChannel);
}
}
}
);
await console.Output.WriteLineAsync($"Fetched {channels} channel(s).");
// Print inaccessible channels
if (inaccessibleChannels.Any())
{
await console.Output.WriteLineAsync();
using (console.WithForegroundColor(ConsoleColor.Red))
{
await console.Error.WriteLineAsync(
"Failed to access the following channel(s):"
);
}
foreach (var dumpChannel in inaccessibleChannels)
await console.Error.WriteLineAsync($"{dumpChannel.Name} ({dumpChannel.Id})");
await console.Error.WriteLineAsync();
}
}
// Filter out unwanted channels
if (!IncludeDirectChannels)
channels.RemoveAll(c => c.IsDirect);
if (!IncludeGuildChannels)
channels.RemoveAll(c => c.IsGuild);
if (!IncludeVoiceChannels)
channels.RemoveAll(c => c.IsVoice);
await ExportAsync(console, channels);
}
}
@@ -1,27 +1,25 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using CliFx;
using CliFx.Binding;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("export", Description = "Exports one or multiple channels.")]
public partial class ExportChannelsCommand : ExportCommandBase
public class ExportChannelsCommand : ExportCommandBase
{
[CommandParameter(
0,
Name = "channel-ids",
// TODO: change this to plural (breaking change)
[CommandOption(
"channel",
'c',
Description = "Channel ID(s). "
+ "If not provided, channel IDs are read from standard input (one per line or as a JSON array), "
+ "enabling piping from the 'list channels' or 'list channels dm' commands."
+ "If provided with category ID(s), all channels inside those categories will be exported."
)]
public IReadOnlyList<Snowflake> ChannelIds { get; set; } = [];
public required IReadOnlyList<Snowflake> ChannelIds { get; init; }
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -29,46 +27,35 @@ public partial class ExportChannelsCommand : ExportCommandBase
var cancellationToken = console.RegisterCancellationHandler();
// If no channel IDs were specified, read them from stdin
var channelIds = new List<Snowflake>(ChannelIds);
if (channelIds.Count == 0 && console.IsInputRedirected)
{
await foreach (var line in console.Input.ReadLinesAsync(cancellationToken))
{
var trimmed = line.Trim();
if (string.IsNullOrEmpty(trimmed))
continue;
// Snowflake IDs are numeric; non-numeric input is treated as a JSON array
if (!char.IsAsciiDigit(trimmed[0]))
{
using var doc = JsonDocument.Parse(trimmed);
foreach (var element in doc.RootElement.EnumerateArray())
channelIds.Add(Snowflake.Parse(element.GetProperty("id").GetString()!));
}
else
{
channelIds.Add(Snowflake.Parse(trimmed));
}
}
}
if (channelIds.Count == 0)
{
throw new CommandException(
"No channel IDs provided. "
+ "Specify channel IDs as arguments or pipe them from the 'list channels' or 'list channels dm' commands."
);
}
await console.Output.WriteLineAsync("Resolving channel(s)...");
var channels = new List<Channel>();
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
foreach (var channelId in channelIds)
foreach (var channelId in ChannelIds)
{
var channel = await Discord.GetChannelAsync(channelId, cancellationToken);
channels.Add(channel);
// 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);
@@ -0,0 +1,27 @@
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportdm", Description = "Exports all direct message channels.")]
public class ExportDirectMessagesCommand : ExportCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
await console.Output.WriteLineAsync("Fetching channels...");
var channels = await Discord.GetGuildChannelsAsync(
Guild.DirectMessages.Id,
cancellationToken
);
await ExportAsync(console, channels);
}
}
@@ -0,0 +1,61 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using Spectre.Console;
namespace DiscordChatExporter.Cli.Commands;
[Command("exportguild", Description = "Exports all channels within the specified server.")]
public class ExportGuildCommand : ExportCommandBase
{
[CommandOption("guild", 'g', Description = "Server ID.")]
public required Snowflake GuildId { get; init; }
[CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; init; } = true;
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
var channels = new List<Channel>();
await console.Output.WriteLineAsync("Fetching channels...");
var fetchedChannelsCount = 0;
await console
.CreateStatusTicker()
.StartAsync(
"...",
async ctx =>
{
await foreach (
var channel in Discord.GetGuildChannelsAsync(GuildId, cancellationToken)
)
{
if (channel.IsCategory)
continue;
if (!IncludeVoiceChannels && channel.IsVoice)
continue;
channels.Add(channel);
ctx.Status(Markup.Escape($"Fetched '{channel.GetHierarchicalName()}'."));
fetchedChannelsCount++;
}
}
);
await console.Output.WriteLineAsync($"Fetched {fetchedChannelsCount} channel(s).");
await ExportAsync(console, channels);
}
}
@@ -1,34 +1,31 @@
using System.Collections.Generic;
using System;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using CliFx.Binding;
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.Json;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("list channels", Description = "Gets the list of channels in one or more servers.")]
public partial class GetChannelsCommand : DiscordCommandBase
[Command("channels", Description = "Get the list of channels in a server.")]
public class GetChannelsCommand : DiscordCommandBase
{
[CommandParameter(0, Name = "server-ids", Description = "Server ID(s).")]
public required IReadOnlyList<Snowflake> ServerIds { get; set; }
[CommandOption("guild", 'g', Description = "Server ID.")]
public required Snowflake GuildId { get; init; }
[CommandOption("include-vc", Description = "Include voice channels.")]
public bool IncludeVoiceChannels { get; set; } = true;
public bool IncludeVoiceChannels { get; init; } = true;
[CommandOption(
"include-threads",
Description = "Which types of threads should be included.",
Converter = typeof(ThreadInclusionModeInputConverter)
Converter = typeof(ThreadInclusionModeBindingConverter)
)]
public ThreadInclusionMode ThreadInclusionMode { get; set; } = ThreadInclusionMode.None;
public ThreadInclusionMode ThreadInclusionMode { get; init; } = ThreadInclusionMode.None;
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -36,44 +33,82 @@ public partial class GetChannelsCommand : DiscordCommandBase
var cancellationToken = console.RegisterCancellationHandler();
var allChannels = new List<Channel>();
var channels = (await Discord.GetGuildChannelsAsync(GuildId, cancellationToken))
.Where(c => !c.IsCategory)
.Where(c => IncludeVoiceChannels || !c.IsVoice)
.OrderBy(c => c.Parent?.Position)
.ThenBy(c => c.Name)
.ToArray();
foreach (var serverId in ServerIds)
{
var channels = (await Discord.GetGuildChannelsAsync(serverId, cancellationToken))
.Where(c => !c.IsCategory)
.Where(c => IncludeVoiceChannels || !c.IsVoice)
.OrderBy(c => c.Parent?.Position)
.ThenBy(c => c.Name)
.ToArray();
var channelIdMaxLength = channels
.Select(c => c.Id.ToString().Length)
.OrderDescending()
.FirstOrDefault();
var threads =
ThreadInclusionMode != ThreadInclusionMode.None
? (
await Discord.GetGuildThreadsAsync(
serverId,
ThreadInclusionMode == ThreadInclusionMode.All,
null,
null,
cancellationToken
)
var threads =
ThreadInclusionMode != ThreadInclusionMode.None
? (
await Discord.GetGuildThreadsAsync(
GuildId,
ThreadInclusionMode == ThreadInclusionMode.All,
null,
null,
cancellationToken
)
.OrderBy(c => c.Name)
.ToArray()
: [];
)
.OrderBy(c => c.Name)
.ToArray()
: [];
foreach (var channel in channels)
foreach (var channel in channels)
{
// Channel ID
await console.Output.WriteAsync(
channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
);
// Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray))
await console.Output.WriteAsync(" | ");
// Channel name
using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync(channel.GetHierarchicalName());
var channelThreads = threads.Where(t => t.Parent?.Id == channel.Id).ToArray();
var channelThreadIdMaxLength = channelThreads
.Select(t => t.Id.ToString().Length)
.OrderDescending()
.FirstOrDefault();
foreach (var channelThread in channelThreads)
{
allChannels.Add(channel);
allChannels.AddRange(threads.Where(t => t.Parent?.Id == channel.Id));
// Indent
await console.Output.WriteAsync(" * ");
// Thread ID
await console.Output.WriteAsync(
channelThread.Id.ToString().PadRight(channelThreadIdMaxLength, ' ')
);
// Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray))
await console.Output.WriteAsync(" | ");
// Thread name
using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteAsync($"Thread / {channelThread.Name}");
// Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray))
await console.Output.WriteAsync(" | ");
// Thread status
using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync(
channelThread.IsArchived ? "Archived" : "Active"
);
}
}
await console.Output.WriteLineAsync(
JsonSerializer.Serialize(
allChannels.ToArray(),
CliJsonSerializerContext.Instance.ChannelArray
)
);
}
}
@@ -1,17 +1,16 @@
using System.Linq;
using System.Text.Json;
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Binding;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Json;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("list channels dm", Description = "Gets the list of direct message channels.")]
public partial class GetDirectChannelsCommand : DiscordCommandBase
[Command("dm", Description = "Gets the list of all direct message channels.")]
public class GetDirectChannelsCommand : DiscordCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -26,8 +25,25 @@ public partial class GetDirectChannelsCommand : DiscordCommandBase
.ThenBy(c => c.Name)
.ToArray();
await console.Output.WriteLineAsync(
JsonSerializer.Serialize(channels, CliJsonSerializerContext.Instance.ChannelArray)
);
var channelIdMaxLength = channels
.Select(c => c.Id.ToString().Length)
.OrderDescending()
.FirstOrDefault();
foreach (var channel in channels)
{
// Channel ID
await console.Output.WriteAsync(
channel.Id.ToString().PadRight(channelIdMaxLength, ' ')
);
// Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray))
await console.Output.WriteAsync(" | ");
// Channel name
using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync(channel.GetHierarchicalName());
}
}
}
@@ -1,17 +1,16 @@
using System.Linq;
using System.Text.Json;
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx.Binding;
using CliFx.Attributes;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Json;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command("list servers", Description = "Gets the list of accessible servers.")]
public partial class GetGuildsCommand : DiscordCommandBase
[Command("guilds", Description = "Gets the list of accessible servers.")]
public class GetGuildsCommand : DiscordCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -25,8 +24,23 @@ public partial class GetGuildsCommand : DiscordCommandBase
.ThenBy(g => g.Name)
.ToArray();
await console.Output.WriteLineAsync(
JsonSerializer.Serialize(guilds, CliJsonSerializerContext.Instance.GuildArray)
);
var guildIdMaxLength = guilds
.Select(g => g.Id.ToString().Length)
.OrderDescending()
.FirstOrDefault();
foreach (var guild in guilds)
{
// Guild ID
await console.Output.WriteAsync(guild.Id.ToString().PadRight(guildIdMaxLength, ' '));
// Separator
using (console.WithForegroundColor(ConsoleColor.DarkGray))
await console.Output.WriteAsync(" | ");
// Guild name
using (console.WithForegroundColor(ConsoleColor.White))
await console.Output.WriteLineAsync(guild.Name);
}
}
}
@@ -1,13 +1,13 @@
using System;
using System.Threading.Tasks;
using CliFx;
using CliFx.Binding;
using CliFx.Attributes;
using CliFx.Infrastructure;
namespace DiscordChatExporter.Cli.Commands;
[Command("guide", Description = "Explains how to obtain the token, server or channel ID.")]
public partial class GuideCommand : ICommand
public class GuideCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console)
{
@@ -74,7 +74,7 @@ public partial class GuideCommand : ICommand
using (console.WithForegroundColor(ConsoleColor.DarkCyan))
{
console.Output.WriteLine(
"https://github.com/Tyrrrz/DiscordChatExporter/blob/prime/.docs"
"https://github.com/Tyrrrz/DiscordChatExporter/blob/master/.docs"
);
}
@@ -1,13 +0,0 @@
using System.Threading.Tasks;
using CliFx;
using CliFx.Binding;
using CliFx.Infrastructure;
namespace DiscordChatExporter.Cli.Commands;
[Command("list", Description = "Lists channels, DMs, or servers.")]
public partial class ListCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) =>
throw new CommandException("Use one of the named commands listed below.", showHelp: true);
}
@@ -1,95 +0,0 @@
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using CliFx;
using CliFx.Binding;
using CliFx.Infrastructure;
using DiscordChatExporter.Cli.Commands.Base;
using DiscordChatExporter.Cli.Utils.Extensions;
using DiscordChatExporter.Cli.Utils.Json;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Cli.Commands;
[Command(
"list unwrap",
Description = "Resolves categories and forums in a channel list to their child channels and threads."
)]
public partial class UnwrapChannelsCommand : DiscordCommandBase
{
public override async ValueTask ExecuteAsync(IConsole console)
{
await base.ExecuteAsync(console);
var cancellationToken = console.RegisterCancellationHandler();
// Read all JSON from stdin (produced by 'list channels' or 'list channels dm')
var sb = new StringBuilder();
await foreach (var line in console.Input.ReadLinesAsync(cancellationToken))
sb.Append(line);
Channel[] channels;
try
{
channels =
JsonSerializer.Deserialize(
sb.ToString().Trim(),
CliJsonSerializerContext.Instance.ChannelArray
) ?? [];
}
catch (JsonException)
{
throw new CommandException(
"Failed to parse input as a JSON channel array. "
+ "Pipe the output of 'list channels' or 'list channels dm' to this command."
);
}
var result = new List<Channel>();
var channelsByGuild = new Dictionary<Snowflake, IReadOnlyList<Channel>>();
foreach (var channel in channels)
{
if (channel.IsCategory)
{
// Expand category to its child channels
var guildChannels =
channelsByGuild.GetValueOrDefault(channel.GuildId)
?? await Discord.GetGuildChannelsAsync(channel.GuildId, cancellationToken);
foreach (var guildChannel in guildChannels)
{
if (guildChannel.Parent?.Id == channel.Id)
result.Add(guildChannel);
}
channelsByGuild[channel.GuildId] = guildChannels;
}
else if (channel.Kind == ChannelKind.GuildForum)
{
// Expand forum to its thread posts
await foreach (
var thread in Discord.GetChannelThreadsAsync(
[channel],
cancellationToken: cancellationToken
)
)
result.Add(thread);
}
else
{
result.Add(channel);
}
}
await console.Output.WriteLineAsync(
JsonSerializer.Serialize(
result.ToArray(),
CliJsonSerializerContext.Instance.ChannelArray
)
);
}
}
@@ -6,28 +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" />
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
<PackageReference Include="Deorcify" PrivateAssets="all" />
<PackageReference Include="Gress" />
<PackageReference Include="Spectre.Console" />
<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.54.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DiscordChatExporter.Core\DiscordChatExporter.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None
Include="dce"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest"
/>
<None
Include="dce.bat"
CopyToOutputDirectory="PreserveNewest"
CopyToPublishDirectory="PreserveNewest"
/>
</ItemGroup>
</Project>
+31 -3
View File
@@ -1,13 +1,41 @@
using System.Threading.Tasks;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using CliFx;
using DiscordChatExporter.Cli.Commands;
using DiscordChatExporter.Cli.Commands.Converters;
using DiscordChatExporter.Core.Exporting.Filtering;
using DiscordChatExporter.Core.Exporting.Partitioning;
namespace DiscordChatExporter.Cli;
public static class Program
{
// Explicit references because CliFx relies on reflection and we're publishing with trimming enabled
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportAllCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportDirectMessagesCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(ExportGuildCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetDirectChannelsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GetGuildsCommand))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(GuideCommand))]
[DynamicDependency(
DynamicallyAccessedMemberTypes.All,
typeof(ThreadInclusionModeBindingConverter)
)]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(TruthyBooleanBindingConverter))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PartitionLimit))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(MessageFilter))]
public static async Task<int> Main(string[] args) =>
await new CommandLineApplicationBuilder()
.AddCommandsFromThisAssembly()
await new CliApplicationBuilder()
.AddCommand<ExportAllCommand>()
.AddCommand<ExportChannelsCommand>()
.AddCommand<ExportDirectMessagesCommand>()
.AddCommand<ExportGuildCommand>()
.AddCommand<GetChannelsCommand>()
.AddCommand<GetDirectChannelsCommand>()
.AddCommand<GetGuildsCommand>()
.AddCommand<GuideCommand>()
.Build()
.RunAsync(args);
}
@@ -1,8 +1,4 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using CliFx.Infrastructure;
using Spectre.Console;
@@ -65,15 +61,4 @@ internal static class ConsoleExtensions
progressTask.StopTask();
}
}
public static async IAsyncEnumerable<string> ReadLinesAsync(
this TextReader reader,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
while (await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line)
{
yield return line;
}
}
}
@@ -1,26 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using DiscordChatExporter.Core.Discord.Data;
namespace DiscordChatExporter.Cli.Utils.Json;
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
GenerationMode = JsonSourceGenerationMode.Metadata
)]
[JsonSerializable(typeof(Channel[]))]
[JsonSerializable(typeof(Guild[]))]
internal partial class CliJsonSerializerContext : JsonSerializerContext
{
// Instance pre-configured with converters for Snowflake (serialised as a string)
// and all enum types (serialised as their name). Defined here so the Core types
// are never touched.
public static CliJsonSerializerContext Instance { get; } =
new(
new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new SnowflakeJsonConverter(), new JsonStringEnumConverter() },
}
);
}
@@ -1,21 +0,0 @@
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
using DiscordChatExporter.Core.Discord;
namespace DiscordChatExporter.Cli.Utils.Json;
internal class SnowflakeJsonConverter : JsonConverter<Snowflake>
{
public override Snowflake Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
) => Snowflake.Parse(reader.GetString()!);
public override void Write(
Utf8JsonWriter writer,
Snowflake value,
JsonSerializerOptions options
) => writer.WriteStringValue(value.ToString());
}
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env sh
exec "$(dirname "$0")/DiscordChatExporter.Cli" "$@"
-2
View File
@@ -1,2 +0,0 @@
@echo off
"%~dp0DiscordChatExporter.Cli.exe" %*
@@ -8855,8 +8855,6 @@ internal static class EmojiIndex
["united_nations"] = "🇺🇳",
};
public static IReadOnlyCollection<string> GetAllNames() => _toCodes.Keys;
public static string? TryGetCode(string name) => _toCodes.GetValueOrDefault(name);
public static string? TryGetName(string code) => _fromCodes.GetValueOrDefault(code);
@@ -27,16 +27,9 @@ public partial record Message(
IReadOnlyList<User> MentionedUsers,
MessageReference? Reference,
Message? ReferencedMessage,
MessageSnapshot? ForwardedMessage,
Interaction? Interaction
) : IHasId
{
public bool IsEmpty { get; } =
string.IsNullOrWhiteSpace(Content)
&& !Attachments.Any()
&& !Embeds.Any()
&& !Stickers.Any();
public bool IsSystemNotification { get; } =
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
@@ -45,7 +38,11 @@ public partial record Message(
// App interactions are rendered as replies in the Discord client, but they are not actually replies
public bool IsReplyLike => IsReply || Interaction is not null;
public bool IsForwarded { get; } = Reference?.Kind == MessageReferenceKind.Forward;
public bool IsEmpty { get; } =
string.IsNullOrWhiteSpace(Content)
&& !Attachments.Any()
&& !Embeds.Any()
&& !Stickers.Any();
public IEnumerable<User> GetReferencedUsers()
{
@@ -174,17 +171,7 @@ public partial record Message
var messageReference = json.GetPropertyOrNull("message_reference")
?.Pipe(MessageReference.Parse);
var referencedMessage = json.GetPropertyOrNull("referenced_message")?.Pipe(Parse);
// Currently Discord only supports 1 snapshot per forward
var forwardedMessage = json.GetPropertyOrNull("message_snapshots")
?.EnumerateArrayOrNull()
?.Select(j => j.GetPropertyOrNull("message"))
.WhereNotNull()
.Select(MessageSnapshot.Parse)
.FirstOrDefault();
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
return new Message(
@@ -204,7 +191,6 @@ public partial record Message
mentionedUsers,
messageReference,
referencedMessage,
forwardedMessage,
interaction
);
}
@@ -5,19 +5,10 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/channel#message-object-message-reference-structure
public record MessageReference(
MessageReferenceKind Kind,
Snowflake? MessageId,
Snowflake? ChannelId,
Snowflake? GuildId
)
public record MessageReference(Snowflake? MessageId, Snowflake? ChannelId, Snowflake? GuildId)
{
public static MessageReference Parse(JsonElement json)
{
var kind =
json.GetPropertyOrNull("type")?.GetInt32OrNull()?.Pipe(t => (MessageReferenceKind)t)
?? MessageReferenceKind.Default;
var messageId = json.GetPropertyOrNull("message_id")
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse);
@@ -30,6 +21,6 @@ public record MessageReference(
?.GetNonWhiteSpaceStringOrNull()
?.Pipe(Snowflake.Parse);
return new MessageReference(kind, messageId, channelId, guildId);
return new MessageReference(messageId, channelId, guildId);
}
}
@@ -1,8 +0,0 @@
namespace DiscordChatExporter.Core.Discord.Data;
// https://discord.com/developers/docs/resources/channel#message-reference-types
public enum MessageReferenceKind
{
Default = 0,
Forward = 1,
}
@@ -1,57 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data;
// https://docs.discord.com/developers/resources/message#message-snapshot-object
public record MessageSnapshot(
DateTimeOffset Timestamp,
DateTimeOffset? EditedTimestamp,
string Content,
IReadOnlyList<Attachment> Attachments,
IReadOnlyList<Embed> Embeds,
IReadOnlyList<Sticker> Stickers
)
{
public static MessageSnapshot Parse(JsonElement json)
{
var timestamp =
json.GetPropertyOrNull("timestamp")?.GetDateTimeOffsetOrNull()
?? DateTimeOffset.MinValue;
var editedTimestamp = json.GetPropertyOrNull("edited_timestamp")?.GetDateTimeOffsetOrNull();
var content = json.GetPropertyOrNull("content")?.GetStringOrNull() ?? "";
var attachments =
json.GetPropertyOrNull("attachments")
?.EnumerateArrayOrNull()
?.Select(Attachment.Parse)
.ToArray()
?? [];
var embeds =
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray()
?? [];
var stickers =
json.GetPropertyOrNull("sticker_items")
?.EnumerateArrayOrNull()
?.Select(Sticker.Parse)
.ToArray()
?? [];
return new MessageSnapshot(
timestamp,
editedTimestamp,
content,
attachments,
embeds,
stickers
);
}
}
+18 -124
View File
@@ -194,23 +194,6 @@ public class DiscordClient(
return Application.Parse(response);
}
private async ValueTask EnsureMessageContentIntentAsync(
CancellationToken cancellationToken = default
)
{
if (await ResolveTokenKindAsync(cancellationToken) != TokenKind.Bot)
return;
var application = await GetApplicationAsync(cancellationToken);
if (application.IsMessageContentIntentEnabled)
return;
throw new DiscordChatExporterException(
"Provided bot account is missing the MESSAGE_CONTENT privileged intent.",
true
);
}
public async ValueTask<User?> TryGetUserAsync(
Snowflake userId,
CancellationToken cancellationToken = default
@@ -446,11 +429,6 @@ public class DiscordClient(
.Where(c => before is null || c.MayHaveMessagesBefore(before.Value))
.ToArray();
// Track yielded thread IDs to avoid duplicates that can occur when a thread transitions
// from active to archived between the two separate API calls used to fetch threads.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1433
var seenThreadIds = new HashSet<Snowflake>();
// User accounts can only fetch threads using the search endpoint
if (await ResolveTokenKindAsync(cancellationToken) == TokenKind.User)
{
@@ -494,9 +472,7 @@ public class DiscordClient(
break;
}
if (seenThreadIds.Add(thread.Id))
yield return thread;
yield return thread;
currentOffset++;
}
@@ -535,12 +511,7 @@ public class DiscordClient(
.Pipe(parentsById.GetValueOrDefault);
if (filteredChannels.Contains(parent))
{
var thread = Channel.Parse(threadJson, parent);
if (seenThreadIds.Add(thread.Id))
yield return thread;
}
yield return Channel.Parse(threadJson, parent);
}
}
@@ -576,14 +547,12 @@ public class DiscordClient(
)
{
var thread = Channel.Parse(threadJson, channel);
yield return thread;
currentBefore = threadJson
.GetProperty("thread_metadata")
.GetProperty("archive_timestamp")
.GetString();
if (seenThreadIds.Add(thread.Id))
yield return thread;
}
if (!response.Value.GetProperty("has_more").GetBoolean())
@@ -595,24 +564,6 @@ public class DiscordClient(
}
}
private async ValueTask<Message?> TryGetFirstMessageAsync(
Snowflake channelId,
Snowflake? after = null,
CancellationToken cancellationToken = default
)
{
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "1")
.SetQueryParameter("after", (after ?? Snowflake.Zero).ToString())
.Build();
var response = await GetJsonResponseAsync(url, cancellationToken);
var message = response.EnumerateArray().Select(Message.Parse).FirstOrDefault();
return message;
}
private async ValueTask<Message?> TryGetLastMessageAsync(
Snowflake channelId,
Snowflake? before = null,
@@ -671,10 +622,22 @@ public class DiscordClient(
yield break;
// If all messages are empty, make sure that it's not because the bot account doesn't
// have the MESSAGE_CONTENT intent enabled.
// have the Message Content Intent enabled.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
if (messages.All(m => m.IsEmpty))
await EnsureMessageContentIntentAsync(cancellationToken);
if (
messages.All(m => m.IsEmpty)
&& await ResolveTokenKindAsync(cancellationToken) == TokenKind.Bot
)
{
var application = await GetApplicationAsync(cancellationToken);
if (!application.IsMessageContentIntentEnabled)
{
throw new DiscordChatExporterException(
"Provided bot account does not have the Message Content Intent enabled.",
true
);
}
}
foreach (var message in messages)
{
@@ -707,75 +670,6 @@ public class DiscordClient(
}
}
public async IAsyncEnumerable<Message> GetMessagesInReverseAsync(
Snowflake channelId,
Snowflake? after = null,
Snowflake? before = null,
IProgress<Percentage>? progress = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default
)
{
// Get the first message in the specified range, so we can later calculate the
// progress based on the difference between message timestamps.
// Snapshotting is not necessary here because new messages can't appear in the past.
var firstMessage = await TryGetFirstMessageAsync(channelId, after, cancellationToken);
if (firstMessage is null || firstMessage.Timestamp > before?.ToDate())
yield break;
// Keep track of the last message in range in order to calculate the progress
var lastMessage = default(Message);
var currentBefore = before;
while (true)
{
var url = new UrlBuilder()
.SetPath($"channels/{channelId}/messages")
.SetQueryParameter("limit", "100")
.SetQueryParameter("before", currentBefore?.ToString())
.Build();
var response = await GetJsonResponseAsync(url, cancellationToken);
var messages = response.EnumerateArray().Select(Message.Parse).ToArray();
// Break if there are no messages (can happen if messages are deleted during execution)
if (!messages.Any())
yield break;
// If all messages are empty, make sure that it's not because the bot account doesn't
// have the MESSAGE_CONTENT intent enabled.
// https://github.com/Tyrrrz/DiscordChatExporter/issues/1106#issuecomment-1741548959
if (messages.All(m => m.IsEmpty))
await EnsureMessageContentIntentAsync(cancellationToken);
foreach (var message in messages)
{
lastMessage ??= message;
// Report progress based on timestamps
if (progress is not null)
{
var exportedDuration = (lastMessage.Timestamp - message.Timestamp).Duration();
var totalDuration = (lastMessage.Timestamp - firstMessage.Timestamp).Duration();
progress.Report(
Percentage.FromFraction(
// Avoid division by zero if all messages have the exact same timestamp
// (which happens when there's only one message in the channel)
totalDuration > TimeSpan.Zero
? exportedDuration / totalDuration
: 1
)
);
}
yield return message;
}
currentBefore = messages.Last().Id;
}
}
public async IAsyncEnumerable<User> GetMessageReactionsAsync(
Snowflake channelId,
Snowflake messageId,
@@ -1,14 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="AngleSharp" />
<PackageReference Include="AsyncKeyedLock" />
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
<PackageReference Include="Gress" />
<PackageReference Include="JsonExtensions" />
<PackageReference Include="Polly" />
<PackageReference Include="RazorBlade" />
<PackageReference Include="Superpower" />
<PackageReference Include="WebMarkupMin.Core" />
<PackageReference Include="YoutubeExplode" />
<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.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>
@@ -64,23 +64,15 @@ public class ChannelExporter(DiscordClient discord)
);
}
var messages = !request.IsReverseMessageOrder
? discord.GetMessagesAsync(
await foreach (
var message in discord.GetMessagesAsync(
request.Channel.Id,
request.After,
request.Before,
progress,
cancellationToken
)
: discord.GetMessagesInReverseAsync(
request.Channel.Id,
request.After,
request.Before,
progress,
cancellationToken
);
await foreach (var message in messages)
)
{
try
{
@@ -37,28 +37,6 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
if (reuse && File.Exists(filePath))
return _previousPathsByUrl[url] = filePath;
// Check for a file cached by the legacy naming scheme (5-char hash) and rename it
// to the new naming scheme to preserve backwards compatibility with existing exports
if (reuse)
{
var legacyFilePath = Path.Combine(workingDirPath, GetLegacyFileNameFromUrl(url));
if (File.Exists(legacyFilePath))
{
// Overwrite in case the destination file was created concurrently between our
// earlier existence check and this move operation
try
{
File.Move(legacyFilePath, filePath, overwrite: true);
return _previousPathsByUrl[url] = filePath;
}
catch (IOException)
{
// The legacy file was moved or deleted concurrently or something else happened.
// Upgrading old files is not crucial, so we can just move on.
}
}
}
Directory.CreateDirectory(workingDirPath);
await Http.ResiliencePipeline.ExecuteAsync(
@@ -78,23 +56,34 @@ internal partial class ExportAssetDownloader(string workingDirPath, bool reuse)
internal partial class ExportAssetDownloader
{
private static string NormalizeUrl(string url)
private static string GetUrlHash(string url)
{
// Remove signature parameters from Discord CDN URLs to normalize them
var uri = new Uri(url);
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
return url;
static string NormalizeUrl(string url)
{
var uri = new Uri(url);
if (!string.Equals(uri.Host, "cdn.discordapp.com", StringComparison.OrdinalIgnoreCase))
return url;
var query = HttpUtility.ParseQueryString(uri.Query);
query.Remove("ex");
query.Remove("is");
query.Remove("hm");
var query = HttpUtility.ParseQueryString(uri.Query);
query.Remove("ex");
query.Remove("is");
query.Remove("hm");
return uri.GetLeftPart(UriPartial.Path) + query;
return uri.GetLeftPart(UriPartial.Path) + query;
}
return SHA256
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
.Pipe(Convert.ToHexStringLower)
// 5 chars ought to be enough for anybody
.Truncate(5);
}
private static string GetFileNameFromUrl(string url, string urlHash)
private static string GetFileNameFromUrl(string url)
{
var urlHash = GetUrlHash(url);
// Try to extract the file name from URL
var fileName = Regex.Match(url, @".+/([^?]*)").Groups[1].Value;
@@ -118,25 +107,4 @@ internal partial class ExportAssetDownloader
fileNameWithoutExtension.Truncate(42) + '-' + urlHash + fileExtension
);
}
private static string GetFileNameFromUrl(string url) =>
GetFileNameFromUrl(
url,
// 16 chars = 64 bits, reaches 1% collision probability at ~609 million files
SHA256
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
.Pipe(Convert.ToHexStringLower)
.Truncate(16)
);
// Legacy naming used a 5-char hash, kept for backwards compatibility with existing exports
private static string GetLegacyFileNameFromUrl(string url) =>
GetFileNameFromUrl(
url,
SHA256
.HashData(Encoding.UTF8.GetBytes(NormalizeUrl(url)))
.Pipe(Convert.ToHexStringLower)
// 5 chars = 20 bits, reaches 1% collision probability at ~145 files
.Truncate(5)
);
}
@@ -33,8 +33,6 @@ public partial class ExportRequest
public MessageFilter MessageFilter { get; }
public bool IsReverseMessageOrder { get; }
public bool ShouldFormatMarkdown { get; }
public bool ShouldDownloadAssets { get; }
@@ -57,7 +55,6 @@ public partial class ExportRequest
Snowflake? before,
PartitionLimit partitionLimit,
MessageFilter messageFilter,
bool isReverseMessageOrder,
bool shouldFormatMarkdown,
bool shouldDownloadAssets,
bool shouldReuseAssets,
@@ -72,7 +69,6 @@ public partial class ExportRequest
Before = before;
PartitionLimit = partitionLimit;
MessageFilter = messageFilter;
IsReverseMessageOrder = isReverseMessageOrder;
ShouldFormatMarkdown = shouldFormatMarkdown;
ShouldDownloadAssets = shouldDownloadAssets;
ShouldReuseAssets = shouldReuseAssets;
@@ -17,7 +17,6 @@ internal class HtmlMessageWriter(Stream stream, ExportContext context, string th
private readonly HtmlMinifier _minifier = new();
private readonly List<Message> _messageGroup = [];
// Note: in reverse order, last message appears earlier than the first message
private bool CanJoinGroup(Message message)
{
// If the group is empty, any message can join it
@@ -119,24 +119,6 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
await _writer.FlushAsync(cancellationToken);
}
private async ValueTask WriteAttachmentAsync(
Attachment attachment,
CancellationToken cancellationToken = default
)
{
_writer.WriteStartObject();
_writer.WriteString("id", attachment.Id.ToString());
_writer.WriteString(
"url",
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
);
_writer.WriteString("fileName", attachment.FileName);
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
_writer.WriteEndObject();
}
private async ValueTask WriteEmbedAuthorAsync(
EmbedAuthor embedAuthor,
CancellationToken cancellationToken = default
@@ -353,24 +335,6 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
await _writer.FlushAsync(cancellationToken);
}
private async ValueTask WriteStickerAsync(
Sticker sticker,
CancellationToken cancellationToken = default
)
{
_writer.WriteStartObject();
_writer.WriteString("id", sticker.Id.ToString());
_writer.WriteString("name", sticker.Name);
_writer.WriteString("format", sticker.Format.ToString());
_writer.WriteString(
"sourceUrl",
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
);
_writer.WriteEndObject();
}
public override async ValueTask WritePreambleAsync(
CancellationToken cancellationToken = default
)
@@ -473,7 +437,19 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteStartArray("attachments");
foreach (var attachment in message.Attachments)
await WriteAttachmentAsync(attachment, cancellationToken);
{
_writer.WriteStartObject();
_writer.WriteString("id", attachment.Id.ToString());
_writer.WriteString(
"url",
await Context.ResolveAssetUrlAsync(attachment.Url, cancellationToken)
);
_writer.WriteString("fileName", attachment.FileName);
_writer.WriteNumber("fileSizeBytes", attachment.FileSize.TotalBytes);
_writer.WriteEndObject();
}
_writer.WriteEndArray();
@@ -489,7 +465,19 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
_writer.WriteStartArray("stickers");
foreach (var sticker in message.Stickers)
await WriteStickerAsync(sticker, cancellationToken);
{
_writer.WriteStartObject();
_writer.WriteString("id", sticker.Id.ToString());
_writer.WriteString("name", sticker.Name);
_writer.WriteString("format", sticker.Format.ToString());
_writer.WriteString(
"sourceUrl",
await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken)
);
_writer.WriteEndObject();
}
_writer.WriteEndArray();
@@ -539,58 +527,12 @@ internal class JsonMessageWriter(Stream stream, ExportContext context)
if (message.Reference is not null)
{
_writer.WriteStartObject("reference");
_writer.WriteString("type", message.Reference.Kind.ToString());
_writer.WriteString("messageId", message.Reference.MessageId?.ToString());
_writer.WriteString("channelId", message.Reference.ChannelId?.ToString());
_writer.WriteString("guildId", message.Reference.GuildId?.ToString());
_writer.WriteEndObject();
}
// Forwarded message
if (message.ForwardedMessage is not null)
{
_writer.WriteStartObject("forwardedMessage");
_writer.WriteString(
"timestamp",
Context.NormalizeDate(message.ForwardedMessage.Timestamp)
);
_writer.WriteString(
"timestampEdited",
message.ForwardedMessage.EditedTimestamp?.Pipe(Context.NormalizeDate)
);
_writer.WriteString(
"content",
await FormatMarkdownAsync(message.ForwardedMessage.Content, cancellationToken)
);
// Forwarded attachments
_writer.WriteStartArray("attachments");
foreach (var attachment in message.ForwardedMessage.Attachments)
await WriteAttachmentAsync(attachment, cancellationToken);
_writer.WriteEndArray();
// Forwarded embeds
_writer.WriteStartArray("embeds");
foreach (var embed in message.ForwardedMessage.Embeds)
await WriteEmbedAsync(embed, cancellationToken);
_writer.WriteEndArray();
// Forwarded stickers
_writer.WriteStartArray("stickers");
foreach (var sticker in message.ForwardedMessage.Stickers)
await WriteStickerAsync(sticker, cancellationToken);
_writer.WriteEndArray();
_writer.WriteEndObject();
}
// Interaction
if (message.Interaction is not null)
{
@@ -34,7 +34,7 @@
}
<div class="chatlog__message-group">
@foreach (var (i, message) in Messages.Index())
@foreach (var (message, i) in Messages.WithIndex())
{
var isFirst = i == 0;
@@ -262,95 +262,6 @@
</div>
}
@* Forwarded message *@
@if (message is { IsForwarded: true, ForwardedMessage: not null })
{
<div class="chatlog__forwarded">
<div class="chatlog__forwarded-header">
<svg class="chatlog__forwarded-icon">
<use href="#forward-icon"></use>
</svg>
<em>Forwarded</em>
</div>
@* Forwarded content *@
@if (!string.IsNullOrWhiteSpace(message.ForwardedMessage.Content))
{
<div class="chatlog__forwarded-content chatlog__markdown">
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.ForwardedMessage.Content))<!--/wmm:ignore--></span>
</div>
}
@* Forwarded attachments *@
@if (message.ForwardedMessage.Attachments.Any())
{
<div class="chatlog__forwarded-attachments">
@foreach (var attachment in message.ForwardedMessage.Attachments)
{
@if (attachment.IsImage)
{
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
<img class="chatlog__forwarded-attachment" src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
</a>
}
else if (attachment.IsVideo)
{
<video class="chatlog__forwarded-attachment" controls>
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
</video>
}
else if (attachment.IsAudio)
{
<audio class="chatlog__forwarded-attachment" controls>
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
</audio>
}
else
{
<div class="chatlog__attachment-generic">
<svg class="chatlog__attachment-generic-icon">
<use href="#attachment-icon"/>
</svg>
<div class="chatlog__attachment-generic-name">
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
@attachment.FileName
</a>
</div>
<div class="chatlog__attachment-generic-size">
@attachment.FileSize
</div>
</div>
}
}
</div>
}
@* Forwarded stickers *@
@foreach (var sticker in message.ForwardedMessage.Stickers)
{
<div class="chatlog__sticker" title="@sticker.Name">
@if (sticker.IsImage)
{
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
}
else if (sticker.Format == StickerFormat.Lottie)
{
<div class="chatlog__sticker--media" data-source="@await ResolveAssetUrlAsync(sticker.SourceUrl)"></div>
}
</div>
}
@* Forwarded timestamp *@
<div class="chatlog__forwarded-timestamp">
<span title="@FormatDate(message.ForwardedMessage.Timestamp, "f")">Originally sent: @FormatDate(message.ForwardedMessage.Timestamp)</span>
@if (message.ForwardedMessage.EditedTimestamp is not null)
{
<span title="@FormatDate(message.ForwardedMessage.EditedTimestamp.Value, "f")"> (edited)</span>
}
</div>
</div>
}
@* Attachments *@
@foreach (var attachment in message.Attachments)
{
@@ -23,11 +23,13 @@ internal static class PlainTextMessageExtensions
: "Removed a recipient.",
MessageKind.Call =>
$"Started a call that lasted {message
$"Started a call that lasted {
message
.CallEndedTimestamp?
.Pipe(t => t - message.Timestamp)
.Pipe(t => t.TotalMinutes)
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"} minutes.",
.ToString("n0", CultureInfo.InvariantCulture) ?? "0"
} minutes.",
MessageKind.ChannelNameChange => !string.IsNullOrWhiteSpace(message.Content)
? $"Changed the channel name: {message.Content}"
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Utils.Extensions;
namespace DiscordChatExporter.Core.Exporting;
@@ -172,7 +173,7 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
await _writer.WriteLineAsync("{Reactions}");
foreach (var (i, reaction) in reactions.Index())
foreach (var (reaction, i) in reactions.WithIndex())
{
cancellationToken.ThrowIfCancellationRequested();
@@ -223,31 +224,6 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
await _writer.WriteLineAsync();
}
private async ValueTask WriteForwardedMessageAsync(
MessageSnapshot forwardedMessage,
CancellationToken cancellationToken = default
)
{
await _writer.WriteLineAsync("{Forwarded Message}");
if (!string.IsNullOrWhiteSpace(forwardedMessage.Content))
{
await _writer.WriteLineAsync(
await FormatMarkdownAsync(forwardedMessage.Content, cancellationToken)
);
}
await _writer.WriteLineAsync(
$"Originally sent: {Context.FormatDate(forwardedMessage.Timestamp)}"
);
await WriteAttachmentsAsync(forwardedMessage.Attachments, cancellationToken);
await WriteEmbedsAsync(forwardedMessage.Embeds, cancellationToken);
await WriteStickersAsync(forwardedMessage.Stickers, cancellationToken);
await _writer.WriteLineAsync();
}
public override async ValueTask WriteMessageAsync(
Message message,
CancellationToken cancellationToken = default
@@ -272,12 +248,6 @@ internal class PlainTextMessageWriter(Stream stream, ExportContext context)
await _writer.WriteLineAsync();
// Forwarded message content
if (message.ForwardedMessage is not null)
{
await WriteForwardedMessageAsync(message.ForwardedMessage, cancellationToken);
}
// Attachments, embeds, reactions, etc.
await WriteAttachmentsAsync(message.Attachments, cancellationToken);
await WriteEmbedsAsync(message.Embeds, cancellationToken);
@@ -304,52 +304,6 @@
unicode-bidi: bidi-override;
}
.chatlog__forwarded {
display: flex;
flex-direction: column;
margin-bottom: 0.15rem;
padding: 0.5rem;
border-left: 4px solid @Themed("#4f545c", "#c7ccd1");
border-radius: 4px;
background-color: @Themed("rgba(46, 48, 54, 0.3)", "rgba(249, 249, 249, 0.3)");
}
.chatlog__forwarded-header {
display: flex;
align-items: center;
margin-bottom: 0.25rem;
color: @Themed("#b5b6b8", "#5f5f60");
font-size: 0.75rem;
font-weight: 600;
}
.chatlog__forwarded-icon {
width: 16px;
height: 16px;
margin-right: 0.25rem;
}
.chatlog__forwarded-content {
color: @Themed("#dcddde", "#2e3338");
font-size: 0.95rem;
}
.chatlog__forwarded-attachments {
margin-top: 0.3rem;
}
.chatlog__forwarded-attachment {
max-width: 300px;
max-height: 200px;
border-radius: 3px;
}
.chatlog__forwarded-timestamp {
margin-top: 0.25rem;
color: @Themed("#a3a6aa", "#5e6772");
font-size: 0.75rem;
}
.chatlog__system-notification-icon {
width: 18px;
height: 18px;
@@ -1045,9 +999,6 @@
<path fill="#b9bbbe" d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.40991 9H3.55274C3.47819 9 3.42168 8.93274 3.43453 8.85931L3.74253 7.09931C3.75258 7.04189 3.80244 7 3.86074 7H7.75991L8.45234 3.09903C8.46251 3.04174 8.51231 3 8.57049 3H10.3267C10.4014 3 10.4579 3.06746 10.4449 3.14097L9.75991 7H15.7599L16.4523 3.09903C16.4625 3.04174 16.5123 3 16.5705 3H18.3267C18.4014 3 18.4579 3.06746 18.4449 3.14097L17.7599 7H21.6171C21.6916 7 21.7481 7.06725 21.7353 7.14069L21.4273 8.90069C21.4172 8.95811 21.3674 9 21.3091 9H17.4099L17.0495 11.04H15.05L15.4104 9H9.41035L8.35035 15H10.5599V17H7.99991L7.30749 20.901C7.29732 20.9583 7.24752 21 7.18934 21H5.43309Z" />
<path fill="#b9bbbe" d="M13.4399 12.96C12.9097 12.96 12.4799 13.3898 12.4799 13.92V20.2213C12.4799 20.7515 12.9097 21.1813 13.4399 21.1813H14.3999C14.5325 21.1813 14.6399 21.2887 14.6399 21.4213V23.4597C14.6399 23.6677 14.8865 23.7773 15.0408 23.6378L17.4858 21.4289C17.6622 21.2695 17.8916 21.1813 18.1294 21.1813H22.5599C23.0901 21.1813 23.5199 20.7515 23.5199 20.2213V13.92C23.5199 13.3898 23.0901 12.96 22.5599 12.96H13.4399Z" />
</symbol>
<symbol id="forward-icon" viewBox="0 0 24 24">
<path fill="#b9bbbe" d="M13 4L21 12L13 20V15C8 15 4 17 1 22C2 16 6 10 13 9V4Z" />
</symbol>
</defs>
</svg>
</head>
@@ -14,7 +14,7 @@ internal static class MatcherExtensions
this IMatcher<TContext, TValue> matcher,
TContext context,
StringSegment segment,
Func<TContext, StringSegment, TValue> fallbackTransform
Func<TContext, StringSegment, TValue> transformFallback
)
{
// Loop through segments divided by individual matches
@@ -40,7 +40,7 @@ internal static class MatcherExtensions
yield return new ParsedMatch<TValue>(
fallbackSegment,
fallbackTransform(context, fallbackSegment)
transformFallback(context, fallbackSegment)
);
}
@@ -57,7 +57,7 @@ internal static class MatcherExtensions
yield return new ParsedMatch<TValue>(
fallbackSegment,
fallbackTransform(context, fallbackSegment)
transformFallback(context, fallbackSegment)
);
}
}
@@ -208,18 +208,53 @@ internal static partial class MarkdownParser
private static readonly IMatcher<MarkdownContext, MarkdownNode> StandardEmojiNodeMatcher =
new RegexMatcher<MarkdownContext, MarkdownNode>(
new Regex(
// Build a pattern from all known emoji, sorted longest-first so that compound
// emoji (e.g. sequences with ZWJ or skin-tone modifiers) are matched before
// their individual components.
"("
+ string.Join(
"|",
EmojiIndex
.GetAllNames()
.OrderByDescending(e => e.Length)
.Select(Regex.Escape)
)
+ ")",
@"("
+
// Country flag emoji (two regional indicator surrogate pairs)
@"(?:\uD83C[\uDDE6-\uDDFF]){2}|"
+
// Digit emoji (digit followed by enclosing mark)
@"\d\p{Me}|"
+
// Surrogate pair
@"\p{Cs}{2}|"
+
// Miscellaneous characters
@"["
+ @"\u2600-\u2604"
+ @"\u260E\u2611"
+ @"\u2614-\u2615"
+ @"\u2618\u261D\u2620"
+ @"\u2622-\u2623"
+ @"\u2626\u262A"
+ @"\u262E-\u262F"
+ @"\u2638-\u263A"
+ @"\u2640\u2642"
+ @"\u2648-\u2653"
+ @"\u265F-\u2660"
+ @"\u2663"
+ @"\u2665-\u2666"
+ @"\u2668\u267B"
+ @"\u267E-\u267F"
+ @"\u2692-\u2697"
+ @"\u2699"
+ @"\u269B-\u269C"
+ @"\u26A0-\u26A1"
+ @"\u26A7"
+ @"\u26AA-\u26AB"
+ @"\u26B0-\u26B1"
+ @"\u26BD-\u26BE"
+ @"\u26C4-\u26C5"
+ @"\u26C8"
+ @"\u26CE-\u26CF"
+ @"\u26D1"
+ @"\u26D3-\u26D4"
+ @"\u26E9-\u26EA"
+ @"\u26F0-\u26F5"
+ @"\u26F7-\u26FA"
+ @"\u26FD"
+ @"]"
+ @")",
DefaultRegexOptions
),
(_, _, m) => new EmojiNode(m.Groups[1].Value)
@@ -275,7 +310,7 @@ internal static partial class MarkdownParser
// Capture the shrug kaomoji.
// This escapes it from matching for formatting.
@"¯\_(ツ)_/¯",
(_, s) => new TextNode(s.ToString())
(s, _) => new TextNode(s.ToString())
);
private static readonly IMatcher<MarkdownContext, MarkdownNode> IgnoredEmojiTextNodeMatcher =
@@ -14,5 +14,5 @@ internal readonly record struct StringSegment(string Source, int StartIndex, int
public StringSegment Relocate(Capture capture) => Relocate(capture.Index, capture.Length);
public override string ToString() => Source[StartIndex..EndIndex];
public override string ToString() => Source.Substring(StartIndex, Length);
}
@@ -12,6 +12,16 @@ public static class CollectionExtensions
}
}
extension<T>(IEnumerable<T> source)
{
public IEnumerable<(T value, int index)> WithIndex()
{
var i = 0;
foreach (var o in source)
yield return (o, i++);
}
}
extension<T>(IEnumerable<T?> source)
where T : class
{
@@ -24,17 +34,4 @@ public static class CollectionExtensions
}
}
}
extension<T>(IEnumerable<T?> source)
where T : struct
{
public IEnumerable<T> WhereNotNull()
{
foreach (var o in source)
{
if (o is not null)
yield return o.Value;
}
}
}
}
-5
View File
@@ -116,11 +116,6 @@
</Style>
</Style>
<!-- Run -->
<Style Selector="Run">
<Setter Property="BaselineAlignment" Value="Center" />
</Style>
<!-- Text box -->
<Style Selector="TextBox">
<Setter Property="FontSize" Value="14" />
+20 -36
View File
@@ -12,6 +12,7 @@ using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.ViewModels;
using DiscordChatExporter.Gui.ViewModels.Components;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
using DiscordChatExporter.Gui.Views;
using Material.Styles.Themes;
using Microsoft.Extensions.DependencyInjection;
@@ -19,12 +20,11 @@ namespace DiscordChatExporter.Gui;
public class App : Application, IDisposable
{
private readonly ServiceProvider _services;
private readonly SettingsService _settingsService;
private readonly DisposableCollector _eventRoot = new();
private bool _isDisposed;
private readonly ServiceProvider _services;
private readonly SettingsService _settingsService;
private readonly MainViewModel _mainViewModel;
public App()
{
@@ -52,14 +52,15 @@ public class App : Application, IDisposable
_services = services.BuildServiceProvider(true);
_settingsService = _services.GetRequiredService<SettingsService>();
_mainViewModel = _services.GetRequiredService<ViewModelManager>().CreateMainViewModel();
// Re-initialize the theme when the user changes it
_eventRoot.Add(
_settingsService.WatchProperty(
o => o.Theme,
v =>
() =>
{
RequestedThemeVariant = v switch
RequestedThemeVariant = _settingsService.Theme switch
{
ThemeVariant.Light => Avalonia.Styling.ThemeVariant.Light,
ThemeVariant.Dark => Avalonia.Styling.ThemeVariant.Dark,
@@ -72,6 +73,13 @@ public class App : Application, IDisposable
);
}
public override void Initialize()
{
base.Initialize();
AvaloniaXamlLoader.Load(this);
}
private void InitializeTheme()
{
var actualTheme = RequestedThemeVariant?.Key switch
@@ -87,37 +95,18 @@ public class App : Application, IDisposable
: Theme.Create(Theme.Dark, Color.Parse("#E8E8E8"), Color.Parse("#F9A825"));
}
public override void Initialize()
{
base.Initialize();
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
// Initialize the default theme, before a custom one (if any) is applied by loading settings
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
desktop.MainWindow = new MainView { DataContext = _mainViewModel };
base.OnFrameworkInitializationCompleted();
// Set up custom theme colors
InitializeTheme();
// Load settings
_settingsService.Load();
// Initialize and configure the main window
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var viewManager = _services.GetRequiredService<ViewManager>();
var viewModelManager = _services.GetRequiredService<ViewModelManager>();
desktop.MainWindow = viewManager.TryBindWindow(viewModelManager.GetMainViewModel());
// Although `App.Dispose()` is invoked from `Program.Main(...)`, on some platforms
// it may be called too late in the shutdown lifecycle. Attach an exit
// handler to ensure timely disposal as a safeguard.
// https://github.com/Tyrrrz/YoutubeDownloader/issues/795
desktop.Exit += (_, _) => Dispose();
}
base.OnFrameworkInitializationCompleted();
}
private void Application_OnActualThemeVariantChanged(object? sender, EventArgs args) =>
@@ -126,11 +115,6 @@ public class App : Application, IDisposable
public void Dispose()
{
if (_isDisposed)
return;
_isDisposed = true;
_eventRoot.Dispose();
_services.Dispose();
}
@@ -4,7 +4,6 @@ using System.Linq;
using Avalonia.Controls.Documents;
using Avalonia.Data.Converters;
using Avalonia.Media;
using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.Views.Controls;
using Markdig;
using Markdig.Syntax;
@@ -21,6 +20,14 @@ public class MarkdownToInlinesConverter : IValueConverter
.UseEmphasisExtras()
.Build();
private static string GetPlainText(MarkdownInline inline) =>
inline switch
{
LiteralInline literal => literal.Content.ToString(),
ContainerInline container => string.Concat(container.Select(GetPlainText)),
_ => string.Empty,
};
private static void ProcessInline(
InlineCollection inlines,
MarkdownInline markdownInline,
@@ -82,7 +89,12 @@ public class MarkdownToInlinesConverter : IValueConverter
case LinkInline link:
{
inlines.Add(new HyperLink { Text = link.GetInnerText(), Url = link.Url });
inlines.Add(
new InlineUIContainer(
new HyperLink { Text = GetPlainText(link), Url = link.Url }
)
);
break;
}
@@ -138,16 +150,13 @@ public class MarkdownToInlinesConverter : IValueConverter
isFirst = false;
var prefix = list.IsOrdered ? $"{itemOrder++}. " : $"{list.BulletType} ";
inlines.Add(new Run(prefix));
foreach (var subBlock in listItem.OfType<ParagraphBlock>())
{
if (subBlock is { Inline: not null })
{
foreach (var markdownInline in subBlock.Inline)
if (subBlock is { Inline: not null } p)
foreach (var markdownInline in p.Inline)
ProcessInline(inlines, markdownInline);
}
}
}
@@ -9,16 +9,13 @@
</PropertyGroup>
<PropertyGroup>
<EncryptionSalt>HimalayanPinkSalt</EncryptionSalt>
<PublishMacOSBundle>false</PublishMacOSBundle>
</PropertyGroup>
<ItemGroup>
<!-- Expose this property in code -->
<ProjectProperty Include="EncryptionSalt" />
</ItemGroup>
<!-- HACK: Disable trim warnings because they seem to break when the code contains C# 14 extension blocks -->
<PropertyGroup>
<PublishMacOSBundle>false</PublishMacOSBundle>
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
<EnableAotAnalyzer>false</EnableAotAnalyzer>
</PropertyGroup>
<ItemGroup>
@@ -26,22 +23,25 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AsyncImageLoader.Avalonia" />
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Diagnostics" Condition="'$(Configuration)' == 'Debug'" />
<PackageReference Include="Cogwheel" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="CSharpier.MsBuild" PrivateAssets="all" />
<PackageReference Include="Deorcify" PrivateAssets="all" />
<PackageReference Include="DialogHost.Avalonia" />
<PackageReference Include="Gress" />
<PackageReference Include="Markdig" />
<PackageReference Include="Material.Avalonia" />
<PackageReference Include="Material.Icons.Avalonia" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Onova" />
<PackageReference Include="ThisAssembly.Project" PrivateAssets="all" />
<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="1.2.5" PrivateAssets="all" />
<PackageReference Include="Deorcify" Version="1.1.0" PrivateAssets="all" />
<PackageReference Include="DialogHost.Avalonia" Version="0.10.4" />
<PackageReference Include="Gress" Version="2.1.1" />
<PackageReference Include="Markdig" Version="1.0.0" />
<PackageReference Include="Material.Avalonia" Version="3.9.2" />
<PackageReference Include="Material.Icons.Avalonia" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageReference Include="Onova" Version="2.6.13" />
</ItemGroup>
<ItemGroup>
@@ -50,7 +50,7 @@
<Target Name="PublishMacOSBundle" AfterTargets="Publish" Condition="$(PublishMacOSBundle)">
<Exec
Command="dotnet run --file &quot;$(ProjectDir)PublishMacOSBundle.csx&quot; -- --publish-dir &quot;$(PublishDir)&quot; --icons-file &quot;$(ProjectDir)../favicon.icns&quot; --full-version $(Version) --short-version $(AssemblyVersion)"
Command="pwsh -ExecutionPolicy Bypass -File $(ProjectDir)/Publish-MacOSBundle.ps1 -PublishDirPath $(PublishDir) -IconsFilePath $(ProjectDir)/../favicon.icns -FullVersion $(Version) -ShortVersion $(AssemblyVersion)"
LogStandardErrorAsError="true"
/>
</Target>
@@ -2,8 +2,8 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
using Avalonia;
using Avalonia.Platform.Storage;
using DialogHostAvalonia;
@@ -13,12 +13,11 @@ namespace DiscordChatExporter.Gui.Framework;
public class DialogManager : IDisposable
{
private readonly SemaphoreSlim _dialogLock = new(1, 1);
private readonly AsyncNonKeyedLocker _dialogLock = new();
public async Task<T?> ShowDialogAsync<T>(DialogViewModelBase<T> dialog)
{
await _dialogLock.WaitAsync();
try
using (await _dialogLock.LockAsync())
{
await DialogHost.Show(
dialog,
@@ -40,15 +39,11 @@ public class DialogManager : IDisposable
);
// Yield to allow DialogHost to fully reset its state before
// another dialog is shown (e.g. when dialogs are shown sequentially).
// another dialog is shown (e.g. when dialogs are shown sequentially)
await Task.Yield();
return dialog.DialogResult;
}
finally
{
_dialogLock.Release();
}
}
public async Task<string?> PromptSaveFilePathAsync(
@@ -29,16 +29,9 @@ public partial class ViewManager
return null;
view.DataContext ??= viewModel;
view.Loaded += async (_, _) => await viewModel.InitializeAsync();
return view;
}
public UserControl<T>? TryBindUserControl<T>(T viewModel)
where T : ViewModelBase => TryBindView(viewModel) as UserControl<T>;
public Window<T>? TryBindWindow<T>(T viewModel)
where T : ViewModelBase => TryBindView(viewModel) as Window<T>;
}
public partial class ViewManager : IDataTemplate
@@ -1,5 +1,4 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
namespace DiscordChatExporter.Gui.Framework;
@@ -10,8 +9,6 @@ public abstract class ViewModelBase : ObservableObject, IDisposable
protected void OnAllPropertiesChanged() => OnPropertyChanged(string.Empty);
public virtual Task InitializeAsync() => Task.CompletedTask;
protected virtual void Dispose(bool disposing) { }
public void Dispose()
@@ -10,12 +10,12 @@ namespace DiscordChatExporter.Gui.Framework;
public class ViewModelManager(IServiceProvider services)
{
public MainViewModel GetMainViewModel() => services.GetRequiredService<MainViewModel>();
public MainViewModel CreateMainViewModel() => services.GetRequiredService<MainViewModel>();
public DashboardViewModel GetDashboardViewModel() =>
public DashboardViewModel CreateDashboardViewModel() =>
services.GetRequiredService<DashboardViewModel>();
public ExportSetupViewModel GetExportSetupViewModel(
public ExportSetupViewModel CreateExportSetupViewModel(
Guild guild,
IReadOnlyList<Channel> channels
)
@@ -28,7 +28,7 @@ public class ViewModelManager(IServiceProvider services)
return viewModel;
}
public MessageBoxViewModel GetMessageBoxViewModel(
public MessageBoxViewModel CreateMessageBoxViewModel(
string title,
string message,
string? okButtonText,
@@ -45,9 +45,9 @@ public class ViewModelManager(IServiceProvider services)
return viewModel;
}
public MessageBoxViewModel GetMessageBoxViewModel(string title, string message) =>
GetMessageBoxViewModel(title, message, "CLOSE", null);
public MessageBoxViewModel CreateMessageBoxViewModel(string title, string message) =>
CreateMessageBoxViewModel(title, message, "CLOSE", null);
public SettingsViewModel GetSettingsViewModel() =>
public SettingsViewModel CreateSettingsViewModel() =>
services.GetRequiredService<SettingsViewModel>();
}
@@ -42,7 +42,7 @@ public partial class LocalizationManager
* Your bot needs to have the **Message Content Intent** enabled to read messages
""",
[nameof(TokenHelpText)] =
"If you have questions or issues, please refer to the [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
"If you have questions or issues, please refer to the [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
// Settings
[nameof(SettingsTitle)] = "Settings",
[nameof(ThemeLabel)] = "Theme",
@@ -52,10 +52,8 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Auto-update",
[nameof(AutoUpdateTooltip)] = "Perform automatic updates on every launch",
[nameof(PersistTokenLabel)] = "Persist token",
[nameof(PersistTokenTooltip)] = """
Save the last used token to a file so that it can be persisted between sessions.
**Warning**: although the token is stored with encryption, it may still be recovered by an attacker who has access to your system.
""",
[nameof(PersistTokenTooltip)] =
"Save the last used token to a file so that it can be persisted between sessions",
[nameof(RateLimitPreferenceLabel)] = "Rate limit preference",
[nameof(RateLimitPreferenceTooltip)] =
"Whether to respect advisory rate limits. If disabled, only hard rate limits (i.e. 429 responses) will be respected.",
@@ -78,17 +76,17 @@ public partial class LocalizationManager
Directory paths must end with a slash to avoid ambiguity.
Available template tokens:
**%g** server ID
**%G** server name
**%t** category ID
**%T** category name
**%c** channel ID
**%C** channel name
**%p** channel position
**%P** category position
**%a** after date
**%b** before date
**%d** current date
- **%g** server ID
- **%G** server name
- **%t** category ID
- **%T** category name
- **%c** channel ID
- **%C** channel name
- **%p** channel position
- **%P** category position
- **%a** after date
- **%b** before date
- **%d** current date
""",
[nameof(FormatLabel)] = "Format",
[nameof(FormatTooltip)] = "Export format",
@@ -106,9 +104,6 @@ public partial class LocalizationManager
[nameof(MessageFilterLabel)] = "Message filter",
[nameof(MessageFilterTooltip)] =
"Only include messages that satisfy this filter (e.g. 'from:foo#1234' or 'has:image'). See the documentation for more info.",
[nameof(ReverseMessageOrderLabel)] = "Reverse messages",
[nameof(ReverseMessageOrderTooltip)] =
"Export messages in reverse chronological order (newest first)",
[nameof(FormatMarkdownLabel)] = "Format markdown",
[nameof(FormatMarkdownTooltip)] =
"Process markdown, mentions, and other special tokens",
@@ -44,7 +44,7 @@ public partial class LocalizationManager
* Votre bot doit avoir l'option **Message Content Intent** activée pour lire les messages
""",
[nameof(TokenHelpText)] =
"Pour les questions ou problèmes, veuillez consulter la [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
"Pour les questions ou problèmes, veuillez consulter la [documentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
// Settings
[nameof(SettingsTitle)] = "Paramètres",
[nameof(ThemeLabel)] = "Thème",
@@ -54,10 +54,8 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Mise à jour automatique",
[nameof(AutoUpdateTooltip)] = "Effectuer des mises à jour automatiques à chaque lancement",
[nameof(PersistTokenLabel)] = "Conserver le token",
[nameof(PersistTokenTooltip)] = """
Enregistrer le dernier token utilisé dans un fichier pour le conserver entre les sessions.
**Avertissement** : bien que le token soit stocké avec chiffrement, il peut toujours être récupéré par un attaquant ayant accès à votre système.
""",
[nameof(PersistTokenTooltip)] =
"Enregistrer le dernier token utilisé dans un fichier pour le conserver entre les sessions",
[nameof(RateLimitPreferenceLabel)] = "Préférence de limite de débit",
[nameof(RateLimitPreferenceTooltip)] =
"Indique s'il faut respecter les limites de débit recommandées. Si désactivé, seules les limites strictes (réponses 429) seront respectées.",
@@ -80,17 +78,17 @@ public partial class LocalizationManager
Les chemins de répertoire doivent se terminer par un slash pour éviter toute ambiguïté.
Jetons de modèle disponibles :
**%g** ID du serveur
**%G** nom du serveur
**%t** ID de la catégorie
**%T** nom de la catégorie
**%c** ID du canal
**%C** nom du canal
**%p** position du canal
**%P** position de la catégorie
**%a** date après
**%b** date avant
**%d** date actuelle
- **%g** ID du serveur
- **%G** nom du serveur
- **%t** ID de la catégorie
- **%T** nom de la catégorie
- **%c** ID du canal
- **%C** nom du canal
- **%p** position du canal
- **%P** position de la catégorie
- **%a** date après
- **%b** date avant
- **%d** date actuelle
""",
[nameof(FormatLabel)] = "Format",
[nameof(FormatTooltip)] = "Format d'exportation",
@@ -108,9 +106,6 @@ public partial class LocalizationManager
[nameof(MessageFilterLabel)] = "Filtre de messages",
[nameof(MessageFilterTooltip)] =
"Inclure uniquement les messages satisfaisant ce filtre (ex. 'from:foo#1234' ou 'has:image'). Voir la documentation pour plus d'informations.",
[nameof(ReverseMessageOrderLabel)] = "Inverser l'ordre des messages",
[nameof(ReverseMessageOrderTooltip)] =
"Exporter les messages en ordre chronologique inversé (les plus récents en premier)",
[nameof(FormatMarkdownLabel)] = "Formater le markdown",
[nameof(FormatMarkdownTooltip)] =
"Traiter le markdown, les mentions et autres tokens spéciaux",
@@ -44,7 +44,7 @@ public partial class LocalizationManager
* Ihr Bot benötigt die aktivierte **Message Content Intent**, um Nachrichten zu lesen
""",
[nameof(TokenHelpText)] =
"Bei Fragen oder Problemen lesen Sie die [Dokumentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
"Bei Fragen oder Problemen lesen Sie die [Dokumentation](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
// Settings
[nameof(SettingsTitle)] = "Einstellungen",
[nameof(ThemeLabel)] = "Design",
@@ -54,10 +54,8 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Automatische Updates",
[nameof(AutoUpdateTooltip)] = "Automatische Updates bei jedem Start durchführen",
[nameof(PersistTokenLabel)] = "Token speichern",
[nameof(PersistTokenTooltip)] = """
Den zuletzt verwendeten Token in einer Datei speichern, damit er zwischen Sitzungen erhalten bleibt.
**Warnung**: Der Token wird mit Verschlüsselung gespeichert, kann aber dennoch von einem Angreifer mit Zugriff auf Ihr System wiederhergestellt werden.
""",
[nameof(PersistTokenTooltip)] =
"Den zuletzt verwendeten Token in einer Datei speichern, damit er zwischen Sitzungen erhalten bleibt",
[nameof(RateLimitPreferenceLabel)] = "Ratenlimit-Einstellung",
[nameof(RateLimitPreferenceTooltip)] =
"Ob empfohlene Ratenlimits eingehalten werden sollen. Wenn deaktiviert, werden nur harte Ratenlimits (d. h. 429-Antworten) eingehalten.",
@@ -80,17 +78,17 @@ public partial class LocalizationManager
Verzeichnispfade müssen mit einem Schrägstrich enden, um Mehrdeutigkeiten zu vermeiden.
Verfügbare Vorlagen-Token:
**%g** Server-ID
**%G** Servername
**%t** Kategorie-ID
**%T** Kategoriename
**%c** Kanal-ID
**%C** Kanalname
**%p** Kanalposition
**%P** Kategorieposition
**%a** Datum ab
**%b** Datum bis
**%d** aktuelles Datum
- **%g** Server-ID
- **%G** Servername
- **%t** Kategorie-ID
- **%T** Kategoriename
- **%c** Kanal-ID
- **%C** Kanalname
- **%p** Kanalposition
- **%P** Kategorieposition
- **%a** Datum ab
- **%b** Datum bis
- **%d** aktuelles Datum
""",
[nameof(FormatLabel)] = "Format",
[nameof(FormatTooltip)] = "Exportformat",
@@ -112,9 +110,6 @@ public partial class LocalizationManager
[nameof(MessageFilterLabel)] = "Nachrichtenfilter",
[nameof(MessageFilterTooltip)] =
"Nur Nachrichten einschließen, die diesem Filter entsprechen (z. B. 'from:foo#1234' oder 'has:image'). Weitere Informationen finden Sie in der Dokumentation.",
[nameof(ReverseMessageOrderLabel)] = "Nachrichtenreihenfolge umkehren",
[nameof(ReverseMessageOrderTooltip)] =
"Nachrichten in umgekehrter chronologischer Reihenfolge exportieren (neueste zuerst)",
[nameof(FormatMarkdownLabel)] = "Markdown formatieren",
[nameof(FormatMarkdownTooltip)] =
"Markdown, Erwähnungen und andere spezielle Token verarbeiten",
@@ -42,7 +42,7 @@ public partial class LocalizationManager
* Tu bot necesita tener habilitado **Message Content Intent** para leer mensajes
""",
[nameof(TokenHelpText)] =
"Si tienes preguntas o problemas, consulta la [documentación](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
"Si tienes preguntas o problemas, consulta la [documentación](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
// Settings
[nameof(SettingsTitle)] = "Ajustes",
[nameof(ThemeLabel)] = "Tema",
@@ -52,10 +52,8 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Actualización automática",
[nameof(AutoUpdateTooltip)] = "Realizar actualizaciones automáticas en cada inicio",
[nameof(PersistTokenLabel)] = "Guardar token",
[nameof(PersistTokenTooltip)] = """
Guardar el último token utilizado en un archivo para conservarlo entre sesiones.
**Advertencia**: aunque el token se almacena con cifrado, aún puede ser recuperado por un atacante con acceso a tu sistema.
""",
[nameof(PersistTokenTooltip)] =
"Guardar el último token utilizado en un archivo para conservarlo entre sesiones",
[nameof(RateLimitPreferenceLabel)] = "Preferencia de límite de velocidad",
[nameof(RateLimitPreferenceTooltip)] =
"Si se deben respetar los límites de velocidad recomendados. Si está desactivado, solo se respetarán los límites estrictos (respuestas 429).",
@@ -78,17 +76,17 @@ public partial class LocalizationManager
Las rutas de directorio deben terminar con una barra diagonal para evitar ambigüedades.
Tokens de plantilla disponibles:
**%g** ID del servidor
**%G** nombre del servidor
**%t** ID de categoría
**%T** nombre de categoría
**%c** ID del canal
**%C** nombre del canal
**%p** posición del canal
**%P** posición de la categoría
**%a** fecha desde
**%b** fecha hasta
**%d** fecha actual
- **%g** ID del servidor
- **%G** nombre del servidor
- **%t** ID de categoría
- **%T** nombre de categoría
- **%c** ID del canal
- **%C** nombre del canal
- **%p** posición del canal
- **%P** posición de la categoría
- **%a** fecha desde
- **%b** fecha hasta
- **%d** fecha actual
""",
[nameof(FormatLabel)] = "Formato",
[nameof(FormatTooltip)] = "Formato de exportación",
@@ -106,9 +104,6 @@ public partial class LocalizationManager
[nameof(MessageFilterLabel)] = "Filtro de mensajes",
[nameof(MessageFilterTooltip)] =
"Solo incluir mensajes que satisfagan este filtro (p. ej. 'from:foo#1234' o 'has:image'). Consulte la documentación para más información.",
[nameof(ReverseMessageOrderLabel)] = "Invertir orden de mensajes",
[nameof(ReverseMessageOrderTooltip)] =
"Exportar mensajes en orden cronológico inverso (los más recientes primero)",
[nameof(FormatMarkdownLabel)] = "Formatear markdown",
[nameof(FormatMarkdownTooltip)] =
"Procesar markdown, menciones y otros tokens especiales",
@@ -42,7 +42,7 @@ public partial class LocalizationManager
* Ваш бот повинен мати включений **Message Content Intent** для читання повідомлень
""",
[nameof(TokenHelpText)] =
"Якщо у вас є запитання або проблеми, зверніться до [документації](https://github.com/Tyrrrz/DiscordChatExporter/tree/prime/.docs)",
"Якщо у вас є запитання або проблеми, зверніться до [документації](https://github.com/Tyrrrz/DiscordChatExporter/tree/master/.docs)",
// Settings
[nameof(SettingsTitle)] = "Налаштування",
[nameof(ThemeLabel)] = "Тема",
@@ -52,10 +52,8 @@ public partial class LocalizationManager
[nameof(AutoUpdateLabel)] = "Авто-оновлення",
[nameof(AutoUpdateTooltip)] = "Виконувати автоматичні оновлення при кожному запуску",
[nameof(PersistTokenLabel)] = "Зберігати токен",
[nameof(PersistTokenTooltip)] = """
Зберігати останній використаний токен у файлі для збереження між сеансами.
**Увага**: хоча токен зберігається із шифруванням, він може бути відновлений зловмисником, який має доступ до вашої системи.
""",
[nameof(PersistTokenTooltip)] =
"Зберігати останній використаний токен у файлі для збереження між сеансами",
[nameof(RateLimitPreferenceLabel)] = "Ліміт запитів",
[nameof(RateLimitPreferenceTooltip)] =
"Чи дотримуватись рекомендованих лімітів запитів. Якщо вимкнено, будуть дотримуватись лише жорсткі ліміти (тобто відповіді 429).",
@@ -78,17 +76,17 @@ public partial class LocalizationManager
Шляхи до директорій повинні закінчуватись слешем для уникнення неоднозначності.
Доступні шаблонні токени:
**%g** ID сервера
**%G** назва сервера
**%t** ID категорії
**%T** назва категорії
**%c** ID каналу
**%C** назва каналу
**%p** позиція каналу
**%P** позиція категорії
**%a** дата після
**%b** дата до
**%d** поточна дата
- **%g** ID сервера
- **%G** назва сервера
- **%t** ID категорії
- **%T** назва категорії
- **%c** ID каналу
- **%C** назва каналу
- **%p** позиція каналу
- **%P** позиція категорії
- **%a** дата після
- **%b** дата до
- **%d** поточна дата
""",
[nameof(FormatLabel)] = "Формат",
[nameof(FormatTooltip)] = "Формат експорту",
@@ -106,9 +104,6 @@ public partial class LocalizationManager
[nameof(MessageFilterLabel)] = "Фільтр повідомлень",
[nameof(MessageFilterTooltip)] =
"Включати лише повідомлення, що відповідають цьому фільтру (напр. 'from:foo#1234' або 'has:image'). Дивіться документацію для більш детальної інформації.",
[nameof(ReverseMessageOrderLabel)] = "Зворотній порядок повідомлень",
[nameof(ReverseMessageOrderTooltip)] =
"Експортувати повідомлення у зворотному хронологічному порядку (найновіші спочатку)",
[nameof(FormatMarkdownLabel)] = "Форматувати markdown",
[nameof(FormatMarkdownTooltip)] =
"Обробляти markdown, згадки та інші спеціальні токени",
@@ -14,12 +14,18 @@ public partial class LocalizationManager : ObservableObject, IDisposable
public LocalizationManager(SettingsService settingsService)
{
_eventRoot.Add(settingsService.WatchProperty(o => o.Language, v => Language = v, true));
_eventRoot.Add(
settingsService.WatchProperty(
o => o.Language,
() => Language = settingsService.Language,
true
)
);
_eventRoot.Add(
this.WatchProperty(
o => o.Language,
_ =>
() =>
{
foreach (var propertyName in EnglishLocalization.Keys)
OnPropertyChanged(propertyName);
@@ -129,8 +135,6 @@ public partial class LocalizationManager
public string PartitionLimitTooltip => Get();
public string MessageFilterLabel => Get();
public string MessageFilterTooltip => Get();
public string ReverseMessageOrderLabel => Get();
public string ReverseMessageOrderTooltip => Get();
public string FormatMarkdownLabel => Get();
public string FormatMarkdownTooltip => Get();
public string DownloadAssetsLabel => Get();
+1 -1
View File
@@ -21,7 +21,7 @@ public static class Program
public static string ProjectReleasesUrl { get; } = $"{ProjectUrl}/releases";
public static string ProjectDocumentationUrl { get; } = ProjectUrl + "/tree/prime/.docs";
public static string ProjectDocumentationUrl { get; } = ProjectUrl + "/tree/master/.docs";
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder.Configure<App>().UsePlatformDetect().LogToTrace();
@@ -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
}
@@ -1,123 +0,0 @@
#!/usr/bin/dotnet --
#:package CliFx
using CliFx;
using CliFx.Binding;
using CliFx.Infrastructure;
[Command(Description = "Publishes the GUI app as a macOS .app bundle.")]
public partial class PublishMacOSBundleCommand : ICommand
{
private const string BundleName = "DiscordChatExporter.app";
private const string AppName = "DiscordChatExporter";
private const string AppCopyright = "© Oleksii Holub";
private const string AppIdentifier = "me.Tyrrrz.DiscordChatExporter";
private const string AppSpokenName = "Discord Chat Exporter";
private const string AppIconName = "AppIcon";
[CommandOption("publish-dir", Description = "Path to the publish output directory.")]
public required string PublishDirPath { get; set; }
[CommandOption("icons-file", Description = "Path to the .icns icons file.")]
public required string IconsFilePath { get; set; }
[CommandOption("full-version", Description = "Full version string (e.g. '1.2.3.4').")]
public required string FullVersion { get; set; }
[CommandOption("short-version", Description = "Short version string (e.g. '1.2.3').")]
public required string ShortVersion { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
// Set up paths
var publishDirPath = Path.GetFullPath(PublishDirPath);
var tempDirPath = Path.GetFullPath(
Path.Combine(publishDirPath, "../publish-macos-app-temp")
);
// Ensure the temporary directory is clean before use in case a previous run crashed
if (Directory.Exists(tempDirPath))
Directory.Delete(tempDirPath, true);
var bundleDirPath = Path.Combine(tempDirPath, BundleName);
var contentsDirPath = Path.Combine(bundleDirPath, "Contents");
try
{
// Copy icons into the .app's Resources folder
Directory.CreateDirectory(Path.Combine(contentsDirPath, "Resources"));
File.Copy(
IconsFilePath,
Path.Combine(contentsDirPath, "Resources", "AppIcon.icns"),
true
);
// Generate the Info.plist metadata file with the app information
// lang=xml
var 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>{AppName}</string>
<key>CFBundleName</key>
<string>{AppName}</string>
<key>CFBundleExecutable</key>
<string>{AppName}</string>
<key>NSHumanReadableCopyright</key>
<string>{AppCopyright}</string>
<key>CFBundleIdentifier</key>
<string>{AppIdentifier}</string>
<key>CFBundleSpokenName</key>
<string>{AppSpokenName}</string>
<key>CFBundleIconFile</key>
<string>{AppIconName}</string>
<key>CFBundleIconName</key>
<string>{AppIconName}</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>
""";
await File.WriteAllTextAsync(Path.Combine(contentsDirPath, "Info.plist"), plistContent);
// Delete the previous bundle if it exists
var existingBundlePath = Path.Combine(publishDirPath, BundleName);
if (Directory.Exists(existingBundlePath))
Directory.Delete(existingBundlePath, true);
// Move all files from the publish directory into the MacOS directory
Directory.CreateDirectory(Path.Combine(contentsDirPath, "MacOS"));
foreach (var entryPath in Directory.GetFileSystemEntries(publishDirPath))
{
var destinationPath = Path.Combine(
contentsDirPath,
"MacOS",
Path.GetFileName(entryPath)
);
if (Directory.Exists(entryPath))
Directory.Move(entryPath, destinationPath);
else
File.Move(entryPath, destinationPath);
}
// Move the final bundle into the publish directory for upload
Directory.Move(bundleDirPath, Path.Combine(publishDirPath, BundleName));
}
finally
{
// Clean up the temporary directory
if (Directory.Exists(tempDirPath))
Directory.Delete(tempDirPath, true);
}
}
}
@@ -1,102 +0,0 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using DiscordChatExporter.Gui.Utils.Extensions;
namespace DiscordChatExporter.Gui.Services;
public partial class SettingsService
{
private class TokenEncryptionConverter : JsonConverter<string?>
{
private const string Prefix = "enc:";
private static readonly Lazy<byte[]> Key = new(() =>
Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(Environment.TryGetMachineId() ?? string.Empty),
Encoding.UTF8.GetBytes(ThisAssembly.Project.EncryptionSalt),
600_000,
HashAlgorithmName.SHA256,
16
)
);
public override string? Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
)
{
var value = reader.GetString();
// No prefix means the token is stored as plain text, which was the case for older
// versions of the application. Load it as is and encrypt it on next save.
if (
string.IsNullOrWhiteSpace(value)
|| !value.StartsWith(Prefix, StringComparison.Ordinal)
)
{
return value;
}
try
{
var encryptedData = Convert.FromHexString(value[Prefix.Length..]);
var tokenData = new byte[encryptedData.AsSpan(28).Length];
// Layout: nonce (12 bytes) | tag (16 bytes) | cipher
using var aes = new AesGcm(Key.Value, 16);
aes.Decrypt(
encryptedData.AsSpan(0, 12),
encryptedData.AsSpan(28),
encryptedData.AsSpan(12, 16),
tokenData
);
return Encoding.UTF8.GetString(tokenData);
}
catch (Exception ex)
when (ex
is FormatException
or CryptographicException
or ArgumentException
or IndexOutOfRangeException
)
{
return null;
}
}
public override void Write(
Utf8JsonWriter writer,
string? value,
JsonSerializerOptions options
)
{
if (string.IsNullOrWhiteSpace(value))
{
writer.WriteNullValue();
return;
}
var tokenData = Encoding.UTF8.GetBytes(value);
var encryptedData = new byte[28 + tokenData.Length];
// Nonce
RandomNumberGenerator.Fill(encryptedData.AsSpan(0, 12));
// Layout: nonce (12 bytes) | tag (16 bytes) | cipher
using var aes = new AesGcm(Key.Value, 16);
aes.Encrypt(
encryptedData.AsSpan(0, 12),
tokenData,
encryptedData.AsSpan(28),
encryptedData.AsSpan(12, 16)
);
writer.WriteStringValue(Prefix + Convert.ToHexStringLower(encryptedData));
}
}
}
@@ -1,4 +1,6 @@
using System.Text.Json.Serialization;
using System;
using System.IO;
using System.Text.Json.Serialization;
using Cogwheel;
using CommunityToolkit.Mvvm.ComponentModel;
using DiscordChatExporter.Core.Discord;
@@ -11,7 +13,10 @@ namespace DiscordChatExporter.Gui.Services;
[ObservableObject]
public partial class SettingsService()
: SettingsBase(StartOptions.Current.SettingsPath, SerializerContext.Default)
: SettingsBase(
Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
SerializerContext.Default
)
{
[ObservableProperty]
public partial bool IsUkraineSupportMessageEnabled { get; set; } = true;
@@ -45,7 +50,6 @@ public partial class SettingsService()
public partial int ParallelLimit { get; set; } = 1;
[ObservableProperty]
[JsonConverter(typeof(TokenEncryptionConverter))]
public partial string? LastToken { get; set; }
[ObservableProperty]
@@ -57,9 +61,6 @@ public partial class SettingsService()
[ObservableProperty]
public partial string? LastMessageFilterValue { get; set; }
[ObservableProperty]
public partial bool LastIsReverseMessageOrder { get; set; }
[ObservableProperty]
public partial bool LastShouldFormatMarkdown { get; set; } = true;
@@ -9,25 +9,24 @@ namespace DiscordChatExporter.Gui.Services;
public class UpdateService(SettingsService settingsService) : IDisposable
{
private readonly IUpdateManager? _updateManager =
OperatingSystem.IsWindows() && StartOptions.Current.IsAutoUpdateAllowed
? new UpdateManager(
new GithubPackageResolver(
"Tyrrrz",
"DiscordChatExporter",
// Examples:
// DiscordChatExporter.win-arm64.zip
// DiscordChatExporter.win-x64.zip
// DiscordChatExporter.linux-x64.zip
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
),
new ZipPackageExtractor()
)
: null;
private readonly IUpdateManager? _updateManager = OperatingSystem.IsWindows()
? new UpdateManager(
new GithubPackageResolver(
"Tyrrrz",
"DiscordChatExporter",
// Examples:
// DiscordChatExporter.win-arm64.zip
// DiscordChatExporter.win-x64.zip
// DiscordChatExporter.linux-x64.zip
$"DiscordChatExporter.{RuntimeInformation.RuntimeIdentifier}.zip"
),
new ZipPackageExtractor()
)
: null;
private Version? _updateVersion;
private bool _isUpdatePrepared;
private bool _isUpdaterLaunched;
private bool _updatePrepared;
private bool _updaterLaunched;
public async ValueTask<Version?> CheckForUpdatesAsync()
{
@@ -52,7 +51,7 @@ public class UpdateService(SettingsService settingsService) : IDisposable
try
{
await _updateManager.PrepareUpdateAsync(_updateVersion = version);
_isUpdatePrepared = true;
_updatePrepared = true;
}
catch (UpdaterAlreadyLaunchedException)
{
@@ -72,13 +71,13 @@ public class UpdateService(SettingsService settingsService) : IDisposable
if (!settingsService.IsAutoUpdateEnabled)
return;
if (_updateVersion is null || !_isUpdatePrepared || _isUpdaterLaunched)
if (_updateVersion is null || !_updatePrepared || _updaterLaunched)
return;
try
{
_updateManager.LaunchUpdater(_updateVersion, needRestart);
_isUpdaterLaunched = true;
_updaterLaunched = true;
}
catch (UpdaterAlreadyLaunchedException)
{
-31
View File
@@ -1,31 +0,0 @@
using System;
using System.IO;
namespace DiscordChatExporter.Gui;
public partial class StartOptions
{
public required string SettingsPath { get; init; }
public required bool IsAutoUpdateAllowed { get; init; }
}
public partial class StartOptions
{
public static StartOptions Current { get; } =
new()
{
SettingsPath =
Environment.GetEnvironmentVariable("DISCORDCHATEXPORTER_SETTINGS_PATH") is { } path
&& !string.IsNullOrWhiteSpace(path)
? Path.EndsInDirectorySeparator(path) || Directory.Exists(path)
? Path.Combine(path, "Settings.dat")
: path
: Path.Combine(AppContext.BaseDirectory, "Settings.dat"),
IsAutoUpdateAllowed = !(
Environment.GetEnvironmentVariable("DISCORDCHATEXPORTER_ALLOW_AUTO_UPDATE")
is { } env
&& env.Equals("false", StringComparison.OrdinalIgnoreCase)
),
};
}
@@ -1,15 +0,0 @@
using System.Windows.Input;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class CommandExtensions
{
extension(ICommand command)
{
public void ExecuteIfCan(object? parameter = null)
{
if (command.CanExecute(parameter))
command.Execute(parameter);
}
}
}
@@ -1,54 +0,0 @@
using System;
using System.IO;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class EnvironmentExtensions
{
extension(Environment)
{
public static string? TryGetMachineId()
{
// Windows: stable GUID written during OS installation
if (OperatingSystem.IsWindows())
{
try
{
using var regKey = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(
@"SOFTWARE\Microsoft\Cryptography"
);
if (
regKey?.GetValue("MachineGuid") is string guid
&& !string.IsNullOrWhiteSpace(guid)
)
return guid;
}
catch { }
}
else
{
// Unix: /etc/machine-id (set once by systemd at first boot)
foreach (var path in new[] { "/etc/machine-id", "/var/lib/dbus/machine-id" })
{
try
{
var id = File.ReadAllText(path).Trim();
if (!string.IsNullOrWhiteSpace(id))
return id;
}
catch { }
}
}
// Last-resort fallback
try
{
return Environment.MachineName;
}
catch
{
return null;
}
}
}
}
@@ -1,18 +0,0 @@
using System.Linq;
using Markdig.Syntax.Inlines;
namespace DiscordChatExporter.Gui.Utils.Extensions;
internal static class MarkdigExtensions
{
extension(Inline inline)
{
public string GetInnerText() =>
inline switch
{
LiteralInline literal => literal.Content.ToString(),
ContainerInline container => string.Concat(container.Select(c => c.GetInnerText())),
_ => string.Empty,
};
}
}
@@ -12,7 +12,7 @@ internal static class NotifyPropertyChangedExtensions
{
public IDisposable WatchProperty<TProperty>(
Expression<Func<TOwner, TProperty>> propertyExpression,
Action<TProperty> callback,
Action callback,
bool watchInitialValue = false
)
{
@@ -20,8 +20,6 @@ internal static class NotifyPropertyChangedExtensions
if (memberExpression?.Member is not PropertyInfo property)
throw new ArgumentException("Provided expression must reference a property.");
var getValue = propertyExpression.Compile();
void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (
@@ -29,14 +27,14 @@ internal static class NotifyPropertyChangedExtensions
|| string.Equals(args.PropertyName, property.Name, StringComparison.Ordinal)
)
{
callback(getValue(owner));
callback();
}
}
owner.PropertyChanged += OnPropertyChanged;
if (watchInitialValue)
callback(getValue(owner));
callback();
return Disposable.Create(() => owner.PropertyChanged -= OnPropertyChanged);
}
@@ -9,7 +9,7 @@ internal static class ProcessExtensions
public static void StartShellExecute(string path)
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo(path) { UseShellExecute = true };
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;
@@ -53,14 +54,14 @@ public partial class DashboardViewModel : ViewModelBase
_eventRoot.Add(
Progress.WatchProperty(
o => o.Current,
_ => OnPropertyChanged(nameof(IsProgressIndeterminate))
() => OnPropertyChanged(nameof(IsProgressIndeterminate))
)
);
_eventRoot.Add(
SelectedChannels.WatchProperty(
o => o.Count,
_ => ExportCommand.NotifyCanExecuteChanged()
() => ExportCommand.NotifyCanExecuteChanged()
)
);
}
@@ -95,17 +96,16 @@ public partial class DashboardViewModel : ViewModelBase
public ObservableCollection<ChannelConnection> SelectedChannels { get; } = [];
public override Task InitializeAsync()
[RelayCommand]
private void Initialize()
{
if (!string.IsNullOrWhiteSpace(_settingsService.LastToken))
Token = _settingsService.LastToken;
return Task.CompletedTask;
}
[RelayCommand]
private async Task ShowSettingsAsync() =>
await _dialogManager.ShowDialogAsync(_viewModelManager.GetSettingsViewModel());
await _dialogManager.ShowDialogAsync(_viewModelManager.CreateSettingsViewModel());
private bool CanPullGuilds() => !IsBusy && !string.IsNullOrWhiteSpace(Token);
@@ -142,7 +142,7 @@ public partial class DashboardViewModel : ViewModelBase
}
catch (Exception ex)
{
var dialog = _viewModelManager.GetMessageBoxViewModel(
var dialog = _viewModelManager.CreateMessageBoxViewModel(
LocalizationManager.ErrorPullingGuildsTitle,
ex.ToString()
);
@@ -209,7 +209,7 @@ public partial class DashboardViewModel : ViewModelBase
}
catch (Exception ex)
{
var dialog = _viewModelManager.GetMessageBoxViewModel(
var dialog = _viewModelManager.CreateMessageBoxViewModel(
LocalizationManager.ErrorPullingChannelsTitle,
ex.ToString()
);
@@ -236,7 +236,7 @@ public partial class DashboardViewModel : ViewModelBase
if (_discord is null || SelectedGuild is null || !SelectedChannels.Any())
return;
var dialog = _viewModelManager.GetExportSetupViewModel(
var dialog = _viewModelManager.CreateExportSetupViewModel(
SelectedGuild,
SelectedChannels.Select(c => c.Channel).ToArray()
);
@@ -275,7 +275,6 @@ public partial class DashboardViewModel : ViewModelBase
dialog.Before?.Pipe(Snowflake.FromDate),
dialog.PartitionLimit,
dialog.MessageFilter,
dialog.IsReverseMessageOrder,
dialog.ShouldFormatMarkdown,
dialog.ShouldDownloadAssets,
dialog.ShouldReuseAssets,
@@ -315,7 +314,7 @@ public partial class DashboardViewModel : ViewModelBase
}
catch (Exception ex)
{
var dialog = _viewModelManager.GetMessageBoxViewModel(
var dialog = _viewModelManager.CreateMessageBoxViewModel(
LocalizationManager.ErrorExportingTitle,
ex.ToString()
);
@@ -62,9 +62,6 @@ public partial class ExportSetupViewModel(
[NotifyPropertyChangedFor(nameof(MessageFilter))]
public partial string? MessageFilterValue { get; set; }
[ObservableProperty]
public partial bool IsReverseMessageOrder { get; set; }
[ObservableProperty]
public partial bool ShouldFormatMarkdown { get; set; }
@@ -102,13 +99,13 @@ public partial class ExportSetupViewModel(
? MessageFilter.Parse(MessageFilterValue)
: MessageFilter.Null;
public override Task InitializeAsync()
[RelayCommand]
private void Initialize()
{
// Persist preferences
SelectedFormat = settingsService.LastExportFormat;
PartitionLimitValue = settingsService.LastPartitionLimitValue;
MessageFilterValue = settingsService.LastMessageFilterValue;
IsReverseMessageOrder = settingsService.LastIsReverseMessageOrder;
ShouldFormatMarkdown = settingsService.LastShouldFormatMarkdown;
ShouldDownloadAssets = settingsService.LastShouldDownloadAssets;
ShouldReuseAssets = settingsService.LastShouldReuseAssets;
@@ -123,10 +120,7 @@ public partial class ExportSetupViewModel(
|| !string.IsNullOrWhiteSpace(MessageFilterValue)
|| ShouldDownloadAssets
|| ShouldReuseAssets
|| !string.IsNullOrWhiteSpace(AssetsDirPath)
|| IsReverseMessageOrder;
return Task.CompletedTask;
|| !string.IsNullOrWhiteSpace(AssetsDirPath);
}
[RelayCommand]
@@ -190,7 +184,6 @@ public partial class ExportSetupViewModel(
settingsService.LastExportFormat = SelectedFormat;
settingsService.LastPartitionLimitValue = PartitionLimitValue;
settingsService.LastMessageFilterValue = MessageFilterValue;
settingsService.LastIsReverseMessageOrder = IsReverseMessageOrder;
settingsService.LastShouldFormatMarkdown = ShouldFormatMarkdown;
settingsService.LastShouldDownloadAssets = ShouldDownloadAssets;
settingsService.LastShouldReuseAssets = ShouldReuseAssets;
@@ -46,9 +46,6 @@ public class SettingsViewModel : DialogViewModelBase
set => _settingsService.Language = value;
}
public bool IsAutoUpdateAvailable { get; } =
OperatingSystem.IsWindows() && StartOptions.Current.IsAutoUpdateAllowed;
public bool IsAutoUpdateEnabled
{
get => _settingsService.IsAutoUpdateEnabled;
@@ -2,6 +2,7 @@
using System.Diagnostics;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.Input;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Localization;
using DiscordChatExporter.Gui.Services;
@@ -21,14 +22,14 @@ public partial class MainViewModel(
{
public string Title { get; } = $"{Program.Name} v{Program.VersionString}";
public DashboardViewModel Dashboard { get; } = viewModelManager.GetDashboardViewModel();
public DashboardViewModel Dashboard { get; } = viewModelManager.CreateDashboardViewModel();
private async Task ShowUkraineSupportMessageAsync()
{
if (!settingsService.IsUkraineSupportMessageEnabled)
return;
var dialog = viewModelManager.GetMessageBoxViewModel(
var dialog = viewModelManager.CreateMessageBoxViewModel(
localizationManager.UkraineSupportTitle,
localizationManager.UkraineSupportMessage,
localizationManager.LearnMoreButton,
@@ -52,7 +53,7 @@ public partial class MainViewModel(
if (Debugger.IsAttached)
return;
var dialog = viewModelManager.GetMessageBoxViewModel(
var dialog = viewModelManager.CreateMessageBoxViewModel(
localizationManager.UnstableBuildTitle,
string.Format(localizationManager.UnstableBuildMessage, Program.Name),
localizationManager.SeeReleasesButton,
@@ -99,7 +100,8 @@ public partial class MainViewModel(
}
}
public override async Task InitializeAsync()
[RelayCommand]
private async Task InitializeAsync()
{
await ShowUkraineSupportMessageAsync();
await ShowDevelopmentBuildMessageAsync();
@@ -160,8 +160,7 @@
<Grid
Background="Transparent"
Classes.category="{Binding Channel.IsCategory}"
ColumnDefinitions="Auto,*,Auto"
DoubleTapped="ChannelGrid_OnDoubleTapped">
ColumnDefinitions="Auto,*,Auto">
<Grid.Styles>
<Style Selector="Grid">
<Style Selector="^:not(.category)">
@@ -220,35 +219,35 @@
<Panel IsVisible="{Binding !AvailableGuilds.Count}">
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Margin="32,16" Spacing="0">
<!-- User token -->
<TextBlock>
<InlineUIContainer>
<materialIcons:MaterialIcon
Width="18"
Height="18"
Margin="0,-2,0,0"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Account" />
</InlineUIContainer>
<Run Text="" />
<Run Text=" " />
<Run
FontSize="16"
FontWeight="SemiBold"
Text="{Binding LocalizationManager.TokenPersonalHeader}" />
</TextBlock>
<TextBlock
FontSize="14"
FontWeight="Light"
Inlines="{Binding LocalizationManager.TokenPersonalTosWarning, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
LineHeight="23"
TextWrapping="Wrap" />
<TextBlock Inlines="{Binding LocalizationManager.TokenPersonalTosWarning, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
FontSize="14"
FontWeight="Light"
LineHeight="23"
TextWrapping="Wrap" />
<TextBlock
FontSize="14"
FontWeight="Light"
Inlines="{Binding LocalizationManager.TokenPersonalInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
LineHeight="23"
TextWrapping="Wrap" />
<TextBlock Inlines="{Binding LocalizationManager.TokenPersonalInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
FontSize="14"
FontWeight="Light"
LineHeight="23"
TextWrapping="Wrap" />
<!-- Bot token -->
<TextBlock Margin="0,12,0,0">
@@ -256,30 +255,29 @@
<materialIcons:MaterialIcon
Width="18"
Height="18"
Margin="0,-2,0,0"
Foreground="{DynamicResource PrimaryHueMidBrush}"
Kind="Robot" />
</InlineUIContainer>
<Run Text="" />
<Run Text=" " />
<Run
FontSize="16"
FontWeight="SemiBold"
Text="{Binding LocalizationManager.TokenBotHeader}" />
</TextBlock>
<TextBlock
FontSize="14"
FontWeight="Light"
Inlines="{Binding LocalizationManager.TokenBotInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
LineHeight="23"
TextWrapping="Wrap" />
<TextBlock Inlines="{Binding LocalizationManager.TokenBotInstructions, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
FontSize="14"
FontWeight="Light"
LineHeight="23"
TextWrapping="Wrap" />
<TextBlock
Margin="0,12,0,0"
FontSize="14"
FontWeight="Light"
Inlines="{Binding LocalizationManager.TokenHelpText, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
LineHeight="23"
TextWrapping="Wrap" />
<TextBlock Margin="0,12,0,0"
FontSize="14"
FontWeight="Light"
LineHeight="23"
TextWrapping="Wrap"
Inlines="{Binding LocalizationManager.TokenHelpText, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" />
</StackPanel>
</ScrollViewer>
</Panel>
@@ -1,10 +1,8 @@
using System.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.Utils.Extensions;
using DiscordChatExporter.Gui.ViewModels.Components;
namespace DiscordChatExporter.Gui.Views.Components;
@@ -13,13 +11,16 @@ public partial class DashboardView : UserControl<DashboardViewModel>
{
public DashboardView() => InitializeComponent();
private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) =>
private void UserControl_OnLoaded(object? sender, RoutedEventArgs args)
{
DataContext.InitializeCommand.Execute(null);
TokenValueTextBox.Focus();
}
private void AvailableGuildsListBox_OnSelectionChanged(
object? sender,
SelectionChangedEventArgs args
) => DataContext.PullChannelsCommand.ExecuteIfCan(null);
) => DataContext.PullChannelsCommand.Execute(null);
private void AvailableChannelsTreeView_OnSelectionChanged(
object? sender,
@@ -35,12 +36,4 @@ public partial class DashboardView : UserControl<DashboardViewModel>
container.IsSelected = false;
}
}
private void ChannelGrid_OnDoubleTapped(object? sender, TappedEventArgs args)
{
if (DataContext.SelectedChannels.Count != 1)
return;
DataContext.ExportCommand.ExecuteIfCan(null);
}
}
@@ -17,4 +17,4 @@
</Style>
</TextBlock.Styles>
</TextBlock>
</UserControl>
</UserControl>
@@ -54,7 +54,8 @@ public partial class HyperLink : UserControl
{
if (Command is not null)
{
Command.ExecuteIfCan(CommandParameter);
if (Command.CanExecute(CommandParameter))
Command.Execute(CommandParameter);
}
else if (!string.IsNullOrWhiteSpace(Url))
{
@@ -10,7 +10,8 @@
xmlns:utils="clr-namespace:DiscordChatExporter.Gui.Utils"
x:Name="UserControl"
Width="380"
x:DataType="dialogs:ExportSetupViewModel">
x:DataType="dialogs:ExportSetupViewModel"
Loaded="UserControl_OnLoaded">
<Grid RowDefinitions="Auto,*,Auto">
<!-- Guild/channel info -->
<Grid
@@ -50,7 +51,11 @@
IsVisible="{Binding IsSingleChannel}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Channels[0], Converter={x:Static converters:ChannelToHierarchicalNameStringConverter.Instance}}">
<Run Text="{Binding Channels[0].Parent.Name, StringFormat='{}{0} / ', FallbackValue='', TargetNullValue='', Mode=OneWay}" /><Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
<TextBlock IsVisible="{Binding !!Channels[0].Parent}">
<Run Text="{Binding Channels[0].Parent.Name, Mode=OneWay}" />
<Run Text="/" />
</TextBlock>
<Run FontWeight="SemiBold" Text="{Binding Channels[0].Name, Mode=OneWay}" />
</TextBlock>
</Grid>
@@ -70,8 +75,8 @@
<ToolTip.Tip>
<TextBlock
MaxWidth="400"
Inlines="{Binding LocalizationManager.OutputPathTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
TextWrapping="Wrap" />
TextWrapping="Wrap"
Inlines="{Binding LocalizationManager.OutputPathTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}" />
</ToolTip.Tip>
<TextBox.InnerRightContent>
<Button
@@ -191,15 +196,6 @@
Theme="{DynamicResource FilledTextBox}"
ToolTip.Tip="{Binding LocalizationManager.MessageFilterTooltip}" />
<!-- Reverse message order -->
<DockPanel
Margin="16,8"
LastChildFill="False"
ToolTip.Tip="{Binding LocalizationManager.ReverseMessageOrderTooltip}">
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.ReverseMessageOrderLabel}" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsReverseMessageOrder}" />
</DockPanel>
<!-- Markdown formatting -->
<DockPanel
Margin="16,8"
@@ -291,4 +287,4 @@
Theme="{DynamicResource MaterialOutlineButton}" />
</Grid>
</Grid>
</UserControl>
</UserControl>
@@ -1,3 +1,4 @@
using Avalonia.Interactivity;
using DiscordChatExporter.Gui.Framework;
using DiscordChatExporter.Gui.ViewModels.Dialogs;
@@ -6,4 +7,7 @@ namespace DiscordChatExporter.Gui.Views.Dialogs;
public partial class ExportSetupView : UserControl<ExportSetupViewModel>
{
public ExportSetupView() => InitializeComponent();
private void UserControl_OnLoaded(object? sender, RoutedEventArgs args) =>
DataContext.InitializeCommand.Execute(null);
}
@@ -33,40 +33,30 @@
<UniformGrid
Grid.Row="2"
Margin="8"
Margin="16"
HorizontalAlignment="Right"
Columns="{Binding ButtonsCount}">
<!-- OK -->
<Button
Margin="8"
HorizontalContentAlignment="Stretch"
Command="{Binding CloseCommand}"
Content="{Binding DefaultButtonText}"
IsDefault="True"
IsVisible="{Binding IsDefaultButtonVisible}"
Theme="{DynamicResource MaterialOutlineButton}"
ToolTip.Tip="{Binding DefaultButtonText}">
Theme="{DynamicResource MaterialOutlineButton}">
<Button.CommandParameter>
<system:Boolean>True</system:Boolean>
</Button.CommandParameter>
<TextBlock
Text="{Binding DefaultButtonText}"
TextAlignment="Center"
TextTrimming="CharacterEllipsis" />
</Button>
<!-- Cancel -->
<Button
Margin="8"
HorizontalContentAlignment="Stretch"
Margin="16,0,0,0"
HorizontalAlignment="Stretch"
Command="{Binding CloseCommand}"
Content="{Binding CancelButtonText}"
IsCancel="True"
IsVisible="{Binding IsCancelButtonVisible}"
Theme="{DynamicResource MaterialOutlineButton}"
ToolTip.Tip="{Binding CancelButtonText}">
<TextBlock
Text="{Binding CancelButtonText}"
TextAlignment="Center"
TextTrimming="CharacterEllipsis" />
</Button>
Theme="{DynamicResource MaterialOutlineButton}" />
</UniformGrid>
</Grid>
</UserControl>
@@ -50,7 +50,8 @@
<!-- Auto-updates -->
<DockPanel
Margin="16,8"
IsVisible="{Binding IsAutoUpdateAvailable}"
IsVisible="{OnPlatform False,
Windows=True}"
LastChildFill="False"
ToolTip.Tip="{Binding LocalizationManager.AutoUpdateTooltip}">
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.AutoUpdateLabel}" />
@@ -58,13 +59,10 @@
</DockPanel>
<!-- Persist token -->
<DockPanel Margin="16,8" LastChildFill="False">
<ToolTip.Tip>
<TextBlock
MaxWidth="400"
Inlines="{Binding LocalizationManager.PersistTokenTooltip, Converter={x:Static converters:MarkdownToInlinesConverter.Instance}}"
TextWrapping="Wrap" />
</ToolTip.Tip>
<DockPanel
Margin="16,8"
LastChildFill="False"
ToolTip.Tip="{Binding LocalizationManager.PersistTokenTooltip}">
<TextBlock DockPanel.Dock="Left" Text="{Binding LocalizationManager.PersistTokenLabel}" />
<ToggleSwitch DockPanel.Dock="Right" IsChecked="{Binding IsTokenPersisted}" />
</DockPanel>
+5 -2
View File
@@ -14,9 +14,12 @@
Icon="/favicon.ico"
RenderOptions.BitmapInterpolationMode="HighQuality"
WindowStartupLocation="CenterScreen">
<dialogHostAvalonia:DialogHost x:Name="DialogHost" CloseOnClickAway="False">
<dialogHostAvalonia:DialogHost
x:Name="DialogHost"
CloseOnClickAway="False"
Loaded="DialogHost_OnLoaded">
<materialStyles:SnackbarHost HostName="Root" SnackbarMaxCounts="3">
<ContentControl Content="{Binding Dashboard}" />
</materialStyles:SnackbarHost>
</dialogHostAvalonia:DialogHost>
</Window>
</Window>

Some files were not shown because too many files have changed in this diff Show More